diff --git a/README.md b/README.md index 199217f9..44c84336 100644 --- a/README.md +++ b/README.md @@ -47,10 +47,22 @@ Every command and flag is documented in the [CLI reference][cli-doc]. See the whole thing run with nothing but Python! -The command below will run a simulated swarm, which you can observe in the web console at http://localhost:8000/console/ : +First, in an empty folder, write a config: ```bash -dotbot run simulator +dotbot config init +``` + +This writes `./dotbot.toml` with a site named `default`: a 2 x 2 m **field**, +where experiments happen, and a **staging** strip below it, where robots park. +`--field 1.5m` or `--field 2x3m` sizes the field, and the rest follows from it. +Commands run from this folder read the file, and it is yours to edit once you +measure a real room. + +Then run a simulated swarm, which you can observe in the web console at http://localhost:8000/console/ : + +```bash +dotbot run simulator --robots 20 ``` The console opens automatically; pass `--headless` to suppress it (it's still @@ -153,32 +165,31 @@ is in the [`swarm` reference][swarm-doc]. ### Calibrate positions (optional) Give the DotBots real-world `(x, y)` with Lighthouse 2. It's a two-step flow: -**collect** a calibration from one DotBot over the air, then **push** it to the -whole fleet - a single DotBot's capture calibrates the shared arena. This needs -the `[calibrate]` extra (opencv, for the homography solve): +**collect** a calibration by placing one DotBot on the four corners of your +site's field, then **push** it to the whole fleet - one capture calibrates the +whole site. This needs the `[calibrate]` extra (opencv, for the homography +solve): ```bash pip install 'pydotbot[calibrate]' ``` -First, collect from one DotBot. Get its address from `dotbot swarm status` (the -**Device Addr** column): +First, flash the `calibrate` app and collect. Each corner is captured when you +press the DotBot's button: ```bash -dotbot swarm status # pick one Device Addr, e.g., BDF2B04BC00D2725 -dotbot swarm stop # DotBots must be idle to capture -dotbot swarm calibrate-lh2 collect --device -d 500 # capture + solve + save +dotbot swarm flash calibrate -ys # the app that captures on a button press +dotbot swarm calibrate-lh2 collect # the field's four corners -> solve -> save ``` -`-d` is your reference square's side, in mm. This saves a -`~/.dotbot/calibrations/calibration-.toml`. Then push that file to the -whole fleet: +This saves the calibration under `~/.dotbot/calibrations//` and prints +its id. Then push it to the whole fleet: ```bash -dotbot swarm calibrate-lh2 push ~/.dotbot/calibrations/calibration-.toml +dotbot swarm calibrate-lh2 push ``` -Full walkthrough - arena sizing and the cabled alternative - is in the +Full walkthrough - choosing the points and the cabled alternative - is in the [LH2 calibration guide][lh2-doc]. ## Going further diff --git a/config_sample.toml b/config_sample.toml deleted file mode 100644 index ac897145..00000000 --- a/config_sample.toml +++ /dev/null @@ -1,27 +0,0 @@ -baudrate = 1000000 - -# # --- Network / addressing --- -# dotbot_address = "FFFFFFFFFFFFFFFF" -# gw_address = "0000000000000000" -adapter = "cloud" -network_id = "A000" -# port = "dotbot-simulator" - -# # --- Controller HTTP interface --- -# controller_http_protocol = "http" -# controller_http_hostname = "localhost" -# controller_http_port = 8000 - -# --- MQTT configuration --- -mqtt_host = "localhost" -mqtt_port = 1883 -mqtt_use_tls = false - -# --- Runtime behavior --- -webbrowser = true -verbose = false -log_level = "info" -log_output = "pydotbot.log" - -# --- Simulator --- -simulator_init_state = "simulator_init_state.toml" diff --git a/doc/api/dotbot.rst b/doc/api/dotbot.rst index a3477881..997d7d14 100644 --- a/doc/api/dotbot.rst +++ b/doc/api/dotbot.rst @@ -33,6 +33,7 @@ Submodules dotbot.server dotbot.sim dotbot.site + dotbot.site_packs dotbot.stream dotbot.trail dotbot.twin diff --git a/doc/api/dotbot.site_packs.rst b/doc/api/dotbot.site_packs.rst new file mode 100644 index 00000000..ccf4b956 --- /dev/null +++ b/doc/api/dotbot.site_packs.rst @@ -0,0 +1,7 @@ +dotbot.site_packs module +======================== + +.. automodule:: dotbot.site_packs + :members: + :undoc-members: + :show-inheritance: diff --git a/doc/cli/config.md b/doc/cli/config.md index 7bd0e54a..f656dbd2 100644 --- a/doc/cli/config.md +++ b/doc/cli/config.md @@ -1,7 +1,7 @@ # `dotbot config` - inspect and scaffold the config `dotbot` reads a `dotbot.toml` so commands don't repeat shared settings - your -gateway connection, swarm id, firmware paths. `config` scaffolds that file and +gateway connection, swarm id, firmware paths, the site you work on. `config` scaffolds that file and shows you what the CLI actually resolved. For the full file format - every key, deployments, the precedence rules - see the [configuration reference](../reference/configuration.md). @@ -16,23 +16,54 @@ deployments, the precedence rules - see the ## `init` -Writes a minimal `./dotbot.toml` - a one-line pointer to the docs, plus any keys -you pre-fill. `--global` writes your per-machine `~/.dotbot/config.toml` instead; -`-f/--force` overwrites an existing file. +Writes a starter `./dotbot.toml` holding a site, so the simulator, calibration +and the examples have a floor to work on from the first run: + +```toml +site = "default" + +# Zero is the top-left corner of the extent, x right, y down, millimetres. +[sites.default] +anchor = "top-left corner of a 5 x 5 m floor; the field starts 1.5 m in from each wall" +extent_mm = [5000, 5000] + +[sites.default.areas] +field = { x = 1500, y = 1500, w = 2000, h = 2000 } +staging = { x = 1500, y = 3500, w = 2000, h = 600 } +``` + +The **field** is where experiments happen and what a calibration covers; the +**staging** strip along its bottom edge is where robots park. Once you have +measured a real room, edit the file: the site is plain TOML, and the +[configuration reference](../reference/configuration.md#sites) has every key. ```bash -dotbot config init # commented starter in ./dotbot.toml +dotbot config init # ./dotbot.toml with a 2 x 2 m field +dotbot config init --field 1.5m --site lab # a 1.5 x 1.5 m field in a site named lab dotbot config init --conn mqtts://broker:8883 --swarm-id 1234 # pre-fill the two common keys dotbot config init --global # ~/.dotbot/config.toml ``` +| Flag | Meaning | +|---|---| +| `--field` | The field's size (default `2m`): one value for a square, `WxH` for a rectangle. A bare number is mm, and `1500mm`, `1.5m` and `2x3m` also work; decimals only with `m`. From 100 mm to 100 m. The extent and the staging strip follow from it. Above 5 m on a side it warns that one LH2 station rarely covers that well. | +| `--site` | The site's name (default `default`); its calibrations are kept under `~/.dotbot/calibrations//`. | +| `--conn` / `--swarm-id` | Pre-fill the shared connection and swarm id. | +| `--global` | Write the per-machine `~/.dotbot/config.toml` instead of `./dotbot.toml`. | +| `-f`, `--force` | Overwrite an existing file, whole. | + +`dotbot.example.toml` in the repository is exactly what `dotbot config init` +writes with no flags. + > MQTT credentials are never file keys - set `DOTBOT_MQTT_USER` / > `DOTBOT_MQTT_PASS` in the environment. ## `show` / `path` -`show` prints the source file, the selected deployment, and the resolved config -as TOML - only the keys actually set, not the full schema. `path` prints just +`show` prints the source file, the selected deployment, the active site, every +site with its areas' roles and where it was read from (inline, or a site pack's +folder), and the resolved config as TOML - only the keys actually set, not the +full schema. `path` prints just the file path (or notes that built-in defaults are in use). Both are read-only; there is no per-key `set` - edit the file, it's yours. @@ -51,4 +82,5 @@ is in the [configuration reference](../reference/configuration.md#precedence). ## See also - [Configuration reference](../reference/configuration.md) - the file format, every key, deployments, precedence. +- [`dotbot site`](site.md) - install a site pack, or export a site to share. - [`dotbot fw`](fw.md) - reads its `[fw]` keys (`segger_dir`, `firmware_repo`) from this same config. diff --git a/doc/cli/index.md b/doc/cli/index.md index 53ebc837..689d60ca 100644 --- a/doc/cli/index.md +++ b/doc/cli/index.md @@ -7,6 +7,7 @@ device swarm run config +site ``` One CLI for the whole DotBot workflow: build firmware, flash one board, control a @@ -29,7 +30,9 @@ dotbot --help | [`run`](run.md) | Start host processes on your computer - controller, gateway bridge, simulator, demos, teleop. | You need the web UI, a gateway bridge, the simulator, or a demo. | Beyond the four namespaces, [`config`](config.md) scaffolds and inspects the -shared `dotbot.toml` the other commands read their defaults from. +shared `dotbot.toml` the other commands read their defaults from, and +[`site`](site.md) installs and exports site packs: the floors you work on, +as folders you can share. ## Which one do I want? @@ -65,6 +68,7 @@ A few signposts so the namespaces don't blur together: - [`swarm`](swarm.md) - run experiments across the fleet. - [`run`](run.md) - launch the controller, gateway bridge, simulator, and demos. - [`config`](config.md) - scaffold and inspect the shared `dotbot.toml`. +- [`site`](site.md) - add a site pack to this machine, or export one to share. Two end-to-end walkthroughs put these together: [build and flash one board](device.md), and [operate a swarm over the air](swarm.md). diff --git a/doc/cli/run.md b/doc/cli/run.md index 9371a6d7..ec49cf99 100644 --- a/doc/cli/run.md +++ b/doc/cli/run.md @@ -41,7 +41,8 @@ dotbot run controller --conn /dev/ttyACM0 | `--controller-http-host` | interface the API binds to (default `127.0.0.1`, loopback). Pass `0.0.0.0` to reach it from another machine - the API is unauthenticated and `/swarmit/*` reaches the swarmit server through it, so only on a network you trust. | | `--headless` | don't open the console in a browser (it's still served) | | `--csv-data-output` | record DotBot data to a CSV file. A registered camera also writes `-camera.csv` beside it, with a `-camera.toml` sidecar saying what the columns mean. | -| `--lh2-calibration` | lighthouse calibration the controller runs on: a file path or an id prefix. Also `[run.controller] lh2_calibration`. | +| `--site` | the site the session works in: its frame, its areas and where its calibrations are looked up. Also `site` in dotbot.toml, or `DOTBOT_SITE`. | +| `--lh2-calibration` | lighthouse calibration the controller runs on: a file path, a `--tag` or an id prefix. Refused when it was made in another site. Also `[run.controller] lh2_calibration`; `[run.controller] lh2_calibration_max_age_days` (default 30) warns when it is older. | | `--camera-calibration` | overhead camera to draw on the map: a file path, or an id prefix of one under `~/.dotbot/calibrations//`. Register one with `run calibrate-camera collect`. Also `[run.controller] camera_calibration` in dotbot.toml. | | `--camera-detect` / `--no-camera-detect` | run the robot detector on that camera's frames (default on). Off serves the layer as a picture only: nothing detected, drawn, pushed or logged. Also `[run.controller] camera_detect` in dotbot.toml. | | `--swarmit-url` | swarmit server behind the console's orchestration panel (default `http://localhost:8001`, matching `swarmit serve`). Also `[run.controller] swarmit_url` in dotbot.toml, or `DOTBOT_SWARMIT_URL`. | @@ -72,15 +73,21 @@ so it shares the controller's flags and serves the same console. ```bash dotbot run simulator dotbot run simulator --robots 500 # a generated fleet +dotbot run simulator --robots 150 --area field+staging dotbot run simulator --robots 500 --write-init-state fleet.toml dotbot run simulator --simulator-init-state fleet.toml ``` -`--robots N` places N robots 200 mm apart in a near-square grid centred in the -site's `field` area (else its first area, else its extent, else a 2 x 2 m -square), the top half of the rows facing up and the rest down, and refuses a -count that does not fit. `--write-init-state` saves that fleet as a file to -edit and reuse with `--simulator-init-state`. +`--robots N` places N robots 200 mm apart, all facing up, in a grid shaped +like and centred in `--area`, and refuses a count that does not fit: an area +holds one robot per 200 mm square. `--area` takes a name from the site's +areas, `x,y,w,h` in mm or a `+`-joined composite, and defaults to the site's +[field](../reference/configuration.md#area-roles) (a 2 x 2 m square when the +site declares nothing); it also places the robots a `--simulator-init-state` +file gives no position. +`[run.controller] simulator_area` sets it from the config. +`--write-init-state` saves that fleet as a file to edit and reuse with +`--simulator-init-state`. ## `calibrate-lh2` - capture & apply (cabled, deprecated) diff --git a/doc/cli/site.md b/doc/cli/site.md new file mode 100644 index 00000000..8cfe05cd --- /dev/null +++ b/doc/cli/site.md @@ -0,0 +1,68 @@ +# `dotbot site` - add and export site packs + +A **site pack** is a site in its own folder, so it can be committed, zipped or +handed to someone: `/site.toml` holds the keys of a `[sites.]` +table, and an optional `/calibrations/` holds the site's LH2 and camera +calibration files. `site add` installs one on this machine and `site export` +writes one. How packs are found, and how they relate to inline `[sites.*]` +tables, is in the [configuration reference](../reference/configuration.md#site-packs). + +## Which command do I want? + +| Goal | Command | +|---|---| +| Use a site someone shared with you | `dotbot site add ` | +| Share a site, or move an inline table into a pack | `dotbot site export ` | +| See every site and where it was read from | `dotbot config show` | + +## `add` + +Copies a pack into `~/.dotbot/sites//`, one of the default `site_dirs`, +so any config on this machine can then name the site. The source can be: + +- a pack folder, whose name is the site's name; +- a zip of one, as `site export` writes it, whose top folder is the site's name; +- `-`, to read such a zip from stdin; +- a git URL whose repository is a pack, named after the site. + +```bash +dotbot site add ./lab # a folder +dotbot site add lab.zip # a zip from `site export` +curl -L https://example.org/lab.zip | dotbot site add - # a zip on stdin +dotbot site add https://github.com//lab.git # a git repository +``` + +A zip is not downloaded from a bare `https://...zip` argument: pipe it in with +`-` as above. `add` refuses to replace a pack of the same name unless you pass +`-f/--force`; a replacement that fails part-way leaves the old pack as it was. +It also refuses a site name that is not letters, digits, `-` and `_`, and a +pack holding links. + +Then select the site with `site = "lab"` in your config, `--site lab` or +`DOTBOT_SITE=lab`. + +## `export` + +Writes the site `` as a pack zip, `.zip` in the current directory +by default. The site can be an inline `[sites.]` table, which is written +out as the pack's `site.toml`, or a pack already. + +```bash +dotbot site export lab # lab.zip +dotbot site export lab --out ~/share/lab.zip --with-calibrations +``` + +| Flag | Meaning | +|---|---| +| `--out FILE` | The zip to write (default `.zip`). | +| `--with-calibrations` | Include the site's calibration files, from its pack's `calibrations/` and from `~/.dotbot/calibrations//`. | +| `-f`, `--force` | Overwrite an existing zip. | + +A pack is plain files, so `unzip`, `git clone` or `cp` into a `site_dirs` +folder work just as well as `site add`. + +## See also + +- [Configuration reference: sites](../reference/configuration.md#sites) - the site table, area roles, `site_dirs`. +- [`dotbot config`](config.md) - `config show` lists every site and its source. +- [LH2 calibration](../guides/lh2-calibration.md) - calibrating a site. diff --git a/doc/cli/swarm.md b/doc/cli/swarm.md index 7ed0c14b..3ad29ec8 100644 --- a/doc/cli/swarm.md +++ b/doc/cli/swarm.md @@ -123,22 +123,23 @@ For another board or an app outside this list, pass the full `.bin` path. ## 6. LH2 calibration over the air -Capture and push a Lighthouse-2 calibration for one DotBot without a cable, -driving it over the swarm. The arena geometry and `-d` sizing live in the -[LH2 calibration guide](../guides/lh2-calibration.md). +Capture a Lighthouse-2 calibration from one DotBot without a cable, then push +it to the fleet. Choosing the points, the span overlay and troubleshooting live +in the [LH2 calibration guide](../guides/lh2-calibration.md). ```bash -dotbot swarm stop # capture only runs in READY -dotbot swarm calibrate-lh2 collect --device BC3D... -d 500 # capture from one DotBot -> solve -> save -dotbot swarm calibrate-lh2 push ~/.dotbot/calibrations/calibration-.toml # apply to every ready DotBot +dotbot swarm flash calibrate -ys # the app that captures on a button press +dotbot swarm calibrate-lh2 collect # the field's four corners -> solve -> save +dotbot swarm calibrate-lh2 push # send it to every robot ``` -`collect` walks one DotBot through the four arena corners over the air, solves the -homography, and saves it under `~/.dotbot/calibrations/`. `push` (no `--device`) then sends -that calibration to **every ready DotBot** - the arena shares one transform. -(`collect --push` is a single-DotBot shortcut: it sends only to the captured DotBot.) -`push` takes a `calibration-*.toml` or the legacy raw payload - the format is -picked by file extension. Get the `--device` address from `dotbot swarm status`. +`collect` asks for the four corners of the site's field in turn, and captures +each when you press the DotBot's button; `--over `, `--square ` and +`--points` choose other points. It solves every station and saves the result +under `~/.dotbot/calibrations//`. `push` then sends it to every robot - +the whole site shares one calibration. It takes a file path, a `--tag` or an +id prefix, and refuses robots that report another site. (`collect --push` sends +only to the robots whose captures built it.) ## Two web servers - don't mix them up diff --git a/doc/conf.py b/doc/conf.py index ff1adb94..eba9a0a2 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -67,6 +67,8 @@ ("py:class", r"MaxLen"), ("py:class", r"NoneType"), ("py:class", r"dotbot\.models\._positions_as_waypoints"), + # dotbot.config has no API page; its schema is the configuration reference + ("py:class", r"dotbot\.config\..*"), ] # -- Options for HTML output ------------------------------------------------- diff --git a/doc/guides/lh2-calibration-cabled.md b/doc/guides/lh2-calibration-cabled.md index 5f41515d..0e8aef22 100644 --- a/doc/guides/lh2-calibration-cabled.md +++ b/doc/guides/lh2-calibration-cabled.md @@ -9,16 +9,15 @@ swap. > **Deprecated.** The cabled path is kept for bench work before a swarm > exists. The supported path is [over the air](lh2-calibration.md). -What LH2 calibration is, and the arena geometry (the `-d` square sizing), are -covered in the [main guide](lh2-calibration.md); this page is just the cabled -capture path. +The [main guide](lh2-calibration.md) explains what LH2 calibration is; this +page is just the cabled capture path. ## Prerequisites - A DotBot v3 you can cable to your machine over USB-C (no external probe - the v3 flashes over its on-board programmer). -- Two LH2 base stations facing the arena, and a square marked on the floor (see - [Sizing `-d`](lh2-calibration.md) in the main guide). +- Two LH2 base stations facing the floor, and a square of known side marked on + it. - The `[calibrate]` extra: ```bash diff --git a/doc/guides/lh2-calibration.md b/doc/guides/lh2-calibration.md index 930e2997..747e0c19 100644 --- a/doc/guides/lh2-calibration.md +++ b/doc/guides/lh2-calibration.md @@ -1,117 +1,145 @@ # Lighthouse 2 (LH2) calibration -Lighthouse 2 gives every DotBot a real-world **(x, y) position** on your arena -floor. Two SteamVR base stations sweep the room with IR; each DotBot's LH2 sensor -times the sweeps. Calibration is the one-time step that maps those raw sweep -counts to metric coordinates: you place one DotBot on four known corners of a -square, capture, and the resulting transform is pushed to the whole fleet. +Lighthouse 2 gives every DotBot a real-world **(x, y) position** in your +[site](../reference/configuration.md#sites). SteamVR base stations sweep the +room with IR; each DotBot's LH2 sensor times the sweeps. Calibration is the +step that maps those raw sweep counts to millimetres: you place one DotBot on +four known points, by default the corners of the site's **field**, capture at +each, and push the result to the whole fleet. You do this once per physical setup (move a base station -> recalibrate). -**The default flow is over the air** - drive one already-deployed DotBot through -the corners over the swarm, no cable and no firmware swap. If you'd rather -calibrate a single DotBot on the bench over USB, see -[LH2 calibration over a cable](lh2-calibration-cabled.md). +**The default flow is over the air** - one already-deployed DotBot, no cable +and no firmware swap. If you'd rather calibrate a single DotBot on the bench +over USB, see [LH2 calibration over a cable](lh2-calibration-cabled.md). ## Prerequisites - A provisioned swarm: a gateway plus sandbox-host DotBots, reachable from your config (see [swarm](../cli/swarm.md)). -- Two LH2 base stations mounted ~2 m up, facing the arena. -- A square marked on the floor with a known side length. +- LH2 base stations mounted ~2 m up, facing the field. +- A site whose field matches the floor: `dotbot config init --field ` + writes one, and the [configuration reference](../reference/configuration.md#sites) + covers measuring your own. Tape the field's corners on the floor. - The `[calibrate]` extra (the homography solve uses opencv): ```bash pip install 'pydotbot[calibrate]' ``` +```{note} +**Base-station channels.** Set your base stations to channels 1, 2, ... N, +with no gaps, one channel per station. Avoid channel 14 for now: it is a known +issue. +``` + +## Choose the points + +By default `collect` uses the four corners of the site's field. A four-point +calibration is tight inside the span of its points and its error grows +outside it, so the whole field is the right default; a smaller span only saves +taping. Three flags choose other points, and they are mutually exclusive: + +| Command | Points | +|---|---| +| `collect` | The field's four corners. | +| `collect --over dev-corner` | Another area's four corners, e.g. a bench corner for development work. | +| `collect --square 800` | The corners of an 800 mm square centred in the field. Quicker to tape; the rest of the field is extrapolated, and `collect` says so. | +| `collect --points ...` | Points you give by hand, repeatable: `x,y` in mm, an area name or `x,y,w,h` for its centre, `:`, or `:corners` for all four. | + +The calibration file records which of these you used, as its +[`points_from`](../reference/configuration.md#how-calibration-points-were-chosen). + +A corner mark is where the DotBot's photodiode lands with the robot inside the +rectangle, its PCB edges on the rectangle's lines and its nose toward the +nearest top or bottom edge. `collect` prints each position as it asks for it. + ## Capture and push -The calibration is a property of the **arena** (the base-station layout), not the -individual DotBot, so you capture once from any one DotBot and apply the result -to the whole fleet. Two steps: +The calibration belongs to the **site** (the base-station layout), not to the +DotBot, so you capture once from any one DotBot and push the result to the +whole fleet. + +Flash and start the `calibrate` app, which captures when you press the +DotBot's button: ```bash -dotbot swarm stop # put the DotBots in READY -dotbot swarm calibrate-lh2 collect \ - --device BC3D3C8A2A6F8E68 -d 500 # capture from one DotBot -> solve -> save -dotbot swarm calibrate-lh2 push \ - ~/.dotbot/calibrations/calibration-.toml # apply to every ready DotBot +dotbot swarm stop # DotBots back in the bootloader +dotbot swarm flash calibrate -ys # flash and start the calibrate app +dotbot swarm calibrate-lh2 collect # the field's four corners -> solve -> save ``` -`collect` walks one DotBot through the four corners - **top-left -> top-right -> -bottom-left -> bottom-right** - (capture only runs while the DotBot is in READY, so -`swarm stop` first). Each prompt triggers a raw-count capture over the air; it -then solves the homography and saves a `calibration-.toml` under `~/.dotbot/` -(the path is printed at the end). Find the `--device` address with -`dotbot swarm status`. +`collect` asks for the points in order - **top-left -> top-right -> +bottom-left -> bottom-right**. Place the DotBot on each, and press its button +once it is still. It then solves every station that saw all four points and +saves `calibration--.toml` under `~/.dotbot/calibrations//`, +printing the path, the id and the command to push it: + +```bash +dotbot swarm calibrate-lh2 push # send it to every robot +``` -`push` with **no `--device`** sends that calibration to **every ready DotBot** - -the whole arena shares one transform. It accepts a `calibration-*.toml` or the -legacy raw `calibration.out` payload; the format is picked by file extension. +`push` takes a file path, the exact `--tag` the calibration was collected +with, or a prefix of its id. It refuses robots that report another site, and +lists the robots that still hold another calibration afterwards. ```{note} -`collect --push` is a **single-DotBot shortcut**: it sends the result to *only* -the `--device` DotBot you captured from (handy to spot-check that one DotBot, or -for a single-DotBot setup). To calibrate the fleet, run the standalone `push` -above - it targets all ready DotBots. +`collect --push` is a **shortcut**: it sends the result only to the robots +whose captures built it. To calibrate the fleet, run the standalone `push`. ``` -Once pushed, the DotBots report positions, which show up live in the -[controller](../cli/run.md) Web UI. +`--device ` still captures from Enter, with the robot's app stopped +(READY), instead of from the button; it is deprecated in favour of the +calibrate app. -### `collect` flags +## Check it on the map -| Flag | Default | Meaning | -|---|---|---| -| `--device` | (required) | DotBot link-layer address in hex (from `dotbot swarm status`). | -| `-d`, `--distance` | calibration default | Square side length, **in mm** (see sizing below). | -| `-n`, `--conn` / `-s`, `--swarm-id` | from config | Swarm connection, like the other `dotbot swarm` commands. | -| `--timeout` | `5` s | Seconds to wait for each capture before re-triggering. | -| `--retries` | `3` | Re-trigger this many times per corner before giving up. | -| `--tag` | - | Arena/setup label (e.g. `office-2x2m`) added to the filename + metadata. | -| `--push` | off | After solving, send to the captured `--device` DotBot **only** (use the standalone `push` for the whole fleet). | +Run the controller on the calibration: -See `dotbot swarm calibrate-lh2 collect --help` for the full list. +```bash +dotbot run controller --lh2-calibration --headless +``` -## Sizing `-d` +In the console, the **Calibrated span** layer outlines where the calibration +was fitted and hatches the rest of the site: a position in the hatch is +extrapolated. Its tooltip gives the calibration's tag and id, its age in days +and how its points were chosen. -`-d` is the **side of your reference square, in millimeters**. The usable arena -is **5× the square side**, with the square centered (a `2·d` margin on every -side): +The controller warns at load when the calibration is older than +`[run.controller] lh2_calibration_max_age_days` (default 30, `0` never warns), +and when robots hold calibrations for stations it did not solve. A moved +station keeps its channel, so age is the only reminder that one might have +moved. -``` - ←─────────────── 5·d ────────────→ -┌──────────────────────────────────┐ ↑ -│ │ │ -│ │ │ -│ ←─── d ───→ │ │ -│ TL ●─────────● TR │ │ -│ │ │ │ 5·d -│ │ │ │ │ -│ BL ●─────────● BR │ │ -│ │ │ -│←── 2·d ──→ │ │ -└──────────────────────────────────┘ ↓ - - ⌖ LH2 base station (mounted ~2 m up, facing the arena) -``` +A calibration belongs to the site it was made in. The controller refuses one +from another site, and one whose recorded anchor differs from the site's. To +move a calibration into another site's frame without capturing again, use +`dotbot swarm calibrate-lh2 reframe`. -`TL/TR/BL/BR` are the four reference points you place the DotBot on; `d` is the -square side (`--distance`, in mm), `5·d` the resulting arena. +## `collect` flags -| `-d` | Square | Usable arena | +| Flag | Default | Meaning | |---|---|---| -| `400` | 40 cm | 2.0 m × 2.0 m | -| `500` | 50 cm | 2.5 m × 2.5 m | -| `800` | 80 cm | 4.0 m × 4.0 m (used for the 725-DotBot Limerick run) | +| `--over AREA` | - | Calibrate over another area's corners. | +| `--square MM` | - | Calibrate over a square this many mm wide, centred in the field. | +| `--points` | the field's corners | Points given by hand (see above). | +| `--site` | from config | The site the points are in, and the folder the calibration is saved under. | +| `--tag` | - | A label (e.g. `hall-2x4m`) added to the metadata; `push` and `--lh2-calibration` accept it. | +| `--push` | off | After solving, send to the robots whose captures built it. | +| `-n`, `--conn` / `-s`, `--swarm-id` | from config | Swarm connection, like the other `dotbot swarm` commands. | +| `--device` | - | Deprecated: capture from Enter on this DotBot, in READY. | + +See `dotbot swarm calibrate-lh2 collect --help` for the full list. ## Troubleshooting -- **Capture times out** - the DotBot isn't in READY (run `dotbot swarm stop` - first), or it can't see both base stations. The address passed to `--device` - must match one from `dotbot swarm status`. -- **Positions look skewed or mirrored** - the corners were captured out of - order. Re-run `collect` and follow TL -> TR -> BL -> BR exactly. -- **Positions are scaled wrong** - `-d` didn't match the real square. It's in - millimeters, not centimeters. +- **No capture arrives** - the `calibrate` app is not running on the DotBot + (`dotbot swarm flash calibrate -ys`), or it cannot see the base stations. +- **A station is "not solved"** - it did not see all four points. Check its + channel (1..N, no gaps) and that nothing blocks its view of the field. +- **Positions look skewed or mirrored** - the DotBot was placed on the corners + out of order. Re-run `collect` and follow the prompts exactly. +- **Positions are off near the edges** - they are outside the calibrated span. + Calibrate over the whole field rather than `--square` or `--over`. +- **The controller refuses the calibration** - it was made in another site. + Select that site with `--site`, or calibrate this one. diff --git a/doc/hardware/index.md b/doc/hardware/index.md index 86c66f53..afe8d881 100644 --- a/doc/hardware/index.md +++ b/doc/hardware/index.md @@ -117,7 +117,7 @@ start `10` (the `-s` prefix selects which probe to talk to). See For position tracking, the testbed uses **Valve Lighthouse 2** base stations. Each DotBot v3 carries an LH2 sensor shield (a TS4231 light-to-digital receiver with a photodiode) that decodes the base station's sweeping IR beams into a -position. One base station illuminates the arena; the DotBots compute where they +position. One base station illuminates the floor; the DotBots compute where they are from what they see. Once the optical setup is in place, calibrate it before relying on the diff --git a/doc/index.md b/doc/index.md index 1bea851c..69978cf5 100644 --- a/doc/index.md +++ b/doc/index.md @@ -35,6 +35,20 @@ own code - one DotBot, or a swarm of hundreds. Pick a starting point: **Prerequisites** below before you start. ``` +```{admonition} Sites and areas +:class: note + +Positions are millimetres in a **site**: the floor you work on, with its zero +at a physical mark you write down and named rectangles called **areas**. An +area can have a role. The **field** is where experiments happen and what a +calibration covers, and a site has at most one. **Staging** is where robots +park and charge. A **corner** is a small patch, such as a bench, that may +overlap other areas and stays hidden in the console until you turn it on. +`dotbot config init` writes a site with a field and a staging strip, and +commands that need a place default to the field. See +[sites](reference/configuration.md#sites) in the configuration reference. +``` + ```{include} ../README.md :relative-images: ``` diff --git a/doc/reference/configuration.md b/doc/reference/configuration.md index 6c01248c..3988b5d5 100644 --- a/doc/reference/configuration.md +++ b/doc/reference/configuration.md @@ -10,8 +10,9 @@ You never need a config file: every setting also has a flag and an env var. The file just makes a repeated setup (a broker URL, a board name, a swarm id) the default. -Create one with `dotbot config init` (it writes a minimal `./dotbot.toml`); -pass `--conn` / `--swarm-id` to pre-fill the two most common keys: +Create one with `dotbot config init` (it writes a `./dotbot.toml` holding a +starter [site](#sites)); pass `--conn` / `--swarm-id` to pre-fill the two most +common keys: ```bash dotbot config init --conn mqtts://broker:8883 --swarm-id 1234 @@ -82,6 +83,8 @@ Set once at the top of the file; any section or deployment can override them. | `swarm_id` | Swarm id selecting the MQTT topic namespace. | | `log_level` | Logging verbosity. | | `default_deployment` | Name of the deployment to select when neither `--deployment` nor `DOTBOT_DEPLOYMENT` is given. | +| `site` | The active [site](#sites): its frame, its areas and the folder its calibrations are kept under. `--site` or `DOTBOT_SITE` overrides it. | +| `site_dirs` | Folders searched, in order, for [site packs](#site-packs) (default `["sites", "~/.dotbot/sites"]`). | ## Section tables @@ -124,7 +127,8 @@ The four tables mirror the four CLI namespaces (`fw` / `device` / `swarm` / | `[run.controller] http_port` | REST/WebSocket port (default 8000). | | `[run.controller] http_host` | Interface the REST/WebSocket API binds to (default `127.0.0.1`). `0.0.0.0` exposes it to the network; the API is unauthenticated. | | `[run.controller] background_map` | Background map image. | -| `[run.controller] lh2_calibration` | Lighthouse calibration to run on: a file path, or an id prefix of one under `~/.dotbot/calibrations//`. | +| `[run.controller] lh2_calibration` | Lighthouse calibration to run on: a file path, the exact `--tag` it was collected with, or an id prefix of one of the site's calibrations (see [where calibrations are found](#where-calibrations-are-found)). | +| `[run.controller] lh2_calibration_max_age_days` | Warn at load when the LH2 calibration is older than this many days (default 30, `0` never warns). Also `DOTBOT_RUN_CONTROLLER_LH2_CALIBRATION_MAX_AGE_DAYS`. | | `[run.controller] camera_calibration` | Overhead camera registration to draw on the map, same form. Written by `dotbot run calibrate-camera collect`. | | `[run.controller] camera_detect` | Run the robot detector on a registered camera (default true). False serves the layer as a picture only, and writes no `-camera.csv`. | | `[run.controller] log_output` | Log output path. | @@ -132,6 +136,7 @@ The four tables mirror the four CLI namespaces (`fw` / `device` / `swarm` / | `[run.controller] headless` | Stay headless - don't open the web UI in a browser on start (default false; it's still served). | | `[run.controller] gw_address` | Gateway address. | | `[run.controller] simulator_init_state` | Initial simulator state. | +| `[run.controller] simulator_area` | Where a simulator places its robots (`--area`): an area name, a `+`-joined composite or `x,y,w,h` in mm. Defaults to the site's field. | | `[run.controller] swarmit_url` | swarmit server the console's orchestration panel talks to, proxied at `/swarmit/*` (default `http://localhost:8001`, which matches `swarmit serve`). | | `[run.controller] mrta_url` | MRTA mode server (dotbot-logistics) the console's MRTA toggle talks to, proxied at `/mrta/*`. Unset by default (no default URL) - the console shows no MRTA control until this is set (typically `http://localhost:8002`, dotbot-logistics' own default port). | | `[run.gateway] serial_port` | Gateway serial port. | @@ -165,6 +170,7 @@ metadata: | `conn` | Broker / link for this deployment. | | `swarm_id` | Swarm id for this deployment. | | `serial_port` | Default serial port for this deployment. | +| `site` | The [site](#sites) this deployment works in, when the top-level `site` should not apply. | | `location` | Descriptive label (shown by `dotbot deployment list`). | | `bots` | Descriptive DotBot count. | @@ -190,6 +196,111 @@ use` or `--deployment`. Useful flags: `--into project` (write the nearest Because MQTT credentials are env-only (below), a published deployment file is not secret - it carries only the broker URL, swarm id, and descriptive labels. +## Sites + +A **site** is the floor you work on. Its `[sites.]` table says where zero +is, how big the floor is, and which named rectangles, **areas**, it holds. +Positions, calibrations and the console map are all in the active site's +frame: millimetres, zero at the top-left corner of the extent, x to the right, +y down. + +```toml +site = "lab" + +[sites.lab] +anchor = "corner of the tiles by the door; x along the window wall" +extent_mm = [5000, 5000] + +[sites.lab.areas] +field = { x = 1500, y = 1500, w = 2000, h = 2000 } +staging = { x = 1500, y = 3500, w = 2000, h = 600 } +dev-corner = { x = 4000, y = 300, w = 700, h = 700, role = "corner" } +``` + +| Key | Meaning | +|---|---| +| `anchor` | Prose saying where zero is on the real floor. No code reads it, but a calibration records it, and one made against another anchor is refused. | +| `extent_mm` | `[width, height]` of the floor. | +| `areas.` | A rectangle `{ x, y, w, h }` in mm, with an optional `role`. | + +The active site is, in order: `--site`, `DOTBOT_SITE`, the selected +deployment's `site`, the top-level `site`, then `default`. +`dotbot config init` writes a `default` site; see [`dotbot config`](../cli/config.md). + +### Area roles + +An area can have one of three roles: + +| Role | Meaning | +|---|---| +| `field` | Where experiments happen and what a calibration covers. The simulator fleet, `swarm calibrate-lh2 collect`, the camera `collect --area` and the console's calibration setup all default to it. | +| `staging` | Where robots park and charge. The charging example needs one. | +| `corner` | A small patch, such as a bench, that may overlap other areas. The console starts it hidden. | + +An area named `field`, `staging` or `corner` has that role; `role = "..."` +gives one to any other name, and beats the one the name implies. An area with +neither has no role. + +A site has **at most one field**; two is a config error naming both. When no +area has the `field` role, the field is the first area that is neither staging +nor corner, else the first area, else the whole extent. Areas keep the order +they are declared in. + +### Site packs + +A site can also live in its own folder, a **site pack**, to commit, zip or hand +to someone: + +```text +lab/ +├── site.toml # the keys of [sites.lab]: anchor, extent_mm, areas +└── calibrations/ # optional: the site's LH2 and camera calibration files +``` + +The folder's name is the site's name. Packs are found in the `site_dirs` +folders, searched in order, and the first folder holding a pack of a name wins. +The default is `["sites", "~/.dotbot/sites"]`: a `sites/` folder next to the +config file, then the folder `dotbot site add` copies packs into. A relative +entry is read from the config file's folder. + +```toml +site = "lab" +site_dirs = ["sites"] +``` + +An inline `[sites.]` table wins over a pack of the same name, with a +one-line notice naming the pack it hides. `dotbot config show` lists every site +and where it was read from. To install or share a pack, see +[`dotbot site`](../cli/site.md). + +### Where calibrations are found + +A calibration is always the one you name: a file path, the exact `--tag` it was +collected with, or a prefix of its id, never "the latest". A tag or an id is +looked up in the site's pack `calibrations/` folder first, then in +`~/.dotbot/calibrations//`, where `collect` writes; the first folder with +a match wins. + +At load, the controller refuses an LH2 or camera calibration made in another +site, and one whose recorded anchor differs from the site's when both record +one. Select the calibration's site with `--site`, or pick a calibration of the +active site. + +### How calibration points were chosen + +Each placement in an LH2 calibration file records how its four points were +chosen, as a `points_from` table: + +| `points_from` | Meaning | +|---|---| +| `{ kind = "field" }` | The field's corners (`collect` with no flag). | +| `{ kind = "over", area = "dev-corner" }` | Another area's corners (`--over`). | +| `{ kind = "square", side_mm = 800 }` | A square centred in the field (`--square`). | +| `{ kind = "points" }` | Points given by hand (`--points`). | + +The console shows it in the calibrated span's tooltip. It is not part of the +calibration's id. + ## MQTT credentials are env-only MQTT username and password are read **only** from the environment: @@ -224,6 +335,8 @@ default_deployment = "inria" # used when --deployment / DOTBOT_D conn = "mqtts://broker.local:8883" swarm_id = "0001" log_level = "info" +site = "lab" # the active site; --site / DOTBOT_SITE override +site_dirs = ["sites"] # site packs next to this file, e.g. sites/hall/site.toml # A physical deployment. Select it with `--deployment inria`, DOTBOT_DEPLOYMENT, or # default_deployment above - don't edit this table to switch deployments. @@ -240,6 +353,16 @@ swarm_id = "0002" location = "Limerick campaign" bots = 725 +# A site: where zero is, the floor's size, and its areas. +[sites.lab] +anchor = "corner of the tiles by the door; x along the window wall" +extent_mm = [5000, 5000] + +[sites.lab.areas] +field = { x = 1500, y = 1500, w = 2000, h = 2000 } # named after its role +staging = { x = 1500, y = 3500, w = 2000, h = 600 } +dev-corner = { x = 4000, y = 300, w = 700, h = 700, role = "corner" } + # Firmware-artifact builds (dotbot fw). [fw] board = "dotbot-v3" @@ -263,6 +386,7 @@ conn = "mqtts://broker.local:8883" [run.controller] http_port = 8000 +lh2_calibration_max_age_days = 30 # warn when the loaded LH2 calibration is older headless = true # default is false; set true to suppress the browser (still served) # background_map = "./map.png" diff --git a/dotbot.example.toml b/dotbot.example.toml new file mode 100644 index 00000000..df7f2dd6 --- /dev/null +++ b/dotbot.example.toml @@ -0,0 +1,13 @@ +# dotbot config. Options + examples: https://pydotbot.readthedocs.io/en/latest/reference/configuration.html +# (MQTT credentials are env-only: DOTBOT_MQTT_USER / DOTBOT_MQTT_PASS.) + +site = "default" + +# Zero is the top-left corner of the extent, x right, y down, millimetres. +[sites.default] +anchor = "top-left corner of a 5 x 5 m floor; the field starts 1.5 m in from each wall" +extent_mm = [5000, 5000] + +[sites.default.areas] +field = { x = 1500, y = 1500, w = 2000, h = 2000 } +staging = { x = 1500, y = 3500, w = 2000, h = 600 } diff --git a/dotbot/adapter.py b/dotbot/adapter.py index 49d43a26..67ea01cd 100644 --- a/dotbot/adapter.py +++ b/dotbot/adapter.py @@ -23,6 +23,7 @@ from marilib.model import EdgeEvent, MariNode from dotbot import SIMULATOR_INIT_STATE_DEFAULT +from dotbot.area import Area from dotbot.dotbot_simulator import ( DotBotSimulatorCommunicationInterface, fleet_init_state, @@ -294,19 +295,27 @@ def __init__( simulator_init_state: str = SIMULATOR_INIT_STATE_DEFAULT, site: Optional[Site] = None, robots: Optional[int] = None, + area: Optional[Area] = None, + calibrated: Optional[int] = None, ): self.simulator_init_state = simulator_init_state self.site = site self.robots = robots + self.area = area + self.calibrated = calibrated def create_simulator(self, on_frame_received: callable): init_state = ( self.simulator_init_state if self.robots is None - else fleet_init_state(self.robots, self.site) + else fleet_init_state(self.robots, self.site, area=self.area) ) return DotBotSimulatorCommunicationInterface( - on_frame_received, init_state, self.site + on_frame_received, + init_state, + self.site, + self.area, + calibrated=self.calibrated, ) diff --git a/dotbot/area.py b/dotbot/area.py index 33b6f75f..699309fc 100644 --- a/dotbot/area.py +++ b/dotbot/area.py @@ -7,11 +7,27 @@ homography: changing it never touches a calibration file. Named areas come from the `[sites..areas.]` tables of a dotbot config file, so a fresh install with no config has none. + +An area may carry a role: `field` is where experiments happen and what gets +calibrated, `staging` is where robots park and charge, `corner` is a small +patch that overlaps other areas and starts hidden in the console. An area +named after a role has it unless it declares another. """ from __future__ import annotations from dataclasses import dataclass, field +from typing import Literal, get_args + +Role = Literal["field", "staging", "corner"] +ROLES: tuple[str, ...] = get_args(Role) + + +def area_role(name: str, role: Role | None = None) -> Role | None: + """The role an area has: the one it declares, else the one its name is.""" + if role is not None: + return role + return name if name in ROLES else None @dataclass(frozen=True) @@ -27,6 +43,7 @@ class Area: w: int h: int name: str = "" + role: Role | None = None @property def x_max(self) -> int: @@ -40,9 +57,16 @@ def y_max(self) -> int: def centre(self) -> tuple[float, float]: return (self.x + self.w / 2, self.y + self.h / 2) - def as_dict(self) -> dict[str, int]: - """The four numbers plus the name, the shape every consumer receives.""" - return {"x": self.x, "y": self.y, "w": self.w, "h": self.h, "name": self.name} + def as_dict(self) -> dict[str, int | str | None]: + """The four numbers, the name and the role, the shape every consumer receives.""" + return { + "x": self.x, + "y": self.y, + "w": self.w, + "h": self.h, + "name": self.name, + "role": self.role, + } @dataclass diff --git a/dotbot/calibration/driver.py b/dotbot/calibration/driver.py index b04f472c..47a189ee 100644 --- a/dotbot/calibration/driver.py +++ b/dotbot/calibration/driver.py @@ -21,7 +21,7 @@ from typing import Any, Callable, Sequence from dotbot.calibration.ota import CAPTURE_READS_DEFAULT -from dotbot.calibration.points import resolve_placement_points +from dotbot.calibration.points import field_corners, resolve_placement_points from dotbot.calibration.push import PushRefused, gate_push, push_worklist from dotbot.calibration.session import ( CalibrationSession, @@ -92,7 +92,8 @@ def preview(self, specs: Sequence[str]) -> dict: Same resolver as `start`, so the points a client shows before committing are the points it then captures. """ - placements = resolve_placement_points(list(specs), self.site.registry()) + specs = list(specs) or [field_corners(self.site)] + placements = resolve_placement_points(specs, self.site.registry()) return { "points": [ placement_dict(index, placement) diff --git a/dotbot/calibration/lighthouse2.py b/dotbot/calibration/lighthouse2.py index e7a3127c..6750643c 100644 --- a/dotbot/calibration/lighthouse2.py +++ b/dotbot/calibration/lighthouse2.py @@ -23,10 +23,11 @@ import tomllib from dataclasses import dataclass, field from pathlib import Path -from typing import Callable, Iterable, Optional, Sequence +from typing import Callable, Iterable, Optional, Sequence, Union import numpy as np +from dotbot.calibration.points import PointsFrom from dotbot.robots import ROBOT_DEFAULT from dotbot.site import SITE_DEFAULT, Site @@ -156,11 +157,13 @@ class Placement: `at` records what the operator typed and nothing reads it back; `points_mm` is resolved once, at capture, and is the only solver input. + `points_from` says how the points were chosen. """ index: int points_mm: list[tuple[float, float]] at: str = "" + points_from: PointsFrom | None = None captured_at: str = "" samples: list[Sample] = field(default_factory=list) @@ -477,7 +480,7 @@ def canonical_serialisation(calibration: Calibration) -> str: One `key=value` line per hashed field, sorted, newline-joined. The rule that decides membership: if changing a field cannot change any computed position, it is not here. So the site's `anchor`, `created_at`, `tag`, - the robot model and a placement's `at` note are all outside it, and a + the robot model and a placement's `at` and `points_from` are all outside it, and a typo fix in a sentence no code reads cannot make a fleet look stale. Zero is the site's anchor by definition, so there is no origin offset to @@ -549,6 +552,19 @@ def toml_matrix(matrix: Sequence[Sequence[float]]) -> str: return f"[{rows}]" +def _toml_inline_table(values: dict[str, str | int]) -> str: + """A flat table of strings and ints, as a TOML inline table.""" + items = ( + ( + f'{key} = "{toml_escape(value)}"' + if isinstance(value, str) + else f"{key} = {value}" + ) + for key, value in values.items() + ) + return "{ " + ", ".join(items) + " }" + + def toml_escape(text: str) -> str: """`text` as the body of a TOML basic string.""" return text.replace("\\", "\\\\").replace('"', '\\"') @@ -583,6 +599,12 @@ def render_calibration(calibration: Calibration) -> str: f"index = {placement.index}", f'at = "{toml_escape(placement.at)}"', f"points_mm = {toml_points(placement.points_mm)}", + ] + if placement.points_from is not None: + out.append( + f"points_from = {_toml_inline_table(placement.points_from.to_dict())}" + ) + out += [ f'captured_at = "{placement.captured_at}"', "samples = [", ] @@ -652,6 +674,11 @@ def read_calibration_file(path: Path) -> Calibration: index=int(raw["index"]), points_mm=[(float(p[0]), float(p[1])) for p in raw["points_mm"]], at=raw.get("at", ""), + points_from=( + PointsFrom.from_dict(raw["points_from"]) + if "points_from" in raw + else None + ), captured_at=raw.get("captured_at", ""), samples=samples, ) @@ -685,13 +712,12 @@ def read_calibration_file(path: Path) -> Calibration: def resolve_calibration_path( spec: str, root: Optional[Path] = None, - site: Optional[str] = None, + site: Union[Site, str, None] = None, ) -> Path: """The file `spec` names: see `resolve_calibration_spec`.""" return resolve_calibration_spec( spec, - root or calibration_root(), - site, + calibration_folders(site, root or calibration_root()), glob=CALIBRATION_TOML_GLOB, metadata=_file_metadata, what="calibration", @@ -699,10 +725,25 @@ def resolve_calibration_path( ) +def calibration_folders( + site: Union[Site, str, None], root: Path +) -> list[tuple[Path, str]]: + """Where a site's calibration files are looked for, in order, as (folder, + pattern prefix): its pack's `calibrations/`, then `root//`. No site + searches every site under `root`.""" + if site is None: + return [(root, "*/")] + if isinstance(site, str): + return [(root / site, "")] + folders = [(root / site.name, "")] + if site.pack_calibrations is not None: + folders.insert(0, (site.pack_calibrations, "")) + return folders + + def resolve_calibration_spec( spec: str, - root: Path, - site: Optional[str], + folders: Sequence[tuple[Path, str]], glob: str, metadata: Callable[[Path], dict], what: str, @@ -710,22 +751,21 @@ def resolve_calibration_spec( ) -> Path: """The file `spec` names, tried in order: a readable path; an exact, case-insensitive `tag` (as typed or as its stored slug); an id prefix of - a `glob` file under `root`, limited to `site` when given. + a `glob` file. Each (folder, prefix) of `folders` is searched in turn, + and the first with a match wins. Raises ValueError when nothing matches, or when a tag or an id prefix - matches several files, listing each one's id, `created_key` and path. + matches several files in one folder, listing each one's id, + `created_key` and path. """ candidate = Path(spec).expanduser() if candidate.is_file(): return candidate - files = { - path: metadata(path) for path in sorted(root.glob(f"{site or '*'}/{glob}")) - } spec_lower = spec.lower() spec_slug = slug_tag(spec).lower() - def unique(kind: str, matches: list) -> Optional[Path]: + def unique(kind: str, matches: list[Path], files: dict) -> Optional[Path]: if len(matches) > 1: lines = [ f" {files[path].get('id', '?')} " @@ -737,29 +777,65 @@ def unique(kind: str, matches: list) -> Optional[Path]: ) return matches[0] if matches else None - found = unique( - "tag", - [ + for folder, prefix in folders: + files = {path: metadata(path) for path in sorted(folder.glob(prefix + glob))} + tags = [ path for path, data in files.items() if str(data.get("tag", "")).lower() in {spec_lower, spec_slug} - {""} - ], - ) or unique( - "id prefix", - [ + ] + ids = [ path for path, data in files.items() if str(data.get("id", "")).lower().startswith(spec_lower) - ], + ] + found = unique("tag", tags, files) or unique("id prefix", ids, files) + if found is not None: + return found + searched = " or ".join( + str(folder / prefix.rstrip("/")) for folder, prefix in folders ) - if found is not None: - return found raise ValueError( f"no {what} matches {spec!r}: it is neither a readable file, an " - f"exact tag, nor the id prefix of a file under {root / (site or '*')}" + f"exact tag, nor the id prefix of a file under {searched}" ) +def calibration_age_days( + calibration: Calibration, now: Optional[datetime.datetime] = None +) -> Optional[float]: + """Days since `created_at`, an ISO 8601 time read as UTC when it names no + zone; None when it is missing or unreadable.""" + try: + created = datetime.datetime.fromisoformat(calibration.created_at) + except ValueError: + return None + if created.tzinfo is None: + created = created.replace(tzinfo=datetime.timezone.utc) + now = now or datetime.datetime.now(datetime.timezone.utc) + return (now - created).total_seconds() / 86400 + + +def check_calibration_site(file_site: Site, site: Site, path: Optional[Path]) -> None: + """Refuse a calibration made in another site, or against another anchor. + + Anchors are compared only when both are recorded. + """ + where = path or "the calibration" + if file_site.name != site.name: + raise ValueError( + f"{where} was made in site {file_site.name!r}, not {site.name!r}; " + f"select site {file_site.name!r} (--site or DOTBOT_SITE) or pick a " + f"calibration of {site.name!r}" + ) + if file_site.anchor and site.anchor and file_site.anchor != site.anchor: + raise ValueError( + f"{where} records the anchor {file_site.anchor!r}, but site " + f"{site.name!r} has {site.anchor!r}: its frame is another one, so " + "recalibrate or correct the site's anchor" + ) + + def _file_metadata(path: Path) -> dict: """The file's `[metadata]` table, read without solving anything.""" try: @@ -772,10 +848,14 @@ def _file_metadata(path: Path) -> dict: def load_calibration( spec: str, root: Optional[Path] = None, - site: Optional[str] = None, + site: Union[Site, str, None] = None, ) -> Calibration: - """Read the calibration `spec` names.""" - return read_calibration_file(resolve_calibration_path(spec, root, site)) + """Read the calibration `spec` names; given a `Site`, refuse one made in + another (`check_calibration_site`).""" + calibration = read_calibration_file(resolve_calibration_path(spec, root, site)) + if isinstance(site, Site): + check_calibration_site(calibration.site, site, calibration.path) + return calibration # --- Manager ---------------------------------------------------------------- diff --git a/dotbot/calibration/points.py b/dotbot/calibration/points.py index 8daa2e3d..d87a79d9 100644 --- a/dotbot/calibration/points.py +++ b/dotbot/calibration/points.py @@ -14,6 +14,7 @@ from __future__ import annotations from dataclasses import dataclass +from typing import Any, Literal, get_args from dotbot.area import Area, AreaRegistry from dotbot.robots import ROBOT_DEFAULT, robot_geometry @@ -22,6 +23,62 @@ # The corners of a rectangle, in the order a placement stores them. CORNERS = ("top-left", "top-right", "bottom-left", "bottom-right") +PointsKind = Literal["field", "over", "square", "points"] +POINTS_KINDS: tuple[str, ...] = get_args(PointsKind) + + +@dataclass(frozen=True) +class PointsFrom: + """How a placement's points were chosen. + + `field`: the field's corners. `over`: the corners of `area`. `square`: a + `side_mm` square centred in the field. `points`: given by hand. + """ + + kind: PointsKind + area: str | None = None + side_mm: int | None = None + + def __post_init__(self) -> None: + if self.kind not in POINTS_KINDS: + raise ValueError( + f"points_from kind {self.kind!r} is not one of " + f"{', '.join(POINTS_KINDS)}" + ) + if (self.kind == "over") != (self.area is not None): + raise ValueError("points_from carries an area exactly when it is `over`") + if (self.kind == "square") != (self.side_mm is not None): + raise ValueError( + "points_from carries a side_mm exactly when it is `square`" + ) + + def __str__(self) -> str: + if self.kind == "over": + return f"over {self.area}" + if self.kind == "square": + return f"square {self.side_mm} mm" + return self.kind + + def to_dict(self) -> dict[str, Any]: + """The fields that are set, as a calibration file's table holds them.""" + out: dict[str, Any] = {"kind": self.kind} + if self.area is not None: + out["area"] = self.area + if self.side_mm is not None: + out["side_mm"] = self.side_mm + return out + + @classmethod + def from_dict(cls, raw: Any) -> PointsFrom: + if not isinstance(raw, dict): + raise ValueError(f"points_from is a table with a `kind`, not {raw!r}") + side_mm = raw.get("side_mm") + return cls( + kind=raw.get("kind", ""), + area=raw.get("area"), + side_mm=None if side_mm is None else int(side_mm), + ) + @dataclass(frozen=True) class PointPlacement: @@ -144,6 +201,87 @@ def resolve_placement_points( return points +def _field(site: Site) -> Area: + field = site.field + if field is None: + raise ValueError( + f"site {site.name!r} declares no areas and no extent, so it has no " + f"field to calibrate over. Add a [sites.{site.name}.areas.field] " + "table to your dotbot config, or give the points with --points" + ) + return field + + +def field_corners(site: Site) -> str: + """The `--points` specification of the site's field corners, the default.""" + return f"{_field(site).name}:corners" + + +def centred_square(area: Area, side_mm: int) -> Area: + """The `side_mm` square centred in `area`, named as its `x,y,w,h` literal.""" + if side_mm <= 0: + raise ValueError(f"a square side is a positive number of mm, not {side_mm}") + if side_mm > min(area.w, area.h): + raise ValueError( + f"a {side_mm} mm square does not fit in {area.name} " + f"({area.w} x {area.h} mm)" + ) + x = area.x + (area.w - side_mm) // 2 + y = area.y + (area.h - side_mm) // 2 + return Area(x, y, side_mm, side_mm, f"{x},{y},{side_mm},{side_mm}") + + +def collect_points( + site: Site, + points: list[str], + over: str | None = None, + square: int | None = None, +) -> tuple[list[str], PointsFrom | None, str]: + """The specification `collect` captures, how it was chosen, and a note. + + One of `points`, `over` and `square` at most; none means the field's + corners. The chosen-how is None when `points_from_specs` can read it off + the specification. The note is the extrapolation warning a square gets, + else empty. + """ + if points: + return points, None, "" + if over is not None: + area = site.registry().resolve(over) + return [f"{area.name}:corners"], PointsFrom("over", area=area.name), "" + if square is None: + return [field_corners(site)], None, "" + field = _field(site) + square_area = centred_square(field, square) + note = ( + f"Only the {square} x {square} mm square is calibrated: the rest of the " + f"{field.w} x {field.h} mm field is extrapolated, and the error grows " + "toward its corners." + ) + return ( + [f"{square_area.name}:corners"], + PointsFrom("square", side_mm=square), + note, + ) + + +def points_from_specs(specs: list[str] | tuple[str, ...], site: Site) -> PointsFrom: + """How a placement's points were chosen, read off its specification. + + `field` for the field's corners, `over` for another named area's corners, + `points` for anything else. `square` is recorded by the caller that built + the square, since its literal rectangle says nothing of where it came from. + """ + if len(specs) == 1 and specs[0].strip().endswith(":corners"): + name = specs[0].strip()[: -len(":corners")] + field = site.field + if field is not None and name == field.name: + return PointsFrom("field") + if name in site.areas: + return PointsFrom("over", area=name) + return PointsFrom("points") + + def _centre(area: Area) -> PointPlacement: """A rectangle's centre, which constrains no pose.""" return PointPlacement(mm=area.centre, area=area.name) diff --git a/dotbot/calibration/session.py b/dotbot/calibration/session.py index 88a1d842..956f46ff 100644 --- a/dotbot/calibration/session.py +++ b/dotbot/calibration/session.py @@ -38,6 +38,9 @@ ) from dotbot.calibration.points import ( PointPlacement, + PointsFrom, + field_corners, + points_from_specs, resolve_placement_points, ) from dotbot.robots import ROBOT_DEFAULT @@ -80,6 +83,7 @@ class CalibrationSession: points: list[SessionPoint] site: Site at: str = "" + points_from: PointsFrom | None = None # The area the expected error will be evaluated over; "" means none. area: str = "" device: str = "" @@ -105,10 +109,16 @@ def resolve( specs: Sequence[str], site: Site | None = None, robot: str = ROBOT_DEFAULT, + points_from: PointsFrom | None = None, **kwargs: Any, ) -> CalibrationSession: - """A session over the points one `--points` specification stands for.""" + """A session over the points one `--points` specification stands for. + + No specification means the site's field corners. `points_from` + defaults to what `points_from_specs` reads off the specification. + """ site = site or Site() + specs = list(specs) or [field_corners(site)] placements = resolve_placement_points(specs, site.registry(), robot) if len(placements) < POINTS_MIN: raise SessionError( @@ -122,6 +132,7 @@ def resolve( ], site=site, at=" ".join(specs), + points_from=points_from or points_from_specs(specs, site), robot=robot, **kwargs, ) @@ -238,6 +249,7 @@ def placement(self) -> Placement: return Placement( index=0, at=self.at, + points_from=self.points_from, points_mm=[p.mm for p in self.points], captured_at=datetime.datetime.now(datetime.timezone.utc).strftime( "%Y-%m-%dT%H:%M:%SZ" diff --git a/dotbot/camera/registration.py b/dotbot/camera/registration.py index dd196f01..24014bd2 100644 --- a/dotbot/camera/registration.py +++ b/dotbot/camera/registration.py @@ -25,7 +25,9 @@ from dotbot.area import Area from dotbot.calibration.lighthouse2 import ( apply_homography, + calibration_folders, calibration_root, + check_calibration_site, compute_homography_matrix, reprojection_residual_mm, resolve_calibration_spec, @@ -323,14 +325,13 @@ def read_camera_calibration_file(path: Path) -> CameraCalibration: def resolve_camera_calibration_path( spec: str, root: Path | None = None, - site: str | None = None, + site: Site | str | None = None, ) -> Path: """The camera file `spec` names, as `resolve_calibration_spec` finds it among `camera-*.toml` files only.""" return resolve_calibration_spec( spec, - root or calibration_root(), - site, + calibration_folders(site, root or calibration_root()), glob=CAMERA_TOML_GLOB, metadata=_camera_file_data, what="camera calibration", @@ -350,12 +351,16 @@ def _camera_file_data(path: Path) -> dict: def load_camera_calibration( spec: str, root: Path | None = None, - site: str | None = None, + site: Site | str | None = None, ) -> CameraCalibration: - """Read the camera calibration `spec` names.""" - return read_camera_calibration_file( + """Read the camera calibration `spec` names; given a `Site`, refuse one + made in another (`check_calibration_site`).""" + calibration = read_camera_calibration_file( resolve_camera_calibration_path(spec, root, site) ) + if isinstance(site, Site): + check_calibration_site(calibration.site, site, calibration.path) + return calibration def write_camera_calibration( diff --git a/dotbot/cli/_site.py b/dotbot/cli/_site.py index 8c8aab12..2966ade5 100644 --- a/dotbot/cli/_site.py +++ b/dotbot/cli/_site.py @@ -15,7 +15,11 @@ import os from typing import Any, Mapping -from dotbot.site import SITE_DEFAULT, Site, site_from_config +import click + +from dotbot.config import ConfigError +from dotbot.site import SITE_DEFAULT, Site +from dotbot.site_packs import resolve_site_entry SITE_ENV = "DOTBOT_SITE" @@ -44,10 +48,23 @@ def resolve_site_name( def site_from_context(ctx: Any, flag: str | None = None) -> tuple[Site, str]: - """The active site, built from the config the root group stashed on `ctx.obj`.""" + """The active site, from the config the root group stashed on `ctx.obj`: + its inline table, else a site pack of that name.""" obj = ctx.obj or {} config = obj.get("config") name, source = resolve_site_name( config=config, deployment=obj.get("deployment"), flag=flag ) - return site_from_config(config, name), source + try: + entry = resolve_site_entry(config, obj.get("config_path"), name) + except ConfigError as exc: + raise click.ClickException(str(exc)) from exc + if entry is None: + return Site(name=name), source + if entry.shadows is not None: + click.echo( + f"note: the inline [sites.{name}] table shadows the site pack at " + f"{entry.shadows}", + err=True, + ) + return entry.site(), source diff --git a/dotbot/cli/camera_calibrate.py b/dotbot/cli/camera_calibrate.py index 66cad7da..a768c209 100644 --- a/dotbot/cli/camera_calibrate.py +++ b/dotbot/cli/camera_calibrate.py @@ -130,12 +130,12 @@ def sheets(out_dir: str, sheet_format: str, per_sheet: bool) -> None: @click.option( "--area", "area_name", - required=True, + default=None, help=( "The one area this camera covers: a name from the site's " "`[sites..areas.]` tables, `x,y,w,h` in frame mm, or a " "`+`-joined composite. The four sheet positions are derived from its " - "corners." + "corners. Defaults to the site's field." ), ) @click.option( @@ -189,7 +189,7 @@ def sheets(out_dir: str, sheet_format: str, per_sheet: bool) -> None: @click.pass_context def collect( ctx: click.Context, - area_name: str, + area_name: str | None, site_name: str | None, camera_spec: str | None, reads: int | None, @@ -210,6 +210,14 @@ def collect( sys.exit(1) site, _ = site_from_context(ctx, site_name) + if area_name is None: + field = site.field + if field is None: + raise click.ClickException( + f"site {site.name!r} declares no areas and no extent, so it has " + "no field for the camera to cover; name one with --area x,y,w,h" + ) + area_name = field.name try: area = site.registry().resolve(area_name) except ValueError as exc: diff --git a/dotbot/cli/config_cmd.py b/dotbot/cli/config_cmd.py index de8713bf..e946fff1 100644 --- a/dotbot/cli/config_cmd.py +++ b/dotbot/cli/config_cmd.py @@ -4,29 +4,146 @@ """`dotbot config` - scaffold and inspect the dotbot configuration. A management group (like `git config` / `kubectl config`): `init` writes a -starter config file (optionally pre-filling `conn` / `swarm_id`); `path` and +starter config file holding a site to work in (optionally pre-filling `conn` / +`swarm_id`); `path` and `show` are read-only inspectors over what the root group resolved onto the Click context (`ctx.obj`): the loaded `DotbotConfig`, its source path, and the selected deployment. There is no per-key `set` - edit the file, it is yours. """ +import re from pathlib import Path from typing import Any import click import tomlkit -from dotbot.config import USER_CONFIG_PATH +from dotbot.cli._site import resolve_site_name +from dotbot.config import USER_CONFIG_PATH, ConfigError +from dotbot.site import SITE_DEFAULT, check_site_name +from dotbot.site_packs import site_catalog _CONFIG_DOCS_URL = ( "https://pydotbot.readthedocs.io/en/latest/reference/configuration.html" ) -# `dotbot config init` writes a *minimal* file: just the keys you pass, plus a -# one-line pointer to the full reference. No wall of commented options - the -# schema lives in the docs, not in everyone's config file. -def _starter_template(conn: str | None = None, swarm_id: str | None = None) -> str: +# The default site's geometry, all derived from the field: a margin of floor +# round it, and a staging strip along its bottom edge. +SITE_MARGIN_MM = 1500 +STAGING_DEPTH_MM = 600 +FIELD_DEFAULT_MM = (2000, 2000) +FIELD_MIN_MM = 100 +FIELD_MAX_MM = 100_000 +# Above this, one LH2 base station rarely covers the field well. +FIELD_COVERAGE_MM = 5000 + +_FIELD_SIDE = re.compile(r"^(?P\d+(?:\.\d+)?)(?Pmm|m|[a-z]+)?$") + + +def _metres(mm: int) -> str: + return f"{mm / 1000:g}" + + +def _field_side(text: str, unit: str | None, spec: str, in_metres: str) -> int: + """One side of `--field` in millimetres; `unit` is None for a bare number. + + `in_metres` is the spelling suggested when a millimetre value looks like + metres. + """ + value = float(text) + if unit == "m": + mm = value * 1000 + if mm > FIELD_MAX_MM: + raise click.BadParameter( + f"a {text} m field is too large; did you mean {text}mm?" + ) + else: + if value < FIELD_MIN_MM: + hint = ( + f"; did you mean {in_metres}?" if value * 1000 >= FIELD_MIN_MM else "" + ) + raise click.BadParameter(f"a {text} mm field is too small{hint}") + if value != int(value): + raise click.BadParameter( + f"{spec!r}: millimetres are whole numbers; use m for decimals" + ) + mm = value + mm = round(mm) + if mm < FIELD_MIN_MM: + raise click.BadParameter(f"a {_metres(mm)} m field is too small") + if mm > FIELD_MAX_MM: + raise click.BadParameter(f"a {_metres(mm)} m field is too large") + return mm + + +def parse_field_size(spec: str) -> tuple[int, int]: + """`--field` as (width, height) in millimetres. + + One value is a square, `WxH` a rectangle. A bare number is millimetres; + `mm` and `m` suffixes are accepted, decimals only on `m`, and a side with + no suffix takes the other side's. + """ + sides = spec.strip().lower().split("x") + if len(sides) not in (1, 2): + raise click.BadParameter(f"{spec!r}: give one size or WxH, e.g. 2m or 2x3m") + parsed = [] + for side in sides: + match = _FIELD_SIDE.match(side.strip()) + if match is None: + raise click.BadParameter( + f"{spec!r}: a size is a number of mm, or ends in mm or m, e.g. 2m" + ) + unit = match["unit"] + if unit not in (None, "mm", "m"): + raise click.BadParameter(f"{spec!r}: units are mm or m, not {unit}") + parsed.append((match["value"], unit)) + units = [unit for _, unit in parsed if unit is not None] + shared = units[-1] if units else None + sizes = [ + _field_side( + value, + unit or shared, + spec, + f"{spec.strip()}m" if not units else f"{value}m", + ) + for value, unit in parsed + ] + return (sizes[0], sizes[-1]) + + +def default_site_toml(name: str, field_mm: tuple[int, int]) -> str: + """The `[sites.]` table `init` writes: a field with a margin of floor + round it and a staging strip along its bottom edge.""" + width, height = field_mm + extent = (width + 2 * SITE_MARGIN_MM, height + 2 * SITE_MARGIN_MM) + x = y = SITE_MARGIN_MM + anchor = ( + f"top-left corner of a {_metres(extent[0])} x {_metres(extent[1])} m " + f"floor; the field starts {_metres(SITE_MARGIN_MM)} m in from each wall" + ) + return ( + "# Zero is the top-left corner of the extent, x right, y down, millimetres.\n" + f"[sites.{name}]\n" + f'anchor = "{anchor}"\n' + f"extent_mm = [{extent[0]}, {extent[1]}]\n" + "\n" + f"[sites.{name}.areas]\n" + f"field = {{ x = {x}, y = {y}, w = {width}, h = {height} }}\n" + f"staging = {{ x = {x}, y = {y + height}, w = {width}, " + f"h = {STAGING_DEPTH_MM} }}\n" + ) + + +# `dotbot config init` writes a *minimal* file: the keys you pass, the site +# your robots work in, and a one-line pointer to the full reference. No wall +# of commented options - the schema lives in the docs, not in everyone's file. +def _starter_template( + conn: str | None = None, + swarm_id: str | None = None, + site: str = SITE_DEFAULT, + field_mm: tuple[int, int] = FIELD_DEFAULT_MM, +) -> str: header = ( f"# dotbot config. Options + examples: {_CONFIG_DOCS_URL}\n" "# (MQTT credentials are env-only: DOTBOT_MQTT_USER / DOTBOT_MQTT_PASS.)\n" @@ -36,9 +153,8 @@ def _starter_template(conn: str | None = None, swarm_id: str | None = None) -> s keys.append(f'conn = "{conn}"') if swarm_id: keys.append(f'swarm_id = "{swarm_id}"') - if keys: - return header + "\n" + "\n".join(keys) + "\n" - return header + keys.append(f'site = "{site}"') + return header + "\n" + "\n".join(keys) + "\n\n" + default_site_toml(site, field_mm) @click.group( @@ -62,14 +178,39 @@ def cmd(): help="Pre-fill the shared connection (broker URL, serial path, or 'simulator').", ) @click.option("--swarm-id", help="Pre-fill the shared swarm id.") -def init(global_, force, conn, swarm_id): - """Write a minimal starter config file you can edit. +@click.option( + "--site", + default=SITE_DEFAULT, + show_default=True, + help="Name the site; its calibrations are kept under that name.", +) +@click.option( + "--field", + "field_spec", + default="2m", + show_default=True, + help="The field's size: one value for a square, WxH for a rectangle. " + "A bare number is mm; 1.5m and 1500mm also work.", +) +def init(global_, force, conn, swarm_id, site, field_spec): + """Write a starter config file you can edit. Defaults to ./dotbot.toml in the current directory; --global writes your user-level ~/.dotbot/config.toml. Refuses to overwrite unless --force. - `--conn` / `--swarm-id` pre-fill those top-level keys; the file otherwise - holds just a one-line pointer to the full reference (no wall of options). + The file names a site with a field, where experiments happen and what + calibration covers, and a staging strip along its bottom edge, with a + margin of floor round both. `--field` sizes the field, and the rest + follows from it. `--conn` / `--swarm-id` pre-fill those top-level keys. """ + try: + check_site_name(site) + except ValueError as exc: + raise click.BadParameter(str(exc), param_hint="'--site'") from exc + try: + field_mm = parse_field_size(field_spec) + except click.BadParameter as exc: + exc.param_hint = "'--field'" + raise if conn is not None: from dotbot.cli._conn import ConnError, parse_connection @@ -84,17 +225,27 @@ def init(global_, force, conn, swarm_id): f"{target} already exists. Pass --force to overwrite it." ) target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(_starter_template(conn, swarm_id)) + target.write_text(_starter_template(conn, swarm_id, site, field_mm)) click.echo(f"Wrote {target}") + click.echo( + f"Site {site}: a {_metres(field_mm[0])} x {_metres(field_mm[1])} m field " + f"with a staging strip below it." + ) + if max(field_mm) > FIELD_COVERAGE_MM: + click.echo( + "Warning: one LH2 base station rarely covers a field over " + f"{_metres(FIELD_COVERAGE_MM)} x {_metres(FIELD_COVERAGE_MM)} m. " + "Add stations, or calibrate only the part the robots use with " + "`dotbot swarm calibrate-lh2 collect --over` or `--square`.", + err=True, + ) if conn or swarm_id: filled = " and ".join( label for label, val in (("conn", conn), ("swarm_id", swarm_id)) if val ) click.echo(f"Set {filled}; review it, then run `dotbot config show`.") else: - click.echo( - "Add your settings (see the link inside), then `dotbot config show`." - ) + click.echo("Edit it to taste (see the link inside), then `dotbot config show`.") @cmd.command() @@ -117,10 +268,29 @@ def _prune(value: Any) -> Any: return value +def _echo_areas(entry) -> None: + """One line per area of a site: its name and its role, saying when the + role is implied by the name.""" + declared = entry.table.areas + areas = entry.site().areas + if not areas: + return + width = max(len(name) for name in areas) + for name, area in areas.items(): + if area.role is None: + role = "no role" + elif declared[name].role is None: + role = f"{area.role} (from its name)" + else: + role = area.role + click.echo(f" {name:<{width}} {role}") + + @cmd.command() @click.pass_context def show(ctx): - """Print the source path, the active deployment, and the loaded config. + """Print the source path, the active deployment, each site and where it + was read from, and the loaded config. None-valued fields are skipped so only what is actually set shows up. """ @@ -130,10 +300,27 @@ def show(ctx): deployment_name = obj.get("deployment_name") source = ( - str(config_path) if config_path is not None else "(none; built-in defaults)" + str(config_path) + if config_path is not None + else "(none; built-in defaults. Create one with: dotbot config init)" ) click.echo(f"source: {source}") click.echo(f"deployment: {deployment_name or '(none)'}") + site_name, site_source = resolve_site_name( + config=config, deployment=obj.get("deployment") + ) + try: + catalog = site_catalog(config, config_path) + except ConfigError as exc: + raise click.ClickException(str(exc)) from exc + known = "" if site_name in catalog else ", which no config or pack defines" + click.echo(f"site: {site_name} (from {site_source}{known})") + if catalog: + click.echo("sites:") + width = max(len(name) for name in catalog) + for name, entry in catalog.items(): + click.echo(f" {name:<{width}} {entry.source}") + _echo_areas(entry) click.echo("") if config is None: @@ -145,9 +332,7 @@ def show(ctx): # so the output is real, round-trippable TOML. data = _prune(config.model_dump()) if not data: - if config_path is None: - click.echo("No config file found. Create one with: dotbot config init") - else: + if config_path is not None: click.echo("(the file sets nothing yet; all built-in defaults)") return click.echo(tomlkit.dumps(data).rstrip()) diff --git a/dotbot/cli/main.py b/dotbot/cli/main.py index 3ce7850b..668e3242 100644 --- a/dotbot/cli/main.py +++ b/dotbot/cli/main.py @@ -66,11 +66,40 @@ "dotbot.cli.deployment_cmd", "List / show configured deployments.", ), + ( + "site", + "dotbot.cli.site_cmd", + "Add a site pack to this machine, or export one to share.", + ), ) +# The commands that read no config, so say nothing about which one is in +# effect: `config` inspects that itself, and `site add` only writes a pack +_CONFIGLESS = {("config",), ("site", "add")} + + +class _RootGroup(LazyGroup): + """The root group, which records the words after its subcommand's name + before its own callback runs, so the callback can tell `site add` from + `site export`.""" + + def resolve_command(self, ctx, args): + name, command, rest = super().resolve_command(ctx, args) + ctx.meta[_SUBCOMMAND_ARGS] = list(rest) + return name, command, rest + + +_SUBCOMMAND_ARGS = "dotbot.subcommand_args" + + +def _reads_config(ctx) -> bool: + words = (ctx.invoked_subcommand, *ctx.meta.get(_SUBCOMMAND_ARGS, [])[:1]) + return not any(words[: len(path)] == path for path in _CONFIGLESS) + + @click.group( - cls=LazyGroup, + cls=_RootGroup, subcommands=_SUBCOMMANDS, help=( "One CLI for the whole DotBot workflow: build and flash firmware, " @@ -136,15 +165,12 @@ def cli(ctx, config_path, deployment_name): except ConfigError as exc: raise click.ClickException(str(exc)) from exc - # The `config` group inspects config state itself (show/path) or scaffolds - # it (init), so the root-level "which config is in effect" echo is redundant - # there - and reads as a contradiction right before `config init` writes one. - if ctx.invoked_subcommand != "config": + if _reads_config(ctx): if path is not None: - click.echo(f"using config file at {path}", err=True) + click.echo(f"Using config file at {path}", err=True) else: click.echo( - f"no config file found (looked for ./{PROJECT_CONFIG_NAME} and " + f"No config file found (looked for ./{PROJECT_CONFIG_NAME} and " f"{USER_CONFIG_PATH}); using built-in defaults", err=True, ) diff --git a/dotbot/cli/site_cmd.py b/dotbot/cli/site_cmd.py new file mode 100644 index 00000000..8daf2574 --- /dev/null +++ b/dotbot/cli/site_cmd.py @@ -0,0 +1,296 @@ +# SPDX-FileCopyrightText: 2026-present Inria +# SPDX-License-Identifier: BSD-3-Clause + +"""`dotbot site` - add and export site packs. + +A site pack is a folder holding `site.toml` and optionally `calibrations/` +(`dotbot.site_packs`). `add` copies one into ~/.dotbot/sites/, where every +config finds it; `export` writes one as a zip, from a pack or from an inline +`[sites.]` table. The same folders can be shared with git, cp or unzip. +""" + +import re +import shutil +import subprocess +import tempfile +import zipfile +from pathlib import Path + +import click +import tomlkit + +from dotbot.config import ConfigError +from dotbot.site import PACK_CALIBRATIONS, check_site_name +from dotbot.site_packs import PACK_FILE, read_pack, site_catalog, user_sites_dir + +_GIT_PREFIXES = ("git@", "git://", "ssh://", "git+") + + +@click.group( + name="site", + help="Add a site pack to this machine, or export one to share.", +) +def cmd(): + pass + + +def _is_git_url(source: str) -> bool: + return source.startswith(_GIT_PREFIXES) or ( + source.startswith(("https://", "http://")) and not source.endswith(".zip") + ) + + +def _pack_in(folder: Path, name: str | None) -> tuple[Path, str]: + """The pack in an unpacked folder: the folder itself, or its one sub-folder. + + With no `name`, as for a zip read from stdin, only the sub-folder can + name the site. + """ + children = [child for child in folder.iterdir() if child.is_dir()] + if (folder / PACK_FILE).is_file(): + if name is None: + raise click.ClickException( + f"the zip on stdin holds {PACK_FILE} at its root, so nothing " + f"names its site: zip the pack folder itself, as `site export` does" + ) + return folder, name + if len(children) == 1 and (children[0] / PACK_FILE).is_file(): + return children[0], children[0].name + raise click.ClickException(f"no {PACK_FILE} found in {name or 'the zip on stdin'}") + + +def _unzip(path: Path, scratch: Path, name: str | None) -> tuple[Path, str]: + target = scratch / "unzipped" + with zipfile.ZipFile(path) as archive: + archive.extractall(target) + return _pack_in(target, name) + + +def _read_stdin(scratch: Path) -> Path: + """The zip piped to stdin, saved under `scratch`.""" + stdin = click.get_binary_stream("stdin") + if stdin.isatty(): + raise click.ClickException( + "`site add -` reads a zip from stdin, and stdin is a terminal; " + "pipe one in: curl -L | dotbot site add -" + ) + path = scratch / "stdin.zip" + with open(path, "wb") as handle: + shutil.copyfileobj(stdin, handle) + if not zipfile.is_zipfile(path): + raise click.ClickException("what came in on stdin is not a zip file") + return path + + +def _git_clone(url: str, target: Path) -> None: + """A shallow clone of `url`; git's `ext::` transport, which runs a + command, is refused.""" + result = subprocess.run( + [ + "git", + "-c", + "protocol.ext.allow=never", + "clone", + "--depth", + "1", + "--", + url, + str(target), + ], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + raise click.ClickException(f"git clone {url} failed:\n{result.stderr.strip()}") + + +def _fetch(source: str, scratch: Path) -> tuple[Path, str]: + """The pack folder SOURCE names, and its site name.""" + if source == "-": + return _unzip(_read_stdin(scratch), scratch, None) + if _is_git_url(source): + url = source.removeprefix("git+") + target = scratch / "clone" + _git_clone(url, target) + name = re.split(r"[/:]", url.rstrip("/"))[-1].removesuffix(".git") + return _pack_in(target, name) + if source.startswith(("https://", "http://")): + raise click.ClickException( + f"{source} is a zip on the web: download it first, or pipe it: " + f"curl -L {source} | dotbot site add -" + ) + path = Path(source).expanduser() + if path.is_dir(): + return _pack_in(path, path.resolve().name) + if path.is_file() and zipfile.is_zipfile(path): + return _unzip(path, scratch, path.stem) + raise click.ClickException( + f"{source} is neither a folder, a zip file nor a git URL" + ) + + +def _check_pack(folder: Path, name: str) -> None: + """Refuse a pack with an unusable name, a link in it, or an invalid + `site.toml`.""" + try: + check_site_name(name) + except ValueError as exc: + raise click.ClickException( + f"{exc}; rename the pack folder (or the repository) to its site's name" + ) from exc + calibrations = folder / PACK_CALIBRATIONS + paths = [folder / PACK_FILE, calibrations] + if calibrations.is_dir() and not calibrations.is_symlink(): + paths += list(calibrations.rglob("*")) + links = [path for path in paths if path.is_symlink()] + if links: + raise click.ClickException( + f"the site pack {name} holds links, which are not copied: " + + ", ".join(str(path.relative_to(folder)) for path in links) + ) + try: + read_pack(folder) + except ConfigError as exc: + raise click.ClickException(str(exc)) from exc + + +def _install(folder: Path, target: Path) -> None: + """Copy the pack in `folder` to `target`, replacing any pack there. + + The copy lands in a hidden sibling of `target` first and is renamed into + place, so a failure leaves whatever was at `target` as it was. + """ + parent = target.parent + parent.mkdir(parents=True, exist_ok=True) + staging = Path(tempfile.mkdtemp(prefix=f".{target.name}-", dir=parent)) + aside = None + try: + shutil.copy2(folder / PACK_FILE, staging / PACK_FILE) + calibrations = folder / PACK_CALIBRATIONS + if calibrations.is_dir(): + shutil.copytree(calibrations, staging / PACK_CALIBRATIONS) + if target.exists(): + old = staging.with_name(f"{staging.name}-old") + target.rename(old) + aside = old + staging.rename(target) + except BaseException: + if aside is not None and not target.exists(): + aside.rename(target) + shutil.rmtree(staging, ignore_errors=True) + raise + if aside is not None: + shutil.rmtree(aside, ignore_errors=True) + + +@cmd.command() +@click.argument("source") +@click.option("--force", "-f", is_flag=True, help="Replace a pack of the same name.") +def add(source, force): + """Copy the site pack SOURCE into ~/.dotbot/sites/. + + SOURCE is a pack folder, a zip of one (as `site export` writes), `-` for + such a zip on stdin, or a git URL whose repository is one. The folder's + name is the site's name. + """ + with tempfile.TemporaryDirectory() as scratch: + folder, name = _fetch(source, Path(scratch)) + _check_pack(folder, name) + target = user_sites_dir() / name + if target.exists() and not force: + raise click.ClickException( + f"{target} already exists. Pass --force to replace it." + ) + _install(folder, target) + count = len(list((target / PACK_CALIBRATIONS).glob("*.toml"))) + click.echo(f"Added site {name} to {target} ({_files(count)})") + click.echo(f'Work in it with `site = "{name}"` in your config, or --site {name}.') + + +def _files(count: int) -> str: + return f"{count} calibration file{'' if count == 1 else 's'}" + + +def _site_toml(table) -> str: + """An inline `[sites.]` table as a pack's `site.toml`.""" + data = table.model_dump(exclude_none=True) + document = tomlkit.document() + for key in ("anchor", "extent_mm"): + if key in data: + document[key] = data[key] + areas = tomlkit.table() + for area_name, area in data.get("areas", {}).items(): + inline = tomlkit.inline_table() + inline.update(area) + areas[area_name] = inline + if areas: + document["areas"] = areas + return tomlkit.dumps(document) + + +@cmd.command() +@click.argument("name") +@click.option( + "--out", + "out_path", + type=click.Path(dir_okay=False), + default=None, + help="The zip to write. Default: .zip in the current directory.", +) +@click.option( + "--with-calibrations", + is_flag=True, + help="Include the site's calibration files, from its pack and from " + "~/.dotbot/calibrations//.", +) +@click.option("--force", "-f", is_flag=True, help="Overwrite an existing zip.") +@click.pass_context +def export(ctx, name, out_path, with_calibrations, force): + """Write the site NAME as a site pack zip. + + NAME is an inline [sites.] table of the config or a site pack. + """ + from dotbot.calibration.lighthouse2 import calibration_root + + obj = ctx.obj or {} + try: + catalog = site_catalog(obj.get("config"), obj.get("config_path")) + except ConfigError as exc: + raise click.ClickException(str(exc)) from exc + entry = catalog.get(name) + if entry is None: + known = ", ".join(sorted(catalog)) or "(none)" + raise click.ClickException(f"unknown site {name!r}; known sites: {known}") + try: + check_site_name(name) + except ValueError as exc: + raise click.ClickException(str(exc)) from exc + target = Path(out_path or f"{name}.zip") + if target.exists() and not force: + raise click.ClickException( + f"{target} already exists. Pass --force to overwrite it." + ) + + if entry.pack is not None: + site_toml = (entry.pack / PACK_FILE).read_bytes() + else: + site_toml = _site_toml(entry.table).encode() + calibrations: dict[str, Path] = {} + if with_calibrations: + site = entry.site() + folders = [site.pack_calibrations, calibration_root() / name] + for folder in folders: + if folder is not None and folder.is_dir(): + for path in sorted(folder.glob("*.toml")): + calibrations.setdefault(path.name, path) + + target.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(target, "w", zipfile.ZIP_DEFLATED) as archive: + archive.writestr(f"{name}/{PACK_FILE}", site_toml) + for file_name, path in calibrations.items(): + archive.write(path, f"{name}/{PACK_CALIBRATIONS}/{file_name}") + click.echo( + f"Wrote {target}: site {name}" + + (f" and {_files(len(calibrations))}" if with_calibrations else "") + ) diff --git a/dotbot/cli/swarm_lh2.py b/dotbot/cli/swarm_lh2.py index b080aeab..8927b55b 100644 --- a/dotbot/cli/swarm_lh2.py +++ b/dotbot/cli/swarm_lh2.py @@ -163,7 +163,30 @@ def _await_point(session, stream, arrivals: queue.Queue): "`:`, or `:corners` for all four in " "capture order. A corner mark is where the photodiode lands with the " "robot inside the rectangle, PCB edges on its lines, nose toward the " - "nearest top or bottom edge. Defaults to `arena:corners`." + "nearest top or bottom edge. Without --points, --over or --square, " + "the four corners of the site's field." + ), +) +@click.option( + "--over", + "over", + default=None, + metavar="AREA", + help=( + "Calibrate over another area's four corners instead of the field's, " + "e.g. `--over dev-corner` for bench work." + ), +) +@click.option( + "--square", + "square", + default=None, + type=int, + metavar="MM", + help=( + "Calibrate over the four corners of a square this many mm wide, " + "centred in the field. Quicker to tape; the rest of the field is " + "extrapolated." ), ) @click.option( @@ -220,6 +243,8 @@ def _collect( conn, swarm_id, points, + over, + square, site_name, reads, timeout, @@ -236,7 +261,11 @@ def _collect( CAPTURE_TIMEOUT_DEFAULT, CaptureSession, ) - from dotbot.calibration.points import collect_header, point_prompt + from dotbot.calibration.points import ( + collect_header, + collect_points, + point_prompt, + ) from dotbot.calibration.session import CalibrationSession, SessionError except ImportError as exc: click.echo( @@ -248,12 +277,20 @@ def _collect( click.echo(f"(import error was: {exc})", err=True) sys.exit(1) - specs = list(points) or ["arena:corners"] + chosen = [flag for flag, v in (("--points", points), ("--over", over)) if v] + if square is not None: + chosen.append("--square") + if len(chosen) > 1: + raise click.UsageError( + f"{' and '.join(chosen)} each choose the points; pass one." + ) site, site_source = site_from_context(ctx, site_name) try: + specs, how, note = collect_points(site, list(points), over, square) session = CalibrationSession.resolve( specs, site=site, + points_from=how, device=device or "", reads=reads if reads is not None else CAPTURE_READS_DEFAULT, timeout=timeout if timeout is not None else CAPTURE_TIMEOUT_DEFAULT, @@ -292,6 +329,9 @@ def read_enter() -> None: site, site_source, len(session.points), session.reads, device or "" ) ) + click.echo(f"Points: {session.at} ({session.points_from}).") + if note: + click.echo(note) trigger = "the robot's button" if device: trigger = "Enter" @@ -403,7 +443,7 @@ def _push(ctx, calibration, conn, swarm_id, site_name, site_changed): site, _ = site_from_context(ctx, site_name) try: - path = resolve_calibration_path(calibration, site=site.name) + path = resolve_calibration_path(calibration, site=site) loaded = read_calibration_file(path) except ValueError as exc: raise click.ClickException(str(exc)) from exc diff --git a/dotbot/config.py b/dotbot/config.py index 179ff987..709d2a80 100644 --- a/dotbot/config.py +++ b/dotbot/config.py @@ -16,23 +16,23 @@ ```toml default_deployment = "inria" -site = "c405-arena" +site = "default" conn = "mqtts://broker.local:8883" # shared; sections/deployments override swarm_id = "0001" +site_dirs = ["sites", "~/.dotbot/sites"] # where site packs are found, in order [deployment.inria] # a named deployment - select, don't edit conn = "mqtts://broker.inria.fr:8883" swarm_id = "0001" -[sites.c405-arena] # a floor: where zero is, how big, its areas -anchor = "the corner where the arena's top wall meets the door wall" -extent_mm = [2000, 4000] +[sites.default] # a floor: where zero is, how big, its areas +anchor = "top-left corner of a 5 x 5 m floor; the field starts 1.5 m in from each wall" +extent_mm = [5000, 5000] -[sites.c405-arena.areas.arena] -x = 0 -y = 0 -w = 2000 -h = 2000 +[sites.default.areas] # a name that is a role has it +field = { x = 1500, y = 1500, w = 2000, h = 2000 } +staging = { x = 1500, y = 3500, w = 2000, h = 600 } +bench = { x = 3000, y = 1500, w = 500, h = 500, role = "corner" } [fw] board = "dotbot-v3" @@ -65,8 +65,11 @@ ConfigDict, Field, ValidationError, + model_validator, ) +from dotbot.area import Role, area_role + # The four CLI namespaces, used to derive env-var names (DOTBOT_
_). SECTIONS = ("fw", "device", "swarm", "run") @@ -150,12 +153,16 @@ class SwarmSection(_Strict): class AreaSection(_Strict): - """One `[sites..areas.]` table: a rectangle in frame millimetres.""" + """One `[sites..areas.]` table: a rectangle in frame millimetres. + + `role` is needed only when the name is not already a role. + """ x: int y: int w: int h: int + role: Role | None = None class SiteSection(_Strict): @@ -171,11 +178,28 @@ class SiteSection(_Strict): extent_mm: tuple[int, int] | None = None areas: dict[str, AreaSection] = Field(default_factory=dict) + @model_validator(mode="after") + def _one_field(self) -> SiteSection: + fields = [ + name + for name, area in self.areas.items() + if area_role(name, area.role) == "field" + ] + if len(fields) > 1: + raise ValueError( + f"a site has at most one field, and {' and '.join(fields)} " + 'are both one; give all but one another role (role = "staging" ' + 'or "corner") or another name' + ) + return self + class ControllerSection(_Strict): http_port: int | None = None http_host: str | None = None lh2_calibration: str | None = None + # Older than this at load, the LH2 calibration is warned about; 0: never + lh2_calibration_max_age_days: int | None = Field(None, ge=0) camera_calibration: str | None = None camera_detect: bool | None = None camera_max_robots: int | None = Field(None, ge=1) @@ -186,6 +210,7 @@ class ControllerSection(_Strict): headless: bool | None = None gw_address: str | None = None simulator_init_state: str | None = None + simulator_area: str | None = None swarmit_url: str | None = None mrta_url: str | None = None @@ -219,6 +244,9 @@ class DotbotConfig(_Strict): # whole config: a deployment selects a site by name, and several # deployments can work the same floor. sites: dict[str, SiteSection] = Field(default_factory=dict) + # Folders searched, in order, for site packs (`dotbot.site_packs`); + # relative entries are read from this file's folder. + site_dirs: list[str] | None = None fw: FwSection = Field(default_factory=FwSection) device: DeviceSection = Field(default_factory=DeviceSection) diff --git a/dotbot/console-web/src/App.tsx b/dotbot/console-web/src/App.tsx index 03dca3f7..9a63db38 100644 --- a/dotbot/console-web/src/App.tsx +++ b/dotbot/console-web/src/App.tsx @@ -1,8 +1,14 @@ -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { clearWaypoints, fetchBuild, fetchConnection, putWaypointBatches } from "./api"; import type { WaypointsSent } from "./api"; -import { loadHiddenAreas, saveHiddenAreas, toggleHidden } from "./areas"; +import { + type AreaVisibility, + hiddenAreaNames, + loadAreaVisibility, + saveAreaVisibility, + toggleShown, +} from "./areas"; import { BodyColorMode, loadBodyColorMode, saveBodyColorMode } from "./bodyColor"; import { CameraOffset, @@ -206,6 +212,7 @@ export const App: React.FC = () => { trails: false, crashedOnly: false, allWaypoints: false, + calibratedSpan: true, }); const [rightTab, setRightTab] = useState("layers"); const [rightCollapsed, setRightCollapsed, setRightCollapsedUnsaved] = usePanel("right"); @@ -229,14 +236,19 @@ export const App: React.FC = () => { }, []); // Area visibility is a map layer, not shared state: no controller call, and - // the set is this browser's. - const [hiddenAreas, updateHiddenAreas] = usePersisted>( - loadHiddenAreas, - saveHiddenAreas, + // the choices are this browser's. + const [areaVisibility, updateAreaVisibility] = usePersisted( + loadAreaVisibility, + saveAreaVisibility, + ); + const hiddenAreas = useMemo( + () => hiddenAreaNames(site?.areas ?? [], areaVisibility), + [site, areaVisibility], ); const onAreaToggle = useCallback( - (name: string) => updateHiddenAreas((prev) => toggleHidden(prev, name)), - [updateHiddenAreas], + (name: string) => + updateAreaVisibility((prev) => toggleShown(prev, site?.areas ?? [], name)), + [updateAreaVisibility, site], ); // So is the camera layer's opacity: a way of looking at the map, and this diff --git a/dotbot/console-web/src/MapView.tsx b/dotbot/console-web/src/MapView.tsx index 4fde74ef..f64263ea 100644 --- a/dotbot/console-web/src/MapView.tsx +++ b/dotbot/console-web/src/MapView.tsx @@ -1,8 +1,9 @@ -import React, { useCallback, useMemo, useRef, useState } from "react"; +import React, { useCallback, useId, useMemo, useRef, useState } from "react"; import { cameraStreamUrl } from "./api"; import { areaColor } from "./areaColor"; import { CalibrationLayer } from "./CalibrationLayer"; +import { calibrationSpans, hatchBox, spanTitle } from "./calibrationSpan"; import { CameraOffset, CameraOpacity, @@ -103,6 +104,8 @@ export interface Layers { trails: boolean; // Every robot's waypoints, not only the selection's. allWaypoints: boolean; + // Where the loaded LH2 calibration was fitted, and the rest hatched. + calibratedSpan: boolean; } export interface SpreadPreviewLeg { @@ -331,6 +334,15 @@ export const MapView: React.FC = (props) => { }; }; + // Floor points in the drawn box's own pixels, as an SVG `points` list. + const pointsPx = (points: [number, number][]) => + points + .map(([x, y]) => { + const { fx, fy } = areaToFraction({ x, y }, props.viewport); + return `${fx * boxW},${fy * boxH}`; + }) + .join(" "); + // The same box in the drawn box's own pixels, for the SVG outlines. const rectPx = (a: Area) => { const tl = areaToFraction({ x: a.x, y: a.y }, props.viewport); @@ -343,11 +355,16 @@ export const MapView: React.FC = (props) => { }; }; + const spanId = useId().replace(/:/g, ""); + const calibration = + props.layers.calibratedSpan ? (props.site?.calibration ?? null) : null; + const spans = useMemo(() => calibrationSpans(calibration), [calibration]); + const hatch = hatchBox(props.site?.extent_mm ?? null); + const drawnAreas = props.siteAreas.filter( (a) => !props.hiddenAreas.has(a.name ?? ""), ); - const colorOf = (a: Area) => - areaColor(a.name ?? "", props.siteAreas.map((o) => o.name)); + const colorOf = (a: Area) => areaColor(a, props.siteAreas); // A camera is drawn on the area it covers, so one the site does not define // has nowhere to land and is left out. @@ -1152,8 +1169,9 @@ export const MapView: React.FC = (props) => { /> {/* The outlines: the site as the one outer silhouette, then one - dashed rectangle per area in the area's own colour, ticked under - Layers > Areas, where the colour is named. Strokes rather than + dashed rectangle per area in its role's colour, ticked under + Layers > Areas, where the role is named. A corner lies over + another area, so its line is heavier and finer-dashed. Strokes rather than borders, because a CSS border under a pixel wide is rounded back up to one and then multiplied by the camera; a stroke keeps the width it is given, so counter-scaling it holds the hairline at @@ -1179,14 +1197,74 @@ export const MapView: React.FC = (props) => { fill={colorOf(a)} fillOpacity={AREA_TINT} stroke={colorOf(a)} - strokeOpacity={0.85} - strokeWidth={chrome} - strokeDasharray={`${5 * chrome} ${4 * chrome}`} + strokeOpacity={a.role === "corner" ? 1 : 0.85} + strokeWidth={a.role === "corner" ? 2 * chrome : chrome} + strokeDasharray={ + a.role === "corner" + ? `${2 * chrome} ${2 * chrome}` + : `${5 * chrome} ${4 * chrome}` + } style={{ pointerEvents: "stroke" }} > {a.name} ))} + {/* The loaded calibration: each placement's span outlined, and + the rest of the site hatched, where positions are + extrapolated. The hatch is the site masked by the spans, so + overlapping placements still leave one clear region. */} + {calibration && spans.length > 0 && ( + + {hatch && ( + <> + + + + + + + {spans.map((span, i) => ( + + ))} + + + + + )} + {spans.map((span, i) => ( + + {spanTitle(calibration, span)} + + ))} + + )} {props.session && ( = ({ style={{ position: "absolute", ...areaBox(a), - border: `1px dashed ${areaColor(a.name ?? "", (site?.areas ?? []).map((o) => o.name))}`, + border: `1px dashed ${areaColor(a, site?.areas ?? [])}`, opacity: 0.85, pointerEvents: "none", }} diff --git a/dotbot/console-web/src/RightPane.tsx b/dotbot/console-web/src/RightPane.tsx index 41964f35..56284d42 100644 --- a/dotbot/console-web/src/RightPane.tsx +++ b/dotbot/console-web/src/RightPane.tsx @@ -68,9 +68,11 @@ export const CheckRow: React.FC<{ onToggle?: () => void; // A colour the row stands for, drawn as a swatch before its label. swatch?: string; + // A short word after the label, such as an area's role. + tag?: string; // Anything the row carries besides its tick, between the label and it. trailing?: React.ReactNode; -}> = ({ label, on, disabled = false, hint, onToggle, swatch, trailing }) => ( +}> = ({ label, on, disabled = false, hint, onToggle, swatch, tag, trailing }) => (
!disabled && onToggle?.()} title={hint} @@ -101,7 +103,24 @@ export const CheckRow: React.FC<{ }} /> )} - {label} + + {label} + {tag && ( + + {tag} + + )} + {trailing} = (props) => { label={a.name ?? ""} on={!props.hiddenAreas.has(a.name ?? "")} onToggle={() => props.onAreaToggle(a.name ?? "")} - swatch={areaColor(a.name ?? "", siteAreas.map((o) => o.name))} + swatch={areaColor(a, siteAreas)} + tag={a.role ?? undefined} trailing={ props.onZoom && (