From 32f2e66831a7c62a40be5d97d2664f24dc95d0d1 Mon Sep 17 00:00:00 2001 From: Marcus Eliasson Date: Thu, 8 Oct 2026 14:27:45 +0200 Subject: [PATCH 1/6] Store and share lighthouse configs, and write them to a swarm Lighthouse configurations can now be stored like swarms: locally as , or shared on the server as /, with the same revisions, sync, pull/push and offline copies. New lh config commands: list, save (the Crazyflie's), import, export, delete, move, pull and push. display, write and check take a stored configuration by ID; write and check without one use the configuration the selected swarm names. A swarm can name the lighthouse config it flies in (`lighthouse:` in the swarm file, set with swarm config lighthouse); a shared swarm needs a shared one. swarm lh check compares every Crazyflie with it (exit code 60 when some differ) and swarm lh write writes it to those that don't have it, refusing Crazyflies whose firmware supports too few base stations before writing anything. Adding Crazyflies to such a swarm says how to give it to them. Swarms and lighthouse configs share one implementation of local files and shared copies (modules/documents.rs, which replaces swarm/shared.rs). The copies of shared swarms move from synced/// to synced//swarms//; existing copies are moved on first use. When an organization gets a new ID on the server, the copies of both kinds move to it, and swarms naming its lighthouse configs follow. Lighthouse config IDs complete in bash, zsh and PowerShell. --- README.md | 6 +- build.rs | 16 + completions/addendum.bash | 7 +- completions/addendum.ps1 | 5 +- completions/addendum.zsh | 1 + docs/lighthouse.md | 106 +- docs/swarm.md | 53 +- src/cli.rs | 159 +- src/main.rs | 58 +- src/modules/documents.rs | 1298 +++++++++++++++++ src/modules/lighthouse/configs.rs | 483 ++++++ .../{lighthouse.rs => lighthouse/mod.rs} | 16 +- src/modules/settings.rs | 9 +- src/modules/swarm/lighthouse.rs | 271 ++++ src/modules/swarm/mod.rs | 221 +-- src/modules/swarm/shared.rs | 837 ----------- src/modules/swarm/store.rs | 119 +- 17 files changed, 2494 insertions(+), 1171 deletions(-) create mode 100644 src/modules/documents.rs create mode 100644 src/modules/lighthouse/configs.rs rename src/modules/{lighthouse.rs => lighthouse/mod.rs} (98%) create mode 100644 src/modules/swarm/lighthouse.rs delete mode 100644 src/modules/swarm/shared.rs diff --git a/README.md b/README.md index b4babd8..e34c6f0 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ during development to quickly access various subsystems in the Crazyflie and sup * Turn the platform on/off or put it to sleep/wake it up * Run stability tests with the Crazyflie * High-level commander (takeoff, land, go-to, trajectories) -* Lighthouse positioning system configuration (read/write base station geometry and calibration) +* Lighthouse positioning system configuration (read/write base station geometry and calibration, store and share configurations, write them to a whole swarm) * Loco positioning system configuration (read/write anchor positions, cfclient-compatible YAML) * AI-agent and scripting friendly (CSV output, classified exit codes, non-interactive mode, command timeouts) @@ -111,7 +111,7 @@ Exit codes: 30 invalid value (range, type, malformed input) 40 --timeout expired on a bounded command 50 a swarm command failed on some of the Crazyflies - 60 a check found differences (lh config check) + 60 a check found differences (lh config check, swarm lh check) ``` To use the CLI you must first select which URI to use, this is done by scanning for available Crazyflies @@ -260,7 +260,7 @@ Exit codes: | 30 | Invalid value (range, type, malformed input) | | 40 | `--timeout` expired on a bounded command | | 50 | A swarm command failed on some of the Crazyflies | -| 60 | A check found differences (`lh config check`) | +| 60 | A check found differences (`lh config check`, `swarm lh check`) | Worked example — read one parameter into a shell variable: diff --git a/build.rs b/build.rs index 3387567..121d513 100644 --- a/build.rs +++ b/build.rs @@ -99,6 +99,22 @@ fn main() { "or lists them when non-interactive):_default", "or lists them when non-interactive):_cfcli_swarm_configs", ); + // Lighthouse config IDs: `--config`, `import --id` and the positional + // ID of the `lh config` commands and `swarm config lighthouse`. + s = s.replace(":CONFIG:_default", ":CONFIG:_cfcli_lh_configs"); + for help in [ + "Stored lighthouse config to display", + "Stored lighthouse config to write", + "Stored lighthouse config to compare with", + "Stored lighthouse config to export", + "Lighthouse config ID (prompts for one if omitted)", + "The lighthouse config to move", + "Only this shared lighthouse config (/)", + "Lighthouse config ID; a shared swarm needs a shared one (/)", + "/ shares it on the server", + ] { + s = s.replace(&format!("{}:_default", help), &format!("{}:_cfcli_lh_configs", help)); + } // Crazyflie names: `--from-swarm` takes one, `--cf`/`--exclude` a // comma-separated list. `::CF` first, it also contains `:CF`. s = s.replace("::CF:_default", "::CF:_cfcli_swarm_units"); diff --git a/completions/addendum.bash b/completions/addendum.bash index 4fa4114..e6025d2 100644 --- a/completions/addendum.bash +++ b/completions/addendum.bash @@ -46,6 +46,9 @@ _cfcli_dynamic() { *" swarm config select "*|*" swarm config delete "*|*" swarm config show "*|*" swarm config export "*) kind="swarm-configs" ;; *" swarm config remove "*|*" swarm config rename "*) kind="swarm-units" ;; + *" swarm config lighthouse "*|*" lh config display "*|*" lh config save "*|*" lh config write "*|\ + *" lh config check "*|*" lh config export "*|*" lh config delete "*|*" lh config move "*|\ + *" lh config pull "*|*" lh config push "*) kind="lh-configs" ;; esac # Option values: `--targets x,y` (plain list) / `--bin t=f` (key=value), @@ -54,14 +57,16 @@ _cfcli_dynamic() { --bin) kind="flash-targets"; suffix="="; nospace=1 ;; --targets) kind="flash-targets" ;; --swarm) kind="swarm-configs" ;; + --config) kind="lh-configs" ;; --cf|--exclude) kind="swarm-units"; nospace=1 ;; --from-swarm) kind="swarm-units" ;; # Values that are a new name, a file or free text. - -o|--output|--id|--name|--description) kind="" ;; + -o|--output|-i|--input|--id|--name|--description) kind="" ;; =) case "${COMP_WORDS[COMP_CWORD-2]}" in --bin) kind="flash-targets"; suffix="="; nospace=1 ;; --targets) kind="flash-targets" ;; --swarm) kind="swarm-configs" ;; + --config) kind="lh-configs" ;; --cf|--exclude) kind="swarm-units"; nospace=1 ;; --from-swarm) kind="swarm-units" ;; esac ;; diff --git a/completions/addendum.ps1 b/completions/addendum.ps1 index f181be2..55291b4 100644 --- a/completions/addendum.ps1 +++ b/completions/addendum.ps1 @@ -15,6 +15,8 @@ 'cfcli;(swarm;)?log;print$' { $cfcliDynKind = 'log-names' } 'cfcli;swarm;config;(select|delete|show|export)$' { $cfcliDynKind = 'swarm-configs' } 'cfcli;swarm;config;(remove|rename)$' { $cfcliDynKind = 'swarm-units' } + 'cfcli;swarm;config;lighthouse$' { $cfcliDynKind = 'lh-configs' } + 'cfcli;lh;config;(display|save|write|check|export|delete|move|pull|push)$' { $cfcliDynKind = 'lh-configs' } } # Option values: `--targets x,y` (plain list) / `--bin t=f` (key=value), # space-separated form. @@ -30,9 +32,10 @@ if ($cfcliPrev -eq '--targets') { $cfcliDynKind = 'flash-targets' } if ($cfcliPrev -eq '--bin') { $cfcliDynKind = 'flash-targets'; $cfcliSuffix = '=' } if ($cfcliPrev -eq '--swarm') { $cfcliDynKind = 'swarm-configs' } + if ($cfcliPrev -eq '--config') { $cfcliDynKind = 'lh-configs' } if ($cfcliPrev -in '--cf', '--exclude', '--from-swarm') { $cfcliDynKind = 'swarm-units' } # Values that are a new name, a file or free text. - if ($cfcliPrev -in '-o', '--output', '--id', '--name', '--description') { $cfcliDynKind = '' } + if ($cfcliPrev -in '-o', '--output', '-i', '--input', '--id', '--name', '--description') { $cfcliDynKind = '' } if ($cfcliDynKind -ne '') { # `cfcli __complete` handles comma-separated lists, returning fully diff --git a/completions/addendum.zsh b/completions/addendum.zsh index 2ff4ee2..50f34df 100644 --- a/completions/addendum.zsh +++ b/completions/addendum.zsh @@ -27,6 +27,7 @@ _cfcli_config_set() { _cfcli__dyn config-keys '=' } _cfcli_log_names() { _cfcli__dyn log-names '' } _cfcli_flash_targets() { _cfcli__dyn flash-targets } _cfcli_swarm_configs() { _cfcli__dyn swarm-configs } +_cfcli_lh_configs() { _cfcli__dyn lh-configs } _cfcli_swarm_units() { _cfcli__dyn swarm-units } _cfcli_swarm_unit_list() { _cfcli__dyn swarm-units '' } # `--bin` is a comma-separated list of `target=file`. In the current segment diff --git a/docs/lighthouse.md b/docs/lighthouse.md index 65e1e51..f5e71ef 100644 --- a/docs/lighthouse.md +++ b/docs/lighthouse.md @@ -41,12 +41,25 @@ the lighthouse memory. Usage: cfcli lh config Commands: - display Display lighthouse configuration in human-readable form - read Read lighthouse configuration as YAML (to file or stdout) - write Write lighthouse configuration from YAML (from file or stdin) - check Compare the Crazyflie's lighthouse configuration with YAML (from file or stdin) + list List the stored lighthouse configs: local ones and shared ones (/) + display Display a lighthouse configuration: a stored one, a file, or the Crazyflie's + read Read the Crazyflie's lighthouse configuration as YAML (to file or stdout) + save Store the Crazyflie's lighthouse configuration as (new, or an update) + write Write a lighthouse configuration to the Crazyflie + check Compare the Crazyflie's lighthouse configuration with one (as for write) + import Store a lighthouse configuration file (from the Crazyflie client or read) + export Write a stored lighthouse configuration to a file the Crazyflie client opens (or stdout) + delete Delete a stored lighthouse configuration + move Share a lighthouse config (cage -> org/cage), take it back (org/cage -> cage), or rename it + pull Get the latest version of the shared lighthouse configs (needed with sync off) + push Upload changes to shared lighthouse configs made with sync off or without the server ``` +`display`, `write` and `check` take a stored configuration by its ID, or a +file with `-i`. `write` and `check` also read YAML piped in, and with none of +these they use the configuration the selected swarm names (see +[Swarms](/docs/swarm.md#lighthouse)). + ### YAML File Format The format matches the one used by the Python `cflib` so configurations can be @@ -100,6 +113,8 @@ Top-level fields: cflib - `geos` — map of `bs_id -> { origin, rotation }` - `calibs` — map of `bs_id -> { uid, sweeps[2] }` +- `name` — optional, the name shown for a stored configuration (cflib + ignores it) Either map can be omitted or empty if you only want to read/write one half. The maps are written in base station order, so reading the same configuration @@ -111,11 +126,12 @@ Render the current configuration in human-readable form, either from the Crazyflie or from a YAML file. ```text -cfcli lh config display [-i ] +cfcli lh config display [ | -i ] ``` Options: +- `` — a stored configuration instead of the Crazyflie's - `-i, --input ` — read from a YAML file instead of the Crazyflie When `--csv` is used (the global flag), `display` emits a long-format CSV @@ -146,6 +162,9 @@ cfcli lh config display # Pretty print a YAML file (no Crazyflie connection) cfcli lh config display -i my_setup.yaml +# Pretty print a stored configuration +cfcli lh config display lab/cage + # Machine-readable CSV cfcli lh config display --csv @@ -183,16 +202,19 @@ diff <(cfcli lh config read) my_reference.yaml ### Write -Write a configuration from a YAML file (or stdin) to the Crazyflie. +Write a configuration to the Crazyflie. ```text -cfcli lh config write [-i ] +cfcli lh config write [ | -i ] ``` Options: -- `-i, --input ` — read YAML from a file. If omitted, YAML is read from - stdin. +- `` — a stored configuration +- `-i, --input ` — read YAML from a file + +With neither, YAML piped in is written, or else the configuration the +selected swarm names. All base station slots the firmware supports are written. Slots present in the YAML are uploaded as valid, while slots omitted from the YAML are written @@ -210,23 +232,23 @@ default firmware. # Write a config from a file cfcli lh config write -i my_setup.yaml +# Write a stored (or shared) config +cfcli lh config write lab/cage + # Pipe YAML in from stdin cat my_setup.yaml | cfcli lh config write ``` ### Check -Compare the configuration on the Crazyflie with a YAML file (or stdin), base -station by base station. +Compare the configuration on the Crazyflie with another one, base station by +base station. ```text -cfcli lh config check [-i ] +cfcli lh config check [ | -i ] ``` -Options: - -- `-i, --input ` — read YAML from a file. If omitted, YAML is read from - stdin. +The configuration is chosen as for [write](#write). Values are compared exactly, as the Crazyflie stores them. For each base station, the geometry and the calibration are: @@ -263,13 +285,59 @@ bs_id,geometry,moved_m,turned_deg,calibration,file_uid,cf_uid cfcli lh config check -i my_setup.yaml # Write only when it differs -cfcli lh config check -i my_setup.yaml; [ $? -eq 60 ] && cfcli lh config write -i my_setup.yaml +cfcli lh config check lab/cage; [ $? -eq 60 ] && cfcli lh config write lab/cage ``` +## Stored and shared configurations + +cfcli keeps lighthouse configurations the way it keeps swarms: local ones in a +`lighthouse` folder next to the cfcli config, named ``, and, when +signed in (`cfcli auth login`), shared ones on the server, named +`/`. A shared configuration has revisions, so when the base +stations are moved, everyone gets the new geometry, and the server's web +page shows what changed. With sync on (the default), commands check the +server for the latest version; with sync off, or without the server, they use +this computer's copy, and `pull` and `push` sync them, as for +[swarms](/docs/swarm.md#sync). + +```text +# Store what a Crazyflie has (after estimating the geometry in cfclient) +cfcli lh config save lab/cage --name "The cage" + +# ... or a file saved by the Crazyflie client +cfcli lh config import Lighthouse_Cage.yaml --id lab/cage --name "The cage" + +# List them +$ cfcli lh config list +ID | Name | Base stations | Stored +---------+----------+---------------+----------------------------- +lab/cage | The cage | 4 | arc.bitcraze.io, revision 2 + +# Give it to a Crazyflie, or check one +cfcli lh config write lab/cage +cfcli lh config check lab/cage + +# A file the Crazyflie client opens +cfcli lh config export lab/cage -o cage.yaml +``` + +`save` and `import` to an existing ID show what changes for each base station +and ask before replacing it (`--force` replaces without asking, and is needed +when not interactive). A configuration without any base station positions is +refused: estimate the geometry first. `base stations` in the list counts the +positioned ones; a Crazyflie also keeps the calibration of base stations it +has seen elsewhere. + +`move` shares a local configuration (`move cage lab/cage`), takes a shared one +back (which deletes it on the server for everyone in the organization), or +renames one. Swarms that name it need `cfcli swarm config lighthouse` with +the new ID. + ## Copy a Configuration Between Crazyflies -Read from one Crazyflie and write to another by piping `read` into `write`, -overriding the `--uri` for each: +Store it from one Crazyflie and write it to the others (`save`, then `write` +or `swarm lh write`), or pipe `read` into `write`, overriding the `--uri` for +each: ```bash cfcli --uri radio://0/80/2M/E7E7E7E7E7 lh config read \ diff --git a/docs/swarm.md b/docs/swarm.md index f4fdd96..60ed8ac 100644 --- a/docs/swarm.md +++ b/docs/swarm.md @@ -484,6 +484,50 @@ so a plan that would put them together is refused before anything is changed. Crazyflies that still have the default address need their own address first, with `cfcli config set address=...`. +## Lighthouse + +A swarm can name the lighthouse configuration it flies in, a stored one (see +[Lighthouse](/docs/lighthouse.md#stored-and-shared-configurations)). A shared +swarm can only name a shared configuration, so everyone who uses it gets it. + +```text +cfcli swarm config lighthouse lab/cage # the selected swarm flies in lab/cage +cfcli swarm config lighthouse # show which one it names +cfcli swarm config lighthouse --clear +``` + +`swarm lh check` compares each Crazyflie's configuration with it, and +`swarm lh write` writes it to the Crazyflies that don't have it yet (all of +them with `--force`), storing it in their flash. `--config` uses another +configuration than the one the swarm names. + +```text +$ cfcli swarm lh check +Lighthouse config 'lab/cage', revision 3: base stations 0, 1, 2, 3 +CF | Lighthouse +------+------------------------------------------ +CF-01 | up to date +CF-02 | BS 2 moved 4.1 cm, turned 0.62° +CF-03 | no position for BS 0, 1, 2, 3 +Error: differs: 2 of 3 Crazyflies have another lighthouse configuration; 'cfcli swarm lh write' gives them this one + +$ cfcli swarm lh write +Lighthouse config 'lab/cage', revision 3: base stations 0, 1, 2, 3 +CF-01: up to date +CF-02: written and stored in flash +CF-03: written and stored in flash +``` + +`check` exits with 60 when a Crazyflie differs, like `lh config check`, and +`--csv` gives one row per Crazyflie (`cf,uri,status,firmware_base_stations, +differing_base_stations`). `write` refuses a Crazyflie whose firmware supports +fewer base stations than the configuration has, before writing anything to it. + +When Crazyflies are added to a swarm that names a lighthouse config, `add` +says how to give it to them (`cfcli swarm lh write --swarm --cf +CF-07`). When the configuration changes, `swarm lh write` updates the +Crazyflies that have the old one. + ## Using one Crazyflie from a swarm `select --from-swarm` selects a Crazyflie from the selected swarm for all the @@ -504,7 +548,8 @@ Swarms are files in a `swarms` folder next to the cfcli config file, on Linux and the selected swarm. Use `import` and `export` rather than editing the files. The copies of shared swarms are kept apart from them, in -`~/.config/cf-cli/synced///.yaml`, with +`~/.config/cf-cli/synced//swarms//.yaml` +(shared lighthouse configs next to them in `.../lighthouse/...`), with `state.json` saying which revision each copy is and whether it has changes that aren't pushed, and `orgs.json` remembering your organizations' IDs. Don't edit those; change shared swarms with the commands. @@ -514,6 +559,7 @@ edit those; change shared swarms with the commands. ```yaml name: Lab Crazyflies description: The bench +lighthouse: lab/cage units: - uri: radio:///80/2M/E7E7E7E701 name: CF-01 @@ -522,5 +568,6 @@ units: description: Test bench ``` -`description` is optional, the rest is required. Fields cfcli doesn't know are -kept when it rewrites a file. +`description` and `lighthouse` (the lighthouse config the swarm flies in, see +[Lighthouse](#lighthouse)) are optional, the rest is required. Fields cfcli +doesn't know are kept when it rewrites a file. diff --git a/src/cli.rs b/src/cli.rs index 1cc1476..c5c08e5 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -200,7 +200,7 @@ const HELP_EPILOG: &str = "\x1b[1m\x1b[4mExit codes:\x1b[0m 30 invalid value (range, type, malformed input) 40 --timeout expired on a bounded command 50 a swarm command failed on some of the Crazyflies - 60 a check found differences (lh config check) + 60 a check found differences (lh config check, swarm lh check) "; #[derive(Parser, Debug)] @@ -383,6 +383,8 @@ enum CompletionKind { SwarmConfigs, /// Crazyflie names in the selected swarm (for `--cf` and `select --from-swarm`) SwarmUnits, + /// Stored lighthouse config IDs (for `lh config` and `--config`) + LhConfigs, } #[derive(Debug, Subcommand)] @@ -396,33 +398,90 @@ enum LighthouseCommands { #[derive(Debug, Subcommand)] enum LighthouseConfigCommands { - /// Display lighthouse configuration in human-readable form + /// List the stored lighthouse configs: local ones and shared ones (/) + List, + /// Display a lighthouse configuration: a stored one, a file, or the Crazyflie's Display(LighthouseDisplayParameters), - /// Read lighthouse configuration as YAML (to file or stdout) + /// Read the Crazyflie's lighthouse configuration as YAML (to file or stdout) Read(LighthouseReadParameters), - /// Write lighthouse configuration from YAML (from file or stdin) + /// Store the Crazyflie's lighthouse configuration as (new, or an update) + Save(LighthouseSaveParameters), + /// Write a lighthouse configuration to the Crazyflie + /// + /// The configuration is a stored one (), a file (-i), YAML piped + /// in, or else the one the selected swarm names. + #[clap(verbatim_doc_comment)] Write(LighthouseWriteParameters), - /// Compare the Crazyflie's lighthouse configuration with YAML (from file or stdin) + /// Compare the Crazyflie's lighthouse configuration with one (as for write) Check(LighthouseCheckParameters), + /// Store a lighthouse configuration file (from the Crazyflie client or read) + Import(LighthouseImportParameters), + /// Write a stored lighthouse configuration to a file the Crazyflie client opens (or stdout) + Export(LighthouseExportParameters), + /// Delete a stored lighthouse configuration + Delete { + /// Lighthouse config ID (prompts for one if omitted) + #[clap(value_name = "CONFIG")] + id: Option, + }, + /// Share a lighthouse config (cage -> org/cage), take it back (org/cage -> cage), or rename it + /// + /// Taking a shared one back deletes it on the server, for everyone in the + /// organization. + Move { + /// The lighthouse config to move + #[clap(value_name = "CONFIG")] + from: String, + /// Its new ID: on this computer, / on the server + #[clap(value_name = "NEW_ID")] + to: String, + }, + /// Get the latest version of the shared lighthouse configs (needed with sync off) + Pull { + /// Only this shared lighthouse config (/) + #[clap(value_name = "CONFIG")] + id: Option, + /// Drop changes on this computer that aren't pushed + #[clap(long)] + force: bool, + }, + /// Upload changes to shared lighthouse configs made with sync off or without the server + Push { + /// Only this shared lighthouse config (/) + #[clap(value_name = "CONFIG")] + id: Option, + /// Overwrite what others uploaded since + #[clap(long)] + force: bool, + }, } #[derive(Debug, Args)] struct LighthouseDisplayParameters { - /// YAML file to display (reads from Crazyflie if omitted) + /// Stored lighthouse config to display + #[clap(value_name = "CONFIG", conflicts_with = "input")] + id: Option, + /// YAML file to display (reads from the Crazyflie if neither is given) #[clap(long, short = 'i', value_hint = ValueHint::FilePath)] input: Option, } #[derive(Debug, Args)] struct LighthouseWriteParameters { - /// YAML file to read configuration from (reads stdin if omitted) + /// Stored lighthouse config to write + #[clap(value_name = "CONFIG", conflicts_with = "input")] + id: Option, + /// YAML file to read the configuration from #[clap(long, short = 'i', value_hint = ValueHint::FilePath)] input: Option, } #[derive(Debug, Args)] struct LighthouseCheckParameters { - /// YAML file to compare with (reads stdin if omitted) + /// Stored lighthouse config to compare with + #[clap(value_name = "CONFIG", conflicts_with = "input")] + id: Option, + /// YAML file to compare with #[clap(long, short = 'i', value_hint = ValueHint::FilePath)] input: Option, } @@ -434,6 +493,45 @@ struct LighthouseReadParameters { output: Option, } +#[derive(Debug, Args)] +struct LighthouseSaveParameters { + /// ID to store it as (letters, digits, '-', '_' and '.'); / shares it on the server + #[clap(value_name = "CONFIG")] + id: String, + /// Name shown for the configuration + #[clap(long)] + name: Option, + /// Replace an existing configuration without asking + #[clap(long)] + force: bool, +} + +#[derive(Debug, Args)] +struct LighthouseImportParameters { + /// The file (Crazyflie client format) + #[clap(value_hint = ValueHint::FilePath)] + file: String, + /// ID to store it as (default: the file name); / shares it on the server + #[clap(long, value_name = "CONFIG")] + id: Option, + /// Name shown for the configuration + #[clap(long)] + name: Option, + /// Replace an existing configuration without asking + #[clap(long)] + force: bool, +} + +#[derive(Debug, Args)] +struct LighthouseExportParameters { + /// Stored lighthouse config to export + #[clap(value_name = "CONFIG")] + id: String, + /// File to write (stdout if omitted) + #[clap(long, short = 'o', value_hint = ValueHint::FilePath)] + output: Option, +} + #[derive(Debug, Subcommand)] enum DebugCommands { /// Trigger a firmware assert-info dump on the console @@ -564,6 +662,39 @@ enum SwarmCommands { }, /// Spread the swarm over several radio channels (reprograms and reboots the Crazyflies that move) Rechannel(SwarmRechannelParameters), + /// Lighthouse configuration of the Crazyflies in the swarm + Lh { + #[clap(flatten)] + target: SwarmTargetArgs, + + #[clap(subcommand)] + command: SwarmLhCommands, + }, +} + +#[derive(Debug, Subcommand)] +enum SwarmLhCommands { + /// Compare each Crazyflie's lighthouse configuration with the swarm's + Check(SwarmLhParameters), + /// Write the swarm's lighthouse configuration to the Crazyflies that don't have it + Write(SwarmLhWriteParameters), +} + +#[derive(Debug, Args)] +struct SwarmLhParameters { + /// Lighthouse config to use instead of the one the swarm names + #[clap(long, value_name = "CONFIG")] + config: Option, +} + +#[derive(Debug, Args)] +struct SwarmLhWriteParameters { + /// Lighthouse config to use instead of the one the swarm names + #[clap(long, value_name = "CONFIG")] + config: Option, + /// Write it also to Crazyflies that already have it + #[clap(long)] + force: bool, } #[derive(Debug, Args)] @@ -727,6 +858,18 @@ enum SwarmConfigCommands { #[clap(value_name = "NEW_ID")] to: String, }, + /// Show or set the lighthouse config the swarm flies in (see 'cfcli lh config') + Lighthouse { + /// Lighthouse config ID; a shared swarm needs a shared one (/) + #[clap(value_name = "CONFIG")] + config: Option, + /// The swarm flies in no particular lighthouse config + #[clap(long, conflicts_with = "config")] + clear: bool, + /// Swarm to change instead of the selected one + #[clap(long, value_name = "SWARM")] + swarm: Option, + }, /// Get the latest version of the shared swarms (needed with sync off) Pull { /// Only this shared swarm (/) diff --git a/src/main.rs b/src/main.rs index 3ac5740..404fe58 100644 --- a/src/main.rs +++ b/src/main.rs @@ -36,6 +36,7 @@ pub mod modules { pub mod debug; pub mod lighthouse; pub mod deck; + pub mod documents; pub mod platform; pub mod swarm; } @@ -536,6 +537,9 @@ fn emit_dynamic_completions(kind: CompletionKind, partial: &str) { .and_then(|swarms| swarms.cached_ids()) .unwrap_or_default(), CompletionKind::SwarmUnits => selected_swarm_units(), + CompletionKind::LhConfigs => modules::lighthouse::configs::LhConfigs::open_cached() + .and_then(|configs| configs.cached_ids()) + .unwrap_or_default(), }; let stdout = std::io::stdout(); @@ -1442,17 +1446,21 @@ async fn run() -> Result<()> { } } Commands::Lh { command: LighthouseCommands::Config { command } } => { - // Files are loaded before connecting, so a bad one fails at once. + use modules::lighthouse::configs::{self, LhConfigs}; + // Files and stored configurations are loaded before connecting, + // so a bad one fails at once. match command { - LighthouseConfigCommands::Display(params) => match ¶ms.input { - Some(file_path) => modules::lighthouse::display_file(file_path, csv)?, - None => { + LighthouseConfigCommands::List => configs::list(&LhConfigs::open(&config)?, csv).await?, + LighthouseConfigCommands::Display(params) => match (¶ms.id, ¶ms.input) { + (Some(id), _) => configs::show(&LhConfigs::open(&config)?, id, csv).await?, + (None, Some(file_path)) => modules::lighthouse::display_file(file_path, csv)?, + (None, None) => { let cf = connect_cf(&mut connected_cf, &link_context, uri.as_str(), toc_cache, args.debug).await?; modules::lighthouse::display(cf, csv, non_interactive).await?; } }, LighthouseConfigCommands::Write(params) => { - let file = modules::lighthouse::load(params.input.as_deref())?; + let (file, _) = configs::source(&config, params.id.as_deref(), params.input.as_deref()).await?; let cf = connect_cf(&mut connected_cf, &link_context, uri.as_str(), toc_cache, args.debug).await?; modules::lighthouse::write(cf, &file, non_interactive).await?; } @@ -1461,10 +1469,44 @@ async fn run() -> Result<()> { modules::lighthouse::read(cf, params.output.as_deref(), non_interactive).await?; } LighthouseConfigCommands::Check(params) => { - let file = modules::lighthouse::load(params.input.as_deref())?; - let source = params.input.as_deref().unwrap_or("the configuration from stdin"); + let (file, source) = configs::source(&config, params.id.as_deref(), params.input.as_deref()).await?; + let cf = connect_cf(&mut connected_cf, &link_context, uri.as_str(), toc_cache, args.debug).await?; + modules::lighthouse::check(cf, &file, &source, csv, non_interactive).await?; + } + LighthouseConfigCommands::Save(params) => { + let lh_configs = LhConfigs::open(&config)?; let cf = connect_cf(&mut connected_cf, &link_context, uri.as_str(), toc_cache, args.debug).await?; - modules::lighthouse::check(cf, &file, source, csv, non_interactive).await?; + configs::save(&lh_configs, cf, ¶ms.id, params.name.as_deref(), params.force, non_interactive).await?; + } + LighthouseConfigCommands::Import(params) => { + configs::import( + &LhConfigs::open(&config)?, + ¶ms.file, + params.id.as_deref(), + params.name.as_deref(), + params.force, + non_interactive, + ) + .await? + } + LighthouseConfigCommands::Export(params) => { + configs::export(&LhConfigs::open(&config)?, ¶ms.id, params.output.as_deref()).await? + } + LighthouseConfigCommands::Delete { id } => { + configs::delete(&LhConfigs::open(&config)?, id.as_deref(), non_interactive).await? + } + LighthouseConfigCommands::Move { from, to } => { + configs::move_config(&LhConfigs::open(&config)?, from, to, non_interactive).await? + } + LighthouseConfigCommands::Pull { id, force } => { + let lh_configs = LhConfigs::open(&config)?; + let (shared, only) = lh_configs.for_sync(id.as_deref())?; + shared.pull(only.as_ref(), *force).await? + } + LighthouseConfigCommands::Push { id, force } => { + let lh_configs = LhConfigs::open(&config)?; + let (shared, only) = lh_configs.for_sync(id.as_deref())?; + shared.push(only.as_ref(), *force).await? } } } diff --git a/src/modules/documents.rs b/src/modules/documents.rs new file mode 100644 index 0000000..0e36dd4 --- /dev/null +++ b/src/modules/documents.rs @@ -0,0 +1,1298 @@ +//! Documents cfcli keeps: swarms and lighthouse configurations. Each kind is +//! a YAML file, kept locally in a folder next to the cfcli config (`swarms`, +//! `lighthouse`) and named ``, and, when cfcli is signed in (`cfcli auth +//! login`), also shared on the server, named `/`. Every command +//! takes either kind of ID. +//! +//! cfcli keeps a copy of each shared document it uses in +//! `synced////.yaml` next to the cfcli config. That +//! is outside the local folders, so Swarmkeeper and `import` never take a +//! copy for a local document. `synced//state.json` says which +//! revision each copy is and whether it has changes the server doesn't have +//! yet. +//! +//! With sync on (`cfcli settings sync`, the default) a command checks the +//! server before it uses a shared document, which costs a 304 when the copy +//! is current, and writes changes straight to the server. The upload carries +//! `If-Match` with the copy's revision, so nobody's upload is overwritten: +//! when someone else changed the document in between, the change is applied +//! again to their version. With sync off, commands use the copies only, and +//! `pull` and `push` sync them. +//! +//! When the server can't be reached, commands use the copies and keep their +//! changes for the next push, so working with the Crazyflies never waits for +//! the internet. +//! +//! The `` in an ID is the user's own ID for the organization, which they +//! can change on the server. `synced//orgs.json` remembers the ID of +//! each organization (the server lists their UUIDs, which never change), so +//! when one changes, the copies of every kind, the selected swarm and the +//! swarms' links to lighthouse configurations move to the new ID, and the +//! old ID says what the new one is. + +use std::collections::BTreeMap; +use std::fmt; +use std::marker::PhantomData; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use anyhow::{bail, Context, Result}; +use reqwest::{header, RequestBuilder, Response, StatusCode}; +use serde::{Deserialize, Serialize}; + +use super::swarm::store::Swarm; +use crate::error::CliError; +use crate::modules::auth::{self, Credentials}; +use crate::Config; + +/// One kind of document. +pub trait Document: Sized { + /// The local folder, the folder of the synced copies, the key in the + /// state file and the server's API path: `swarms`. + const FOLDER: &'static str; + /// What one is called in messages: `swarm`. + const NOUN: &'static str; + /// The commands that handle them, for hints: `swarm config`. + const COMMAND: &'static str; + /// The item count in the server's list: `units`. + const SERVER_COUNT: &'static str; + + fn from_yaml(yaml: &str) -> Result; + fn to_yaml(&self) -> Result; + /// The name (may be empty) and item count, for lists. + fn summary(&self) -> (String, usize); + + /// Change organization `old` to `new` in the documents this one names + /// (a swarm its lighthouse configuration). Returns whether it changed. + fn rename_org(&mut self, _old: &str, _new: &str) -> bool { + false + } +} + +/// IDs are file names: letters, digits, '-', '_' and '.', not first. +pub fn check_id(id: &str) -> Result<()> { + if !is_id(id) { + bail!(CliError::InvalidValue(format!( + "{} ID '{}': use letters, digits, '-', '_' and '.' (not first)", + D::NOUN, + id + ))); + } + Ok(()) +} + +fn is_id(id: &str) -> bool { + !id.is_empty() && !id.starts_with('.') && id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) +} + +// ---- Local documents ---- + +/// A folder of documents of one kind. +pub struct Store { + dir: PathBuf, + kind: PhantomData D>, +} + +impl Store { + /// The local documents, next to the cfcli config file. + pub fn open() -> Result { + let config = confy::get_configuration_file_path("cf-cli", None) + .context("could not find the cfcli config folder")?; + Ok(Store::at(config.with_file_name(D::FOLDER))) + } + + /// A store in another folder: the copies of shared documents, and tests. + pub fn at(dir: PathBuf) -> Self { + Store { dir, kind: PhantomData } + } + + pub fn dir(&self) -> &Path { + &self.dir + } + + fn path(&self, id: &str) -> PathBuf { + self.dir.join(format!("{}.yaml", id)) + } + + pub fn exists(&self, id: &str) -> bool { + self.path(id).is_file() + } + + /// The IDs of all stored documents, sorted. + pub fn ids(&self) -> Result> { + let entries = match std::fs::read_dir(&self.dir) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(e).with_context(|| format!("reading {}", self.dir.display())), + }; + let mut ids: Vec = entries + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path()) + .filter(|path| path.extension().is_some_and(|ext| ext == "yaml")) + .filter_map(|path| path.file_stem().map(|stem| stem.to_string_lossy().into_owned())) + .filter(|id| is_id(id)) + .collect(); + ids.sort(); + Ok(ids) + } + + /// The file exactly as stored. + pub fn read_raw(&self, id: &str) -> Result { + let path = self.path(id); + match std::fs::read_to_string(&path) { + Ok(yaml) => Ok(yaml), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => bail!(CliError::NotFound(format!( + "{} '{}' (see 'cfcli {} list')", + D::NOUN, + id, + D::COMMAND + ))), + Err(e) => Err(e).with_context(|| format!("reading {}", path.display())), + } + } + + pub fn load(&self, id: &str) -> Result { + D::from_yaml(&self.read_raw(id)?).with_context(|| format!("in {}", self.path(id).display())) + } + + /// Write a document, replacing any old file in one step. + pub fn save(&self, id: &str, document: &D) -> Result<()> { + check_id::(id)?; + std::fs::create_dir_all(&self.dir).with_context(|| format!("creating {}", self.dir.display()))?; + let path = self.path(id); + let tmp = self.dir.join(format!(".{}.yaml.tmp", id)); + std::fs::write(&tmp, document.to_yaml()?).with_context(|| format!("writing {}", tmp.display()))?; + std::fs::rename(&tmp, &path).with_context(|| format!("writing {}", path.display()))?; + Ok(()) + } + + pub fn delete(&self, id: &str) -> Result<()> { + let path = self.path(id); + match std::fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + bail!(CliError::NotFound(format!("{} '{}'", D::NOUN, id))) + } + Err(e) => Err(e).with_context(|| format!("deleting {}", path.display())), + } + } +} + +// ---- Shared documents ---- + +/// A shared document's ID, `/`. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct SharedId { + pub org: String, + pub name: String, +} + +impl SharedId { + /// `/`, or None for a local ID (no '/'). + pub fn parse(id: &str) -> Result> { + let Some((org, name)) = id.split_once('/') else { + return Ok(None); + }; + let org_ok = !org.is_empty() && org.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'); + if !org_ok { + bail!(CliError::InvalidValue(format!( + "ID '{}': the organization is lower-case letters, digits and '-'", + id + ))); + } + if !is_id(name) { + bail!(CliError::InvalidValue(format!( + "ID '{}': after the organization, use letters, digits, '-', '_' and '.' (not first)", + id + ))); + } + Ok(Some(SharedId { org: org.to_string(), name: name.to_string() })) + } +} + +impl fmt::Display for SharedId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}/{}", self.org, self.name) + } +} + +/// What cfcli knows about its copy of a shared document. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] +pub struct CopyState { + /// The server's revision the copy is based on. 0 for a document created + /// here while the server couldn't be reached, which isn't on it yet. + pub revision: i32, + /// The copy has changes the server doesn't have. + pub pending: bool, +} + +/// `state.json`: the copies of each kind, by `/`. +type State = BTreeMap>; + +/// A shared document as `list` shows it. +pub struct Listed { + pub id: SharedId, + pub name: String, + pub count: usize, + pub copy: CopyState, +} + +/// A document in the server's list. +struct ServerItem { + id: SharedId, + /// Never changes, unlike the user's ID for the organization (`id.org`). + /// Servers before per-user IDs don't send it. + org_uuid: Option, + name: String, + count: usize, + revision: i32, +} + +/// `orgs.json`: the user's IDs for their organizations. +#[derive(Debug, Default, Serialize, Deserialize)] +struct OrgIds { + /// The ID of each organization, by UUID, as the server last listed it. + #[serde(default)] + ids: BTreeMap, + /// Old IDs and what they became, for telling the user. + #[serde(default)] + renamed: BTreeMap, +} + +/// What an upload asks the server to check first. +#[derive(Clone, Copy)] +enum Condition { + /// Only if the document is still at this revision. + Revision(i32), + /// Only if the document doesn't exist. + New, + /// Overwrite whatever is there. + None, +} + +enum Upload { + Done(i32), + /// Someone else changed (or created, or deleted) the document first. + Conflict, + Offline, +} + +/// The shared documents of one kind on the server cfcli is signed in to. +pub struct Shared { + credentials: Credentials, + /// `synced/` next to the cfcli config. + dir: PathBuf, + sync: bool, + client: reqwest::Client, + /// The server didn't answer once; don't wait for it again in this command. + offline: AtomicBool, + kind: PhantomData D>, +} + +impl Shared { + /// None when cfcli isn't signed in. Doesn't contact the server. + pub fn open(sync: bool) -> Result> { + let Some(credentials) = auth::load()? else { + return Ok(None); + }; + let config = confy::get_configuration_file_path("cf-cli", None) + .context("could not find the cfcli config folder")?; + // "localhost:3000" would be an invalid folder name on Windows. + let host = auth::host(&credentials.server).replace([':', '/', '\\'], "_"); + let dir = config.with_file_name("synced").join(host); + move_old_swarm_copies(&dir); + let client = reqwest::Client::builder() + .user_agent(concat!("cfcli/", env!("CARGO_PKG_VERSION"))) + .connect_timeout(Duration::from_secs(3)) + .timeout(Duration::from_secs(15)) + .build()?; + Ok(Some(Shared { credentials, dir, sync, client, offline: AtomicBool::new(false), kind: PhantomData })) + } + + pub fn host(&self) -> &str { + auth::host(&self.credentials.server) + } + + pub fn sync(&self) -> bool { + self.sync + } + + // ---- The copies and their state ---- + + fn copies(&self, org: &str) -> Store { + Store::at(self.dir.join(D::FOLDER).join(org)) + } + + fn state_path(&self) -> PathBuf { + self.dir.join("state.json") + } + + fn state(&self) -> Result { + read_state(&self.state_path()) + } + + /// The copies of this kind. + fn copy_states(&self) -> Result> { + Ok(self.state()?.remove(D::FOLDER).unwrap_or_default()) + } + + fn save_state(&self, state: &State) -> Result<()> { + std::fs::create_dir_all(&self.dir).with_context(|| format!("creating {}", self.dir.display()))?; + let path = self.state_path(); + let tmp = self.dir.join(".state.json.tmp"); + std::fs::write(&tmp, serde_json::to_string_pretty(state)?).with_context(|| format!("writing {}", tmp.display()))?; + std::fs::rename(&tmp, &path).with_context(|| format!("writing {}", path.display())) + } + + pub fn copy_state(&self, id: &SharedId) -> Result> { + Ok(self.copy_states()?.get(&id.to_string()).copied()) + } + + fn set_copy_state(&self, id: &SharedId, copy: Option) -> Result<()> { + let mut state = self.state()?; + let copies = state.entry(D::FOLDER.to_string()).or_default(); + match copy { + Some(copy) => copies.insert(id.to_string(), copy), + None => copies.remove(&id.to_string()), + }; + self.save_state(&state) + } + + /// Keep `document` as the copy of `id`. + fn keep(&self, id: &SharedId, document: &D, copy: CopyState) -> Result<()> { + self.copies(&id.org).save(&id.name, document)?; + self.set_copy_state(id, Some(copy)) + } + + fn forget(&self, id: &SharedId) -> Result<()> { + let copies = self.copies(&id.org); + if copies.exists(&id.name) { + copies.delete(&id.name)?; + } + self.set_copy_state(id, None) + } + + fn has_copy(&self, id: &SharedId) -> bool { + self.copies(&id.org).exists(&id.name) + } + + /// The copy, without asking the server. For shell completion and the + /// like, which must never wait for the network. + pub fn cached(&self, id: &SharedId) -> Result { + if !self.has_copy(id) { + if let Some(now) = self.renamed(id)? { + bail!(renamed_error::(id, &now)); + } + bail!(CliError::NotFound(format!("a copy of {} '{}' on this computer", D::NOUN, id))); + } + self.copies(&id.org).load(&id.name) + } + + /// The shared documents there are copies of, without asking the server. + pub fn cached_ids(&self) -> Vec { + self.copy_states() + .map(|copies| copies.keys().filter_map(|id| SharedId::parse(id).ok().flatten()).collect()) + .unwrap_or_default() + } + + // ---- Organization IDs ---- + + fn org_ids(&self) -> Result { + let path = self.dir.join("orgs.json"); + match std::fs::read_to_string(&path) { + Ok(text) => serde_json::from_str(&text).with_context(|| format!("reading {}", path.display())), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(OrgIds::default()), + Err(e) => Err(e).with_context(|| format!("reading {}", path.display())), + } + } + + fn save_org_ids(&self, orgs: &OrgIds) -> Result<()> { + std::fs::create_dir_all(&self.dir).with_context(|| format!("creating {}", self.dir.display()))?; + let path = self.dir.join("orgs.json"); + std::fs::write(&path, serde_json::to_string_pretty(orgs)?).with_context(|| format!("writing {}", path.display())) + } + + /// What `id` is called now, if the user changed the ID of its + /// organization. Also selects the swarm under the new ID, if the + /// selected one is in that organization. + fn renamed(&self, id: &SharedId) -> Result> { + let Some(org) = self.org_ids()?.renamed.get(&id.org).cloned() else { + return Ok(None); + }; + let now = SharedId { org, name: id.name.clone() }; + select_renamed(&[(id.org.clone(), now.org.clone())]); + Ok(Some(now)) + } + + /// Follow the organizations whose ID the user changed since the last + /// list: the copies of every kind move to the new ID, and so do the + /// selected swarm and the swarms' links to lighthouse configurations. + fn follow_renames(&self, server: &[ServerItem]) -> Result<()> { + let mut orgs = self.org_ids()?; + let before = serde_json::to_string(&orgs)?; + let mut renames: Vec<(String, String)> = Vec::new(); + for item in server { + let Some(uuid) = &item.org_uuid else { continue }; + if let Some(old) = orgs.ids.insert(uuid.clone(), item.id.org.clone()) { + if old != item.id.org && !renames.contains(&(old.clone(), item.id.org.clone())) { + renames.push((old, item.id.org.clone())); + } + } + } + for (old, new) in &renames { + for to in orgs.renamed.values_mut().filter(|to| *to == old) { + *to = new.clone(); + } + orgs.renamed.insert(old.clone(), new.clone()); + } + // An ID in use is no longer an old one. + for item in server { + orgs.renamed.remove(&item.id.org); + } + if serde_json::to_string(&orgs)? != before { + self.save_org_ids(&orgs)?; + } + if renames.is_empty() { + return Ok(()); + } + + // Take all the moving copies out first, so that two organizations + // can swap IDs. The copies of every kind move, not only this one's. + let mut state = self.state()?; + let mut moving = Vec::new(); + for (kind, copies) in state.iter_mut() { + for (old, new) in &renames { + let ids: Vec = copies + .keys() + .filter_map(|key| SharedId::parse(key).ok().flatten()) + .filter(|id| id.org == *old) + .collect(); + for id in ids { + let copy = copies.remove(&id.to_string()).unwrap_or_default(); + let path = self.dir.join(kind).join(old).join(format!("{}.yaml", id.name)); + let yaml = match std::fs::read_to_string(&path) { + Ok(yaml) => { + std::fs::remove_file(&path).with_context(|| format!("moving {}", path.display()))?; + Some(yaml) + } + Err(_) => None, + }; + moving.push((kind.clone(), SharedId { org: new.clone(), name: id.name }, copy, yaml)); + } + } + } + for (kind, id, copy, yaml) in moving { + if let Some(yaml) = yaml { + let dir = self.dir.join(&kind).join(&id.org); + std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?; + let path = dir.join(format!("{}.yaml", id.name)); + std::fs::write(&path, yaml).with_context(|| format!("writing {}", path.display()))?; + } + state.entry(kind).or_default().insert(id.to_string(), copy); + } + self.save_state(&state)?; + for kind in state.keys() { + for (old, _) in &renames { + // Only goes when empty. + let _ = std::fs::remove_dir(self.dir.join(kind).join(old)); + } + } + + // Swarms name their lighthouse configuration with the organization's + // ID: the local ones and the copies. (The server sends the copies + // with the new ID anyway; this keeps those not synced yet right.) + let mut stores = vec![Store::::open()?]; + for key in state.get(Swarm::FOLDER).into_iter().flat_map(|copies| copies.keys()) { + if let Some(id) = SharedId::parse(key).ok().flatten() { + let store = Store::::at(self.dir.join(Swarm::FOLDER).join(&id.org)); + if !stores.iter().any(|s| s.dir() == store.dir()) { + stores.push(store); + } + } + } + for store in &stores { + for id in store.ids()? { + let Ok(mut swarm) = store.load(&id) else { continue }; + let mut changed = false; + for (old, new) in &renames { + changed |= swarm.rename_org(old, new); + } + if changed { + store.save(&id, &swarm)?; + } + } + } + + select_renamed(&renames); + for (old, new) in &renames { + println!( + "Your organization '{}' on {} is now called '{}': its swarms and lighthouse configs are '{}/'", + old, + self.host(), + new, + new + ); + } + Ok(()) + } + + // ---- Talking to the server ---- + + fn url(&self, id: &SharedId) -> Result { + auth::endpoint(&self.credentials.server, &format!("/api/v1/{}/{}/{}", D::FOLDER, id.org, id.name)) + } + + /// Send a request with the key. None when the server can't be reached; + /// the first time, cfcli says it uses the copies instead. + async fn send(&self, request: RequestBuilder) -> Result> { + self.send_quietly(request, false).await + } + + async fn send_quietly(&self, request: RequestBuilder, quiet: bool) -> Result> { + if self.offline.load(Ordering::Relaxed) { + return Ok(None); + } + match request.bearer_auth(&self.credentials.key).send().await { + Ok(response) if response.status() == StatusCode::UNAUTHORIZED => bail!(CliError::NotFound(format!( + "a working key: {} no longer accepts '{}'; sign in again with 'cfcli auth login'", + self.host(), + self.credentials.key_name + ))), + Ok(response) => Ok(Some(response)), + Err(e) if e.is_connect() || e.is_timeout() => { + self.offline.store(true, Ordering::Relaxed); + if !quiet { + eprintln!( + "Can't reach {}: using the copies of shared {}s on this computer. \ + Changes are kept and uploaded {}.", + self.host(), + D::NOUN, + if self.sync { + "when it answers again".to_string() + } else { + format!("with 'cfcli {} push'", D::COMMAND) + } + ); + } + Ok(None) + } + Err(e) => Err(e).with_context(|| CliError::Connection(self.host().to_string())), + } + } + + /// Like [`Shared::send`], for things that can't be done without the server. + async fn send_online(&self, request: RequestBuilder, what: &str) -> Result { + match self.send_quietly(request, true).await? { + Some(response) => Ok(response), + None => bail!(CliError::Connection(format!("{} needs {}, which can't be reached", what, self.host()))), + } + } + + /// The documents on the server, in all the user's organizations. + async fn server_list(&self) -> Result>> { + let url = auth::endpoint(&self.credentials.server, &format!("/api/v1/{}", D::FOLDER))?; + let Some(response) = self.send(self.client.get(url)).await? else { + return Ok(None); + }; + let items: Vec> = + auth::answer(&self.credentials.server, response).await?; + let mut listed = Vec::new(); + for item in items { + let text = |key: &str| item.get(key).and_then(|v| v.as_str()).unwrap_or_default().to_string(); + let Some(id) = SharedId::parse(&text("id")).ok().flatten() else { + continue; + }; + let number = |key: &str| item.get(key).and_then(|v| v.as_u64()).unwrap_or_default(); + listed.push(ServerItem { + id, + org_uuid: Some(text("org_uuid")).filter(|uuid| !uuid.is_empty()), + name: text("name"), + count: number(D::SERVER_COUNT) as usize, + revision: number("revision") as i32, + }); + } + self.follow_renames(&listed)?; + Ok(Some(listed)) + } + + /// After the server answered 404 for `id`: fail saying what it is called + /// now, if the user changed the ID of its organization. Gets the list, + /// which moves the copies. + async fn fail_if_renamed(&self, id: &SharedId) -> Result<()> { + self.server_list().await?; + match self.renamed(id)? { + Some(now) => bail!(renamed_error::(id, &now)), + None => Ok(()), + } + } + + /// Download `id` into its copy unless the copy is current. Returns false + /// when the server can't be reached. + async fn download(&self, id: &SharedId, force: bool) -> Result { + let copy = self.copy_state(id)?.unwrap_or_default(); + let mut request = self.client.get(self.url(id)?); + if !force && copy.revision > 0 && self.has_copy(id) { + request = request.header(header::IF_NONE_MATCH, format!("\"{}\"", copy.revision)); + } + let Some(response) = self.send(request).await? else { + return Ok(false); + }; + match response.status() { + StatusCode::NOT_MODIFIED => Ok(true), + StatusCode::NOT_FOUND => { + self.fail_if_renamed(id).await?; + // Deleted on the server, or no longer visible to the user. + if copy.pending { + bail!(CliError::NotFound(format!( + "{} '{}' on {}, though this computer has changes to it that aren't uploaded; \ + 'cfcli {} push --force {}' creates it again with them", + D::NOUN, + id, + self.host(), + D::COMMAND, + id + ))); + } + self.forget(id)?; + bail!(CliError::NotFound(format!("{} '{}' on {}", D::NOUN, id, self.host()))) + } + status if status.is_success() => { + let revision = etag_revision(&response)?; + let yaml = response.text().await?; + let document = D::from_yaml(&yaml).with_context(|| format!("{} '{}' from {}", D::NOUN, id, self.host()))?; + self.keep(id, &document, CopyState { revision, pending: false })?; + Ok(true) + } + _ => auth::answer::(&self.credentials.server, response).await.map(|_| true), + } + } + + async fn upload(&self, id: &SharedId, document: &D, condition: Condition) -> Result { + let mut request = self + .client + .put(self.url(id)?) + .header(header::CONTENT_TYPE, "application/yaml") + .body(document.to_yaml()?); + request = match condition { + Condition::Revision(revision) => request.header(header::IF_MATCH, format!("\"{}\"", revision)), + Condition::New => request.header(header::IF_NONE_MATCH, "*"), + Condition::None => request, + }; + let Some(response) = self.send(request).await? else { + return Ok(Upload::Offline); + }; + if response.status() == StatusCode::PRECONDITION_FAILED { + return Ok(Upload::Conflict); + } + if response.status() == StatusCode::NOT_FOUND { + self.fail_if_renamed(id).await?; + } + #[derive(Deserialize)] + struct Uploaded { + revision: i32, + } + let uploaded: Uploaded = auth::answer(&self.credentials.server, response).await?; + Ok(Upload::Done(uploaded.revision)) + } + + // ---- What the commands do ---- + + /// Upload the copy of `id` if it has changes. Fails on a conflict, saying + /// how to settle it. Returns false when the server can't be reached. + async fn push_pending(&self, id: &SharedId, force: bool) -> Result { + let Some(copy) = self.copy_state(id)? else { + return Ok(true); + }; + if !copy.pending { + return Ok(true); + } + let document = self.copies(&id.org).load(&id.name)?; + let condition = match (force, copy.revision) { + (true, _) => Condition::None, + (false, 0) => Condition::New, + (false, revision) => Condition::Revision(revision), + }; + match self.upload(id, &document, condition).await? { + Upload::Done(revision) => { + self.set_copy_state(id, Some(CopyState { revision, pending: false }))?; + Ok(true) + } + Upload::Offline => Ok(false), + Upload::Conflict => bail!(CliError::InvalidValue(format!( + "{} '{}' changed on {} since this computer's copy ({}). Keep theirs with \ + 'cfcli {} pull --force {}', or yours with 'cfcli {} push --force {}'", + D::NOUN, + id, + self.host(), + match copy.revision { + 0 => "someone created it first".to_string(), + revision => format!("revision {}", revision), + }, + D::COMMAND, + id, + D::COMMAND, + id + ))), + } + } + + /// The document, checked with the server first when sync is on (or when + /// there's no copy yet). + pub async fn load(&self, id: &SharedId) -> Result { + if (self.sync || !self.has_copy(id)) && self.push_pending(id, false).await? { + self.download(id, false).await?; + } + self.cached(id) + } + + /// Change the document. `change` may run more than once: when someone + /// else uploaded in between, it runs again on their version. With sync + /// off, or without the server, it changes the copy and keeps it for a + /// push. + pub async fn change(&self, id: &SharedId, mut change: impl FnMut(&mut D) -> Result) -> Result { + let mut online = false; + if self.sync || !self.has_copy(id) { + online = self.push_pending(id, false).await? && self.download(id, false).await?; + } + if online { + for _ in 0..3 { + let mut document = self.cached(id)?; + let before = document.to_yaml()?; + let revision = self.copy_state(id)?.unwrap_or_default().revision; + let result = change(&mut document)?; + if document.to_yaml()? == before { + return Ok(result); + } + match self.upload(id, &document, Condition::Revision(revision)).await? { + Upload::Done(revision) => { + self.keep(id, &document, CopyState { revision, pending: false })?; + return Ok(result); + } + // Someone else was first: their version, then the change again. + Upload::Conflict => { + self.download(id, true).await?; + } + Upload::Offline => break, + } + } + } + let mut document = self.cached(id)?; + let before = document.to_yaml()?; + let result = change(&mut document)?; + if document.to_yaml()? == before { + return Ok(result); + } + let revision = self.copy_state(id)?.unwrap_or_default().revision; + self.keep(id, &document, CopyState { revision, pending: true })?; + if !self.sync { + println!("Changed this computer's copy of '{}'; 'cfcli {} push' uploads it", id, D::COMMAND); + } + Ok(result) + } + + /// Create the document on the server. Returns false when it already + /// exists. Without the server, the copy waits for a push. + pub async fn create(&self, id: &SharedId, document: &D) -> Result { + if self.has_copy(id) && self.copy_state(id)?.is_some_and(|c| c.pending) { + return Ok(false); + } + match self.upload(id, document, Condition::New).await? { + Upload::Done(revision) => self.keep(id, document, CopyState { revision, pending: false })?, + Upload::Conflict => return Ok(false), + Upload::Offline => { + self.keep(id, document, CopyState { revision: 0, pending: true })?; + println!("Created '{}' on this computer; 'cfcli {} push' uploads it", id, D::COMMAND); + } + } + Ok(true) + } + + /// Delete the document on the server, and the copy. Needs the server, + /// unless the document never got there. + pub async fn delete(&self, id: &SharedId) -> Result<()> { + let copy = self.copy_state(id)?; + if copy.is_some_and(|c| c.revision == 0) { + return self.forget(id); + } + let mut request = self.client.delete(self.url(id)?); + // With sync off, the user decided on the copy they have: don't delete + // what someone uploaded since. With sync on, the copy is just the + // last one used, so the document goes whatever its revision. + if let Some(copy) = copy.filter(|_| !self.sync) { + request = request.header(header::IF_MATCH, format!("\"{}\"", copy.revision)); + } + let response = self.send_online(request, &format!("deleting a shared {}", D::NOUN)).await?; + match response.status() { + status if status.is_success() => self.forget(id), + StatusCode::NOT_FOUND => { + self.fail_if_renamed(id).await?; + self.forget(id)?; + bail!(CliError::NotFound(format!("{} '{}' on {}", D::NOUN, id, self.host()))) + } + StatusCode::PRECONDITION_FAILED => bail!(CliError::InvalidValue(format!( + "{} '{}' changed on {} since this computer's copy; \ + 'cfcli {} pull --force {}' gets the new version", + D::NOUN, + id, + self.host(), + D::COMMAND, + id + ))), + _ => auth::answer::(&self.credentials.server, response).await.map(|_| ()), + } + } + + /// The shared documents: from the server when sync is on (and it + /// answers), else from the copies. Documents created here and not + /// uploaded are included either way. + pub async fn list(&self) -> Result> { + let server = if self.sync { self.server_list().await? } else { None }; + // After the list, which moves the copies of renamed organizations. + let copies = self.copy_states()?; + let mut listed = Vec::new(); + match server { + Some(server) => { + for item in server { + let copy = copies.get(&item.id.to_string()).copied().unwrap_or(CopyState { + revision: item.revision, + pending: false, + }); + listed.push(Listed { id: item.id, name: item.name, count: item.count, copy }); + } + // Copies of documents gone from the server go too, unless + // they have changes to upload. + for (key, copy) in &copies { + let Some(id) = SharedId::parse(key)? else { continue }; + if listed.iter().any(|l| l.id == id) { + continue; + } + if copy.pending { + listed.push(self.listed_copy(id, *copy)); + } else { + self.forget(&id)?; + } + } + } + None => { + for (key, copy) in &copies { + if let Some(id) = SharedId::parse(key)? { + listed.push(self.listed_copy(id, *copy)); + } + } + } + } + listed.sort_by(|a, b| a.id.cmp(&b.id)); + Ok(listed) + } + + fn listed_copy(&self, id: SharedId, copy: CopyState) -> Listed { + let (name, count) = match self.cached(&id) { + Ok(document) => document.summary(), + Err(e) => (format!("(can't read: {:#})", e), 0), + }; + Listed { id, name, count, copy } + } + + /// Get the latest version of every shared document (or of one). Copies + /// with changes that aren't uploaded are kept, unless `force`. + pub async fn pull(&self, only: Option<&SharedId>, force: bool) -> Result<()> { + let Some(server) = self.server_list().await? else { + bail!(CliError::Connection(format!("{} can't be reached", self.host()))); + }; + let on_server: Vec = server.into_iter().map(|item| item.id).collect(); + if let Some(only) = only { + if let Some(now) = self.renamed(only)? { + bail!(renamed_error::(only, &now)); + } + if !on_server.contains(only) { + bail!(CliError::NotFound(format!("{} '{}' on {}", D::NOUN, only, self.host()))); + } + } + let mut kept = 0; + for id in on_server.iter().filter(|id| only.is_none_or(|only| *id == only)) { + let copy = self.copy_state(id)?; + if copy.is_some_and(|c| c.pending) && !force { + println!("Kept '{}': it has changes that aren't uploaded", id); + kept += 1; + continue; + } + let before = copy.map(|c| c.revision); + self.download(id, force).await?; + let after = self.copy_state(id)?.map(|c| c.revision); + if before != after { + println!("Pulled '{}' (revision {})", id, after.unwrap_or_default()); + } + } + // Copies of documents deleted on the server. + for id in self.cached_ids() { + if on_server.contains(&id) || only.is_some_and(|only| *only != id) { + continue; + } + let pending = self.copy_state(&id)?.is_some_and(|c| c.pending); + if pending && !force { + println!("Kept '{}': it is gone from {}, but has changes that aren't uploaded", id, self.host()); + kept += 1; + } else { + self.forget(&id)?; + println!("Removed '{}': it is gone from {}", id, self.host()); + } + } + if kept > 0 { + println!( + "Upload kept changes with 'cfcli {} push', or drop them with 'pull --force'", + D::COMMAND + ); + } + Ok(()) + } + + /// Upload the changes made while sync was off or the server was out of + /// reach. `force` overwrites what others uploaded in between. + pub async fn push(&self, only: Option<&SharedId>, force: bool) -> Result<()> { + // Copies of organizations with a new ID move to it first. + self.server_list().await?; + if let Some(only) = only { + if let Some(now) = self.renamed(only)? { + bail!(renamed_error::(only, &now)); + } + } + let pending: Vec = self + .copy_states()? + .iter() + .filter(|(_, copy)| copy.pending) + .filter_map(|(id, _)| SharedId::parse(id).ok().flatten()) + .filter(|id| only.is_none_or(|only| id == only)) + .collect(); + if pending.is_empty() { + println!("Nothing to push"); + return Ok(()); + } + let mut failed = Vec::new(); + for id in &pending { + match self.push_pending(id, force).await { + Ok(true) => println!( + "Pushed '{}' (revision {})", + id, + self.copy_state(id)?.unwrap_or_default().revision + ), + Ok(false) => bail!(CliError::Connection(format!("{} can't be reached", self.host()))), + Err(e) => { + match e.downcast_ref::() { + Some(CliError::InvalidValue(message)) => println!("{}", message), + _ => println!("{:#}", e), + } + failed.push(id.to_string()); + } + } + } + if !failed.is_empty() { + bail!(CliError::InvalidValue(format!("not pushed: {}", failed.join(", ")))); + } + Ok(()) + } +} + +fn renamed_error(id: &SharedId, now: &SharedId) -> CliError { + CliError::NotFound(format!( + "{} '{}' (your organization '{}' is now called '{}'; use '{}')", + D::NOUN, + id, + id.org, + now.org, + now + )) +} + +/// Select the swarm under its organization's new ID, if one of `renames` +/// (old, new) changed the selected swarm's. +fn select_renamed(renames: &[(String, String)]) { + let Ok(mut config) = confy::load::("cf-cli", None) else { + return; + }; + let Some(selected) = config.swarm.as_deref().and_then(|id| SharedId::parse(id).ok().flatten()) else { + return; + }; + if let Some((_, new)) = renames.iter().find(|(old, _)| *old == selected.org) { + let now = SharedId { org: new.clone(), name: selected.name }; + config.swarm = Some(now.to_string()); + if confy::store("cf-cli", None, config).is_ok() { + println!("The selected swarm is '{}' now", now); + } + } +} + +fn read_state(path: &Path) -> Result { + match std::fs::read_to_string(path) { + Ok(text) => serde_json::from_str(&text).with_context(|| format!("reading {}", path.display())), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(State::default()), + Err(e) => Err(e).with_context(|| format!("reading {}", path.display())), + } +} + +/// cfcli kept the copies of shared swarms in `synced///` +/// before there were other kinds of documents; move them to +/// `synced//swarms//`. +fn move_old_swarm_copies(dir: &Path) { + let swarms = dir.join("swarms"); + if swarms.exists() { + return; + } + let Ok(mut state) = read_state(&dir.join("state.json")) else { + return; + }; + let orgs: std::collections::BTreeSet = state + .remove("swarms") + .unwrap_or_default() + .keys() + .filter_map(|id| id.split_once('/').map(|(org, _)| org.to_string())) + .collect(); + for org in orgs { + let old = dir.join(&org); + if old.is_dir() && std::fs::create_dir_all(&swarms).is_ok() { + let _ = std::fs::rename(&old, swarms.join(&org)); + } + } +} + +/// The revision in a response's ETag, `"N"`. +fn etag_revision(response: &Response) -> Result { + response + .headers() + .get(header::ETAG) + .and_then(|etag| etag.to_str().ok()) + .and_then(|etag| etag.trim_start_matches("W/").trim_matches('"').parse().ok()) + .context("the server sent a document without its revision") +} + +// ---- Both behind one kind of ID ---- + +/// Local and shared documents of one kind behind one kind of ID: `` is +/// a file in the local folder, `/` a document on the server. +pub struct Documents { + pub local: Store, + /// None when cfcli isn't signed in. + pub shared: Option>, +} + +impl Documents { + pub fn open(config: &Config) -> Result { + Ok(Documents { local: Store::open()?, shared: Shared::open(config.sync_on())? }) + } + + /// For shell completion: only [`Documents::cached`] and + /// [`Documents::cached_ids`] are used, which never ask the server. + pub fn open_cached() -> Result { + Ok(Documents { local: Store::open()?, shared: Shared::open(false)? }) + } + + pub fn shared(&self, id: &SharedId) -> Result<&Shared> { + self.shared.as_ref().ok_or_else(|| { + CliError::NotFound(format!( + "a sign-in: '{}' is a shared {}, sign in with 'cfcli auth login'", + id, + D::NOUN + )) + .into() + }) + } + + pub async fn load(&self, id: &str) -> Result { + match SharedId::parse(id)? { + None => self.local.load(id), + Some(shared) => self.shared(&shared)?.load(&shared).await, + } + } + + /// The document without asking the server (a shared one's copy), for + /// shell completion and pickers. + pub fn cached(&self, id: &str) -> Result { + match SharedId::parse(id)? { + None => self.local.load(id), + Some(shared) => self.shared(&shared)?.cached(&shared), + } + } + + /// Change a document. `change` may run more than once for a shared one + /// (see [`Shared::change`]), so it must not ask the user anything. + pub async fn change(&self, id: &str, mut change: impl FnMut(&mut D) -> Result) -> Result { + match SharedId::parse(id)? { + None => { + let mut document = self.local.load(id)?; + let before = document.to_yaml()?; + let result = change(&mut document)?; + if document.to_yaml()? != before { + self.local.save(id, &document)?; + } + Ok(result) + } + Some(shared) => self.shared(&shared)?.change(&shared, change).await, + } + } + + /// Create a document. A shared one is created on the server. + pub async fn create(&self, id: &str, document: &D) -> Result<()> { + match SharedId::parse(id)? { + None => { + check_id::(id)?; + if self.local.exists(id) { + bail!(CliError::InvalidValue(format!("{} '{}' already exists", D::NOUN, id))); + } + self.local.save(id, document) + } + Some(shared_id) => { + let shared = self.shared(&shared_id)?; + if !shared.create(&shared_id, document).await? { + bail!(CliError::InvalidValue(format!( + "{} '{}' already exists on {}", + D::NOUN, + id, + shared.host() + ))); + } + Ok(()) + } + } + } + + /// Delete a document. A shared one is deleted on the server, for everyone. + pub async fn delete(&self, id: &str) -> Result<()> { + match SharedId::parse(id)? { + None => self.local.delete(id), + Some(shared) => self.shared(&shared)?.delete(&shared).await, + } + } + + /// Local documents and the shared ones there are copies of, without + /// asking the server. + pub fn cached_ids(&self) -> Result> { + let mut ids = self.local.ids()?; + if let Some(shared) = &self.shared { + ids.extend(shared.cached_ids().into_iter().map(|id| id.to_string())); + } + Ok(ids) + } + + /// Where a document is kept, for the user. + pub fn place(&self, id: &str) -> String { + match (&self.shared, SharedId::parse(id)) { + (Some(shared), Ok(Some(_))) => shared.host().to_string(), + _ => "this computer".to_string(), + } + } + + /// The server for `pull`/`push`, and the one shared document to sync if + /// given. + pub fn for_sync(&self, id: Option<&str>) -> Result<(&Shared, Option)> { + let Some(shared) = &self.shared else { + bail!(CliError::NotFound(format!( + "a sign-in: shared {}s need 'cfcli auth login'", + D::NOUN + ))); + }; + let only = match id { + None => None, + Some(id) => Some(SharedId::parse(id)?.ok_or_else(|| { + CliError::InvalidValue(format!( + "'{}' is a local {}; only shared ones (/) are pulled and pushed", + id, + D::NOUN + )) + })?), + }; + Ok((shared, only)) + } +} + +/// A document as `list` and the pickers show it. +pub struct Entry { + pub id: String, + pub name: String, + /// The item count, "?" when the file can't be read. + pub count: String, + /// Where it is kept: "this computer" or the server. + pub place: String, + pub revision: Option, + pub pending: bool, +} + +impl Entry { + pub fn stored(&self) -> String { + match self.revision { + None => self.place.clone(), + Some(0) => format!("{}, not uploaded yet", self.place), + Some(revision) if self.pending => format!("{}, revision {}, changes not pushed", self.place, revision), + Some(revision) => format!("{}, revision {}", self.place, revision), + } + } +} + +impl Documents { + /// The local documents, then the shared ones. + pub async fn entries(&self) -> Result> { + let mut entries: Vec = self + .local + .ids()? + .into_iter() + .map(|id| { + let (name, count) = match self.local.load(&id) { + Ok(document) => { + let (name, count) = document.summary(); + (name, count.to_string()) + } + Err(e) => (format!("(can't read: {:#})", e), "?".to_string()), + }; + Entry { id, name, count, place: "this computer".to_string(), revision: None, pending: false } + }) + .collect(); + if let Some(shared) = &self.shared { + for listed in shared.list().await? { + entries.push(Entry { + id: listed.id.to_string(), + name: listed.name, + count: listed.count.to_string(), + place: shared.host().to_string(), + revision: Some(listed.copy.revision), + pending: listed.copy.pending, + }); + } + } + Ok(entries) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_shared_and_local_ids() { + assert_eq!(SharedId::parse("lab").unwrap(), None); + let id = SharedId::parse("bitcraze-lab/cage").unwrap().unwrap(); + assert_eq!((id.org.as_str(), id.name.as_str()), ("bitcraze-lab", "cage")); + assert_eq!(id.to_string(), "bitcraze-lab/cage"); + for bad in ["Lab/cage", "/cage", "lab/", "lab/a/b", "lab/.x", "la b/x"] { + assert!(SharedId::parse(bad).is_err(), "{}", bad); + } + } + + #[test] + fn moves_old_swarm_copies() { + let dir = std::env::temp_dir().join(format!("cfcli-docs-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("lab")).unwrap(); + std::fs::write(dir.join("lab").join("cage.yaml"), "name: x\nunits: []\n").unwrap(); + std::fs::write(dir.join("state.json"), r#"{"swarms": {"lab/cage": {"revision": 3, "pending": true}}}"#).unwrap(); + move_old_swarm_copies(&dir); + assert!(dir.join("swarms").join("lab").join("cage.yaml").is_file()); + assert!(!dir.join("lab").exists()); + // The state file has the same shape as before. + let state = read_state(&dir.join("state.json")).unwrap(); + assert_eq!(state["swarms"]["lab/cage"].revision, 3); + // Only once. + std::fs::create_dir_all(dir.join("lab")).unwrap(); + move_old_swarm_copies(&dir); + assert!(dir.join("lab").exists()); + std::fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/src/modules/lighthouse/configs.rs b/src/modules/lighthouse/configs.rs new file mode 100644 index 0000000..2f2dad9 --- /dev/null +++ b/src/modules/lighthouse/configs.rs @@ -0,0 +1,483 @@ +//! Stored lighthouse configurations: `lh config list`, `save`, `import`, +//! `export`, `delete`, `move`, `pull` and `push`, and the configuration +//! that `write` and `check` use. +//! +//! Like swarms, local configurations are files in a `lighthouse` folder next +//! to the cfcli config, named ``, and shared ones are on the server, +//! named `/` (see [`crate::modules::documents`]). The files are +//! the Crazyflie client's format, so `export` gives a file it opens. A file +//! may also have a `name`, which the client ignores. + +use anyhow::{anyhow, bail, Context, Result}; +use crazyflie_lib::Crazyflie; +use inquire::{Confirm, Select}; +use tabled::Tabled; + +use super::{compare, describe_distance, CalibrationDelta, LighthouseConfigFile, Part}; +use crate::error::CliError; +use crate::modules::documents::{Document, Documents, SharedId}; +use crate::modules::swarm::Swarms; +use crate::utils::display::{csv_row, print_table, table}; +use crate::Config; + +impl Document for LighthouseConfigFile { + const FOLDER: &'static str = "lighthouse"; + const NOUN: &'static str = "lighthouse config"; + const COMMAND: &'static str = "lh config"; + const SERVER_COUNT: &'static str = "base_stations"; + + fn from_yaml(yaml: &str) -> Result { + LighthouseConfigFile::from_yaml(yaml) + } + + fn to_yaml(&self) -> Result { + LighthouseConfigFile::to_yaml(self) + } + + /// The base stations with a position: a Crazyflie also keeps the + /// calibration of base stations it has seen elsewhere. + fn summary(&self) -> (String, usize) { + (self.name().unwrap_or_default().to_string(), self.geos.len()) + } +} + +impl LighthouseConfigFile { + /// The `name` in the file, if it has one. + pub fn name(&self) -> Option<&str> { + self.extra.get("name").and_then(|v| v.as_str()).filter(|name| !name.trim().is_empty()) + } + + pub fn set_name(&mut self, name: &str) { + self.extra.insert("name".into(), name.into()); + } +} + +/// Local and shared lighthouse configurations. +pub(crate) type LhConfigs = Documents; + +fn base_stations(n: usize) -> String { + match n { + 1 => "1 base station".to_string(), + n => format!("{} base stations", n), + } +} + +/// A stored configuration for the user: `'lab/cage' (revision 3)`. +fn describe(configs: &LhConfigs, id: &str) -> String { + let revision = SharedId::parse(id) + .ok() + .flatten() + .and_then(|shared| configs.shared(&shared).ok()?.copy_state(&shared).ok()?) + .map(|copy| copy.revision) + .filter(|revision| *revision > 0); + match revision { + Some(revision) => format!("'{}' (revision {})", id, revision), + None => format!("'{}'", id), + } +} + +// ---- Which configuration a command uses ---- + +/// The configuration for `write` and `check`, and how to name it: a stored +/// one (`id`), a file (`input`), what is piped in, or else the one the +/// selected swarm names. +pub async fn source(config: &Config, id: Option<&str>, input: Option<&str>) -> Result<(LighthouseConfigFile, String)> { + use std::io::IsTerminal; + if let Some(id) = id { + let configs = LhConfigs::open(config)?; + return Ok((configs.load(id).await?, describe(&configs, id))); + } + if input.is_some() || !std::io::stdin().is_terminal() { + let file = super::load(input)?; + return Ok((file, input.unwrap_or("the configuration from stdin").to_string())); + } + let linked = match &config.swarm { + Some(swarm) => Swarms::open(config)?.load(swarm).await?.lighthouse, + None => None, + }; + match linked { + Some(id) => { + let configs = LhConfigs::open(config)?; + let file = configs.load(&id).await?; + let label = describe(&configs, &id); + println!("Using lighthouse config {}, which the selected swarm flies in", label); + Ok((file, label)) + } + None => bail!(CliError::MissingArg( + "a lighthouse configuration: give a stored one's ID, a file with -i, or pipe one in \ + (the selected swarm names none)" + .to_string() + )), + } +} + +// ---- Listing and showing ---- + +/// One row of `lh config list`. +#[derive(Tabled)] +struct ConfigRow { + #[tabled(rename = "ID")] + id: String, + #[tabled(rename = "Name")] + name: String, + #[tabled(rename = "Base stations")] + count: String, + #[tabled(rename = "Stored")] + stored: String, +} + +pub async fn list(configs: &LhConfigs, csv: bool) -> Result<()> { + let entries = configs.entries().await?; + if csv { + csv_row(&["id", "name", "base_stations", "stored", "revision", "not_pushed"]); + for entry in &entries { + let revision = entry.revision.map(|r| r.to_string()).unwrap_or_default(); + csv_row(&[ + &entry.id, + &entry.name, + &entry.count, + &entry.place, + &revision, + if entry.pending { "yes" } else { "no" }, + ]); + } + } else if entries.is_empty() { + println!( + "No lighthouse configs yet. Store a Crazyflie's with 'cfcli lh config save ', or \ + import a file from the Crazyflie client with 'cfcli lh config import '." + ); + } else { + let rows: Vec = entries + .iter() + .map(|entry| ConfigRow { + id: entry.id.clone(), + name: entry.name.clone(), + count: entry.count.clone(), + stored: entry.stored(), + }) + .collect(); + print_table(&table(&rows)); + if configs.shared.as_ref().is_some_and(|shared| !shared.sync()) && entries.iter().any(|e| e.revision.is_some()) { + println!("Sync is off: shared configs are this computer's copies ('cfcli lh config pull' gets the latest)"); + } + } + Ok(()) +} + +/// `lh config display ` +pub async fn show(configs: &LhConfigs, id: &str, csv: bool) -> Result<()> { + let file = configs.load(id).await?; + let title = match file.name() { + Some(name) => format!("Lighthouse Configuration {}: {}", describe(configs, id), name), + None => format!("Lighthouse Configuration {}", describe(configs, id)), + }; + super::print_config(&file, &title, csv); + Ok(()) +} + +/// Let the user pick a stored configuration. +async fn pick(configs: &LhConfigs, message: &str) -> Result { + let entries = configs.entries().await?; + if entries.is_empty() { + bail!(CliError::NotFound( + "lighthouse configs; store one with 'cfcli lh config save '".to_string() + )); + } + let labels: Vec = entries + .iter() + .map(|e| match e.name.is_empty() { + true => format!("{} ({} base stations)", e.id, e.count), + false => format!("{} - {} ({} base stations)", e.id, e.name, e.count), + }) + .collect(); + let picked = Select::new(message, labels) + .raw_prompt() + .map_err(|_| anyhow!("No lighthouse config selected"))?; + Ok(entries[picked.index].id.clone()) +} + +// ---- Storing ---- + +/// What changes for each base station when `old` becomes `new`. +pub fn changes(old: &LighthouseConfigFile, new: &LighthouseConfigFile) -> Vec { + let mut changes = Vec::new(); + for diff in compare(new, old) { + let bs = format!("BS {} (channel {})", diff.id, diff.id + 1); + match diff.geometry { + Part::Differs(d) => changes.push(format!( + "{}: moved {}, turned {:.2}°", + bs, + describe_distance(d.moved_m), + d.turned_deg + )), + Part::OnlyInFile => changes.push(format!("{}: position added", bs)), + Part::OnlyOnCf => changes.push(format!("{}: position removed", bs)), + Part::Same | Part::Absent => {} + } + match diff.calibration { + Part::Differs(CalibrationDelta::Replaced { file_uid, cf_uid }) => changes.push(format!( + "{}: another base station, 0x{:08X} instead of 0x{:08X}", + bs, file_uid, cf_uid + )), + Part::Differs(CalibrationDelta::Values { .. }) => changes.push(format!("{}: calibration changed", bs)), + Part::OnlyInFile => changes.push(format!("{}: calibration added", bs)), + Part::OnlyOnCf => changes.push(format!("{}: calibration removed", bs)), + Part::Same | Part::Absent => {} + } + } + if old.name() != new.name() { + changes.push(format!("name: {}", new.name().unwrap_or("(none)"))); + } + changes +} + +/// Store `new` as `id`: create it, or replace what is there after showing +/// what changes and asking (unless `force`). A shared configuration keeps +/// the old one as an earlier revision. +async fn store(configs: &LhConfigs, id: &str, mut new: LighthouseConfigFile, force: bool, non_interactive: bool) -> Result<()> { + if new.geos.is_empty() { + bail!(CliError::InvalidValue( + "the configuration has no base station positions (geometry); estimate it first, \ + e.g. in the Crazyflie client's Lighthouse tab" + .to_string() + )); + } + let existing = match configs.load(id).await { + Ok(old) => Some(old), + Err(e) if matches!(e.downcast_ref::(), Some(CliError::NotFound(_))) => None, + Err(e) => return Err(e), + }; + let Some(old) = existing else { + configs.create(id, &new).await?; + println!( + "Stored lighthouse config {} on {}: {}", + describe(configs, id), + configs.place(id), + base_stations(new.geos.len()) + ); + return Ok(()); + }; + + // What the new configuration doesn't say, like its name, stays. + for (key, value) in &old.extra { + if !new.extra.contains_key(key) { + new.extra.insert(key.clone(), value.clone()); + } + } + let changes = changes(&old, &new); + if changes.is_empty() { + println!("Lighthouse config {} is already this configuration", describe(configs, id)); + return Ok(()); + } + println!("Changes to lighthouse config {}:", describe(configs, id)); + for change in &changes { + println!(" {}", change); + } + if !force { + crate::require_arg(non_interactive, "--force")?; + let question = match SharedId::parse(id)? { + Some(shared) => format!("Update '{}' on {}, for everyone in {}?", id, configs.place(id), shared.org), + None => format!("Replace '{}'?", id), + }; + if !Confirm::new(&question).with_default(false).prompt().unwrap_or(false) { + println!("Nothing changed"); + return Ok(()); + } + } + configs + .change(id, |file| { + *file = new.clone(); + Ok(()) + }) + .await?; + println!("Updated lighthouse config {}", describe(configs, id)); + Ok(()) +} + +/// `lh config save `: the Crazyflie's configuration. +pub async fn save( + configs: &LhConfigs, + cf: &Crazyflie, + id: &str, + name: Option<&str>, + force: bool, + non_interactive: bool, +) -> Result<()> { + check_new_id(configs, id)?; + let mut file = super::read_with_progress(cf, non_interactive).await?; + if let Some(name) = name { + file.set_name(name); + } + store(configs, id, file, force, non_interactive).await +} + +/// `lh config import `: a file from the Crazyflie client (or `read`). +pub async fn import( + configs: &LhConfigs, + path: &str, + id: Option<&str>, + name: Option<&str>, + force: bool, + non_interactive: bool, +) -> Result<()> { + let id = match id { + Some(id) => id.to_string(), + None => std::path::Path::new(path) + .file_stem() + .map(|stem| stem.to_string_lossy().into_owned()) + .unwrap_or_default(), + }; + check_new_id(configs, &id).with_context(|| format!("importing {} (pick another ID with --id)", path))?; + let mut file = super::load(Some(path))?; + if let Some(name) = name { + file.set_name(name); + } + store(configs, &id, file, force, non_interactive).await +} + +/// An ID to store under: a valid file name, and for a shared one, a sign-in. +fn check_new_id(configs: &LhConfigs, id: &str) -> Result<()> { + match SharedId::parse(id)? { + None => crate::modules::documents::check_id::(id), + Some(shared) => configs.shared(&shared).map(|_| ()), + } +} + +/// `lh config export `: the file, for the Crazyflie client. +pub async fn export(configs: &LhConfigs, id: &str, output: Option<&str>) -> Result<()> { + let yaml = configs.load(id).await?.to_yaml()?; + match output { + Some(path) => { + std::fs::write(path, yaml).with_context(|| format!("writing {}", path))?; + println!("Exported lighthouse config {} to {}", describe(configs, id), path); + } + None => print!("{}", yaml), + } + Ok(()) +} + +// ---- Deleting and moving ---- + +pub async fn delete(configs: &LhConfigs, id: Option<&str>, non_interactive: bool) -> Result<()> { + let id = match id { + Some(id) => id.to_string(), + None => { + crate::require_arg(non_interactive, "")?; + pick(configs, "Select the lighthouse config to delete:").await? + } + }; + // Deleting can't be undone, so ask whenever there is someone to ask. + if !non_interactive { + let question = match SharedId::parse(&id)? { + Some(shared) => format!( + "Delete lighthouse config '{}' on {}, for everyone in {}?", + id, + configs.place(&id), + shared.org + ), + None => format!("Delete lighthouse config '{}'?", id), + }; + if !Confirm::new(&question).with_default(false).prompt().unwrap_or(false) { + println!("Nothing deleted"); + return Ok(()); + } + } + configs.delete(&id).await?; + println!("Deleted lighthouse config '{}'", id); + Ok(()) +} + +/// `lh config move`: share one (cage -> org/cage), take it back +/// (org/cage -> cage), or rename it. +pub async fn move_config(configs: &LhConfigs, from: &str, to: &str, non_interactive: bool) -> Result<()> { + if from == to { + bail!(CliError::InvalidValue(format!("'{}' is already where it is", from))); + } + check_new_id(configs, to)?; + let file = configs.load(from).await?; + if SharedId::parse(to)?.is_none() && configs.local.exists(to) { + bail!(CliError::InvalidValue(format!("lighthouse config '{}' already exists", to))); + } + // Moving a shared configuration away deletes it for everyone: ask. + if let Some(shared) = SharedId::parse(from)? { + if !non_interactive { + let question = format!( + "Move '{}' to '{}'? It is deleted on {} for everyone in {}.", + from, + to, + configs.place(from), + shared.org + ); + if !Confirm::new(&question).with_default(false).prompt().unwrap_or(false) { + println!("Nothing moved"); + return Ok(()); + } + } + } + configs.create(to, &file).await?; + if let Err(e) = configs.delete(from).await { + bail!("copied '{}' to '{}', but '{}' is still there: {:#}", from, to, from, e); + } + println!( + "Moved lighthouse config '{}' from {} to '{}' on {}", + from, + configs.place(from), + to, + configs.place(to) + ); + println!("Swarms that name '{}' need 'cfcli swarm config lighthouse {}'", from, to); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const FILE: &str = "\ +name: The cage +type: lighthouse_system_configuration +version: '1' +systemType: 2 +geos: + 0: + origin: [0.0, 0.0, 2.0] + rotation: [[1, 0, 0], [0, 1, 0], [0, 0, 1]] +calibs: + 0: + uid: 2360210604 + sweeps: + - {phase: 0.0, tilt: -0.051, curve: 0.275, gibmag: -0.005, gibphase: 2.281, ogeemag: -0.184, ogeephase: 1.847} + - {phase: -0.004, tilt: 0.047, curve: 0.367, gibmag: -0.005, gibphase: 2.548, ogeemag: -0.124, ogeephase: 2.051} +"; + + #[test] + fn name_and_summary() { + let mut file = LighthouseConfigFile::from_yaml(FILE).unwrap(); + assert_eq!(file.name(), Some("The cage")); + assert_eq!(file.summary(), ("The cage".to_string(), 1)); + file.set_name("Fish tank"); + assert!(file.to_yaml().unwrap().contains("name: Fish tank")); + // A file without positions counts no base stations. + file.geos.clear(); + assert_eq!(file.summary().1, 0); + } + + #[test] + fn describes_changes() { + let old = LighthouseConfigFile::from_yaml(FILE).unwrap(); + assert!(changes(&old, &old).is_empty()); + let mut new = old.clone(); + new.geos.get_mut(&0).unwrap().origin[0] = 0.05; + new.calibs.get_mut(&0).unwrap().uid = 1; + new.geos.insert(3, new.geos[&0].clone()); + let changes = changes(&old, &new); + assert_eq!( + changes, + [ + "BS 0 (channel 1): moved 5.0 cm, turned 0.00°", + "BS 0 (channel 1): another base station, 0x00000001 instead of 0x8CADF4AC", + "BS 3 (channel 4): position added", + ] + ); + } +} diff --git a/src/modules/lighthouse.rs b/src/modules/lighthouse/mod.rs similarity index 98% rename from src/modules/lighthouse.rs rename to src/modules/lighthouse/mod.rs index 67f2436..9c6f854 100644 --- a/src/modules/lighthouse.rs +++ b/src/modules/lighthouse/mod.rs @@ -20,6 +20,8 @@ use std::collections::{BTreeMap, BTreeSet}; use crate::error::CliError; use crate::utils::display::csv_row; +pub mod configs; + /// The `type` of a lighthouse configuration file. pub const FILE_TYPE: &str = "lighthouse_system_configuration"; /// The file version written. cflib reads only this one. @@ -416,7 +418,7 @@ fn calibration_delta(file: &CalibrationFileEntry, cf: &CalibrationFileEntry) -> } } -fn describe_distance(m: f64) -> String { +pub(crate) fn describe_distance(m: f64) -> String { if m < 0.01 { format!("{:.1} mm", m * 1000.0) } else { @@ -642,7 +644,7 @@ pub fn load(file_path: Option<&str>) -> Result { .with_context(|| format!("Failed to load {}", file_path.unwrap_or("the configuration from stdin"))) } -async fn read_with_progress(cf: &Crazyflie, non_interactive: bool) -> Result { +pub(crate) async fn read_with_progress(cf: &Crazyflie, non_interactive: bool) -> Result { let count = supported_base_stations(cf).unwrap_or(MAX_BASE_STATIONS); let progress_bar = make_progress(2 * count as usize, "Reading", non_interactive); let pb = progress_bar.clone(); @@ -654,18 +656,18 @@ async fn read_with_progress(cf: &Crazyflie, non_interactive: bool) -> Result Result<()> { let config = read_with_progress(cf, non_interactive).await?; - print_config(&config, None, csv); + print_config(&config, "Lighthouse Configuration", csv); Ok(()) } /// Display lighthouse configuration from a YAML file (no connection needed) pub fn display_file(file_path: &str, csv: bool) -> Result<()> { let config = load(Some(file_path))?; - print_config(&config, Some(file_path), csv); + print_config(&config, &format!("Lighthouse Configuration File: {}", file_path), csv); Ok(()) } -fn print_config(config: &LighthouseConfigFile, file_path: Option<&str>, csv: bool) { +pub(crate) fn print_config(config: &LighthouseConfigFile, title: &str, csv: bool) { if csv { csv_row(&["section", "bs_id", "key", "value"]); for (id, geo) in &config.geos { @@ -677,10 +679,6 @@ fn print_config(config: &LighthouseConfigFile, file_path: Option<&str>, csv: boo return; } - let title = match file_path { - Some(path) => format!("Lighthouse Configuration File: {}", path), - None => "Lighthouse Configuration".to_string(), - }; println!("{}", title); println!("{}", "=".repeat(title.chars().count())); println!(); diff --git a/src/modules/settings.rs b/src/modules/settings.rs index 813d543..550a3b2 100644 --- a/src/modules/settings.rs +++ b/src/modules/settings.rs @@ -100,11 +100,14 @@ pub fn sync_set(config: &mut Config, on: bool) { println!("Could not save settings: {:?}", err); }); if on { - println!("Sync is on: swarm commands check the server first and upload changes right away"); + println!( + "Sync is on: commands check the server first for shared swarms and lighthouse configs, \ + and upload changes right away" + ); } else { println!( - "Sync is off: swarm commands use this computer's copies of shared swarms; \ - 'cfcli swarm config pull' and 'push' sync them" + "Sync is off: commands use this computer's copies of shared swarms and lighthouse configs; \ + 'cfcli swarm config pull'/'push' and 'cfcli lh config pull'/'push' sync them" ); } } diff --git a/src/modules/swarm/lighthouse.rs b/src/modules/swarm/lighthouse.rs new file mode 100644 index 0000000..754f63b --- /dev/null +++ b/src/modules/swarm/lighthouse.rs @@ -0,0 +1,271 @@ +//! The lighthouse configuration of a swarm: which one it flies in (`swarm +//! config lighthouse`), and checking and writing it on its Crazyflies +//! (`swarm lh check|write`). +//! +//! A swarm names a stored lighthouse configuration (`lighthouse:` in the +//! swarm file). A shared swarm names a shared one, so that everyone who uses +//! the swarm gets it. Writing skips the Crazyflies that already have it, +//! which makes `swarm lh write` the way to give new Crazyflies the swarm's +//! configuration, and to update all of them when it changes. + +use anyhow::{bail, Result}; +use tabled::Tabled; + +use super::runner::{csv_row_for, split, Runner, SwarmRow}; +use super::Swarms; +use crate::error::CliError; +use crate::modules::documents::SharedId; +use crate::modules::lighthouse::configs::LhConfigs; +use crate::modules::lighthouse::{ + check_supported, compare, describe_distance, is_same, read_config, supported_base_stations, write_config, + BaseStationDiff, CalibrationDelta, LighthouseConfigFile, Part, +}; +use crate::utils::display::{csv_row, print_table, table}; +use crate::{Config, SwarmLhCommands}; + +/// `swarm config lighthouse [CONFIG] [--clear]` +pub async fn link(swarms: &Swarms, config: &Config, id: &str, new: Option<&str>, clear: bool) -> Result<()> { + if !clear && new.is_none() { + let swarm = swarms.load(id).await?; + match swarm.lighthouse { + Some(lighthouse) => println!("Swarm '{}' flies in lighthouse config '{}'", id, lighthouse), + None => println!( + "Swarm '{}' names no lighthouse config; set one with 'cfcli swarm config lighthouse '", + id + ), + } + return Ok(()); + } + if let Some(new) = new { + if SharedId::parse(id)?.is_some() && SharedId::parse(new)?.is_none() { + bail!(CliError::InvalidValue(format!( + "'{}' is shared, so it can only name a shared lighthouse config (/), \ + not '{}' on this computer; share it with 'cfcli lh config move'", + id, new + ))); + } + // It must exist (and a shared one gets a copy here). + LhConfigs::open(config)?.load(new).await?; + } + let new = new.map(str::to_string); + swarms + .change(id, |swarm| { + swarm.lighthouse = new.clone(); + Ok(()) + }) + .await?; + match &new { + Some(new) => println!("Swarm '{}' flies in lighthouse config '{}'", id, new), + None => println!("Swarm '{}' names no lighthouse config now", id), + } + Ok(()) +} + +/// `swarm lh check|write` +pub async fn run( + config: &Config, + runner: &Runner<'_>, + swarm_id: &str, + linked: Option<&str>, + command: &SwarmLhCommands, + csv: bool, +) -> Result<()> { + let given = match command { + SwarmLhCommands::Check(params) => params.config.as_deref(), + SwarmLhCommands::Write(params) => params.config.as_deref(), + }; + let Some(id) = given.or(linked) else { + bail!(CliError::NotFound(format!( + "lighthouse config of swarm '{}'; name one with 'cfcli swarm config lighthouse ' or give --config", + swarm_id + ))); + }; + let configs = LhConfigs::open(config)?; + let wanted = configs.load(id).await?; + if !csv { + let revision = SharedId::parse(id)? + .and_then(|shared| configs.shared(&shared).ok()?.copy_state(&shared).ok()?) + .map(|copy| format!(", revision {}", copy.revision)) + .unwrap_or_default(); + println!( + "Lighthouse config '{}'{}: base stations {}", + id, + revision, + ids(&wanted.geos.keys().copied().collect::>()) + ); + } + match command { + SwarmLhCommands::Check(_) => check(runner, &wanted, csv).await, + SwarmLhCommands::Write(params) => write(runner, &wanted, params.force).await, + } +} + +fn ids(ids: &[u8]) -> String { + match ids { + [] => "none".to_string(), + ids => ids.iter().map(|id| id.to_string()).collect::>().join(", "), + } +} + +/// What a check found on one Crazyflie. +struct Found { + diffs: Vec, + /// How many base stations its firmware supports, if that can be told. + supported: Option, +} + +impl Found { + fn up_to_date(&self) -> bool { + is_same(&self.diffs) + } + + /// Base stations whose geometry or calibration differs. + fn differing(&self) -> Vec { + self.diffs.iter().filter(|d| !d.is_same()).map(|d| d.id).collect() + } + + /// "BS 4, 5 not on the Crazyflie; BS 2 moved 3.1 cm; firmware supports 4 base stations" + fn summary(&self, wanted: &LighthouseConfigFile) -> String { + if self.up_to_date() { + return "up to date".to_string(); + } + let select = |f: &dyn Fn(&BaseStationDiff) -> bool| { + self.diffs.iter().filter(|d| f(d)).map(|d| d.id).collect::>() + }; + let mut parts = Vec::new(); + let missing = select(&|d| matches!(d.geometry, Part::OnlyInFile)); + if !missing.is_empty() { + parts.push(format!("no position for BS {}", ids(&missing))); + } + for d in &self.diffs { + if let Part::Differs(delta) = d.geometry { + parts.push(format!( + "BS {} moved {}, turned {:.2}°", + d.id, + describe_distance(delta.moved_m), + delta.turned_deg + )); + } + } + let extra = select(&|d| matches!(d.geometry, Part::OnlyOnCf)); + if !extra.is_empty() { + parts.push(format!("position for BS {} that the config hasn't", ids(&extra))); + } + let replaced = select(&|d| matches!(d.calibration, Part::Differs(CalibrationDelta::Replaced { .. }))); + if !replaced.is_empty() { + parts.push(format!("another base station on BS {}", ids(&replaced))); + } + let calibration = select(&|d| { + matches!( + d.calibration, + Part::Differs(CalibrationDelta::Values { .. }) | Part::OnlyInFile | Part::OnlyOnCf + ) + }); + if !calibration.is_empty() { + parts.push(format!("calibration differs for BS {}", ids(&calibration))); + } + if let Some(supported) = self.supported { + if wanted.ids().iter().any(|id| *id >= supported) { + parts.push(format!("its firmware supports only BS 0-{}", supported.saturating_sub(1))); + } + } + parts.join("; ") + } +} + +/// One row of `swarm lh check`. +#[derive(Tabled)] +struct CheckRow { + #[tabled(rename = "Lighthouse")] + status: String, +} + +async fn check(runner: &Runner<'_>, wanted: &LighthouseConfigFile, csv: bool) -> Result<()> { + let results = runner + .connected("Reading", async |cf, _| { + let on_cf = read_config(cf, |_, _| {}).await?; + Ok(Found { diffs: compare(wanted, &on_cf), supported: supported_base_stations(cf) }) + }) + .await; + let (done, outcome) = split(results); + let differing = done.iter().filter(|(_, found)| !found.up_to_date()).count(); + if csv { + csv_row(&["cf", "uri", "status", "firmware_base_stations", "differing_base_stations"]); + for (i, found) in &done { + let differing: Vec = found.differing().iter().map(|id| id.to_string()).collect(); + csv_row_for( + &runner.targets[*i], + &[ + if found.up_to_date() { "up_to_date" } else { "differs" }, + &found.supported.map(|n| n.to_string()).unwrap_or_default(), + &differing.join(";"), + ], + ); + } + } else { + let rows: Vec> = done + .iter() + .map(|(i, found)| SwarmRow { + cf: runner.targets[*i].name.clone(), + row: CheckRow { status: found.summary(wanted) }, + }) + .collect(); + if !rows.is_empty() { + print_table(&table(&rows)); + } + } + outcome.print_failures(&runner.targets); + outcome.finish()?; + if differing > 0 { + bail!(CliError::Differs(format!( + "{} of {} have another lighthouse configuration; 'cfcli swarm lh write' gives them this one", + differing, + super::crazyflies(done.len()) + ))); + } + Ok(()) +} + +async fn write(runner: &Runner<'_>, wanted: &LighthouseConfigFile, force: bool) -> Result<()> { + let results = runner + .connected("Writing", async |cf, _| { + // Refused before anything is written. + check_supported(wanted, supported_base_stations(cf))?; + if !force && is_same(&compare(wanted, &read_config(cf, |_, _| {}).await?)) { + return Ok(false); + } + write_config(cf, wanted, |_, _| {}).await?; + let back = read_config(cf, |_, _| {}).await?; + if !is_same(&compare(wanted, &back)) { + bail!("the Crazyflie has another configuration after writing it"); + } + Ok(true) + }) + .await; + let (done, outcome) = split(results); + for (i, target) in runner.targets.iter().enumerate() { + if let Some((_, written)) = done.iter().find(|(d, _)| *d == i) { + println!( + "{}: {}", + target.name, + if *written { "written and stored in flash" } else { "up to date" } + ); + } + } + outcome.print_failures(&runner.targets); + outcome.finish() +} + +/// A hint after Crazyflies were added to a swarm that names a lighthouse +/// configuration: how to give it to them. +pub fn hint_for_new(swarm: &str, lighthouse: Option<&str>, added: &[String]) { + if let (Some(lighthouse), false) = (lighthouse, added.is_empty()) { + println!( + "The swarm flies in lighthouse config '{}'; give it to the new Crazyflies with \ + 'cfcli swarm lh write --swarm {} --cf {}'", + lighthouse, + swarm, + added.join(",") + ); + } +} diff --git a/src/modules/swarm/mod.rs b/src/modules/swarm/mod.rs index 294ed59..1520395 100644 --- a/src/modules/swarm/mod.rs +++ b/src/modules/swarm/mod.rs @@ -3,7 +3,8 @@ //! //! Local swarms are files in the Swarmkeeper format (see [`store`]), named //! ``. When cfcli is signed in (`cfcli auth login`), shared swarms on -//! the server sit next to them, named `/` (see [`shared`]); every +//! the server sit next to them, named `/` (see +//! [`crate::modules::documents`]); every //! command takes either. One swarm is selected and stored in the cfcli //! config; `--swarm` picks another one for a single command. Crazyflies are //! named by their short `name`, which is also what `--cf`/`--exclude` and @@ -11,9 +12,9 @@ mod bootload; mod commands; +mod lighthouse; mod rechannel; mod runner; -pub mod shared; pub mod store; use anyhow::{anyhow, bail, Context, Result}; @@ -30,121 +31,12 @@ use crate::{ SwarmExportParameters, SwarmImportParameters, SwarmTargetArgs, }; use runner::Runner; -use shared::{Shared, SharedId}; -use store::{Store, Swarm, Unit}; +use crate::modules::documents::{Documents, Entry, SharedId}; +use store::{Swarm, Unit}; /// Local and shared swarms behind one kind of ID: `` is a file in /// the swarms folder, `/` a swarm on the server. -pub(crate) struct Swarms { - pub local: Store, - /// None when cfcli isn't signed in. - pub shared: Option, -} - -impl Swarms { - pub fn open(config: &Config) -> Result { - Ok(Swarms { local: Store::open()?, shared: Shared::open(config.sync_on())? }) - } - - /// For shell completion: only [`Swarms::cached`] and - /// [`Swarms::cached_ids`] are used, which never ask the server. - pub fn open_cached() -> Result { - Ok(Swarms { local: Store::open()?, shared: Shared::open(false)? }) - } - - fn shared(&self, id: &SharedId) -> Result<&Shared> { - self.shared.as_ref().ok_or_else(|| { - CliError::NotFound(format!( - "a sign-in: '{}' is a shared swarm, sign in with 'cfcli auth login'", - id - )) - .into() - }) - } - - pub async fn load(&self, id: &str) -> Result { - match SharedId::parse(id)? { - None => self.local.load(id), - Some(shared) => self.shared(&shared)?.load(&shared).await, - } - } - - /// The swarm without asking the server (a shared one's copy), for shell - /// completion and pickers. - pub fn cached(&self, id: &str) -> Result { - match SharedId::parse(id)? { - None => self.local.load(id), - Some(shared) => self.shared(&shared)?.cached(&shared), - } - } - - /// Change a swarm. `change` may run more than once for a shared swarm - /// (see [`Shared::change`]), so it must not ask the user anything. - pub async fn change(&self, id: &str, mut change: impl FnMut(&mut Swarm) -> Result) -> Result { - match SharedId::parse(id)? { - None => { - let mut swarm = self.local.load(id)?; - let before = swarm.to_yaml()?; - let result = change(&mut swarm)?; - if swarm.to_yaml()? != before { - self.local.save(id, &swarm)?; - } - Ok(result) - } - Some(shared) => self.shared(&shared)?.change(&shared, change).await, - } - } - - /// Create a swarm. A shared one is created on the server. - pub async fn create(&self, id: &str, swarm: &Swarm) -> Result<()> { - match SharedId::parse(id)? { - None => { - store::check_id(id)?; - if self.local.exists(id) { - bail!(CliError::InvalidValue(format!("swarm '{}' already exists", id))); - } - self.local.save(id, swarm) - } - Some(shared_id) => { - let shared = self.shared(&shared_id)?; - if !shared.create(&shared_id, swarm).await? { - bail!(CliError::InvalidValue(format!( - "swarm '{}' already exists on {}", - id, - shared.host() - ))); - } - Ok(()) - } - } - } - - /// Delete a swarm. A shared one is deleted on the server, for everyone. - pub async fn delete(&self, id: &str) -> Result<()> { - match SharedId::parse(id)? { - None => self.local.delete(id), - Some(shared) => self.shared(&shared)?.delete(&shared).await, - } - } - - /// Local swarms and the shared ones there are copies of, without asking - /// the server. - pub fn cached_ids(&self) -> Result> { - let mut ids = self.local.ids()?; - if let Some(shared) = &self.shared { - ids.extend(shared.cached_ids().into_iter().map(|id| id.to_string())); - } - Ok(ids) - } - - /// Where a swarm is kept, for the user. - fn place(&self, id: &str) -> String { - match (&self.shared, SharedId::parse(id)) { - (Some(shared), Ok(Some(_))) => shared.host().to_string(), - _ => "this computer".to_string(), - } - } -} +pub(crate) type Swarms = Documents; pub(crate) async fn run( config: &mut Config, @@ -187,12 +79,16 @@ pub(crate) async fn run( export(&swarms, &swarm_id(config, params.id.as_deref())?, params).await } SwarmConfigCommands::Move { from, to } => move_swarm(&swarms, config, from, to, non_interactive).await, + SwarmConfigCommands::Lighthouse { config: new, clear, swarm } => { + let id = swarm_id(config, swarm.as_deref())?; + lighthouse::link(&swarms, config, &id, new.as_deref(), *clear).await + } SwarmConfigCommands::Pull { id, force } => { - let (shared, only) = shared_for_sync(&swarms, id.as_deref())?; + let (shared, only) = swarms.for_sync(id.as_deref())?; shared.pull(only.as_ref(), *force).await } SwarmConfigCommands::Push { id, force } => { - let (shared, only) = shared_for_sync(&swarms, id.as_deref())?; + let (shared, only) = swarms.for_sync(id.as_deref())?; shared.push(only.as_ref(), *force).await } }, @@ -223,6 +119,13 @@ pub(crate) async fn run( let runner = runner(&swarms, config, target, link_context, toc_cache).await?; commands::debug(&runner, command, csv).await } + SwarmCommands::Lh { target, command } => { + let id = swarm_id(config, target.swarm.as_deref())?; + let swarm = swarms.load(&id).await?; + let selected = swarm.select(&id, &target.cf, &target.exclude)?; + let runner = Runner::new(link_context, config, toc_cache, &swarm, &selected).await?; + lighthouse::run(config, &runner, &id, swarm.lighthouse.as_deref(), command, csv).await + } SwarmCommands::Rechannel(params) => { rechannel::rechannel(&swarms, config, params, link_context, toc_cache, non_interactive).await } @@ -253,25 +156,6 @@ fn swarm_id(config: &Config, given: Option<&str>) -> Result { } } -/// The server for `pull`/`push`, and the one shared swarm to sync if given. -fn shared_for_sync<'a>(swarms: &'a Swarms, id: Option<&str>) -> Result<(&'a Shared, Option)> { - let Some(shared) = &swarms.shared else { - bail!(CliError::NotFound( - "a sign-in: shared swarms need 'cfcli auth login'".to_string() - )); - }; - let only = match id { - None => None, - Some(id) => Some(SharedId::parse(id)?.ok_or_else(|| { - CliError::InvalidValue(format!( - "'{}' is a local swarm; only shared swarms (/) are pulled and pushed", - id - )) - })?), - }; - Ok((shared, only)) -} - fn save_config(config: &Config) { confy::store("cf-cli", None, config.clone()).unwrap_or_else(|err| { println!("Could not save configuration: {:?}", err); @@ -304,57 +188,6 @@ fn crazyflies(n: usize) -> String { } } -/// A swarm as `list` and the pickers show it. -struct Entry { - id: String, - name: String, - count: String, - /// Where it is kept: "this computer" or the server. - place: String, - revision: Option, - pending: bool, -} - -impl Entry { - fn stored(&self) -> String { - match self.revision { - None => self.place.clone(), - Some(0) => format!("{}, not uploaded yet", self.place), - Some(revision) if self.pending => format!("{}, revision {}, changes not pushed", self.place, revision), - Some(revision) => format!("{}, revision {}", self.place, revision), - } - } -} - -/// The local swarms, then the shared ones. -async fn entries(swarms: &Swarms) -> Result> { - let mut entries: Vec = swarms - .local - .ids()? - .into_iter() - .map(|id| { - let (name, count) = match swarms.local.load(&id) { - Ok(swarm) => (swarm.name, swarm.units.len().to_string()), - Err(e) => (format!("(can't read: {:#})", e), "?".to_string()), - }; - Entry { id, name, count, place: "this computer".to_string(), revision: None, pending: false } - }) - .collect(); - if let Some(shared) = &swarms.shared { - for listed in shared.list().await? { - entries.push(Entry { - id: listed.id.to_string(), - name: listed.name, - count: listed.units.to_string(), - place: shared.host().to_string(), - revision: Some(listed.copy.revision), - pending: listed.copy.pending, - }); - } - } - Ok(entries) -} - /// One row of `swarm config list`. #[derive(Tabled)] struct SwarmRow { @@ -371,7 +204,7 @@ struct SwarmRow { } async fn list(swarms: &Swarms, config: &Config, csv: bool) -> Result<()> { - let entries = entries(swarms).await?; + let entries = swarms.entries().await?; let selected = |entry: &Entry| config.swarm.as_deref() == Some(entry.id.as_str()); if csv { @@ -427,7 +260,7 @@ async fn select(swarms: &Swarms, config: &mut Config, id: Option<&str>, non_inte /// Let the user pick a swarm, starting at the selected one. async fn pick_swarm(swarms: &Swarms, config: &Config, message: &str) -> Result { - let entries = entries(swarms).await?; + let entries = swarms.entries().await?; if entries.is_empty() { bail!(CliError::NotFound( "swarms; create one with 'cfcli swarm config create '".to_string() @@ -457,7 +290,7 @@ async fn create(swarms: &Swarms, config: &mut Config, params: &SwarmCreateParame // of the name. let name = match (¶ms.name, SharedId::parse(¶ms.id)?) { (Some(name), _) => name.clone(), - (None, Some(shared)) => shared.swarm, + (None, Some(shared)) => shared.name, (None, None) => params.id.clone(), }; let swarm = Swarm::new(name, params.description.clone()); @@ -558,6 +391,9 @@ async fn show(swarms: &Swarms, id: &str, csv: bool) -> Result<()> { if SharedId::parse(id)?.is_some() { println!("Shared on {}", swarms.place(id)); } + if let Some(lighthouse) = &swarm.lighthouse { + println!("Flies in lighthouse config '{}'", lighthouse); + } println!(); if swarm.units.is_empty() { println!("No Crazyflies yet, add some with 'cfcli swarm config add'."); @@ -663,9 +499,10 @@ async fn add_to( } } - let messages = swarms + let (messages, added, lighthouse) = swarms .change(id, |swarm| { let mut messages = Vec::new(); + let mut added = Vec::new(); for uri in &uris { if let Some(i) = swarm.find_link(uri)? { messages.push(format!("{} is already in the swarm as {}", uri, swarm.units[i].name)); @@ -680,6 +517,7 @@ async fn add_to( None => swarm.next_name(), }; messages.push(format!("Added {} {}", name, uri)); + added.push(name.clone()); swarm.units.push(Unit { uri: uri.clone(), name, @@ -687,12 +525,13 @@ async fn add_to( extra: Default::default(), }); } - Ok(messages) + Ok((messages, added, swarm.lighthouse.clone())) }) .await?; for message in messages { println!("{}", message); } + lighthouse::hint_for_new(id, lighthouse.as_deref(), &added); Ok(()) } diff --git a/src/modules/swarm/shared.rs b/src/modules/swarm/shared.rs deleted file mode 100644 index 2f399b9..0000000 --- a/src/modules/swarm/shared.rs +++ /dev/null @@ -1,837 +0,0 @@ -//! Shared swarms: swarms kept on the server (`cfcli auth login`), named -//! `/`, next to the local ones, which are named ``. -//! -//! cfcli keeps a copy of each shared swarm it uses in -//! `synced///.yaml` next to the cfcli config. That is -//! outside the `swarms` folder, so Swarmkeeper and `import` never take a copy -//! for a local swarm. `synced//state.json` says which revision each -//! copy is and whether it has changes the server doesn't have yet. -//! -//! With sync on (`cfcli settings sync`, the default) a command checks the -//! server before it uses a shared swarm, which costs a 304 when the copy is -//! current, and writes changes straight to the server. The upload carries -//! `If-Match` with the copy's revision, so nobody's upload is overwritten: -//! when someone else changed the swarm in between, the change is applied -//! again to their version. With sync off, commands use the copies only, and -//! `swarm config pull` and `push` sync them. -//! -//! When the server can't be reached, commands use the copies and keep their -//! changes for the next push, so working with the Crazyflies never waits for -//! the internet. -//! -//! The `` in an ID is the user's own ID for the organization, which they -//! can change on the server. `synced//orgs.json` remembers the ID of -//! each organization (the server lists their UUIDs, which never change), so -//! when one changes, the copies and the selected swarm move to the new ID, -//! and the old ID says what the new one is. - -use std::collections::BTreeMap; -use std::fmt; -use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::Duration; - -use anyhow::{bail, Context, Result}; -use reqwest::{header, RequestBuilder, Response, StatusCode}; -use serde::{Deserialize, Serialize}; - -use super::store::{self, Store, Swarm}; -use crate::error::CliError; -use crate::modules::auth::{self, Credentials}; -use crate::Config; - -/// A shared swarm's ID, `/`. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -pub struct SharedId { - pub org: String, - pub swarm: String, -} - -impl SharedId { - /// `/`, or None for a local ID (no '/'). - pub fn parse(id: &str) -> Result> { - let Some((org, swarm)) = id.split_once('/') else { - return Ok(None); - }; - let org_ok = !org.is_empty() - && org.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'); - if !org_ok { - bail!(CliError::InvalidValue(format!( - "swarm ID '{}': the organization is lower-case letters, digits and '-'", - id - ))); - } - store::check_id(swarm)?; - Ok(Some(SharedId { org: org.to_string(), swarm: swarm.to_string() })) - } -} - -impl fmt::Display for SharedId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}/{}", self.org, self.swarm) - } -} - -/// What cfcli knows about its copy of a shared swarm. -#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] -pub struct CopyState { - /// The server's revision the copy is based on. 0 for a swarm created - /// here while the server couldn't be reached, which isn't on it yet. - pub revision: i32, - /// The copy has changes the server doesn't have. - pub pending: bool, -} - -#[derive(Debug, Default, Serialize, Deserialize)] -struct State { - #[serde(default)] - swarms: BTreeMap, -} - -/// A shared swarm as `swarm config list` shows it. -pub struct Listed { - pub id: SharedId, - pub name: String, - pub units: usize, - pub copy: CopyState, -} - -#[derive(Deserialize)] -struct ServerSwarm { - org: String, - /// Never changes, unlike the user's ID for the organization (`org`). - /// Servers before per-user IDs don't send it. - #[serde(default)] - org_uuid: Option, - swarm: String, - name: String, - units: usize, - revision: i32, -} - -/// `orgs.json`: the user's IDs for their organizations. -#[derive(Debug, Default, Serialize, Deserialize)] -struct OrgIds { - /// The ID of each organization, by UUID, as the server last listed it. - #[serde(default)] - ids: BTreeMap, - /// Old IDs and what they became, for telling the user. - #[serde(default)] - renamed: BTreeMap, -} - -/// What an upload asks the server to check first. -#[derive(Clone, Copy)] -enum Condition { - /// Only if the swarm is still at this revision. - Revision(i32), - /// Only if the swarm doesn't exist. - New, - /// Overwrite whatever is there. - None, -} - -enum Upload { - Done(i32), - /// Someone else changed (or created, or deleted) the swarm first. - Conflict, - Offline, -} - -/// The shared swarms of the server cfcli is signed in to. -pub struct Shared { - credentials: Credentials, - /// `synced/` next to the cfcli config. - dir: PathBuf, - sync: bool, - client: reqwest::Client, - /// The server didn't answer once; don't wait for it again in this command. - offline: AtomicBool, -} - -impl Shared { - /// None when cfcli isn't signed in. Doesn't contact the server. - pub fn open(sync: bool) -> Result> { - let Some(credentials) = auth::load()? else { - return Ok(None); - }; - let config = confy::get_configuration_file_path("cf-cli", None) - .context("could not find the cfcli config folder")?; - // "localhost:3000" would be an invalid folder name on Windows. - let host = auth::host(&credentials.server).replace([':', '/', '\\'], "_"); - let dir = config.with_file_name("synced").join(host); - let client = reqwest::Client::builder() - .user_agent(concat!("cfcli/", env!("CARGO_PKG_VERSION"))) - .connect_timeout(Duration::from_secs(3)) - .timeout(Duration::from_secs(15)) - .build()?; - Ok(Some(Shared { credentials, dir, sync, client, offline: AtomicBool::new(false) })) - } - - pub fn host(&self) -> &str { - auth::host(&self.credentials.server) - } - - pub fn sync(&self) -> bool { - self.sync - } - - // ---- The copies and their state ---- - - fn copies(&self, org: &str) -> Store { - Store::at(self.dir.join(org)) - } - - fn state_path(&self) -> PathBuf { - self.dir.join("state.json") - } - - fn state(&self) -> Result { - let path = self.state_path(); - match std::fs::read_to_string(&path) { - Ok(text) => serde_json::from_str(&text).with_context(|| format!("reading {}", path.display())), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(State::default()), - Err(e) => Err(e).with_context(|| format!("reading {}", path.display())), - } - } - - fn save_state(&self, state: &State) -> Result<()> { - std::fs::create_dir_all(&self.dir).with_context(|| format!("creating {}", self.dir.display()))?; - let path = self.state_path(); - let tmp = self.dir.join(".state.json.tmp"); - std::fs::write(&tmp, serde_json::to_string_pretty(state)?).with_context(|| format!("writing {}", tmp.display()))?; - std::fs::rename(&tmp, &path).with_context(|| format!("writing {}", path.display())) - } - - pub fn copy_state(&self, id: &SharedId) -> Result> { - Ok(self.state()?.swarms.get(&id.to_string()).copied()) - } - - fn set_copy_state(&self, id: &SharedId, copy: Option) -> Result<()> { - let mut state = self.state()?; - match copy { - Some(copy) => state.swarms.insert(id.to_string(), copy), - None => state.swarms.remove(&id.to_string()), - }; - self.save_state(&state) - } - - /// Keep `swarm` as the copy of `id`. - fn keep(&self, id: &SharedId, swarm: &Swarm, copy: CopyState) -> Result<()> { - self.copies(&id.org).save(&id.swarm, swarm)?; - self.set_copy_state(id, Some(copy)) - } - - fn forget(&self, id: &SharedId) -> Result<()> { - let copies = self.copies(&id.org); - if copies.exists(&id.swarm) { - copies.delete(&id.swarm)?; - } - self.set_copy_state(id, None) - } - - fn has_copy(&self, id: &SharedId) -> bool { - self.copies(&id.org).exists(&id.swarm) - } - - /// The copy, without asking the server. For shell completion and the - /// like, which must never wait for the network. - pub fn cached(&self, id: &SharedId) -> Result { - if !self.has_copy(id) { - if let Some(now) = self.renamed(id)? { - bail!(renamed_error(id, &now)); - } - bail!(CliError::NotFound(format!("a copy of swarm '{}' on this computer", id))); - } - self.copies(&id.org).load(&id.swarm) - } - - /// The shared swarms there are copies of, without asking the server. - pub fn cached_ids(&self) -> Vec { - self.state() - .map(|state| state.swarms.keys().filter_map(|id| SharedId::parse(id).ok().flatten()).collect()) - .unwrap_or_default() - } - - // ---- Organization IDs ---- - - fn org_ids(&self) -> Result { - let path = self.dir.join("orgs.json"); - match std::fs::read_to_string(&path) { - Ok(text) => serde_json::from_str(&text).with_context(|| format!("reading {}", path.display())), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(OrgIds::default()), - Err(e) => Err(e).with_context(|| format!("reading {}", path.display())), - } - } - - fn save_org_ids(&self, orgs: &OrgIds) -> Result<()> { - std::fs::create_dir_all(&self.dir).with_context(|| format!("creating {}", self.dir.display()))?; - let path = self.dir.join("orgs.json"); - std::fs::write(&path, serde_json::to_string_pretty(orgs)?).with_context(|| format!("writing {}", path.display())) - } - - /// What `id` is called now, if the user changed the ID of its - /// organization. Also selects it instead, if `id` was selected. - fn renamed(&self, id: &SharedId) -> Result> { - let Some(org) = self.org_ids()?.renamed.get(&id.org).cloned() else { - return Ok(None); - }; - let now = SharedId { org, swarm: id.swarm.clone() }; - select_renamed(&[(id.org.clone(), now.org.clone())]); - Ok(Some(now)) - } - - /// Follow the organizations whose ID the user changed since the last - /// list: their copies move to the new ID, and so does the selected swarm. - fn follow_renames(&self, server: &[ServerSwarm]) -> Result<()> { - let mut orgs = self.org_ids()?; - let before = serde_json::to_string(&orgs)?; - let mut renames: Vec<(String, String)> = Vec::new(); - for s in server { - let Some(uuid) = &s.org_uuid else { continue }; - if let Some(old) = orgs.ids.insert(uuid.clone(), s.org.clone()) { - if old != s.org && !renames.contains(&(old.clone(), s.org.clone())) { - renames.push((old, s.org.clone())); - } - } - } - for (old, new) in &renames { - for to in orgs.renamed.values_mut().filter(|to| *to == old) { - *to = new.clone(); - } - orgs.renamed.insert(old.clone(), new.clone()); - } - // An ID in use is no longer an old one. - for s in server { - orgs.renamed.remove(&s.org); - } - if serde_json::to_string(&orgs)? != before { - self.save_org_ids(&orgs)?; - } - if renames.is_empty() { - return Ok(()); - } - - // Take all the moving copies out first, so that two organizations - // can swap IDs. - let mut state = self.state()?; - let mut moving = Vec::new(); - for (old, new) in &renames { - let ids: Vec = state - .swarms - .keys() - .filter_map(|key| SharedId::parse(key).ok().flatten()) - .filter(|id| id.org == *old) - .collect(); - for id in ids { - let copy = state.swarms.remove(&id.to_string()).unwrap_or_default(); - let copies = self.copies(old); - let swarm = match copies.exists(&id.swarm) { - true => { - let swarm = copies.load(&id.swarm)?; - copies.delete(&id.swarm)?; - Some(swarm) - } - false => None, - }; - moving.push((SharedId { org: new.clone(), swarm: id.swarm }, copy, swarm)); - } - } - for (id, copy, swarm) in moving { - if let Some(swarm) = swarm { - self.copies(&id.org).save(&id.swarm, &swarm)?; - } - state.swarms.insert(id.to_string(), copy); - } - self.save_state(&state)?; - for (old, _) in &renames { - // Only goes when empty. - let _ = std::fs::remove_dir(self.dir.join(old)); - } - select_renamed(&renames); - for (old, new) in &renames { - println!("Your organization '{}' on {} is now called '{}': its swarms are '{}/'", old, self.host(), new, new); - } - Ok(()) - } - - // ---- Talking to the server ---- - - fn url(&self, id: &SharedId) -> Result { - auth::endpoint(&self.credentials.server, &format!("/api/v1/swarms/{}/{}", id.org, id.swarm)) - } - - /// Send a request with the key. None when the server can't be reached; - /// the first time, cfcli says it uses the copies instead. - async fn send(&self, request: RequestBuilder) -> Result> { - self.send_quietly(request, false).await - } - - async fn send_quietly(&self, request: RequestBuilder, quiet: bool) -> Result> { - if self.offline.load(Ordering::Relaxed) { - return Ok(None); - } - match request.bearer_auth(&self.credentials.key).send().await { - Ok(response) if response.status() == StatusCode::UNAUTHORIZED => bail!(CliError::NotFound(format!( - "a working key: {} no longer accepts '{}'; sign in again with 'cfcli auth login'", - self.host(), - self.credentials.key_name - ))), - Ok(response) => Ok(Some(response)), - Err(e) if e.is_connect() || e.is_timeout() => { - self.offline.store(true, Ordering::Relaxed); - if !quiet { - eprintln!( - "Can't reach {}: using the copies of shared swarms on this computer. \ - Changes are kept and uploaded {}.", - self.host(), - if self.sync { - "when it answers again" - } else { - "with 'cfcli swarm config push'" - } - ); - } - Ok(None) - } - Err(e) => Err(e).with_context(|| CliError::Connection(self.host().to_string())), - } - } - - /// Like [`Shared::send`], for things that can't be done without the server. - async fn send_online(&self, request: RequestBuilder, what: &str) -> Result { - match self.send_quietly(request, true).await? { - Some(response) => Ok(response), - None => bail!(CliError::Connection(format!("{} needs {}, which can't be reached", what, self.host()))), - } - } - - /// The swarms on the server, in all the user's organizations. - async fn server_list(&self) -> Result>> { - let url = auth::endpoint(&self.credentials.server, "/api/v1/swarms")?; - let Some(response) = self.send(self.client.get(url)).await? else { - return Ok(None); - }; - let server: Vec = auth::answer(&self.credentials.server, response).await?; - self.follow_renames(&server)?; - Ok(Some(server)) - } - - /// After the server answered 404 for `id`: fail saying what it is called - /// now, if the user changed the ID of its organization. Gets the list, - /// which moves the copies. - async fn fail_if_renamed(&self, id: &SharedId) -> Result<()> { - self.server_list().await?; - match self.renamed(id)? { - Some(now) => bail!(renamed_error(id, &now)), - None => Ok(()), - } - } - - /// Download `id` into its copy unless the copy is current. Returns false - /// when the server can't be reached. - async fn download(&self, id: &SharedId, force: bool) -> Result { - let copy = self.copy_state(id)?.unwrap_or_default(); - let mut request = self.client.get(self.url(id)?); - if !force && copy.revision > 0 && self.has_copy(id) { - request = request.header(header::IF_NONE_MATCH, format!("\"{}\"", copy.revision)); - } - let Some(response) = self.send(request).await? else { - return Ok(false); - }; - match response.status() { - StatusCode::NOT_MODIFIED => Ok(true), - StatusCode::NOT_FOUND => { - self.fail_if_renamed(id).await?; - // Deleted on the server, or no longer visible to the user. - if copy.pending { - bail!(CliError::NotFound(format!( - "swarm '{}' on {}, though this computer has changes to it that aren't uploaded; \ - 'cfcli swarm config push --force {}' creates it again with them", - id, - self.host(), - id - ))); - } - self.forget(id)?; - bail!(CliError::NotFound(format!("swarm '{}' on {}", id, self.host()))) - } - status if status.is_success() => { - let revision = etag_revision(&response)?; - let yaml = response.text().await?; - let swarm = Swarm::from_yaml(&yaml).with_context(|| format!("swarm '{}' from {}", id, self.host()))?; - self.keep(id, &swarm, CopyState { revision, pending: false })?; - Ok(true) - } - _ => auth::answer::(&self.credentials.server, response).await.map(|_| true), - } - } - - async fn upload(&self, id: &SharedId, swarm: &Swarm, condition: Condition) -> Result { - let mut request = self - .client - .put(self.url(id)?) - .header(header::CONTENT_TYPE, "application/yaml") - .body(swarm.to_yaml()?); - request = match condition { - Condition::Revision(revision) => request.header(header::IF_MATCH, format!("\"{}\"", revision)), - Condition::New => request.header(header::IF_NONE_MATCH, "*"), - Condition::None => request, - }; - let Some(response) = self.send(request).await? else { - return Ok(Upload::Offline); - }; - if response.status() == StatusCode::PRECONDITION_FAILED { - return Ok(Upload::Conflict); - } - if response.status() == StatusCode::NOT_FOUND { - self.fail_if_renamed(id).await?; - } - #[derive(Deserialize)] - struct Uploaded { - revision: i32, - } - let uploaded: Uploaded = auth::answer(&self.credentials.server, response).await?; - Ok(Upload::Done(uploaded.revision)) - } - - // ---- What the swarm commands do ---- - - /// Upload the copy of `id` if it has changes. Fails on a conflict, saying - /// how to settle it. Returns false when the server can't be reached. - async fn push_pending(&self, id: &SharedId, force: bool) -> Result { - let Some(copy) = self.copy_state(id)? else { - return Ok(true); - }; - if !copy.pending { - return Ok(true); - } - let swarm = self.copies(&id.org).load(&id.swarm)?; - let condition = match (force, copy.revision) { - (true, _) => Condition::None, - (false, 0) => Condition::New, - (false, revision) => Condition::Revision(revision), - }; - match self.upload(id, &swarm, condition).await? { - Upload::Done(revision) => { - self.set_copy_state(id, Some(CopyState { revision, pending: false }))?; - Ok(true) - } - Upload::Offline => Ok(false), - Upload::Conflict => bail!(CliError::InvalidValue(format!( - "swarm '{}' changed on {} since this computer's copy ({}). Keep theirs with \ - 'cfcli swarm config pull --force {}', or yours with 'cfcli swarm config push --force {}'", - id, - self.host(), - match copy.revision { - 0 => "someone created it first".to_string(), - revision => format!("revision {}", revision), - }, - id, - id - ))), - } - } - - /// The swarm, checked with the server first when sync is on (or when - /// there's no copy yet). - pub async fn load(&self, id: &SharedId) -> Result { - if (self.sync || !self.has_copy(id)) && self.push_pending(id, false).await? { - self.download(id, false).await?; - } - self.cached(id) - } - - /// Change the swarm. `change` may run more than once: when someone else - /// uploaded in between, it runs again on their version. With sync off, - /// or without the server, it changes the copy and keeps it for a push. - pub async fn change(&self, id: &SharedId, mut change: impl FnMut(&mut Swarm) -> Result) -> Result { - let mut online = false; - if self.sync || !self.has_copy(id) { - online = self.push_pending(id, false).await? && self.download(id, false).await?; - } - if online { - for _ in 0..3 { - let mut swarm = self.cached(id)?; - let before = swarm.to_yaml()?; - let revision = self.copy_state(id)?.unwrap_or_default().revision; - let result = change(&mut swarm)?; - if swarm.to_yaml()? == before { - return Ok(result); - } - match self.upload(id, &swarm, Condition::Revision(revision)).await? { - Upload::Done(revision) => { - self.keep(id, &swarm, CopyState { revision, pending: false })?; - return Ok(result); - } - // Someone else was first: their version, then the change again. - Upload::Conflict => { - self.download(id, true).await?; - } - Upload::Offline => break, - } - } - } - let mut swarm = self.cached(id)?; - let before = swarm.to_yaml()?; - let result = change(&mut swarm)?; - if swarm.to_yaml()? == before { - return Ok(result); - } - let revision = self.copy_state(id)?.unwrap_or_default().revision; - self.keep(id, &swarm, CopyState { revision, pending: true })?; - if !self.sync { - println!("Changed this computer's copy of '{}'; 'cfcli swarm config push' uploads it", id); - } - Ok(result) - } - - /// Create the swarm on the server. Returns false when it already exists. - /// Without the server, the copy waits for a push. - pub async fn create(&self, id: &SharedId, swarm: &Swarm) -> Result { - if self.has_copy(id) && self.copy_state(id)?.is_some_and(|c| c.pending) { - return Ok(false); - } - match self.upload(id, swarm, Condition::New).await? { - Upload::Done(revision) => self.keep(id, swarm, CopyState { revision, pending: false })?, - Upload::Conflict => return Ok(false), - Upload::Offline => { - self.keep(id, swarm, CopyState { revision: 0, pending: true })?; - println!("Created '{}' on this computer; 'cfcli swarm config push' uploads it", id); - } - } - Ok(true) - } - - /// Delete the swarm on the server, and the copy. Needs the server, - /// unless the swarm never got there. - pub async fn delete(&self, id: &SharedId) -> Result<()> { - let copy = self.copy_state(id)?; - if copy.is_some_and(|c| c.revision == 0) { - return self.forget(id); - } - let mut request = self.client.delete(self.url(id)?); - // With sync off, the user decided on the copy they have: don't delete - // what someone uploaded since. With sync on, the copy is just the - // last one used, so the swarm goes whatever its revision. - if let Some(copy) = copy.filter(|_| !self.sync) { - request = request.header(header::IF_MATCH, format!("\"{}\"", copy.revision)); - } - let response = self.send_online(request, "deleting a shared swarm").await?; - match response.status() { - status if status.is_success() => self.forget(id), - StatusCode::NOT_FOUND => { - self.fail_if_renamed(id).await?; - self.forget(id)?; - bail!(CliError::NotFound(format!("swarm '{}' on {}", id, self.host()))) - } - StatusCode::PRECONDITION_FAILED => bail!(CliError::InvalidValue(format!( - "swarm '{}' changed on {} since this computer's copy; \ - 'cfcli swarm config pull --force {}' gets the new version", - id, - self.host(), - id - ))), - _ => auth::answer::(&self.credentials.server, response).await.map(|_| ()), - } - } - - /// The shared swarms: from the server when sync is on (and it answers), - /// else from the copies. Swarms created here and not uploaded are - /// included either way. - pub async fn list(&self) -> Result> { - let server = if self.sync { self.server_list().await? } else { None }; - // After the list, which moves the copies of renamed organizations. - let state = self.state()?; - let mut listed = Vec::new(); - match server { - Some(server) => { - for s in server { - let id = SharedId { org: s.org, swarm: s.swarm }; - let copy = state.swarms.get(&id.to_string()).copied().unwrap_or(CopyState { - revision: s.revision, - pending: false, - }); - listed.push(Listed { id, name: s.name, units: s.units, copy }); - } - // Copies of swarms gone from the server go too, unless they - // have changes to upload. - for (key, copy) in &state.swarms { - let Some(id) = SharedId::parse(key)? else { continue }; - if listed.iter().any(|l| l.id == id) { - continue; - } - if copy.pending { - listed.push(self.listed_copy(id, *copy)); - } else { - self.forget(&id)?; - } - } - } - None => { - for (key, copy) in &state.swarms { - if let Some(id) = SharedId::parse(key)? { - listed.push(self.listed_copy(id, *copy)); - } - } - } - } - listed.sort_by(|a, b| a.id.cmp(&b.id)); - Ok(listed) - } - - fn listed_copy(&self, id: SharedId, copy: CopyState) -> Listed { - let (name, units) = match self.cached(&id) { - Ok(swarm) => (swarm.name, swarm.units.len()), - Err(e) => (format!("(can't read: {:#})", e), 0), - }; - Listed { id, name, units, copy } - } - - /// Get the latest version of every shared swarm (or of one). Copies - /// with changes that aren't uploaded are kept, unless `force`. - pub async fn pull(&self, only: Option<&SharedId>, force: bool) -> Result<()> { - let Some(server) = self.server_list().await? else { - bail!(CliError::Connection(format!("{} can't be reached", self.host()))); - }; - let on_server: Vec = server.into_iter().map(|s| SharedId { org: s.org, swarm: s.swarm }).collect(); - if let Some(only) = only { - if let Some(now) = self.renamed(only)? { - bail!(renamed_error(only, &now)); - } - if !on_server.contains(only) { - bail!(CliError::NotFound(format!("swarm '{}' on {}", only, self.host()))); - } - } - let mut kept = 0; - for id in on_server.iter().filter(|id| only.is_none_or(|only| *id == only)) { - let copy = self.copy_state(id)?; - if copy.is_some_and(|c| c.pending) && !force { - println!("Kept '{}': it has changes that aren't uploaded", id); - kept += 1; - continue; - } - let before = copy.map(|c| c.revision); - self.download(id, force).await?; - let after = self.copy_state(id)?.map(|c| c.revision); - if before != after { - println!("Pulled '{}' (revision {})", id, after.unwrap_or_default()); - } - } - // Copies of swarms deleted on the server. - for id in self.cached_ids() { - if on_server.contains(&id) || only.is_some_and(|only| *only != id) { - continue; - } - let pending = self.copy_state(&id)?.is_some_and(|c| c.pending); - if pending && !force { - println!("Kept '{}': it is gone from {}, but has changes that aren't uploaded", id, self.host()); - kept += 1; - } else { - self.forget(&id)?; - println!("Removed '{}': it is gone from {}", id, self.host()); - } - } - if kept > 0 { - println!("Upload kept changes with 'cfcli swarm config push', or drop them with 'pull --force'"); - } - Ok(()) - } - - /// Upload the changes made while sync was off or the server was out of - /// reach. `force` overwrites what others uploaded in between. - pub async fn push(&self, only: Option<&SharedId>, force: bool) -> Result<()> { - // Copies of organizations with a new ID move to it first. - self.server_list().await?; - if let Some(only) = only { - if let Some(now) = self.renamed(only)? { - bail!(renamed_error(only, &now)); - } - } - let pending: Vec = self - .state()? - .swarms - .iter() - .filter(|(_, copy)| copy.pending) - .filter_map(|(id, _)| SharedId::parse(id).ok().flatten()) - .filter(|id| only.is_none_or(|only| id == only)) - .collect(); - if pending.is_empty() { - println!("Nothing to push"); - return Ok(()); - } - let mut failed = Vec::new(); - for id in &pending { - match self.push_pending(id, force).await { - Ok(true) => println!( - "Pushed '{}' (revision {})", - id, - self.copy_state(id)?.unwrap_or_default().revision - ), - Ok(false) => bail!(CliError::Connection(format!("{} can't be reached", self.host()))), - Err(e) => { - match e.downcast_ref::() { - Some(CliError::InvalidValue(message)) => println!("{}", message), - _ => println!("{:#}", e), - } - failed.push(id.to_string()); - } - } - } - if !failed.is_empty() { - bail!(CliError::InvalidValue(format!("not pushed: {}", failed.join(", ")))); - } - Ok(()) - } -} - -fn renamed_error(id: &SharedId, now: &SharedId) -> CliError { - CliError::NotFound(format!( - "swarm '{}' (your organization '{}' is now called '{}'; use '{}')", - id, id.org, now.org, now - )) -} - -/// Select the swarm under its organization's new ID, if one of `renames` -/// (old, new) changed the selected swarm's. -fn select_renamed(renames: &[(String, String)]) { - let Ok(mut config) = confy::load::("cf-cli", None) else { - return; - }; - let Some(selected) = config.swarm.as_deref().and_then(|id| SharedId::parse(id).ok().flatten()) else { - return; - }; - if let Some((_, new)) = renames.iter().find(|(old, _)| *old == selected.org) { - let now = SharedId { org: new.clone(), swarm: selected.swarm }; - config.swarm = Some(now.to_string()); - if confy::store("cf-cli", None, config).is_ok() { - println!("The selected swarm is '{}' now", now); - } - } -} - -/// The revision in a response's ETag, `"N"`. -fn etag_revision(response: &Response) -> Result { - response - .headers() - .get(header::ETAG) - .and_then(|etag| etag.to_str().ok()) - .and_then(|etag| etag.trim_start_matches("W/").trim_matches('"').parse().ok()) - .context("the server sent a swarm without its revision") -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_shared_and_local_ids() { - assert_eq!(SharedId::parse("lab").unwrap(), None); - let id = SharedId::parse("bitcraze-lab/cage").unwrap().unwrap(); - assert_eq!((id.org.as_str(), id.swarm.as_str()), ("bitcraze-lab", "cage")); - assert_eq!(id.to_string(), "bitcraze-lab/cage"); - for bad in ["Lab/cage", "/cage", "lab/", "lab/a/b", "lab/.x", "la b/x"] { - assert!(SharedId::parse(bad).is_err(), "{}", bad); - } - } -} diff --git a/src/modules/swarm/store.rs b/src/modules/swarm/store.rs index 52ce56c..e6e73bf 100644 --- a/src/modules/swarm/store.rs +++ b/src/modules/swarm/store.rs @@ -14,13 +14,14 @@ //! ``` //! //! Fields cfcli doesn't know are kept when a file is rewritten, so nothing -//! Swarmkeeper adds later is lost. +//! Swarmkeeper adds later is lost. A swarm may name the lighthouse +//! configuration it flies in (`lighthouse: lab/cage`). -use anyhow::{bail, Context, Result}; +use anyhow::{bail, Result}; use serde::{Deserialize, Serialize}; -use std::path::{Path, PathBuf}; use crate::error::CliError; +use crate::modules::documents::{self, Document}; use crate::utils::radio::{self, RadioUri}; /// One swarm file. @@ -29,6 +30,10 @@ pub struct Swarm { pub name: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, + /// The lighthouse configuration the swarm flies in: a local `` or a + /// shared `/` (see `cfcli lh config`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lighthouse: Option, // Always written, even when empty: Swarmkeeper requires the field. #[serde(default)] pub units: Vec, @@ -90,21 +95,12 @@ pub fn check_name(name: &str) -> Result<()> { /// Check a swarm ID, which is also a file name. pub fn check_id(id: &str) -> Result<()> { - let valid = !id.is_empty() - && !id.starts_with('.') - && id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')); - if !valid { - bail!(CliError::InvalidValue(format!( - "swarm ID '{}': use letters, digits, '-', '_' and '.' (not first)", - id - ))); - } - Ok(()) + documents::check_id::(id) } impl Swarm { pub fn new(name: String, description: Option) -> Self { - Swarm { name, description, units: Vec::new(), extra: serde_yaml::Mapping::new() } + Swarm { name, description, lighthouse: None, units: Vec::new(), extra: serde_yaml::Mapping::new() } } pub fn from_yaml(yaml: &str) -> Result { @@ -202,90 +198,37 @@ impl Swarm { } } -/// The folder holding the swarm files. -pub struct Store { - dir: PathBuf, -} +/// The folder holding the swarm files, next to the cfcli config file. +pub type Store = documents::Store; -impl Store { - /// The store next to the cfcli config file. - pub fn open() -> Result { - let config = confy::get_configuration_file_path("cf-cli", None) - .context("could not find the cfcli config folder")?; - Ok(Store { dir: config.with_file_name("swarms") }) - } +impl Document for Swarm { + const FOLDER: &'static str = "swarms"; + const NOUN: &'static str = "swarm"; + const COMMAND: &'static str = "swarm config"; + const SERVER_COUNT: &'static str = "units"; - /// A store in another folder: the copies of shared swarms, and tests. - pub fn at(dir: PathBuf) -> Self { - Store { dir } + fn from_yaml(yaml: &str) -> Result { + Swarm::from_yaml(yaml) } - pub fn dir(&self) -> &Path { - &self.dir + fn to_yaml(&self) -> Result { + Swarm::to_yaml(self) } - fn path(&self, id: &str) -> PathBuf { - self.dir.join(format!("{}.yaml", id)) + fn summary(&self) -> (String, usize) { + (self.name.clone(), self.units.len()) } - pub fn exists(&self, id: &str) -> bool { - self.path(id).is_file() - } - - /// The IDs of all stored swarms, sorted. - pub fn ids(&self) -> Result> { - let entries = match std::fs::read_dir(&self.dir) { - Ok(entries) => entries, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - Err(e) => return Err(e).with_context(|| format!("reading {}", self.dir.display())), + /// The organization of a shared lighthouse configuration. + fn rename_org(&mut self, old: &str, new: &str) -> bool { + let Some((org, config)) = self.lighthouse.as_deref().and_then(|l| l.split_once('/')) else { + return false; }; - let mut ids: Vec = entries - .filter_map(|entry| entry.ok()) - .map(|entry| entry.path()) - .filter(|path| path.extension().is_some_and(|ext| ext == "yaml")) - .filter_map(|path| path.file_stem().map(|stem| stem.to_string_lossy().into_owned())) - .filter(|id| check_id(id).is_ok()) - .collect(); - ids.sort(); - Ok(ids) - } - - /// The file exactly as stored. - pub fn read_raw(&self, id: &str) -> Result { - let path = self.path(id); - match std::fs::read_to_string(&path) { - Ok(yaml) => Ok(yaml), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - bail!(CliError::NotFound(format!("swarm '{}' (see 'cfcli swarm config list')", id))) - } - Err(e) => Err(e).with_context(|| format!("reading {}", path.display())), - } - } - - pub fn load(&self, id: &str) -> Result { - Swarm::from_yaml(&self.read_raw(id)?).with_context(|| format!("in {}", self.path(id).display())) - } - - /// Write a swarm, replacing any old file in one step. - pub fn save(&self, id: &str, swarm: &Swarm) -> Result<()> { - check_id(id)?; - std::fs::create_dir_all(&self.dir).with_context(|| format!("creating {}", self.dir.display()))?; - let path = self.path(id); - let tmp = self.dir.join(format!(".{}.yaml.tmp", id)); - std::fs::write(&tmp, swarm.to_yaml()?).with_context(|| format!("writing {}", tmp.display()))?; - std::fs::rename(&tmp, &path).with_context(|| format!("writing {}", path.display()))?; - Ok(()) - } - - pub fn delete(&self, id: &str) -> Result<()> { - let path = self.path(id); - match std::fs::remove_file(&path) { - Ok(()) => Ok(()), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - bail!(CliError::NotFound(format!("swarm '{}'", id))) - } - Err(e) => Err(e).with_context(|| format!("deleting {}", path.display())), + if org != old { + return false; } + self.lighthouse = Some(format!("{}/{}", new, config)); + true } } From de266d784bb7ddc52e52536bcc4a963110a79cf7 Mon Sep 17 00:00:00 2001 From: Marcus Eliasson Date: Thu, 8 Oct 2026 21:57:07 +0200 Subject: [PATCH 2/6] Point out swarms left naming a deleted lighthouse config, and other fixes lh config delete and move list the swarms that fly in the configuration: the local swarms and, for a shared configuration, the shared swarms the server lists (this computer's copies when it can't be reached). delete asks with the list shown, and both say how to give those swarms another configuration; a shared swarm can't follow one moved to this computer. lh config write and check without a configuration read stdin only when something is piped in. When stdin is empty, as for a command in a script or a cron job, they use the selected swarm's configuration, as in a terminal, instead of failing on an empty file. swarm lh write says which base stations a Crazyflie took another calibration for when the configuration it reads back differs from the one written. With sync on, lists of shared swarms and lighthouse configs show the server's revision, not that of this computer's copy, unless the copy has changes that aren't pushed. Stored configurations are named with "changes not pushed" when that is so. auth, settings sync and settings show mention lighthouse configs next to swarms, and settings show prints the lighthouse config folder. --- README.md | 4 +- docs/auth.md | 12 +-- docs/lighthouse.md | 27 ++++++- docs/settings.md | 13 +-- docs/swarm.md | 14 +++- src/cli.rs | 4 +- src/main.rs | 2 +- src/modules/auth.rs | 2 +- src/modules/documents.rs | 27 ++++++- src/modules/lighthouse/configs.rs | 128 ++++++++++++++++++++++++++---- src/modules/lighthouse/mod.rs | 40 ++++++---- src/modules/settings.rs | 7 +- src/modules/swarm/lighthouse.rs | 97 ++++++++++++++++++---- 13 files changed, 304 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index e34c6f0..9c68e85 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ Commands: lh Lighthouse positioning system configuration deck Decks attached to the Crazyflie swarm Swarms of Crazyflies: store them and run commands on them - auth Your account for sharing swarms: sign in, sign out + auth Your account for sharing swarms and lighthouse configs: sign in, sign out completions Generate a shell completion script (printed to stdout) help Print this message or the help of the given subcommand(s) @@ -245,7 +245,7 @@ prompt) a few flags make the output predictable: `platform info`, `swarm config list`/`show`, `swarm scan`, `swarm platform info`, `swarm param get`, `swarm log print`, `swarm deck list`, `swarm debug assert`, `swarm bootload info`, - `lh config display`, `lh config check`). Other + `swarm lh check`, `lh config list`/`display`/`check`). Other commands ignore the flag. Exit codes: diff --git a/docs/auth.md b/docs/auth.md index 2c63463..cafa941 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -1,7 +1,8 @@ # Signing in -The `auth` command signs cfcli in to the server that shares swarms between -cfcli, Swarmkeeper and the people you fly with. Signing in is optional: +The `auth` command signs cfcli in to the server that shares swarms and +lighthouse configurations between cfcli, Swarmkeeper and the people you fly +with. Signing in is optional: everything else in cfcli works without it. ```text @@ -44,8 +45,8 @@ cfcli auth status ``` Prints the server, your name, the key's name and the organizations your swarms -are shared in. When cfcli isn't signed in, or the key no longer works, it says -so and exits with code 20. +and lighthouse configurations are shared in. When cfcli isn't signed in, or +the key no longer works, it says so and exits with code 20. ## Sign out @@ -58,7 +59,8 @@ Revokes cfcli's key on the server and forgets it. ## The key cfcli signs in with an API key of its own, named "cfcli on <computer>". It -reads and uploads swarms as you in the organizations you are a member of. It +reads and uploads swarms and lighthouse configurations as you in the +organizations you are a member of. It is listed on the server's API keys page, where you can revoke it; that signs cfcli out too. diff --git a/docs/lighthouse.md b/docs/lighthouse.md index f5e71ef..3465ec6 100644 --- a/docs/lighthouse.md +++ b/docs/lighthouse.md @@ -58,7 +58,8 @@ Commands: `display`, `write` and `check` take a stored configuration by its ID, or a file with `-i`. `write` and `check` also read YAML piped in, and with none of these they use the configuration the selected swarm names (see -[Swarms](/docs/swarm.md#lighthouse)). +[Swarms](/docs/swarm.md#lighthouse)), also when stdin is empty, as for a +command in a script or a cron job. ### YAML File Format @@ -214,7 +215,8 @@ Options: - `-i, --input ` — read YAML from a file With neither, YAML piped in is written, or else the configuration the -selected swarm names. +selected swarm names. Stdin that isn't a terminal but has nothing in it (a +script, a cron job) counts as nothing piped in. All base station slots the firmware supports are written. Slots present in the YAML are uploaded as valid, while slots omitted from the YAML are written @@ -330,8 +332,25 @@ has seen elsewhere. `move` shares a local configuration (`move cage lab/cage`), takes a shared one back (which deletes it on the server for everyone in the organization), or -renames one. Swarms that name it need `cfcli swarm config lighthouse` with -the new ID. +renames one. Swarms that fly in it keep the old ID: `move` lists them and the +`cfcli swarm config lighthouse` command that gives them the new one. A shared +swarm can only name a shared configuration, so taking one back leaves its +shared swarms without it. + +`delete` deletes a configuration, a shared one on the server for everyone in +the organization. Before asking, it lists the swarms that fly in it: the +local ones and, for a shared configuration, the organization's shared swarms +(the server's list, or this computer's copies when the server can't be +reached). They keep naming it, so give them another one, or none with +`cfcli swarm config lighthouse --clear`. + +```text +$ cfcli lh config delete lab/cage +Swarms that fly in lighthouse config 'lab/cage': 'lab/flight-test' +? Delete lighthouse config 'lab/cage' on arc.bitcraze.io, for everyone in lab? Yes +Deleted lighthouse config 'lab/cage' +'lab/flight-test' still names it: give it another with 'cfcli swarm config lighthouse --swarm ', or none with '--clear' +``` ## Copy a Configuration Between Crazyflies diff --git a/docs/settings.md b/docs/settings.md index d2e0e33..ca6750e 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -11,7 +11,7 @@ Commands: show Show all current settings timeout Manage the connection timeout address Manage scan addresses - sync Whether swarm commands sync shared swarms with the server + sync Whether commands sync shared swarms and lighthouse configs with the server help Print this message or the help of the given subcommand(s) ``` @@ -23,7 +23,7 @@ Display all current settings at once: cfcli settings show ``` -This prints the connection timeout, the configured scan addresses, the path to the preserved console history file (used by the global `-p`/`--preserve-console` flag — see [Console](console.md)), the selected swarm and the folder the swarms are stored in (see [Swarms](swarm.md)). +This prints the connection timeout, the configured scan addresses, the path to the preserved console history file (used by the global `-p`/`--preserve-console` flag — see [Console](console.md)), the selected swarm, whether sync is on, and the folders swarms and lighthouse configurations are stored in (see [Swarms](swarm.md) and [Lighthouse](lighthouse.md#stored-and-shared-configurations)). ## Connection timeout @@ -92,12 +92,13 @@ If all addresses are removed, the list is automatically reset to the default cfcli settings address clear ``` -## Syncing shared swarms +## Syncing shared swarms and lighthouse configs -Whether swarm commands check the server before using a [shared swarm](swarm.md#sharing-swarms) +Whether commands check the server before using a [shared swarm](swarm.md#sharing-swarms) +or [lighthouse configuration](lighthouse.md#stored-and-shared-configurations) and upload changes right away (on, the default), or use this computer's copies -so they run faster (off). With sync off, `cfcli swarm config pull` and `push` -sync the copies. +so they run faster (off). With sync off, `cfcli swarm config pull`/`push` and +`cfcli lh config pull`/`push` sync the copies. ```bash cfcli settings sync show diff --git a/docs/swarm.md b/docs/swarm.md index 60ed8ac..34b9cdb 100644 --- a/docs/swarm.md +++ b/docs/swarm.md @@ -285,6 +285,7 @@ These commands work like the normal ones, on every Crazyflie in the swarm: | `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) | +| `cfcli swarm lh check \| write` | The lighthouse configuration of each Crazyflie, see [Lighthouse](#lighthouse) | `--cf` and `--exclude` (comma-separated names) pick some of the Crazyflies, and `--swarm ` runs on another swarm than the selected one: @@ -503,7 +504,7 @@ configuration than the one the swarm names. ```text $ cfcli swarm lh check -Lighthouse config 'lab/cage', revision 3: base stations 0, 1, 2, 3 +Lighthouse config 'lab/cage' (revision 3): base stations 0, 1, 2, 3 CF | Lighthouse ------+------------------------------------------ CF-01 | up to date @@ -512,7 +513,7 @@ CF-03 | no position for BS 0, 1, 2, 3 Error: differs: 2 of 3 Crazyflies have another lighthouse configuration; 'cfcli swarm lh write' gives them this one $ cfcli swarm lh write -Lighthouse config 'lab/cage', revision 3: base stations 0, 1, 2, 3 +Lighthouse config 'lab/cage' (revision 3): base stations 0, 1, 2, 3 CF-01: up to date CF-02: written and stored in flash CF-03: written and stored in flash @@ -523,6 +524,15 @@ CF-03: written and stored in flash differing_base_stations`). `write` refuses a Crazyflie whose firmware supports fewer base stations than the configuration has, before writing anything to it. +`write` reads each configuration back after writing it. A Crazyflie that sees +a base station whose UID isn't the configuration's takes that base station's +calibration, so the configuration doesn't stay; `write` then fails for that +Crazyflie and says which base stations: + +```text +CF-02: written, but then the Crazyflie took the calibration of the base stations it sees, which aren't the config's (BS 1 sees 0x2E08C9A4, the config has 0x8CADF4AC). If a base station was replaced, its geometry may need a new estimate; then store the configuration again with 'cfcli lh config save' +``` + When Crazyflies are added to a swarm that names a lighthouse config, `add` says how to give it to them (`cfcli swarm lh write --swarm --cf CF-07`). When the configuration changes, `swarm lh write` updates the diff --git a/src/cli.rs b/src/cli.rs index c5c08e5..0c54811 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -353,7 +353,7 @@ enum Commands { command: SwarmCommands, }, - /// Your account for sharing swarms: sign in, sign out + /// Your account for sharing swarms and lighthouse configs: sign in, sign out Auth { #[clap(subcommand)] command: AuthCommands, @@ -996,7 +996,7 @@ enum SettingsCommands { #[clap(subcommand)] command: SettingsAddressCommands, }, - /// Whether swarm commands sync shared swarms with the server + /// Whether commands sync shared swarms and lighthouse configs with the server Sync { #[clap(subcommand)] command: SettingsSyncCommands, diff --git a/src/main.rs b/src/main.rs index 404fe58..672f420 100644 --- a/src/main.rs +++ b/src/main.rs @@ -157,7 +157,7 @@ pub struct Config { /// ID of the selected swarm (see `cfcli swarm config select`) #[serde(default)] swarm: Option, - /// Whether swarm commands sync shared swarms with the server (see + /// Whether commands sync shared swarms and lighthouse configs with the server (see /// `cfcli settings sync`); None is the default, on. #[serde(default)] sync: Option, diff --git a/src/modules/auth.rs b/src/modules/auth.rs index 19ee66c..333edfc 100644 --- a/src/modules/auth.rs +++ b/src/modules/auth.rs @@ -199,7 +199,7 @@ fn print_orgs(server: &str, orgs: &[OrgInfo]) { ); return; } - println!("Swarms are shared in:"); + println!("Swarms and lighthouse configs are shared in:"); for org in orgs { println!(" {} ({}, {})", org.name, org.slug, org.role); } diff --git a/src/modules/documents.rs b/src/modules/documents.rs index 0e36dd4..64edfac 100644 --- a/src/modules/documents.rs +++ b/src/modules/documents.rs @@ -616,6 +616,23 @@ impl Shared { Ok(Some(listed)) } + /// A list the server keeps about `id`, `GET /api/v1////` + /// (the swarms that name a lighthouse configuration). None when the + /// server can't be reached; empty when it doesn't have `id`. + pub async fn related(&self, id: &SharedId, what: &str) -> Result>>> { + let url = auth::endpoint( + &self.credentials.server, + &format!("/api/v1/{}/{}/{}/{}", D::FOLDER, id.org, id.name, what), + )?; + let Some(response) = self.send_quietly(self.client.get(url), true).await? else { + return Ok(None); + }; + if response.status() == StatusCode::NOT_FOUND { + return Ok(Some(Vec::new())); + } + auth::answer(&self.credentials.server, response).await.map(Some) + } + /// After the server answered 404 for `id`: fail saying what it is called /// now, if the user changed the ID of its organization. Gets the list, /// which moves the copies. @@ -854,10 +871,12 @@ impl Shared { match server { Some(server) => { for item in server { - let copy = copies.get(&item.id.to_string()).copied().unwrap_or(CopyState { - revision: item.revision, - pending: false, - }); + // The server's revision, which the next command gets, + // unless the copy has changes to upload. + let copy = match copies.get(&item.id.to_string()) { + Some(copy) if copy.pending => *copy, + _ => CopyState { revision: item.revision, pending: false }, + }; listed.push(Listed { id: item.id, name: item.name, count: item.count, copy }); } // Copies of documents gone from the server go too, unless diff --git a/src/modules/lighthouse/configs.rs b/src/modules/lighthouse/configs.rs index 2f2dad9..646904f 100644 --- a/src/modules/lighthouse/configs.rs +++ b/src/modules/lighthouse/configs.rs @@ -62,16 +62,17 @@ fn base_stations(n: usize) -> String { } } -/// A stored configuration for the user: `'lab/cage' (revision 3)`. -fn describe(configs: &LhConfigs, id: &str) -> String { - let revision = SharedId::parse(id) +/// A stored configuration for the user: `'lab/cage' (revision 3)`, and +/// whether this computer's copy has changes the server doesn't have. +pub(crate) fn describe(configs: &LhConfigs, id: &str) -> String { + let copy = SharedId::parse(id) .ok() .flatten() - .and_then(|shared| configs.shared(&shared).ok()?.copy_state(&shared).ok()?) - .map(|copy| copy.revision) - .filter(|revision| *revision > 0); - match revision { - Some(revision) => format!("'{}' (revision {})", id, revision), + .and_then(|shared| configs.shared(&shared).ok()?.copy_state(&shared).ok()?); + match copy { + Some(copy) if copy.revision == 0 => format!("'{}' (not uploaded yet)", id), + Some(copy) if copy.pending => format!("'{}' (revision {}, changes not pushed)", id, copy.revision), + Some(copy) => format!("'{}' (revision {})", id, copy.revision), None => format!("'{}'", id), } } @@ -80,16 +81,17 @@ fn describe(configs: &LhConfigs, id: &str) -> String { /// The configuration for `write` and `check`, and how to name it: a stored /// one (`id`), a file (`input`), what is piped in, or else the one the -/// selected swarm names. +/// selected swarm names (also when stdin is empty, as in a script). pub async fn source(config: &Config, id: Option<&str>, input: Option<&str>) -> Result<(LighthouseConfigFile, String)> { - use std::io::IsTerminal; if let Some(id) = id { let configs = LhConfigs::open(config)?; return Ok((configs.load(id).await?, describe(&configs, id))); } - if input.is_some() || !std::io::stdin().is_terminal() { - let file = super::load(input)?; - return Ok((file, input.unwrap_or("the configuration from stdin").to_string())); + if let Some(path) = input { + return Ok((super::load(path)?, path.to_string())); + } + if let Some(file) = super::load_piped()? { + return Ok((file, "the configuration from stdin".to_string())); } let linked = match &config.swarm { Some(swarm) => Swarms::open(config)?.load(swarm).await?.lighthouse, @@ -328,7 +330,7 @@ pub async fn import( .unwrap_or_default(), }; check_new_id(configs, &id).with_context(|| format!("importing {} (pick another ID with --id)", path))?; - let mut file = super::load(Some(path))?; + let mut file = super::load(path)?; if let Some(name) = name { file.set_name(name); } @@ -358,6 +360,56 @@ pub async fn export(configs: &LhConfigs, id: &str, output: Option<&str>) -> Resu // ---- Deleting and moving ---- +/// The swarms that fly in lighthouse config `id`: the local swarms, and the +/// shared swarms the server lists (only a shared config can be named by +/// them), or this computer's copies of them when it can't be reached. +async fn swarms_naming(configs: &LhConfigs, id: &str) -> Vec { + let names = |swarm: &crate::modules::swarm::store::Swarm| swarm.lighthouse.as_deref() == Some(id); + let Ok(swarms) = Swarms::open_cached() else { + return Vec::new(); + }; + let mut naming: Vec = swarms + .local + .ids() + .unwrap_or_default() + .into_iter() + .filter(|swarm| swarms.local.load(swarm).is_ok_and(|s| names(&s))) + .collect(); + let Ok(Some(shared)) = SharedId::parse(id) else { + return naming; + }; + let listed = match configs.shared(&shared) { + Ok(server) => server.related(&shared, "swarms").await.ok().flatten(), + Err(_) => None, + }; + match listed { + Some(listed) => naming.extend( + listed + .iter() + .filter_map(|swarm| swarm.get("id").and_then(|id| id.as_str()).map(str::to_string)), + ), + None => { + if let Some(copies) = &swarms.shared { + for swarm in copies.cached_ids() { + if copies.cached(&swarm).is_ok_and(|s| names(&s)) { + naming.push(swarm.to_string()); + } + } + } + } + } + naming +} + +fn quoted(ids: &[String]) -> String { + ids.iter().map(|id| format!("'{}'", id)).collect::>().join(", ") +} + +/// "names" and "it" for one swarm, "name" and "them" for more. +fn verb_and_pronoun(ids: &[String]) -> (&'static str, &'static str) { + if ids.len() == 1 { ("names", "it") } else { ("name", "them") } +} + pub async fn delete(configs: &LhConfigs, id: Option<&str>, non_interactive: bool) -> Result<()> { let id = match id { Some(id) => id.to_string(), @@ -366,6 +418,10 @@ pub async fn delete(configs: &LhConfigs, id: Option<&str>, non_interactive: bool pick(configs, "Select the lighthouse config to delete:").await? } }; + let naming = swarms_naming(configs, &id).await; + if !naming.is_empty() { + println!("Swarms that fly in lighthouse config '{}': {}", id, quoted(&naming)); + } // Deleting can't be undone, so ask whenever there is someone to ask. if !non_interactive { let question = match SharedId::parse(&id)? { @@ -384,6 +440,16 @@ pub async fn delete(configs: &LhConfigs, id: Option<&str>, non_interactive: bool } configs.delete(&id).await?; println!("Deleted lighthouse config '{}'", id); + if !naming.is_empty() { + let (names, them) = verb_and_pronoun(&naming); + println!( + "{} still {} it: give {} another with 'cfcli swarm config lighthouse --swarm ', \ + or none with '--clear'", + quoted(&naming), + names, + them + ); + } Ok(()) } @@ -398,8 +464,12 @@ pub async fn move_config(configs: &LhConfigs, from: &str, to: &str, non_interact if SharedId::parse(to)?.is_none() && configs.local.exists(to) { bail!(CliError::InvalidValue(format!("lighthouse config '{}' already exists", to))); } + let naming = swarms_naming(configs, from).await; // Moving a shared configuration away deletes it for everyone: ask. if let Some(shared) = SharedId::parse(from)? { + if !naming.is_empty() { + println!("Swarms that fly in lighthouse config '{}': {}", from, quoted(&naming)); + } if !non_interactive { let question = format!( "Move '{}' to '{}'? It is deleted on {} for everyone in {}.", @@ -425,7 +495,35 @@ pub async fn move_config(configs: &LhConfigs, from: &str, to: &str, non_interact to, configs.place(to) ); - println!("Swarms that name '{}' need 'cfcli swarm config lighthouse {}'", from, to); + if !naming.is_empty() { + // A shared swarm can only name a shared configuration. + let to_shared = SharedId::parse(to)?.is_some(); + let (can, cannot): (Vec, Vec) = naming + .into_iter() + .partition(|swarm| to_shared || !matches!(SharedId::parse(swarm), Ok(Some(_)))); + if !can.is_empty() { + let (names, them) = verb_and_pronoun(&can); + println!( + "{} still {} '{}': 'cfcli swarm config lighthouse {} --swarm ' gives {} '{}'", + quoted(&can), + names, + from, + to, + them, + to + ); + } + if !cannot.is_empty() { + let (names, _) = verb_and_pronoun(&cannot); + println!( + "{} still {} '{}': a shared swarm can't name '{}' on this computer", + quoted(&cannot), + names, + from, + to + ); + } + } Ok(()) } diff --git a/src/modules/lighthouse/mod.rs b/src/modules/lighthouse/mod.rs index 9c6f854..a51843d 100644 --- a/src/modules/lighthouse/mod.rs +++ b/src/modules/lighthouse/mod.rs @@ -626,22 +626,30 @@ pub async fn write_config( // ---- Commands ---- -/// Read a configuration file, or stdin when no path is given. -pub fn load(file_path: Option<&str>) -> Result { - let yaml = match file_path { - Some(path) => std::fs::read_to_string(path) - .with_context(|| format!("Failed to read lighthouse config file: {}", path))?, - None => { - use std::io::Read; - let mut buf = String::new(); - std::io::stdin() - .read_to_string(&mut buf) - .context("Failed to read lighthouse config from stdin")?; - buf - } - }; +/// Read a configuration file. +pub fn load(path: &str) -> Result { + let yaml = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read lighthouse config file: {}", path))?; + LighthouseConfigFile::from_yaml(&yaml).with_context(|| format!("Failed to load {}", path)) +} + +/// The configuration piped in, or None when nothing is: stdin is a +/// terminal, or empty, as for a command run from a script or cron. +pub fn load_piped() -> Result> { + use std::io::{IsTerminal, Read}; + if std::io::stdin().is_terminal() { + return Ok(None); + } + let mut yaml = String::new(); + std::io::stdin() + .read_to_string(&mut yaml) + .context("Failed to read lighthouse config from stdin")?; + if yaml.trim().is_empty() { + return Ok(None); + } LighthouseConfigFile::from_yaml(&yaml) - .with_context(|| format!("Failed to load {}", file_path.unwrap_or("the configuration from stdin"))) + .context("Failed to load the configuration from stdin") + .map(Some) } pub(crate) async fn read_with_progress(cf: &Crazyflie, non_interactive: bool) -> Result { @@ -662,7 +670,7 @@ pub async fn display(cf: &Crazyflie, csv: bool, non_interactive: bool) -> Result /// Display lighthouse configuration from a YAML file (no connection needed) pub fn display_file(file_path: &str, csv: bool) -> Result<()> { - let config = load(Some(file_path))?; + let config = load(file_path)?; print_config(&config, &format!("Lighthouse Configuration File: {}", file_path), csv); Ok(()) } diff --git a/src/modules/settings.rs b/src/modules/settings.rs index 550a3b2..a5c342e 100644 --- a/src/modules/settings.rs +++ b/src/modules/settings.rs @@ -1,5 +1,7 @@ use anyhow::Result; use crate::{Config, console_preserve_path, decode_address}; +use crate::modules::documents; +use crate::modules::lighthouse::LighthouseConfigFile; use crate::modules::swarm::store::Store; pub fn show(config: &Config) { @@ -15,6 +17,9 @@ pub fn show(config: &Config) { if let Ok(store) = Store::open() { println!("Swarm folder: {}", store.dir().display()); } + if let Ok(store) = documents::Store::::open() { + println!("Lighthouse config folder: {}", store.dir().display()); + } } pub fn timeout_show(config: &Config) { @@ -88,7 +93,7 @@ pub fn address_clear(config: &mut Config) { pub fn sync_show(config: &Config) { println!( - "Sync shared swarms: {}{}", + "Sync shared swarms and lighthouse configs: {}{}", if config.sync_on() { "on" } else { "off" }, if config.sync.is_none() { " (default)" } else { "" } ); diff --git a/src/modules/swarm/lighthouse.rs b/src/modules/swarm/lighthouse.rs index 754f63b..197d880 100644 --- a/src/modules/swarm/lighthouse.rs +++ b/src/modules/swarm/lighthouse.rs @@ -8,14 +8,14 @@ //! which makes `swarm lh write` the way to give new Crazyflies the swarm's //! configuration, and to update all of them when it changes. -use anyhow::{bail, Result}; +use anyhow::{anyhow, bail, Result}; use tabled::Tabled; use super::runner::{csv_row_for, split, Runner, SwarmRow}; use super::Swarms; use crate::error::CliError; use crate::modules::documents::SharedId; -use crate::modules::lighthouse::configs::LhConfigs; +use crate::modules::lighthouse::configs::{describe, LhConfigs}; use crate::modules::lighthouse::{ check_supported, compare, describe_distance, is_same, read_config, supported_base_stations, write_config, BaseStationDiff, CalibrationDelta, LighthouseConfigFile, Part, @@ -83,14 +83,9 @@ pub async fn run( let configs = LhConfigs::open(config)?; let wanted = configs.load(id).await?; if !csv { - let revision = SharedId::parse(id)? - .and_then(|shared| configs.shared(&shared).ok()?.copy_state(&shared).ok()?) - .map(|copy| format!(", revision {}", copy.revision)) - .unwrap_or_default(); println!( - "Lighthouse config '{}'{}: base stations {}", - id, - revision, + "Lighthouse config {}: base stations {}", + describe(&configs, id), ids(&wanted.geos.keys().copied().collect::>()) ); } @@ -218,9 +213,10 @@ async fn check(runner: &Runner<'_>, wanted: &LighthouseConfigFile, csv: bool) -> outcome.finish()?; if differing > 0 { bail!(CliError::Differs(format!( - "{} of {} have another lighthouse configuration; 'cfcli swarm lh write' gives them this one", + "{} of {} {} another lighthouse configuration; 'cfcli swarm lh write' gives them this one", differing, - super::crazyflies(done.len()) + super::crazyflies(done.len()), + if differing == 1 { "has" } else { "have" } ))); } Ok(()) @@ -235,9 +231,9 @@ async fn write(runner: &Runner<'_>, wanted: &LighthouseConfigFile, force: bool) return Ok(false); } write_config(cf, wanted, |_, _| {}).await?; - let back = read_config(cf, |_, _| {}).await?; - if !is_same(&compare(wanted, &back)) { - bail!("the Crazyflie has another configuration after writing it"); + let diffs = compare(wanted, &read_config(cf, |_, _| {}).await?); + if !is_same(&diffs) { + return Err(not_kept(wanted, diffs)); } Ok(true) }) @@ -256,6 +252,40 @@ async fn write(runner: &Runner<'_>, wanted: &LighthouseConfigFile, force: bool) outcome.finish() } +/// Why a Crazyflie doesn't have the configuration when it is read back +/// right after writing. Usually the Crazyflie has taken the calibration of +/// a base station it sees whose UID isn't the configuration's (see +/// [`CalibrationDelta::Replaced`]). +fn not_kept(wanted: &LighthouseConfigFile, diffs: Vec) -> anyhow::Error { + let replaced: Vec = diffs + .iter() + .filter_map(|d| match d.calibration { + Part::Differs(CalibrationDelta::Replaced { file_uid, cf_uid }) => Some(format!( + "BS {} sees 0x{:08X}, the config has 0x{:08X}", + d.id, cf_uid, file_uid + )), + _ => None, + }) + .collect(); + let only_replaced = diffs.iter().all(|d| { + matches!(d.geometry, Part::Same | Part::Absent) + && matches!( + d.calibration, + Part::Same | Part::Absent | Part::Differs(CalibrationDelta::Replaced { .. }) + ) + }); + if !replaced.is_empty() && only_replaced { + return anyhow!( + "written, but then the Crazyflie took the calibration of the base stations it sees, which aren't the \ + config's ({}). If a base station was replaced, its geometry may need a new estimate; then store \ + the configuration again with 'cfcli lh config save'", + replaced.join("; ") + ); + } + let found = Found { diffs, supported: None }; + anyhow!("the Crazyflie has another configuration after writing it: {}", found.summary(wanted)) +} + /// A hint after Crazyflies were added to a swarm that names a lighthouse /// configuration: how to give it to them. pub fn hint_for_new(swarm: &str, lighthouse: Option<&str>, added: &[String]) { @@ -269,3 +299,42 @@ pub fn hint_for_new(swarm: &str, lighthouse: Option<&str>, added: &[String]) { ); } } + +#[cfg(test)] +mod tests { + use super::*; + + const FILE: &str = "\ +type: lighthouse_system_configuration +version: '1' +systemType: 2 +geos: + 0: + origin: [0.0, 0.0, 2.0] + rotation: [[1, 0, 0], [0, 1, 0], [0, 0, 1]] +calibs: + 0: + uid: 2360210604 + sweeps: + - {phase: 0.0, tilt: -0.051, curve: 0.275, gibmag: -0.005, gibphase: 2.281, ogeemag: -0.184, ogeephase: 1.847} + - {phase: -0.004, tilt: 0.047, curve: 0.367, gibmag: -0.005, gibphase: 2.548, ogeemag: -0.124, ogeephase: 2.051} +"; + + #[test] + fn says_why_a_write_did_not_stay() { + let wanted = LighthouseConfigFile::from_yaml(FILE).unwrap(); + + // The Crazyflie took the calibration of the base station it sees. + let mut seen = wanted.clone(); + seen.calibs.get_mut(&0).unwrap().uid = 0x12345678; + let message = not_kept(&wanted, compare(&wanted, &seen)).to_string(); + assert!(message.contains("BS 0 sees 0x12345678, the config has 0x8CADF4AC"), "{}", message); + + // Anything else: what differs. + let mut moved = seen.clone(); + moved.geos.get_mut(&0).unwrap().origin[0] = 0.05; + let message = not_kept(&wanted, compare(&wanted, &moved)).to_string(); + assert!(message.starts_with("the Crazyflie has another configuration"), "{}", message); + assert!(message.contains("BS 0 moved 5.0 cm"), "{}", message); + } +} From 1650a780efe9c3af7c407d5dc37e95f7aa7c2156 Mon Sep 17 00:00:00 2001 From: Marcus Eliasson Date: Thu, 8 Oct 2026 22:19:08 +0200 Subject: [PATCH 3/6] Rename swarm config lighthouse to swarm config lh The same name as cfcli lh and swarm lh. The field in the swarm file stays lighthouse:. --- build.rs | 2 +- completions/addendum.bash | 2 +- completions/addendum.ps1 | 2 +- docs/lighthouse.md | 6 +++--- docs/swarm.md | 6 +++--- src/cli.rs | 2 +- src/modules/lighthouse/configs.rs | 4 ++-- src/modules/swarm/lighthouse.rs | 8 ++++---- src/modules/swarm/mod.rs | 2 +- 9 files changed, 17 insertions(+), 17 deletions(-) diff --git a/build.rs b/build.rs index 121d513..f6732e5 100644 --- a/build.rs +++ b/build.rs @@ -100,7 +100,7 @@ fn main() { "or lists them when non-interactive):_cfcli_swarm_configs", ); // Lighthouse config IDs: `--config`, `import --id` and the positional - // ID of the `lh config` commands and `swarm config lighthouse`. + // ID of the `lh config` commands and `swarm config lh`. s = s.replace(":CONFIG:_default", ":CONFIG:_cfcli_lh_configs"); for help in [ "Stored lighthouse config to display", diff --git a/completions/addendum.bash b/completions/addendum.bash index e6025d2..7f16750 100644 --- a/completions/addendum.bash +++ b/completions/addendum.bash @@ -46,7 +46,7 @@ _cfcli_dynamic() { *" swarm config select "*|*" swarm config delete "*|*" swarm config show "*|*" swarm config export "*) kind="swarm-configs" ;; *" swarm config remove "*|*" swarm config rename "*) kind="swarm-units" ;; - *" swarm config lighthouse "*|*" lh config display "*|*" lh config save "*|*" lh config write "*|\ + *" swarm config lh "*|*" lh config display "*|*" lh config save "*|*" lh config write "*|\ *" lh config check "*|*" lh config export "*|*" lh config delete "*|*" lh config move "*|\ *" lh config pull "*|*" lh config push "*) kind="lh-configs" ;; esac diff --git a/completions/addendum.ps1 b/completions/addendum.ps1 index 55291b4..f63c4a4 100644 --- a/completions/addendum.ps1 +++ b/completions/addendum.ps1 @@ -15,7 +15,7 @@ 'cfcli;(swarm;)?log;print$' { $cfcliDynKind = 'log-names' } 'cfcli;swarm;config;(select|delete|show|export)$' { $cfcliDynKind = 'swarm-configs' } 'cfcli;swarm;config;(remove|rename)$' { $cfcliDynKind = 'swarm-units' } - 'cfcli;swarm;config;lighthouse$' { $cfcliDynKind = 'lh-configs' } + 'cfcli;swarm;config;lh$' { $cfcliDynKind = 'lh-configs' } 'cfcli;lh;config;(display|save|write|check|export|delete|move|pull|push)$' { $cfcliDynKind = 'lh-configs' } } # Option values: `--targets x,y` (plain list) / `--bin t=f` (key=value), diff --git a/docs/lighthouse.md b/docs/lighthouse.md index 3465ec6..01722ef 100644 --- a/docs/lighthouse.md +++ b/docs/lighthouse.md @@ -333,7 +333,7 @@ has seen elsewhere. `move` shares a local configuration (`move cage lab/cage`), takes a shared one back (which deletes it on the server for everyone in the organization), or renames one. Swarms that fly in it keep the old ID: `move` lists them and the -`cfcli swarm config lighthouse` command that gives them the new one. A shared +`cfcli swarm config lh` command that gives them the new one. A shared swarm can only name a shared configuration, so taking one back leaves its shared swarms without it. @@ -342,14 +342,14 @@ the organization. Before asking, it lists the swarms that fly in it: the local ones and, for a shared configuration, the organization's shared swarms (the server's list, or this computer's copies when the server can't be reached). They keep naming it, so give them another one, or none with -`cfcli swarm config lighthouse --clear`. +`cfcli swarm config lh --clear`. ```text $ cfcli lh config delete lab/cage Swarms that fly in lighthouse config 'lab/cage': 'lab/flight-test' ? Delete lighthouse config 'lab/cage' on arc.bitcraze.io, for everyone in lab? Yes Deleted lighthouse config 'lab/cage' -'lab/flight-test' still names it: give it another with 'cfcli swarm config lighthouse --swarm ', or none with '--clear' +'lab/flight-test' still names it: give it another with 'cfcli swarm config lh --swarm ', or none with '--clear' ``` ## Copy a Configuration Between Crazyflies diff --git a/docs/swarm.md b/docs/swarm.md index 34b9cdb..5529bdc 100644 --- a/docs/swarm.md +++ b/docs/swarm.md @@ -492,9 +492,9 @@ A swarm can name the lighthouse configuration it flies in, a stored one (see swarm can only name a shared configuration, so everyone who uses it gets it. ```text -cfcli swarm config lighthouse lab/cage # the selected swarm flies in lab/cage -cfcli swarm config lighthouse # show which one it names -cfcli swarm config lighthouse --clear +cfcli swarm config lh lab/cage # the selected swarm flies in lab/cage +cfcli swarm config lh # show which one it names +cfcli swarm config lh --clear ``` `swarm lh check` compares each Crazyflie's configuration with it, and diff --git a/src/cli.rs b/src/cli.rs index 0c54811..438a275 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -859,7 +859,7 @@ enum SwarmConfigCommands { to: String, }, /// Show or set the lighthouse config the swarm flies in (see 'cfcli lh config') - Lighthouse { + Lh { /// Lighthouse config ID; a shared swarm needs a shared one (/) #[clap(value_name = "CONFIG")] config: Option, diff --git a/src/modules/lighthouse/configs.rs b/src/modules/lighthouse/configs.rs index 646904f..d008486 100644 --- a/src/modules/lighthouse/configs.rs +++ b/src/modules/lighthouse/configs.rs @@ -443,7 +443,7 @@ pub async fn delete(configs: &LhConfigs, id: Option<&str>, non_interactive: bool if !naming.is_empty() { let (names, them) = verb_and_pronoun(&naming); println!( - "{} still {} it: give {} another with 'cfcli swarm config lighthouse --swarm ', \ + "{} still {} it: give {} another with 'cfcli swarm config lh --swarm ', \ or none with '--clear'", quoted(&naming), names, @@ -504,7 +504,7 @@ pub async fn move_config(configs: &LhConfigs, from: &str, to: &str, non_interact if !can.is_empty() { let (names, them) = verb_and_pronoun(&can); println!( - "{} still {} '{}': 'cfcli swarm config lighthouse {} --swarm ' gives {} '{}'", + "{} still {} '{}': 'cfcli swarm config lh {} --swarm ' gives {} '{}'", quoted(&can), names, from, diff --git a/src/modules/swarm/lighthouse.rs b/src/modules/swarm/lighthouse.rs index 197d880..d670b20 100644 --- a/src/modules/swarm/lighthouse.rs +++ b/src/modules/swarm/lighthouse.rs @@ -1,5 +1,5 @@ //! The lighthouse configuration of a swarm: which one it flies in (`swarm -//! config lighthouse`), and checking and writing it on its Crazyflies +//! config lh`), and checking and writing it on its Crazyflies //! (`swarm lh check|write`). //! //! A swarm names a stored lighthouse configuration (`lighthouse:` in the @@ -23,14 +23,14 @@ use crate::modules::lighthouse::{ use crate::utils::display::{csv_row, print_table, table}; use crate::{Config, SwarmLhCommands}; -/// `swarm config lighthouse [CONFIG] [--clear]` +/// `swarm config lh [CONFIG] [--clear]` pub async fn link(swarms: &Swarms, config: &Config, id: &str, new: Option<&str>, clear: bool) -> Result<()> { if !clear && new.is_none() { let swarm = swarms.load(id).await?; match swarm.lighthouse { Some(lighthouse) => println!("Swarm '{}' flies in lighthouse config '{}'", id, lighthouse), None => println!( - "Swarm '{}' names no lighthouse config; set one with 'cfcli swarm config lighthouse '", + "Swarm '{}' names no lighthouse config; set one with 'cfcli swarm config lh '", id ), } @@ -76,7 +76,7 @@ pub async fn run( }; let Some(id) = given.or(linked) else { bail!(CliError::NotFound(format!( - "lighthouse config of swarm '{}'; name one with 'cfcli swarm config lighthouse ' or give --config", + "lighthouse config of swarm '{}'; name one with 'cfcli swarm config lh ' or give --config", swarm_id ))); }; diff --git a/src/modules/swarm/mod.rs b/src/modules/swarm/mod.rs index 1520395..35230bb 100644 --- a/src/modules/swarm/mod.rs +++ b/src/modules/swarm/mod.rs @@ -79,7 +79,7 @@ pub(crate) async fn run( export(&swarms, &swarm_id(config, params.id.as_deref())?, params).await } SwarmConfigCommands::Move { from, to } => move_swarm(&swarms, config, from, to, non_interactive).await, - SwarmConfigCommands::Lighthouse { config: new, clear, swarm } => { + SwarmConfigCommands::Lh { config: new, clear, swarm } => { let id = swarm_id(config, swarm.as_deref())?; lighthouse::link(&swarms, config, &id, new.as_deref(), *clear).await } From 3fef02c8303bacd9fcf01c53aedcb06ce58eb491 Mon Sep 17 00:00:00 2001 From: Marcus Eliasson Date: Thu, 8 Oct 2026 22:43:25 +0200 Subject: [PATCH 4/6] Pick lighthouse configs from a list, and keep swarms to their own kind swarm config lh without a configuration lets the user pick one, starting at the one the swarm names, and shows it when not interactive. lh config write without one lets the user pick from all the stored configurations, starting at the selected swarm's; without a terminal it still writes the selected swarm's. A local swarm can only name a lighthouse config on this computer, and a shared swarm only one in its organization, which everyone who uses the swarm can read. The list for swarm config lh shows those, and lh config move says which swarms can't follow a configuration to its new ID. --- build.rs | 2 +- docs/lighthouse.md | 41 +++++++++---- docs/swarm.md | 14 +++-- src/cli.rs | 13 ++-- src/main.rs | 6 +- src/modules/lighthouse/configs.rs | 77 +++++++++++++++++++----- src/modules/swarm/lighthouse.rs | 98 ++++++++++++++++++++++++------- src/modules/swarm/mod.rs | 2 +- 8 files changed, 195 insertions(+), 58 deletions(-) diff --git a/build.rs b/build.rs index f6732e5..8f81ef6 100644 --- a/build.rs +++ b/build.rs @@ -110,7 +110,7 @@ fn main() { "Lighthouse config ID (prompts for one if omitted)", "The lighthouse config to move", "Only this shared lighthouse config (/)", - "Lighthouse config ID; a shared swarm needs a shared one (/)", + "Lighthouse config ID, picked from a list if omitted (a local swarm names one on this computer, a shared swarm one in its organization)", "/ shares it on the server", ] { s = s.replace(&format!("{}:_default", help), &format!("{}:_cfcli_lh_configs", help)); diff --git a/docs/lighthouse.md b/docs/lighthouse.md index 01722ef..6e0b20c 100644 --- a/docs/lighthouse.md +++ b/docs/lighthouse.md @@ -46,7 +46,7 @@ Commands: read Read the Crazyflie's lighthouse configuration as YAML (to file or stdout) save Store the Crazyflie's lighthouse configuration as (new, or an update) write Write a lighthouse configuration to the Crazyflie - check Compare the Crazyflie's lighthouse configuration with one (as for write) + check Compare the Crazyflie's lighthouse configuration with one import Store a lighthouse configuration file (from the Crazyflie client or read) export Write a stored lighthouse configuration to a file the Crazyflie client opens (or stdout) delete Delete a stored lighthouse configuration @@ -56,10 +56,11 @@ Commands: ``` `display`, `write` and `check` take a stored configuration by its ID, or a -file with `-i`. `write` and `check` also read YAML piped in, and with none of -these they use the configuration the selected swarm names (see -[Swarms](/docs/swarm.md#lighthouse)), also when stdin is empty, as for a -command in a script or a cron job. +file with `-i`. `write` and `check` also read YAML piped in. With none of +these, `write` lists the stored configurations to pick one from, and `check` +uses the configuration the selected swarm names (see +[Swarms](/docs/swarm.md#lighthouse)). Without a terminal, as for a command in +a script or a cron job, `write` uses the selected swarm's too. ### YAML File Format @@ -214,9 +215,20 @@ Options: - `` — a stored configuration - `-i, --input ` — read YAML from a file -With neither, YAML piped in is written, or else the configuration the -selected swarm names. Stdin that isn't a terminal but has nothing in it (a -script, a cron job) counts as nothing piped in. +With neither, YAML piped in is written. Otherwise, in a terminal, `write` +lists all the stored configurations (local ones and the shared ones in all +your organizations) to pick one from, starting at the one the selected swarm +names. Without a terminal it writes the selected swarm's configuration. Stdin +that isn't a terminal but has nothing in it (a script, a cron job) counts as +nothing piped in. + +```text +$ cfcli lh config write +? Lighthouse config to write: + cage - Local cage (4 base stations) +> lab/cage - The cage (4 base stations) + other/room - Other room (4 base stations) +``` All base station slots the firmware supports are written. Slots present in the YAML are uploaded as valid, while slots omitted from the YAML are written @@ -237,6 +249,9 @@ cfcli lh config write -i my_setup.yaml # Write a stored (or shared) config cfcli lh config write lab/cage +# Pick one of the stored configs +cfcli lh config write + # Pipe YAML in from stdin cat my_setup.yaml | cfcli lh config write ``` @@ -250,7 +265,8 @@ base station. cfcli lh config check [ | -i ] ``` -The configuration is chosen as for [write](#write). +The configuration is chosen as for [write](#write), except that without one +`check` doesn't ask: it uses the configuration the selected swarm names. Values are compared exactly, as the Crazyflie stores them. For each base station, the geometry and the calibration are: @@ -333,9 +349,10 @@ has seen elsewhere. `move` shares a local configuration (`move cage lab/cage`), takes a shared one back (which deletes it on the server for everyone in the organization), or renames one. Swarms that fly in it keep the old ID: `move` lists them and the -`cfcli swarm config lh` command that gives them the new one. A shared -swarm can only name a shared configuration, so taking one back leaves its -shared swarms without it. +`cfcli swarm config lh` command that gives them the new one. A local swarm +can only name a configuration on this computer, and a shared swarm one in its +organization, so not every swarm can follow: taking a shared configuration +back, for example, leaves its shared swarms without it. `delete` deletes a configuration, a shared one on the server for everyone in the organization. Before asking, it lists the swarms that fly in it: the diff --git a/docs/swarm.md b/docs/swarm.md index 5529bdc..0df97fa 100644 --- a/docs/swarm.md +++ b/docs/swarm.md @@ -488,15 +488,21 @@ with `cfcli config set address=...`. ## Lighthouse A swarm can name the lighthouse configuration it flies in, a stored one (see -[Lighthouse](/docs/lighthouse.md#stored-and-shared-configurations)). A shared -swarm can only name a shared configuration, so everyone who uses it gets it. +[Lighthouse](/docs/lighthouse.md#stored-and-shared-configurations)). A local +swarm names a configuration on this computer, and a shared swarm one in its +organization, so everyone who uses the swarm can get it. ```text cfcli swarm config lh lab/cage # the selected swarm flies in lab/cage -cfcli swarm config lh # show which one it names +cfcli swarm config lh # pick one from a list cfcli swarm config lh --clear ``` +Without a configuration, `swarm config lh` lists the ones to pick from, +starting at the one the swarm names: the configurations on this computer for +a local swarm, and those in its organization for a shared one. When not +interactive, it shows the one the swarm names, as `swarm config show` does. + `swarm lh check` compares each Crazyflie's configuration with it, and `swarm lh write` writes it to the Crazyflies that don't have it yet (all of them with `--force`), storing it in their flash. `--config` uses another @@ -569,7 +575,7 @@ edit those; change shared swarms with the commands. ```yaml name: Lab Crazyflies description: The bench -lighthouse: lab/cage +lighthouse: cage units: - uri: radio:///80/2M/E7E7E7E701 name: CF-01 diff --git a/src/cli.rs b/src/cli.rs index 438a275..e0cc1a7 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -409,10 +409,15 @@ enum LighthouseConfigCommands { /// Write a lighthouse configuration to the Crazyflie /// /// The configuration is a stored one (), a file (-i), YAML piped - /// in, or else the one the selected swarm names. + /// in, or else one picked from a list (in a terminal) or the one the + /// selected swarm names. #[clap(verbatim_doc_comment)] Write(LighthouseWriteParameters), - /// Compare the Crazyflie's lighthouse configuration with one (as for write) + /// Compare the Crazyflie's lighthouse configuration with one + /// + /// The configuration is a stored one (), a file (-i), YAML piped + /// in, or else the one the selected swarm names. + #[clap(verbatim_doc_comment)] Check(LighthouseCheckParameters), /// Store a lighthouse configuration file (from the Crazyflie client or read) Import(LighthouseImportParameters), @@ -858,9 +863,9 @@ enum SwarmConfigCommands { #[clap(value_name = "NEW_ID")] to: String, }, - /// Show or set the lighthouse config the swarm flies in (see 'cfcli lh config') + /// Set the lighthouse config the swarm flies in, or show it when not interactive (see 'cfcli lh config') Lh { - /// Lighthouse config ID; a shared swarm needs a shared one (/) + /// Lighthouse config ID, picked from a list if omitted (a local swarm names one on this computer, a shared swarm one in its organization) #[clap(value_name = "CONFIG")] config: Option, /// The swarm flies in no particular lighthouse config diff --git a/src/main.rs b/src/main.rs index 672f420..283bd2e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1460,7 +1460,8 @@ async fn run() -> Result<()> { } }, LighthouseConfigCommands::Write(params) => { - let (file, _) = configs::source(&config, params.id.as_deref(), params.input.as_deref()).await?; + let (file, _) = + configs::source(&config, params.id.as_deref(), params.input.as_deref(), !non_interactive).await?; let cf = connect_cf(&mut connected_cf, &link_context, uri.as_str(), toc_cache, args.debug).await?; modules::lighthouse::write(cf, &file, non_interactive).await?; } @@ -1469,7 +1470,8 @@ async fn run() -> Result<()> { modules::lighthouse::read(cf, params.output.as_deref(), non_interactive).await?; } LighthouseConfigCommands::Check(params) => { - let (file, source) = configs::source(&config, params.id.as_deref(), params.input.as_deref()).await?; + let (file, source) = + configs::source(&config, params.id.as_deref(), params.input.as_deref(), false).await?; let cf = connect_cf(&mut connected_cf, &link_context, uri.as_str(), toc_cache, args.debug).await?; modules::lighthouse::check(cf, &file, &source, csv, non_interactive).await?; } diff --git a/src/modules/lighthouse/configs.rs b/src/modules/lighthouse/configs.rs index d008486..d7f3587 100644 --- a/src/modules/lighthouse/configs.rs +++ b/src/modules/lighthouse/configs.rs @@ -15,7 +15,7 @@ use tabled::Tabled; use super::{compare, describe_distance, CalibrationDelta, LighthouseConfigFile, Part}; use crate::error::CliError; -use crate::modules::documents::{Document, Documents, SharedId}; +use crate::modules::documents::{Document, Documents, Entry, SharedId}; use crate::modules::swarm::Swarms; use crate::utils::display::{csv_row, print_table, table}; use crate::Config; @@ -80,9 +80,15 @@ pub(crate) fn describe(configs: &LhConfigs, id: &str) -> String { // ---- Which configuration a command uses ---- /// The configuration for `write` and `check`, and how to name it: a stored -/// one (`id`), a file (`input`), what is piped in, or else the one the +/// one (`id`), a file (`input`), what is piped in, or else one the user +/// picks (when `pick`, starting at the selected swarm's) or the one the /// selected swarm names (also when stdin is empty, as in a script). -pub async fn source(config: &Config, id: Option<&str>, input: Option<&str>) -> Result<(LighthouseConfigFile, String)> { +pub async fn source( + config: &Config, + id: Option<&str>, + input: Option<&str>, + pick: bool, +) -> Result<(LighthouseConfigFile, String)> { if let Some(id) = id { let configs = LhConfigs::open(config)?; return Ok((configs.load(id).await?, describe(&configs, id))); @@ -93,11 +99,22 @@ pub async fn source(config: &Config, id: Option<&str>, input: Option<&str>) -> R if let Some(file) = super::load_piped()? { return Ok((file, "the configuration from stdin".to_string())); } - let linked = match &config.swarm { - Some(swarm) => Swarms::open(config)?.load(swarm).await?.lighthouse, - None => None, - }; - match linked { + if pick { + let configs = LhConfigs::open(config)?; + let entries = configs.entries().await?; + if entries.is_empty() { + bail!(CliError::NotFound( + "lighthouse configs; store one with 'cfcli lh config save ' or \ + 'cfcli lh config import ', or give a file with -i" + .to_string() + )); + } + // A swarm that can't be loaded just leaves the cursor at the top. + let current = selected_swarm_config(config).await.ok().flatten(); + let id = pick_entry(&entries, "Lighthouse config to write:", current.as_deref())?; + return Ok((configs.load(&id).await?, describe(&configs, &id))); + } + match selected_swarm_config(config).await? { Some(id) => { let configs = LhConfigs::open(config)?; let file = configs.load(&id).await?; @@ -113,6 +130,14 @@ pub async fn source(config: &Config, id: Option<&str>, input: Option<&str>) -> R } } +/// The lighthouse configuration the selected swarm names. +async fn selected_swarm_config(config: &Config) -> Result> { + match &config.swarm { + Some(swarm) => Ok(Swarms::open(config)?.load(swarm).await?.lighthouse), + None => Ok(None), + } +} + // ---- Listing and showing ---- /// One row of `lh config list`. @@ -185,6 +210,11 @@ async fn pick(configs: &LhConfigs, message: &str) -> Result { "lighthouse configs; store one with 'cfcli lh config save '".to_string() )); } + pick_entry(&entries, message, None) +} + +/// Let the user pick one of `entries`, starting at `current`. +pub(crate) fn pick_entry(entries: &[Entry], message: &str, current: Option<&str>) -> Result { let labels: Vec = entries .iter() .map(|e| match e.name.is_empty() { @@ -192,7 +222,9 @@ async fn pick(configs: &LhConfigs, message: &str) -> Result { false => format!("{} - {} ({} base stations)", e.id, e.name, e.count), }) .collect(); + let start = current.and_then(|current| entries.iter().position(|e| e.id == current)).unwrap_or(0); let picked = Select::new(message, labels) + .with_starting_cursor(start) .raw_prompt() .map_err(|_| anyhow!("No lighthouse config selected"))?; Ok(entries[picked.index].id.clone()) @@ -360,6 +392,17 @@ pub async fn export(configs: &LhConfigs, id: &str, output: Option<&str>) -> Resu // ---- Deleting and moving ---- +/// Whether swarm `swarm` may name lighthouse config `config`: a local swarm +/// one on this computer, a shared swarm one in its organization, which +/// everyone who uses the swarm can read. +pub(crate) fn may_name(swarm: &str, config: &str) -> bool { + match (SharedId::parse(swarm), SharedId::parse(config)) { + (Ok(None), Ok(None)) => true, + (Ok(Some(swarm)), Ok(Some(config))) => swarm.org == config.org, + _ => false, + } +} + /// The swarms that fly in lighthouse config `id`: the local swarms, and the /// shared swarms the server lists (only a shared config can be named by /// them), or this computer's copies of them when it can't be reached. @@ -496,11 +539,7 @@ pub async fn move_config(configs: &LhConfigs, from: &str, to: &str, non_interact configs.place(to) ); if !naming.is_empty() { - // A shared swarm can only name a shared configuration. - let to_shared = SharedId::parse(to)?.is_some(); - let (can, cannot): (Vec, Vec) = naming - .into_iter() - .partition(|swarm| to_shared || !matches!(SharedId::parse(swarm), Ok(Some(_)))); + let (can, cannot): (Vec, Vec) = naming.into_iter().partition(|swarm| may_name(swarm, to)); if !can.is_empty() { let (names, them) = verb_and_pronoun(&can); println!( @@ -516,7 +555,8 @@ pub async fn move_config(configs: &LhConfigs, from: &str, to: &str, non_interact if !cannot.is_empty() { let (names, _) = verb_and_pronoun(&cannot); println!( - "{} still {} '{}': a shared swarm can't name '{}' on this computer", + "{} still {} '{}', and can't name '{}': a local swarm names lighthouse configs on this \ + computer, a shared swarm those in its organization", quoted(&cannot), names, from, @@ -548,6 +588,15 @@ calibs: - {phase: -0.004, tilt: 0.047, curve: 0.367, gibmag: -0.005, gibphase: 2.548, ogeemag: -0.124, ogeephase: 2.051} "; + #[test] + fn swarms_name_configs_of_their_own_kind() { + assert!(may_name("bench", "cage")); + assert!(may_name("lab/flight", "lab/cage")); + assert!(!may_name("bench", "lab/cage")); + assert!(!may_name("lab/flight", "cage")); + assert!(!may_name("lab/flight", "other/room")); + } + #[test] fn name_and_summary() { let mut file = LighthouseConfigFile::from_yaml(FILE).unwrap(); diff --git a/src/modules/swarm/lighthouse.rs b/src/modules/swarm/lighthouse.rs index d670b20..70005ec 100644 --- a/src/modules/swarm/lighthouse.rs +++ b/src/modules/swarm/lighthouse.rs @@ -14,8 +14,8 @@ use tabled::Tabled; use super::runner::{csv_row_for, split, Runner, SwarmRow}; use super::Swarms; use crate::error::CliError; -use crate::modules::documents::SharedId; -use crate::modules::lighthouse::configs::{describe, LhConfigs}; +use crate::modules::documents::{Entry, SharedId}; +use crate::modules::lighthouse::configs::{describe, may_name, pick_entry, LhConfigs}; use crate::modules::lighthouse::{ check_supported, compare, describe_distance, is_same, read_config, supported_base_stations, write_config, BaseStationDiff, CalibrationDelta, LighthouseConfigFile, Part, @@ -23,26 +23,55 @@ use crate::modules::lighthouse::{ use crate::utils::display::{csv_row, print_table, table}; use crate::{Config, SwarmLhCommands}; -/// `swarm config lh [CONFIG] [--clear]` -pub async fn link(swarms: &Swarms, config: &Config, id: &str, new: Option<&str>, clear: bool) -> Result<()> { - if !clear && new.is_none() { - let swarm = swarms.load(id).await?; - match swarm.lighthouse { - Some(lighthouse) => println!("Swarm '{}' flies in lighthouse config '{}'", id, lighthouse), - None => println!( - "Swarm '{}' names no lighthouse config; set one with 'cfcli swarm config lh '", - id - ), +/// `swarm config lh [CONFIG] [--clear]`. Without a configuration, the user +/// picks one, or, when not interactive, it shows the one the swarm names. +pub async fn link( + swarms: &Swarms, + config: &Config, + id: &str, + new: Option<&str>, + clear: bool, + non_interactive: bool, +) -> Result<()> { + let picked; + let new = match new { + Some(new) => Some(new), + None if clear => None, + None => { + let current = swarms.load(id).await?.lighthouse; + if non_interactive { + match current { + Some(lighthouse) => println!("Swarm '{}' flies in lighthouse config '{}'", id, lighthouse), + None => println!( + "Swarm '{}' names no lighthouse config; set one with 'cfcli swarm config lh '", + id + ), + } + return Ok(()); + } + picked = pick_for(config, id, current.as_deref()).await?; + Some(picked.as_str()) } - return Ok(()); - } + }; if let Some(new) = new { - if SharedId::parse(id)?.is_some() && SharedId::parse(new)?.is_none() { - bail!(CliError::InvalidValue(format!( - "'{}' is shared, so it can only name a shared lighthouse config (/), \ - not '{}' on this computer; share it with 'cfcli lh config move'", - id, new - ))); + if !may_name(id, new) { + bail!(CliError::InvalidValue(match (SharedId::parse(id)?, SharedId::parse(new)?) { + (None, _) => format!( + "'{}' is a swarm on this computer, so it can only name a lighthouse config on this \ + computer, not '{}'; share the swarm with 'cfcli swarm config move {} /{}' to fly in \ + a shared one", + id, new, id, id + ), + (Some(swarm), None) => format!( + "'{}' is shared in {}, so it can only name a lighthouse config in {} ({}/), not \ + '{}' on this computer; share it with 'cfcli lh config move {} {}/{}'", + id, swarm.org, swarm.org, swarm.org, new, new, swarm.org, new + ), + (Some(swarm), Some(_)) => format!( + "'{}' is shared in {}, so it can only name a lighthouse config in {} ({}/), not '{}'", + id, swarm.org, swarm.org, swarm.org, new + ), + })); } // It must exist (and a shared one gets a copy here). LhConfigs::open(config)?.load(new).await?; @@ -61,6 +90,35 @@ pub async fn link(swarms: &Swarms, config: &Config, id: &str, new: Option<&str>, Ok(()) } +/// Let the user pick the configuration swarm `id` flies in: one on this +/// computer for a local swarm, one in its organization for a shared one. +async fn pick_for(config: &Config, id: &str, current: Option<&str>) -> Result { + let org = SharedId::parse(id)?.map(|shared| shared.org); + let entries: Vec = LhConfigs::open(config)? + .entries() + .await? + .into_iter() + .filter(|entry| may_name(id, &entry.id)) + .collect(); + if entries.is_empty() { + bail!(CliError::NotFound(match &org { + None => "lighthouse configs on this computer; store one with 'cfcli lh config save ' or \ + 'cfcli lh config import '" + .to_string(), + Some(org) => format!( + "lighthouse configs in {}; store one with 'cfcli lh config save {}/', or share one with \ + 'cfcli lh config move {}/'", + org, org, org + ), + })); + } + let message = match current { + Some(current) => format!("Lighthouse config for swarm '{}' (now '{}'):", id, current), + None => format!("Lighthouse config for swarm '{}':", id), + }; + pick_entry(&entries, &message, current) +} + /// `swarm lh check|write` pub async fn run( config: &Config, diff --git a/src/modules/swarm/mod.rs b/src/modules/swarm/mod.rs index 35230bb..0c066e5 100644 --- a/src/modules/swarm/mod.rs +++ b/src/modules/swarm/mod.rs @@ -81,7 +81,7 @@ pub(crate) async fn run( SwarmConfigCommands::Move { from, to } => move_swarm(&swarms, config, from, to, non_interactive).await, SwarmConfigCommands::Lh { config: new, clear, swarm } => { let id = swarm_id(config, swarm.as_deref())?; - lighthouse::link(&swarms, config, &id, new.as_deref(), *clear).await + lighthouse::link(&swarms, config, &id, new.as_deref(), *clear, non_interactive).await } SwarmConfigCommands::Pull { id, force } => { let (shared, only) = swarms.for_sync(id.as_deref())?; From 7bdcd7c02f0751f3fedb5b0ffc394a0a7ccf694c Mon Sep 17 00:00:00 2001 From: Marcus Eliasson Date: Thu, 8 Oct 2026 22:50:27 +0200 Subject: [PATCH 5/6] Drop a lighthouse config a moved or imported swarm can't name swarm config move and import copied the swarm's lighthouse config along, so a swarm moved to another organization, or between this computer and the server, named one it can't (and the server refuses a shared swarm naming another organization's config). The link is dropped when the new ID can't name it, and cfcli says so and how to name another one. --- docs/lighthouse.md | 4 +++- docs/swarm.md | 6 ++++++ src/modules/swarm/lighthouse.rs | 37 +++++++++++++++++++++++++++++++++ src/modules/swarm/mod.rs | 22 +++++++++++++------- 4 files changed, 61 insertions(+), 8 deletions(-) diff --git a/docs/lighthouse.md b/docs/lighthouse.md index 6e0b20c..93c7136 100644 --- a/docs/lighthouse.md +++ b/docs/lighthouse.md @@ -352,7 +352,9 @@ renames one. Swarms that fly in it keep the old ID: `move` lists them and the `cfcli swarm config lh` command that gives them the new one. A local swarm can only name a configuration on this computer, and a shared swarm one in its organization, so not every swarm can follow: taking a shared configuration -back, for example, leaves its shared swarms without it. +back, for example, leaves its shared swarms without it. The server refuses a +shared swarm that names another organization's configuration, so that +everyone who sees the swarm can see it. `delete` deletes a configuration, a shared one on the server for everyone in the organization. Before asking, it lists the swarms that fly in it: the diff --git a/docs/swarm.md b/docs/swarm.md index 0df97fa..5fe1793 100644 --- a/docs/swarm.md +++ b/docs/swarm.md @@ -195,6 +195,12 @@ cfcli swarm config move lab bitcraze-lab/lab cfcli swarm config move bitcraze-lab/lab lab ``` +A swarm only names a lighthouse configuration it can name where it ends up +(see [Lighthouse](#lighthouse)): one on this computer for a local swarm, one +in its organization for a shared one. When a move or an import takes it +somewhere it can't name its configuration, it names none afterwards, and +cfcli says so. + `move` also renames a swarm (`move lab lab-old`). `import` can put a file straight into a shared swarm with `--id bitcraze-lab/`. diff --git a/src/modules/swarm/lighthouse.rs b/src/modules/swarm/lighthouse.rs index 70005ec..0b0a640 100644 --- a/src/modules/swarm/lighthouse.rs +++ b/src/modules/swarm/lighthouse.rs @@ -12,6 +12,7 @@ use anyhow::{anyhow, bail, Result}; use tabled::Tabled; use super::runner::{csv_row_for, split, Runner, SwarmRow}; +use super::store::Swarm; use super::Swarms; use crate::error::CliError; use crate::modules::documents::{Entry, SharedId}; @@ -358,10 +359,46 @@ pub fn hint_for_new(swarm: &str, lighthouse: Option<&str>, added: &[String]) { } } +/// When a swarm becomes swarm `id` (moved or imported), drop the lighthouse +/// configuration it names if `id` can't name it (see [`may_name`]): the +/// server refuses a shared swarm naming another organization's. Returns what +/// to tell the user. +pub fn drop_unnameable(id: &str, swarm: &mut Swarm) -> Option { + let dropped = swarm.lighthouse.take_if(|lighthouse| !may_name(id, lighthouse))?; + Some(format!( + "Swarm '{}' names no lighthouse config: it can't name '{}' (a local swarm names lighthouse configs on \ + this computer, a shared swarm those in its organization); name one with 'cfcli swarm config lh \ + --swarm {}'", + id, dropped, id + )) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn drops_what_the_new_id_cannot_name() { + let swarm = |lighthouse: &str| Swarm { + lighthouse: Some(lighthouse.to_string()), + ..Swarm::new("s".to_string(), None) + }; + for (id, lighthouse, kept) in [ + ("lab/s", "lab/cage", true), + ("other/s", "lab/cage", false), + ("s", "lab/cage", false), + ("lab/s", "cage", false), + ("s", "cage", true), + ] { + let mut moved = swarm(lighthouse); + let note = drop_unnameable(id, &mut moved); + assert_eq!(moved.lighthouse.is_some(), kept, "{} naming {}", id, lighthouse); + assert_eq!(note.is_none(), kept, "{} naming {}", id, lighthouse); + } + let mut none = Swarm::new("s".to_string(), None); + assert!(drop_unnameable("lab/s", &mut none).is_none()); + } + const FILE: &str = "\ type: lighthouse_system_configuration version: '1' diff --git a/src/modules/swarm/mod.rs b/src/modules/swarm/mod.rs index 0c066e5..5ae649c 100644 --- a/src/modules/swarm/mod.rs +++ b/src/modules/swarm/mod.rs @@ -695,7 +695,7 @@ async fn import(swarms: &Swarms, config: &mut Config, params: &SwarmImportParame } // Read and check every file before writing any of them. - let mut imports: Vec<(String, Swarm, &str)> = Vec::new(); + let mut imports: Vec<(String, Swarm, &str, Option)> = Vec::new(); for file in ¶ms.files { let yaml = match std::fs::read_to_string(file) { Ok(yaml) => yaml, @@ -704,7 +704,7 @@ async fn import(swarms: &Swarms, config: &mut Config, params: &SwarmImportParame } Err(e) => return Err(e).with_context(|| format!("reading {}", file)), }; - let swarm = Swarm::from_yaml(&yaml).with_context(|| format!("in {}", file))?; + let mut swarm = Swarm::from_yaml(&yaml).with_context(|| format!("in {}", file))?; let id = match ¶ms.id { Some(id) => id.clone(), None => std::path::Path::new(file) @@ -726,13 +726,14 @@ async fn import(swarms: &Swarms, config: &mut Config, params: &SwarmImportParame swarms.shared(&shared)?; } } - if imports.iter().any(|(other, _, _)| *other == id) { + if imports.iter().any(|(other, _, _, _)| *other == id) { bail!(CliError::InvalidValue(format!("two of the files would both become swarm '{}'", id))); } - imports.push((id, swarm, file)); + let dropped = lighthouse::drop_unnameable(&id, &mut swarm); + imports.push((id, swarm, file, dropped)); } - for (id, swarm, file) in &mut imports { + for (id, swarm, file, dropped) in &mut imports { let any_radio = swarm.use_any_radio(); match SharedId::parse(id)? { None => swarms.local.save(id, swarm)?, @@ -760,8 +761,11 @@ async fn import(swarms: &Swarms, config: &mut Config, params: &SwarmImportParame print!(", {} URIs now use radio:/// (any Crazyradio)", any_radio); } println!(); + if let Some(note) = dropped { + println!("{}", note); + } } - if let Some((id, swarm, _)) = imports.first() { + if let Some((id, swarm, _, _)) = imports.first() { select_if_none(swarms, config, id, swarm); } Ok(()) @@ -791,7 +795,8 @@ async fn move_swarm(swarms: &Swarms, config: &mut Config, from: &str, to: &str, } let from_shared = SharedId::parse(from)?; let to_shared = SharedId::parse(to)?; - let swarm = swarms.load(from).await?; + let mut swarm = swarms.load(from).await?; + let dropped = lighthouse::drop_unnameable(to, &mut swarm); if to_shared.is_none() && swarms.local.exists(to) { bail!(CliError::InvalidValue(format!("swarm '{}' already exists", to))); } @@ -824,6 +829,9 @@ async fn move_swarm(swarms: &Swarms, config: &mut Config, from: &str, to: &str, to, swarms.place(to) ); + if let Some(note) = dropped { + println!("{}", note); + } if config.swarm.as_deref() == Some(from) { set_selected(config, to, &swarm); } From db8b00530e3917f44ee02dd28ffad750948005bf Mon Sep 17 00:00:00 2001 From: Marcus Eliasson Date: Thu, 8 Oct 2026 23:32:21 +0200 Subject: [PATCH 6/6] Add lh config name to show or change a lighthouse config's name Files from the Crazyflie client have no name, and the name could only be given when storing one (save/import --name). The new command shows it, or sets it for a local or shared configuration, like swarm config name; the ID stays the same, and a shared one gets a new revision. --- build.rs | 1 + completions/addendum.bash | 2 +- completions/addendum.ps1 | 2 +- docs/lighthouse.md | 11 +++++++++++ src/cli.rs | 9 +++++++++ src/main.rs | 3 +++ src/modules/lighthouse/configs.rs | 31 +++++++++++++++++++++++++++++-- 7 files changed, 55 insertions(+), 4 deletions(-) diff --git a/build.rs b/build.rs index 8f81ef6..f72283a 100644 --- a/build.rs +++ b/build.rs @@ -107,6 +107,7 @@ fn main() { "Stored lighthouse config to write", "Stored lighthouse config to compare with", "Stored lighthouse config to export", + "Lighthouse config to name", "Lighthouse config ID (prompts for one if omitted)", "The lighthouse config to move", "Only this shared lighthouse config (/)", diff --git a/completions/addendum.bash b/completions/addendum.bash index 7f16750..e03146e 100644 --- a/completions/addendum.bash +++ b/completions/addendum.bash @@ -47,7 +47,7 @@ _cfcli_dynamic() { kind="swarm-configs" ;; *" swarm config remove "*|*" swarm config rename "*) kind="swarm-units" ;; *" swarm config lh "*|*" lh config display "*|*" lh config save "*|*" lh config write "*|\ - *" lh config check "*|*" lh config export "*|*" lh config delete "*|*" lh config move "*|\ + *" lh config check "*|*" lh config export "*|*" lh config name "*|*" lh config delete "*|*" lh config move "*|\ *" lh config pull "*|*" lh config push "*) kind="lh-configs" ;; esac diff --git a/completions/addendum.ps1 b/completions/addendum.ps1 index f63c4a4..b25559b 100644 --- a/completions/addendum.ps1 +++ b/completions/addendum.ps1 @@ -16,7 +16,7 @@ 'cfcli;swarm;config;(select|delete|show|export)$' { $cfcliDynKind = 'swarm-configs' } 'cfcli;swarm;config;(remove|rename)$' { $cfcliDynKind = 'swarm-units' } 'cfcli;swarm;config;lh$' { $cfcliDynKind = 'lh-configs' } - 'cfcli;lh;config;(display|save|write|check|export|delete|move|pull|push)$' { $cfcliDynKind = 'lh-configs' } + 'cfcli;lh;config;(display|save|write|check|export|name|delete|move|pull|push)$' { $cfcliDynKind = 'lh-configs' } } # Option values: `--targets x,y` (plain list) / `--bin t=f` (key=value), # space-separated form. diff --git a/docs/lighthouse.md b/docs/lighthouse.md index 93c7136..2d42957 100644 --- a/docs/lighthouse.md +++ b/docs/lighthouse.md @@ -49,6 +49,7 @@ Commands: check Compare the Crazyflie's lighthouse configuration with one import Store a lighthouse configuration file (from the Crazyflie client or read) export Write a stored lighthouse configuration to a file the Crazyflie client opens (or stdout) + name Show or set the name shown for a lighthouse config (its ID stays the same; 'move' changes the ID) delete Delete a stored lighthouse configuration move Share a lighthouse config (cage -> org/cage), take it back (org/cage -> cage), or rename it pull Get the latest version of the shared lighthouse configs (needed with sync off) @@ -346,6 +347,16 @@ refused: estimate the geometry first. `base stations` in the list counts the positioned ones; a Crazyflie also keeps the calibration of base stations it has seen elsewhere. +`name` shows the name a configuration is shown with, or gives it one; the ID +stays the same, and a shared configuration gets a new revision. Files from +the Crazyflie client have no name, so this names one after importing it +(`import --name` names it at once): + +```text +cfcli lh config name lab/cage "The cage" +cfcli lh config name lab/cage # prints: The cage +``` + `move` shares a local configuration (`move cage lab/cage`), takes a shared one back (which deletes it on the server for everyone in the organization), or renames one. Swarms that fly in it keep the old ID: `move` lists them and the diff --git a/src/cli.rs b/src/cli.rs index e0cc1a7..54534d9 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -423,6 +423,15 @@ enum LighthouseConfigCommands { Import(LighthouseImportParameters), /// Write a stored lighthouse configuration to a file the Crazyflie client opens (or stdout) Export(LighthouseExportParameters), + /// Show or set the name shown for a lighthouse config (its ID stays the same; 'move' changes the ID) + Name { + /// Lighthouse config to name + #[clap(value_name = "CONFIG")] + id: String, + /// New name (shows the current one if omitted) + #[clap(value_name = "NAME")] + name: Option, + }, /// Delete a stored lighthouse configuration Delete { /// Lighthouse config ID (prompts for one if omitted) diff --git a/src/main.rs b/src/main.rs index 283bd2e..7b06672 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1494,6 +1494,9 @@ async fn run() -> Result<()> { LighthouseConfigCommands::Export(params) => { configs::export(&LhConfigs::open(&config)?, ¶ms.id, params.output.as_deref()).await? } + LighthouseConfigCommands::Name { id, name } => { + configs::name(&LhConfigs::open(&config)?, id, name.as_deref()).await? + } LighthouseConfigCommands::Delete { id } => { configs::delete(&LhConfigs::open(&config)?, id.as_deref(), non_interactive).await? } diff --git a/src/modules/lighthouse/configs.rs b/src/modules/lighthouse/configs.rs index d7f3587..2e55537 100644 --- a/src/modules/lighthouse/configs.rs +++ b/src/modules/lighthouse/configs.rs @@ -1,6 +1,6 @@ //! Stored lighthouse configurations: `lh config list`, `save`, `import`, -//! `export`, `delete`, `move`, `pull` and `push`, and the configuration -//! that `write` and `check` use. +//! `export`, `name`, `delete`, `move`, `pull` and `push`, and the +//! configuration that `write` and `check` use. //! //! Like swarms, local configurations are files in a `lighthouse` folder next //! to the cfcli config, named ``, and shared ones are on the server, @@ -390,6 +390,33 @@ pub async fn export(configs: &LhConfigs, id: &str, output: Option<&str>) -> Resu Ok(()) } +/// `lh config name [NAME]`: show the name a configuration is shown +/// with, or set it. The ID stays the same. +pub async fn name(configs: &LhConfigs, id: &str, name: Option<&str>) -> Result<()> { + let Some(name) = name else { + match configs.load(id).await?.name() { + Some(name) => println!("{}", name), + None => eprintln!( + "Lighthouse config '{}' has no name; give it one with 'cfcli lh config name {} '", + id, id + ), + } + return Ok(()); + }; + let name = name.trim(); + if name.is_empty() { + bail!(CliError::InvalidValue("a lighthouse config's name can't be empty".to_string())); + } + configs + .change(id, |file| { + file.set_name(name); + Ok(()) + }) + .await?; + println!("Lighthouse config {} is named '{}' now", describe(configs, id), name); + Ok(()) +} + // ---- Deleting and moving ---- /// Whether swarm `swarm` may name lighthouse config `config`: a local swarm