From 23497834df722b759cd2dca62195e4af686669e7 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Tue, 29 Sep 2026 10:32:28 +0200 Subject: [PATCH 01/37] dotbot: give site areas a role and default to the field everywhere Breaking: nothing prefers an area named `arena` any more; the simulator places robots in the site's field, the area with role `field`, else the first that is not staging or a corner. GET /controller/site now lists areas in declared order, each with its role, and names the field. AI-assisted: Claude Opus 5.5 --- dotbot/area.py | 30 ++++++++- dotbot/config.py | 41 +++++++++--- dotbot/controller_app.py | 3 +- dotbot/dotbot_simulator.py | 32 +++------- dotbot/examples/qrkey_demo/client.py | 11 +--- dotbot/models.py | 33 +++++++++- dotbot/rest.py | 11 +--- dotbot/server.py | 12 +--- dotbot/simulator_init_state.toml | 7 +- dotbot/site.py | 35 +++++++++- dotbot/tests/test_calibration_lighthouse2.py | 4 ++ dotbot/tests/test_cli_config.py | 23 +++++++ dotbot/tests/test_cli_site.py | 67 ++++++++++++++++++-- dotbot/tests/test_config.py | 38 +++++++++++ dotbot/tests/test_controller_app.py | 1 + dotbot/tests/test_dotbot_simulator.py | 41 ++++++------ dotbot/tests/test_qrkey_app.py | 5 +- dotbot/tests/test_server.py | 63 ++++++++++++++++-- 18 files changed, 353 insertions(+), 104 deletions(-) diff --git a/dotbot/area.py b/dotbot/area.py index 33b6f75f..816c9b66 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 + +Role = Literal["field", "staging", "corner"] +ROLES: tuple[str, ...] = ("field", "staging", "corner") + + +def area_role(name: str, role: str | None = None) -> str | 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: str | 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/config.py b/dotbot/config.py index 179ff987..b660a2e3 100644 --- a/dotbot/config.py +++ b/dotbot/config.py @@ -16,7 +16,7 @@ ```toml default_deployment = "inria" -site = "c405-arena" +site = "default" conn = "mqtts://broker.local:8883" # shared; sections/deployments override swarm_id = "0001" @@ -24,15 +24,14 @@ 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 +64,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 +152,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,6 +177,21 @@ 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 diff --git a/dotbot/controller_app.py b/dotbot/controller_app.py index 6e4d997f..4f85c4b0 100644 --- a/dotbot/controller_app.py +++ b/dotbot/controller_app.py @@ -177,7 +177,6 @@ def _generated_fleet(robots, write_init_state, init_state, site, dotbot_simulato "pass one of them." ) from dotbot.dotbot_simulator import ( - FLEET_AREA_DEFAULT, FLEET_PITCH_MM, FleetDoesNotFit, fleet_init_state, @@ -189,7 +188,7 @@ def _generated_fleet(robots, write_init_state, init_state, site, dotbot_simulato fleet = fleet_init_state(robots, site) except FleetDoesNotFit as exc: raise click.ClickException(str(exc)) from exc - area = placement_area(site, FLEET_AREA_DEFAULT) + area = placement_area(site) print( f"Simulated fleet: {robots} robots {FLEET_PITCH_MM} mm apart in " f"{area.name or 'the default area'} ({area.w} x {area.h} mm)" diff --git a/dotbot/dotbot_simulator.py b/dotbot/dotbot_simulator.py index 07caf2c3..71d1522c 100644 --- a/dotbot/dotbot_simulator.py +++ b/dotbot/dotbot_simulator.py @@ -58,11 +58,7 @@ 102 # fixed schedule size; slotframe ≈ 126 ms → avg latency ≈ 63 ms ) -# Where a world file's unpositioned robots go. `arena` is the area name the -# rest of the CLI already defaults to (`--points` resolves `arena:corners`). -PLACEMENT_AREA_DEFAULT = "arena" -# Where `--robots N` puts a generated fleet, and how far apart -FLEET_AREA_DEFAULT = "field" +# How far apart `--robots N` puts a generated fleet FLEET_PITCH_MM = 200 # Headings of a generated fleet's two halves, 0 facing +y (down) FLEET_FACING_UP, FLEET_FACING_DOWN = 180, 0 @@ -219,23 +215,13 @@ def resolve_init_state_path(path: str) -> str: return path -def placement_area( - site: Optional[Site] = None, preferred: str = PLACEMENT_AREA_DEFAULT -) -> Area: - """The rectangle a fleet is spread over. - - The site's `preferred` area, else its first declared area, else its whole - extent, else a 2 x 2 m square at the frame origin for a site that - measures neither. +def placement_area(site: Optional[Site] = None) -> Area: + """The rectangle a fleet is spread over: the site's field (`Site.field`), + else a 2 x 2 m square at the frame origin for a site that declares nothing. """ - if site is not None: - area = site.areas.get(preferred) - if area is not None: - return area - for first in site.areas.values(): - return first - if site.extent is not None: - return site.extent + area = site.field if site is not None else None + if area is not None: + return area side = PLACEMENT_EXTENT_DEFAULT_MM return Area(0, 0, side, side) @@ -311,14 +297,14 @@ def fleet_init_state( count: int, site: Optional[Site] = None, pitch_mm: int = FLEET_PITCH_MM ) -> InitStateToml: """`count` robots in a near-square grid `pitch_mm` apart, centred in the - site's `field` area (see `placement_area`). + site's field (see `placement_area`). Rows fill left to right and a short last row is centred under the others. The top half of the rows face up (-y), the rest down (+y). Raises `FleetDoesNotFit` when the grid, with half a pitch of margin all round, is larger than the area. """ - area = placement_area(site, FLEET_AREA_DEFAULT) + area = placement_area(site) columns, rows = _grid_shape(count) if columns * pitch_mm > area.w or rows * pitch_mm > area.h: where = f"{area.name} " if area.name else "" diff --git a/dotbot/examples/qrkey_demo/client.py b/dotbot/examples/qrkey_demo/client.py index f896cd16..0fbb3021 100644 --- a/dotbot/examples/qrkey_demo/client.py +++ b/dotbot/examples/qrkey_demo/client.py @@ -21,7 +21,6 @@ from dotbot import CONTROLLER_HTTP_HOSTNAME_DEFAULT, CONTROLLER_HTTP_PORT_DEFAULT from dotbot.logger import LOGGER from dotbot.models import ( - DotBotAreaModel, DotBotMoveRawCommandModel, DotBotReplyModel, DotBotRequestModel, @@ -258,15 +257,7 @@ def on_request(self, payload): elif request.request == DotBotRequestType.SITE: logger.info("Publish the site") site = self.worker.run(self.client.fetch_site()) - model = DotBotSiteModel( - name=site.name, - anchor=site.anchor, - extent_mm=list(site.extent_mm) if site.extent_mm else None, - areas=[ - DotBotAreaModel(**a.as_dict()) - for a in sorted(site.areas.values(), key=lambda a: a.name) - ], - ) + model = DotBotSiteModel.from_site(site) message = DotBotReplyModel( request=DotBotRequestType.SITE, data=model.model_dump(), diff --git a/dotbot/models.py b/dotbot/models.py index 7f274695..896432ce 100644 --- a/dotbot/models.py +++ b/dotbot/models.py @@ -14,8 +14,10 @@ from pydantic import BaseModel, BeforeValidator, Field, field_validator +from dotbot.area import Area from dotbot.protocol import ApplicationType, ControlModeType, WaypointsStatus from dotbot.robots import ROBOT_DEFAULT, BodyPose +from dotbot.site import Site # Points of trail the controller keeps per robot MAX_TRAIL_SIZE = 1000 @@ -183,13 +185,14 @@ class DotBotWaypointsSent(BaseModel): class DotBotAreaModel(BaseModel): - """One named rectangle in frame millimetres.""" + """One named rectangle in frame millimetres, and its role if it has one.""" x: int y: int w: int h: int name: str = "" + role: Optional[Literal["field", "staging", "corner"]] = None class DotBotSiteModel(BaseModel): @@ -197,12 +200,40 @@ class DotBotSiteModel(BaseModel): `extent_mm` is `[width, height]`, zero at its top-left corner, which is where `anchor` points. A site with no measured extent reports none. + `areas` are in the order the config declares them. `field` names the + area experiments and calibration default to (`Site.field`): an area's + name, an `x,y,w,h` literal for a site with an extent and no areas, or + None for a site that declares neither. """ name: str anchor: str = "" extent_mm: Optional[List[int]] = None areas: List[DotBotAreaModel] = [] + field: Optional[str] = None + + @classmethod + def from_site(cls, site: Site) -> "DotBotSiteModel": + field = site.field + return cls( + name=site.name, + anchor=site.anchor, + extent_mm=list(site.extent_mm) if site.extent_mm else None, + areas=[DotBotAreaModel(**a.as_dict()) for a in site.areas.values()], + field=field.name if field is not None else None, + ) + + def to_site(self) -> Site: + return Site( + name=self.name, + anchor=self.anchor, + extent_mm=( + (self.extent_mm[0], self.extent_mm[1]) if self.extent_mm else None + ), + areas={ + a.name: Area(a.x, a.y, a.w, a.h, a.name, a.role) for a in self.areas + }, + ) class DotBotCameraModel(BaseModel): diff --git a/dotbot/rest.py b/dotbot/rest.py index ab5dcac0..692fc4f3 100644 --- a/dotbot/rest.py +++ b/dotbot/rest.py @@ -11,7 +11,6 @@ import httpx -from dotbot.area import Area from dotbot.logger import LOGGER, setup_logging from dotbot.models import ( DotBotModel, @@ -83,15 +82,7 @@ async def fetch_site(self) -> Site: f"Failed to fetch the site: {response} {response.text}" ) return Site() - model = DotBotSiteModel(**response.json()) - return Site( - name=model.name, - anchor=model.anchor, - extent_mm=( - (model.extent_mm[0], model.extent_mm[1]) if model.extent_mm else None - ), - areas={a.name: Area(a.x, a.y, a.w, a.h, a.name) for a in model.areas}, - ) + return DotBotSiteModel(**response.json()).to_site() async def _send_command(self, address, application, resource, command): self._logger.info( diff --git a/dotbot/server.py b/dotbot/server.py index 4f4596c1..9b2671d1 100644 --- a/dotbot/server.py +++ b/dotbot/server.py @@ -31,7 +31,6 @@ from dotbot.logger import LOGGER from dotbot.models import ( MAX_TRAIL_SIZE, - DotBotAreaModel, DotBotBackgroundMapModel, DotBotBodyModel, DotBotBuildModel, @@ -568,16 +567,7 @@ async def device_poses(): ) async def site(): """Active site HTTP GET handler.""" - current = api.controller.site - return DotBotSiteModel( - name=current.name, - anchor=current.anchor, - extent_mm=list(current.extent_mm) if current.extent_mm else None, - areas=[ - DotBotAreaModel(**a.as_dict()) - for a in sorted(current.areas.values(), key=lambda a: a.name) - ], - ) + return DotBotSiteModel.from_site(api.controller.site) @api.get( diff --git a/dotbot/simulator_init_state.toml b/dotbot/simulator_init_state.toml index a1bdfc3b..61ddbe3a 100644 --- a/dotbot/simulator_init_state.toml +++ b/dotbot/simulator_init_state.toml @@ -5,9 +5,10 @@ # the sandbox dotbot app: waypoints are steered on board, and the advertised # LH2 position is the photodiode, a lever arm ahead of the axle. # -# A robot with no `pos_x` / `pos_y` is placed inside the active site: its -# `arena` area, else its first declared area, else its whole extent, else a -# 2 x 2 m square at the frame origin. Unplaced robots share a grid, so they +# A robot with no `pos_x` / `pos_y` is placed inside the active site's field +# (the area with role `field`, else the first area that is neither staging nor +# a corner, else the first area, else the whole extent), else a 2 x 2 m square +# at the frame origin. Unplaced robots share a grid, so they # spread over the area rather than stack. Give both keys to pin a robot to # frame millimetres instead. diff --git a/dotbot/site.py b/dotbot/site.py index b17141d4..134479e4 100644 --- a/dotbot/site.py +++ b/dotbot/site.py @@ -19,7 +19,7 @@ from dataclasses import dataclass, field from typing import Any -from dotbot.area import Area, AreaRegistry +from dotbot.area import Area, AreaRegistry, area_role SITE_DEFAULT = "default" @@ -57,6 +57,30 @@ def valid_mm(self) -> tuple[int, int, int, int] | None: return None return (0, 0, int(self.extent_mm[0]), int(self.extent_mm[1])) + @property + def field(self) -> Area | None: + """Where experiments happen: what a fleet, a calibration and a camera default to. + + The area whose role is `field`, else the first area that is neither + `staging` nor `corner`, else the first area, else the whole extent, + named as its `x,y,w,h` literal so the registry resolves it. None for + a site that declares nothing. + """ + areas = list(self.areas.values()) + roles = [area_role(area.name, area.role) for area in areas] + for area, role in zip(areas, roles): + if role == "field": + return area + for area, role in zip(areas, roles): + if role not in ("staging", "corner"): + return area + if areas: + return areas[0] + extent = self.extent + if extent is None: + return None + return Area(0, 0, extent.w, extent.h, f"0,0,{extent.w},{extent.h}") + def registry(self) -> AreaRegistry: """The resolver `--points` runs against.""" return AreaRegistry(named=dict(self.areas), site=self.name) @@ -78,7 +102,14 @@ def site_from_config(config: Any, name: str) -> Site: anchor=getattr(table, "anchor", None) or "", extent_mm=(int(extent[0]), int(extent[1])) if extent else None, areas={ - area_name: Area(x=area.x, y=area.y, w=area.w, h=area.h, name=area_name) + area_name: Area( + x=area.x, + y=area.y, + w=area.w, + h=area.h, + name=area_name, + role=area_role(area_name, getattr(area, "role", None)), + ) for area_name, area in (getattr(table, "areas", None) or {}).items() }, ) diff --git a/dotbot/tests/test_calibration_lighthouse2.py b/dotbot/tests/test_calibration_lighthouse2.py index 9819f3d1..e4054a50 100644 --- a/dotbot/tests/test_calibration_lighthouse2.py +++ b/dotbot/tests/test_calibration_lighthouse2.py @@ -488,6 +488,7 @@ def test_area_resolution_forms(): "w": 2000, "h": 2000, "name": "annex", + "role": None, } assert registry.resolve("0,0,500,600").as_dict() == { "x": 0, @@ -495,6 +496,7 @@ def test_area_resolution_forms(): "w": 500, "h": 600, "name": "0,0,500,600", + "role": None, } composite = registry.resolve("arena+wing") assert composite.as_dict() == { @@ -503,6 +505,7 @@ def test_area_resolution_forms(): "w": 3330, "h": 4000, "name": "arena+wing", + "role": None, } @@ -580,6 +583,7 @@ def test_a_site_extent_is_the_plausibility_fence(): "w": 2000, "h": 4000, "name": "c405-arena", + "role": None, } diff --git a/dotbot/tests/test_cli_config.py b/dotbot/tests/test_cli_config.py index 34ae5618..c2e8e486 100644 --- a/dotbot/tests/test_cli_config.py +++ b/dotbot/tests/test_cli_config.py @@ -39,6 +39,29 @@ def test_root_bad_config_errors(runner, tmp_path): assert "config" in result.output.lower() +@pytest.mark.parametrize( + "areas, error", + [ + ("field = { x = 0, y = 0, w = 10, h = 10 }\n", None), + ('pen = { x = 0, y = 0, w = 10, h = 10, role = "staging" }\n', None), + ('pen = { x = 0, y = 0, w = 10, h = 10, role = "main" }\n', "role"), + ( + "field = { x = 0, y = 0, w = 10, h = 10 }\n" + 'pen = { x = 0, y = 0, w = 10, h = 10, role = "field" }\n', + "field and pen are both one", + ), + ], +) +def test_root_checks_area_roles(runner, tmp_path, areas, error): + cfg = _write(tmp_path, f"[sites.hall.areas]\n{areas}") + result = runner.invoke(cli, ["-c", str(cfg), "config", "show"]) + if error is None: + assert result.exit_code == 0, result.output + else: + assert result.exit_code != 0 + assert error in result.output + + def test_root_missing_config_errors(runner, tmp_path): result = runner.invoke(cli, ["-c", str(tmp_path / "nope.toml"), "fw", "--help"]) assert result.exit_code != 0 diff --git a/dotbot/tests/test_cli_site.py b/dotbot/tests/test_cli_site.py index c4f35ff8..48f26d17 100644 --- a/dotbot/tests/test_cli_site.py +++ b/dotbot/tests/test_cli_site.py @@ -7,9 +7,11 @@ default is what a fresh install gets, and a real name comes from the config. """ +import pytest + from dotbot.cli._site import resolve_site_name from dotbot.config import load_config_text, select_deployment -from dotbot.site import SITE_DEFAULT, site_from_config +from dotbot.site import SITE_DEFAULT, Site, site_from_config def test_no_config_falls_back_to_a_neutral_package_site(): @@ -62,19 +64,20 @@ def test_a_site_table_becomes_its_anchor_extent_and_areas(): "[sites.c405-arena]\n" 'anchor = "the arena top-left corner, C405"\n' "extent_mm = [2000, 4000]\n" - "[sites.c405-arena.areas.arena]\n" + "[sites.c405-arena.areas.field]\n" "x = 0\ny = 0\nw = 2000\nh = 2000\n" ) site = site_from_config(config, "c405-arena") assert site.anchor == "the arena top-left corner, C405" assert site.extent_mm == (2000, 4000) assert site.valid_mm == (0, 0, 2000, 4000) - assert site.registry().resolve("arena").as_dict() == { + assert site.registry().resolve("field").as_dict() == { "x": 0, "y": 0, "w": 2000, "h": 2000, - "name": "arena", + "name": "field", + "role": "field", } @@ -87,3 +90,59 @@ def test_a_named_site_with_no_table_is_empty_rather_than_an_error(): None, {}, ) + + +def _site(areas: str, extent: str = "") -> Site: + config = load_config_text(f"[sites.hall]\n{extent}[sites.hall.areas]\n{areas}") + return site_from_config(config, "hall") + + +def test_an_area_named_after_a_role_has_it_unless_it_declares_another(): + site = _site( + "staging = { x = 0, y = 0, w = 10, h = 10 }\n" + 'field = { x = 0, y = 0, w = 10, h = 10, role = "corner" }\n' + "pen = { x = 0, y = 0, w = 10, h = 10 }\n" + ) + assert {name: a.role for name, a in site.areas.items()} == { + "staging": "staging", + "field": "corner", + "pen": None, + } + + +@pytest.mark.parametrize( + "areas, field", + [ + # the field, wherever it is declared + ( + "staging = { x = 0, y = 0, w = 10, h = 10 }\n" + 'main = { x = 5, y = 5, w = 10, h = 10, role = "field" }\n', + "main", + ), + # no field: the first area that is neither staging nor a corner + ( + "staging = { x = 0, y = 0, w = 10, h = 10 }\n" + 'bench = { x = 0, y = 0, w = 5, h = 5, role = "corner" }\n' + "pen = { x = 0, y = 0, w = 10, h = 10 }\n", + "pen", + ), + # only staging and corners: the first area + ( + 'bench = { x = 0, y = 0, w = 5, h = 5, role = "corner" }\n' + "staging = { x = 0, y = 0, w = 10, h = 10 }\n", + "bench", + ), + ], +) +def test_the_field_falls_back_in_order(areas, field): + assert _site(areas).field.name == field + + +def test_a_site_with_no_areas_takes_its_extent_as_the_field(): + field = _site("", "extent_mm = [5000, 4000]\n").field + assert (field.x, field.y, field.w, field.h) == (0, 0, 5000, 4000) + assert field.name == "0,0,5000,4000" + + +def test_a_site_that_declares_nothing_has_no_field(): + assert Site().field is None diff --git a/dotbot/tests/test_config.py b/dotbot/tests/test_config.py index 14f41195..b4ef07ca 100644 --- a/dotbot/tests/test_config.py +++ b/dotbot/tests/test_config.py @@ -163,6 +163,44 @@ def test_load_camera_limit_out_of_range_rejected(tmp_path, line): cfg.load_config(path) +# --- site areas and their roles ---------------------------------------------- + + +def _areas(body: str) -> dict: + return cfg.load_config_text(f"[sites.hall.areas]\n{body}").sites["hall"].areas + + +def test_an_area_role_is_optional_and_checked(): + areas = _areas( + "field = { x = 0, y = 0, w = 2000, h = 2000 }\n" + 'bench = { x = 0, y = 0, w = 500, h = 500, role = "corner" }\n' + ) + assert areas["field"].role is None + assert areas["bench"].role == "corner" + with pytest.raises(cfg.ConfigError, match="role"): + _areas('pen = { x = 0, y = 0, w = 1, h = 1, role = "arena" }\n') + + +def test_two_fields_are_refused_naming_both(): + with pytest.raises(cfg.ConfigError, match="field and main are both one"): + _areas( + "field = { x = 0, y = 0, w = 2000, h = 2000 }\n" + 'main = { x = 0, y = 0, w = 500, h = 500, role = "field" }\n' + ) + + +def test_an_explicit_role_frees_a_role_name_for_another_area(): + areas = _areas( + 'field = { x = 0, y = 0, w = 2000, h = 2000, role = "staging" }\n' + 'main = { x = 0, y = 0, w = 500, h = 500, role = "field" }\n' + ) + assert set(areas) == {"field", "main"} + + +def test_a_site_with_staging_and_no_field_loads(): + assert set(_areas("staging = { x = 0, y = 0, w = 2000, h = 600 }\n")) == {"staging"} + + # --- deployment selection ------------------------------------------------------ diff --git a/dotbot/tests/test_controller_app.py b/dotbot/tests/test_controller_app.py index b63cc4fb..ea359491 100644 --- a/dotbot/tests/test_controller_app.py +++ b/dotbot/tests/test_controller_app.py @@ -360,6 +360,7 @@ def test_run_simulator_keeps_the_site_tables(controller, _asyncio_run, tmp_path) "w": 2000, "h": 2000, "name": "arena", + "role": None, } diff --git a/dotbot/tests/test_dotbot_simulator.py b/dotbot/tests/test_dotbot_simulator.py index d6e17354..7ca73823 100644 --- a/dotbot/tests/test_dotbot_simulator.py +++ b/dotbot/tests/test_dotbot_simulator.py @@ -102,26 +102,27 @@ def test_the_address_rendering_round_trips(): name="hall", extent_mm=(20000, 30000), areas={ - "charging": Area(1000, 1000, 1000, 500, "charging"), - "arena": Area(14000, 22000, 2000, 2000, "arena"), + "charging": Area(1000, 1000, 1000, 500, "charging", "staging"), + "field": Area(14000, 22000, 2000, 2000, "field", "field"), }, ) -def test_the_placement_area_is_the_arena_whatever_its_declaration_order(): - assert placement_area(HALL).name == "arena" +def test_the_placement_area_is_the_field_whatever_its_declaration_order(): + assert placement_area(HALL).name == "field" -def test_a_site_with_areas_but_no_arena_places_in_the_first_declared_one(): +def test_a_site_without_a_field_places_in_its_first_area_that_is_not_staging(): site = Site( name="hall", extent_mm=(20000, 30000), areas={ - "charging": Area(1000, 1000, 1000, 500, "charging"), + "charging": Area(1000, 1000, 1000, 500, "charging", "staging"), + "bench": Area(1000, 2000, 1000, 1000, "bench", "corner"), "workshop": Area(5000, 5000, 3000, 3000, "workshop"), }, ) - assert placement_area(site).name == "charging" + assert placement_area(site).name == "workshop" def test_a_site_with_an_extent_and_no_areas_places_over_the_whole_extent(): @@ -158,7 +159,7 @@ def test_no_robots_need_no_grid(): assert grid_positions(Area(0, 0, 2000, 2000), 0) == [] -def test_an_unpositioned_fleet_lands_inside_the_sites_arena(): +def test_an_unpositioned_fleet_lands_inside_the_sites_field(): fleet = [SimulatedDotBotSettings(address=f"{i:016X}") for i in range(4)] placed = place_dotbots(fleet, HALL) assert [(bot.pos_x, bot.pos_y) for bot in placed] == [ @@ -177,7 +178,7 @@ def test_an_explicit_position_is_left_alone_and_takes_no_grid_cell(): placed = place_dotbots(fleet, HALL) assert (placed[0].pos_x, placed[0].pos_y) == (7, 9) # The one unplaced robot is alone on its grid, so it takes the centre. - assert (placed[1].pos_x, placed[1].pos_y) == HALL.areas["arena"].centre + assert (placed[1].pos_x, placed[1].pos_y) == HALL.areas["field"].centre def test_a_fully_positioned_fleet_is_returned_unchanged(): @@ -192,8 +193,8 @@ def test_a_fully_positioned_fleet_is_returned_unchanged(): name="hall", extent_mm=(20000, 30000), areas={ - "arena": Area(5000, 5000, 2000, 2000, "arena"), - "field": Area(2000, 10000, 16000, 16000, "field"), + "staging": Area(0, 0, 20000, 2000, "staging", "staging"), + "field": Area(2000, 10000, 16000, 16000, "field", "field"), }, ) @@ -237,11 +238,15 @@ def test_without_a_field_the_fleet_goes_to_the_first_area_then_the_extent(): assert len(bots) == 1000 -def test_a_fleet_that_does_not_fit_is_refused_with_how_many_do(): +@pytest.mark.parametrize( + "site", + [None, Site(areas={"field": Area(1500, 1500, 2000, 2000, "field", "field")})], +) +def test_a_fleet_that_does_not_fit_is_refused_with_how_many_do(site): assert fleet_capacity(Area(0, 0, 2000, 2000)) == 100 - fleet_init_state(100) + fleet_init_state(100, site) with pytest.raises(FleetDoesNotFit, match="101 robots.*at most 100 do"): - fleet_init_state(101) + fleet_init_state(101, site) def test_the_capacity_of_a_narrow_area_counts_its_near_square_grid(): @@ -269,19 +274,19 @@ def test_the_simulator_runs_a_generated_fleet_without_a_file(): ] -def test_the_packaged_world_spreads_its_fleet_over_the_active_arena(): +def test_the_packaged_world_spreads_its_fleet_over_the_active_field(): """End to end from the shipped world file: every declared robot must start - inside the site's arena, not in a corner of the floor.""" + inside the site's field, not in a corner of the floor.""" interface = DotBotSimulatorCommunicationInterface( on_frame_received=lambda *_: None, simulator_init_state=str(packaged_init_state_path()), site=HALL, ) - arena = HALL.areas["arena"] + field = HALL.areas["field"] assert len(interface.dotbots) == 5 assert len({(bot.pos_x, bot.pos_y) for bot in interface.dotbots}) == 5 assert all( - arena.x < bot.pos_x < arena.x_max and arena.y < bot.pos_y < arena.y_max + field.x < bot.pos_x < field.x_max and field.y < bot.pos_y < field.y_max for bot in interface.dotbots ) diff --git a/dotbot/tests/test_qrkey_app.py b/dotbot/tests/test_qrkey_app.py index 6d66e0a7..cc85d1e7 100644 --- a/dotbot/tests/test_qrkey_app.py +++ b/dotbot/tests/test_qrkey_app.py @@ -135,7 +135,8 @@ def test_a_site_request_replies_with_the_whole_site(site_client): "anchor": "the arena's top-left corner", "extent_mm": [2000, 4000], "areas": [ - {"x": 0, "y": 2000, "w": 2000, "h": 2000, "name": "annex"}, - {"x": 0, "y": 0, "w": 2000, "h": 2000, "name": "arena"}, + {"x": 0, "y": 0, "w": 2000, "h": 2000, "name": "arena", "role": None}, + {"x": 0, "y": 2000, "w": 2000, "h": 2000, "name": "annex", "role": None}, ], + "field": "arena", } diff --git a/dotbot/tests/test_server.py b/dotbot/tests/test_server.py index 38472f86..1ab6a727 100644 --- a/dotbot/tests/test_server.py +++ b/dotbot/tests/test_server.py @@ -1328,14 +1328,17 @@ async def test_get_robot_models(): @pytest.mark.asyncio async def test_get_controller_site(): - """The console draws the whole site, so it needs the extent and the areas.""" + """The console draws the whole site, so it needs the extent, the areas in + their declared order with their roles, and which one is the field.""" api.controller.site = Site( name="c405-arena", anchor="the arena's top-left corner, against the door wall of C405", extent_mm=(2000, 4000), areas={ - "arena": Area(0, 0, 2000, 2000, "arena"), - "annex": Area(0, 2000, 2000, 2000, "annex"), + "staging": Area(0, 2000, 2000, 2000, "staging", "staging"), + "field": Area(0, 0, 2000, 2000, "field", "field"), + "dev-corner": Area(1000, 0, 1000, 1000, "dev-corner", "corner"), + "field+staging": Area(0, 0, 2000, 4000, "field+staging"), }, ) response = await client.get("/controller/site") @@ -1345,12 +1348,61 @@ async def test_get_controller_site(): "anchor": "the arena's top-left corner, against the door wall of C405", "extent_mm": [2000, 4000], "areas": [ - {"x": 0, "y": 2000, "w": 2000, "h": 2000, "name": "annex"}, - {"x": 0, "y": 0, "w": 2000, "h": 2000, "name": "arena"}, + { + "x": 0, + "y": 2000, + "w": 2000, + "h": 2000, + "name": "staging", + "role": "staging", + }, + {"x": 0, "y": 0, "w": 2000, "h": 2000, "name": "field", "role": "field"}, + { + "x": 1000, + "y": 0, + "w": 1000, + "h": 1000, + "name": "dev-corner", + "role": "corner", + }, + { + "x": 0, + "y": 0, + "w": 2000, + "h": 4000, + "name": "field+staging", + "role": None, + }, ], + "field": "field", } +def test_the_site_payload_reads_back_as_the_same_site(): + from dotbot.models import DotBotSiteModel + + site = Site( + name="hall", + anchor="north-west corner", + extent_mm=(5000, 4000), + areas={ + "pen": Area(0, 0, 10, 10, "pen", "corner"), + "main": Area(5, 5, 10, 10, "main", "field"), + }, + ) + model = DotBotSiteModel(**DotBotSiteModel.from_site(site).model_dump()) + assert model.field == "main" + assert model.to_site() == site + assert list(model.to_site().areas) == ["pen", "main"] + + +@pytest.mark.asyncio +async def test_get_controller_site_with_only_an_extent_names_its_literal_field(): + api.controller.site = Site(name="hall", extent_mm=(5000, 4000)) + response = await client.get("/controller/site") + assert response.json()["field"] == "0,0,5000,4000" + + @pytest.mark.asyncio async def test_get_controller_site_with_nothing_measured(): """A fresh install reports its neutral site rather than inventing a floor.""" @@ -1362,6 +1414,7 @@ async def test_get_controller_site_with_nothing_measured(): "anchor": "", "extent_mm": None, "areas": [], + "field": None, } From e4a1f411ad8818f0a216e968234951a543353722 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Tue, 29 Sep 2026 10:32:34 +0200 Subject: [PATCH 02/37] dotbot/examples: make the simulated fleet's site a generic sim-hall with roles AI-assisted: Claude Opus 5.5 --- dotbot/examples/simulator_fleet/README.md | 25 ++++++++++++----------- dotbot/examples/simulator_fleet/site.toml | 20 +++++++----------- dotbot/tests/test_dotbot_simulator.py | 18 ++++++++++++++++ 3 files changed, 38 insertions(+), 25 deletions(-) diff --git a/dotbot/examples/simulator_fleet/README.md b/dotbot/examples/simulator_fleet/README.md index 1792c807..99c15ced 100644 --- a/dotbot/examples/simulator_fleet/README.md +++ b/dotbot/examples/simulator_fleet/README.md @@ -1,10 +1,11 @@ -# A simulated fleet in the AIO hall +# A simulated fleet in a large hall A 20 x 30 m site with room for up to 1000 simulated DotBots. -`site.toml` defines the site `inria-aio-c`: its extent and four areas, the -2 x 2 m `arena`, a `staging` strip along the north wall, the main 16 x 16 m -`field` and a `charging` strip along the south wall. +`site.toml` defines the site `sim-hall`: its extent and three areas, a +`staging` strip along the north wall, the 16 x 16 m `field` and a `charging` +strip along the south wall, which `role = "staging"` makes a second staging +area. `--robots N` starts N robots in a near-square block centred on the field, 200 mm apart centre to centre, the top half of the rows facing the staging @@ -18,12 +19,12 @@ and 47 mm tail to tail where the two halves meet. From this folder, pick a fleet size: ```bash -BROWSER=true dotbot -c site.toml run simulator --site inria-aio-c --robots 10 --controller-http-port 8100 --headless -BROWSER=true dotbot -c site.toml run simulator --site inria-aio-c --robots 50 --controller-http-port 8100 --headless -BROWSER=true dotbot -c site.toml run simulator --site inria-aio-c --robots 100 --controller-http-port 8100 --headless -BROWSER=true dotbot -c site.toml run simulator --site inria-aio-c --robots 200 --controller-http-port 8100 --headless -BROWSER=true dotbot -c site.toml run simulator --site inria-aio-c --robots 500 --controller-http-port 8100 --headless -BROWSER=true dotbot -c site.toml run simulator --site inria-aio-c --robots 1000 --controller-http-port 8100 --headless +BROWSER=true dotbot -c site.toml run simulator --site sim-hall --robots 10 --controller-http-port 8100 --headless +BROWSER=true dotbot -c site.toml run simulator --site sim-hall --robots 50 --controller-http-port 8100 --headless +BROWSER=true dotbot -c site.toml run simulator --site sim-hall --robots 100 --controller-http-port 8100 --headless +BROWSER=true dotbot -c site.toml run simulator --site sim-hall --robots 200 --controller-http-port 8100 --headless +BROWSER=true dotbot -c site.toml run simulator --site sim-hall --robots 500 --controller-http-port 8100 --headless +BROWSER=true dotbot -c site.toml run simulator --site sim-hall --robots 1000 --controller-http-port 8100 --headless ``` Then open . Drop `BROWSER=true` and @@ -33,8 +34,8 @@ To move robots, change their headings or mix in Mari robots, write the fleet to a file, edit it, and run from it: ```bash -BROWSER=true dotbot -c site.toml run simulator --site inria-aio-c --robots 500 --write-init-state fleet.toml --controller-http-port 8100 --headless -BROWSER=true dotbot -c site.toml run simulator --site inria-aio-c --simulator-init-state fleet.toml --controller-http-port 8100 --headless +BROWSER=true dotbot -c site.toml run simulator --site sim-hall --robots 500 --write-init-state fleet.toml --controller-http-port 8100 --headless +BROWSER=true dotbot -c site.toml run simulator --site sim-hall --simulator-init-state fleet.toml --controller-http-port 8100 --headless ``` `-c site.toml` makes this file the whole config, so no other `dotbot.toml` diff --git a/dotbot/examples/simulator_fleet/site.toml b/dotbot/examples/simulator_fleet/site.toml index a891da46..41bf7c74 100644 --- a/dotbot/examples/simulator_fleet/site.toml +++ b/dotbot/examples/simulator_fleet/site.toml @@ -1,31 +1,25 @@ -# The AIO hall for the 500-robot simulation, loaded with `dotbot -c site.toml`. +# A 20 x 30 m hall for fleet simulations, loaded with `dotbot -c site.toml`. # Zero is the top-left corner of the extent, x right, y down, millimetres. -# The extent and the arena are the testbed's placeholder numbers for the hall. -site = "inria-aio-c" +site = "sim-hall" -[sites.inria-aio-c] +[sites.sim-hall] anchor = "the north-west corner of the hall's floor marking" extent_mm = [20000, 30000] -[sites.inria-aio-c.areas.arena] # a 2 x 2 m square inside the hall -x = 5000 -y = 5000 -w = 2000 -h = 2000 - -[sites.inria-aio-c.areas.staging] # a 2 m strip along the north wall +[sites.sim-hall.areas.staging] # a 2 m strip along the north wall x = 0 y = 0 w = 20000 h = 2000 -[sites.inria-aio-c.areas.field] # the main field, where the robots start +[sites.sim-hall.areas.field] # the field, where the robots start x = 2000 y = 10000 w = 16000 h = 16000 -[sites.inria-aio-c.areas.charging] # a 2 m strip along the south wall +[sites.sim-hall.areas.charging] # a 2 m strip along the south wall +role = "staging" x = 0 y = 28000 w = 20000 diff --git a/dotbot/tests/test_dotbot_simulator.py b/dotbot/tests/test_dotbot_simulator.py index 7ca73823..de5e1b26 100644 --- a/dotbot/tests/test_dotbot_simulator.py +++ b/dotbot/tests/test_dotbot_simulator.py @@ -255,6 +255,24 @@ def test_the_capacity_of_a_narrow_area_counts_its_near_square_grid(): fleet_init_state(2, Site(areas={"strip": Area(0, 0, 2000, 200, "strip")})) +def test_the_simulator_example_puts_a_thousand_robots_in_its_field(): + from pathlib import Path + + import dotbot + from dotbot.config import load_config + from dotbot.site import site_from_config + + path = Path(dotbot.__file__).parent / "examples" / "simulator_fleet" / "site.toml" + config = load_config(path) + site = site_from_config(config, config.site) + assert site.areas["charging"].role == "staging" + field = site.field + assert field.name == "field" + bots = fleet_init_state(1000, site).dotbots + assert all(field.x < b.pos_x < field.x_max for b in bots) + assert all(field.y < b.pos_y < field.y_max for b in bots) + + def test_a_written_fleet_reads_back_as_the_same_robots(tmp_path): fleet = fleet_init_state(50, FIELD_SITE) path = tmp_path / "fleet.toml" From a1c4f7df5e3e6930f4283a7a5111033dba72b2f8 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Tue, 29 Sep 2026 10:32:43 +0200 Subject: [PATCH 03/37] dotbot/calibration: default collect to the field, add --over and --square AI-assisted: Claude Opus 5.5 --- dotbot/calibration/driver.py | 5 +- dotbot/calibration/lighthouse2.py | 10 +- dotbot/calibration/points.py | 78 ++++++++++++ dotbot/calibration/session.py | 13 +- dotbot/cli/swarm_lh2.py | 46 +++++++- dotbot/models.py | 8 +- dotbot/server.py | 5 +- dotbot/tests/test_calibration_lighthouse2.py | 89 +++++++++++++- dotbot/tests/test_calibration_session.py | 118 ++++++++++++++++++- 9 files changed, 356 insertions(+), 16 deletions(-) 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..482a9033 100644 --- a/dotbot/calibration/lighthouse2.py +++ b/dotbot/calibration/lighthouse2.py @@ -156,11 +156,14 @@ 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: `field`, `over `, + `square ` or `points`. """ index: int points_mm: list[tuple[float, float]] at: str = "" + points_from: str = "" 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 @@ -583,6 +586,10 @@ 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: + out.append(f'points_from = "{toml_escape(placement.points_from)}"') + out += [ f'captured_at = "{placement.captured_at}"', "samples = [", ] @@ -652,6 +659,7 @@ 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=raw.get("points_from", ""), captured_at=raw.get("captured_at", ""), samples=samples, ) diff --git a/dotbot/calibration/points.py b/dotbot/calibration/points.py index 8daa2e3d..b8623e74 100644 --- a/dotbot/calibration/points.py +++ b/dotbot/calibration/points.py @@ -144,6 +144,84 @@ 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], str | 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"], f"over {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"], f"square {square}", note + + +def points_from_specs(specs: list[str] | tuple[str, ...], site: Site) -> str: + """How a placement's points were chosen, as its calibration file records it. + + `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 "field" + if name in site.areas: + return f"over {name}" + return "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..03880a29 100644 --- a/dotbot/calibration/session.py +++ b/dotbot/calibration/session.py @@ -38,6 +38,8 @@ ) from dotbot.calibration.points import ( PointPlacement, + field_corners, + points_from_specs, resolve_placement_points, ) from dotbot.robots import ROBOT_DEFAULT @@ -80,6 +82,7 @@ class CalibrationSession: points: list[SessionPoint] site: Site at: str = "" + points_from: str = "" # The area the expected error will be evaluated over; "" means none. area: str = "" device: str = "" @@ -105,10 +108,16 @@ def resolve( specs: Sequence[str], site: Site | None = None, robot: str = ROBOT_DEFAULT, + points_from: str | 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 +131,7 @@ def resolve( ], site=site, at=" ".join(specs), + points_from=points_from or points_from_specs(specs, site), robot=robot, **kwargs, ) @@ -238,6 +248,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/cli/swarm_lh2.py b/dotbot/cli/swarm_lh2.py index b080aeab..1b0f06b3 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" diff --git a/dotbot/models.py b/dotbot/models.py index 896432ce..92a5e809 100644 --- a/dotbot/models.py +++ b/dotbot/models.py @@ -391,12 +391,12 @@ class DotBotCalibrationSessionModel(BaseModel): class DotBotCalibrationStartModel(BaseModel): """Where this session's points are, in `--points` form. - `area` names the area the expected error is evaluated over; empty means - none chosen. `reads` is captures averaged per point; None takes the - session's own default. + Empty `points` means the site's field corners. `area` names the area the + expected error is evaluated over; empty means none chosen. `reads` is + captures averaged per point; None takes the session's own default. """ - points: Union[str, List[str]] = "arena:corners" + points: Union[str, List[str]] = [] device: str = "" area: str = "" reads: Optional[int] = None diff --git a/dotbot/server.py b/dotbot/server.py index 9b2671d1..a6809d35 100644 --- a/dotbot/server.py +++ b/dotbot/server.py @@ -622,9 +622,8 @@ async def camera_stream(area: str): ) async def calibration_session_start(request: DotBotCalibrationStartModel): """Calibration-session HTTP POST handler.""" - specs = ( - [request.points] if isinstance(request.points, str) else list(request.points) - ) + points = request.points + specs = ([points] if points else []) if isinstance(points, str) else list(points) return await _calibration( api.controller.calibration_session.start( specs, request.device, request.area, request.reads diff --git a/dotbot/tests/test_calibration_lighthouse2.py b/dotbot/tests/test_calibration_lighthouse2.py index e4054a50..196342d8 100644 --- a/dotbot/tests/test_calibration_lighthouse2.py +++ b/dotbot/tests/test_calibration_lighthouse2.py @@ -27,7 +27,15 @@ render_calibration, resolve_calibration_path, ) -from dotbot.calibration.points import collect_header, point_prompt, resolve_points +from dotbot.calibration.points import ( + centred_square, + collect_header, + collect_points, + field_corners, + point_prompt, + points_from_specs, + resolve_points, +) from dotbot.site import Site # A plausible wall-mounted station: the magnitude of perspective row real @@ -256,6 +264,19 @@ def test_schema_2_round_trips_and_re_solves_to_the_same_matrices_and_id( assert f'id = "{loaded.id}"' in written_again +def test_points_from_round_trips_through_the_file(monkeypatch, tmp_path): + corners = [(-0.25, -0.25), (0.25, -0.25), (-0.25, 0.25), (0.25, 0.25)] + placement = replace(_consistent_placement(corners), points_from="over dev-corner") + monkeypatch.setattr(lighthouse2, "CALIBRATION_DIR", tmp_path) + manager = LighthouseManager(placements=[placement]) + manager.solve() + path = manager.save_calibration() + + parsed = tomllib.loads(path.read_text()) + assert parsed["placement"][0]["points_from"] == "over dev-corner" + assert read_calibration_file(path).placements[0].points_from == "over dev-corner" + + def test_schema_1_file_is_rejected(tmp_path): path = tmp_path / "calibration-2026-01-01T00-00-00Z-deadbeef.toml" path.write_text( @@ -284,6 +305,7 @@ def test_calibration_id_ignores_the_descriptive_fields(monkeypatch, tmp_path): original.tag = "another-session" original.robot = "dotbot-v9" original.placements[0].at = "typed by hand" + original.placements[0].points_from = "square 500" assert original.id == before @@ -816,3 +838,68 @@ def _five_point_placement(): counts = counts_for_camera_point(camera[0] + 0.002 * index, camera[1], 0) samples.append(Sample(0, index, [round(counts.count1)], [round(counts.count2)])) return Placement(index=0, points_mm=points, samples=samples) + + +# --- where collect calibrates by default ------------------------------------ + +ROLED = Site( + name="c405-arena", + extent_mm=(2000, 4000), + areas={ + "staging": Area(0, 2000, 2000, 2000, "staging", "staging"), + "field": Area(0, 0, 2000, 2000, "field", "field"), + "dev-corner": Area(1000, 0, 1000, 1000, "dev-corner", "corner"), + }, +) + + +def test_collect_defaults_to_the_fields_corners(): + assert field_corners(ROLED) == "field:corners" + assert collect_points(ROLED, []) == (["field:corners"], None, "") + assert points_from_specs(["field:corners"], ROLED) == "field" + + +def test_a_site_with_only_an_extent_calibrates_over_the_extent(): + site = Site(name="hall", extent_mm=(5000, 4000)) + assert field_corners(site) == "0,0,5000,4000:corners" + assert points_from_specs([field_corners(site)], site) == "field" + assert resolve_points(field_corners(site), site.registry())[3].mm == ( + 5000 - 47.0, + 4000 - 18.5, + ) + + +def test_a_site_with_no_field_names_the_fix(): + with pytest.raises(ValueError, match=r"no field.*\[sites.default.areas.field\]"): + field_corners(Site()) + + +def test_over_calibrates_another_areas_corners(): + assert collect_points(ROLED, [], over="dev-corner") == ( + ["dev-corner:corners"], + "over dev-corner", + "", + ) + assert points_from_specs(["dev-corner:corners"], ROLED) == "over dev-corner" + with pytest.raises(ValueError, match="unknown area 'nowhere'"): + collect_points(ROLED, [], over="nowhere") + + +def test_square_calibrates_a_centred_square_and_says_the_rest_is_extrapolated(): + specs, how, note = collect_points(ROLED, [], square=500) + assert specs == ["750,750,500,500:corners"] + assert how == "square 500" + assert "the rest of the 2000 x 2000 mm field is extrapolated" in note + assert points_from_specs(specs, ROLED) == "points" + + +@pytest.mark.parametrize("side", [0, -5, 2001]) +def test_a_square_that_is_empty_or_does_not_fit_is_refused(side): + with pytest.raises(ValueError): + centred_square(ROLED.areas["field"], side) + + +def test_typed_points_are_recorded_as_points(): + specs = ["47,18.5", "1953,18.5", "47,1981.5", "1953,1981.5"] + assert collect_points(ROLED, specs) == (specs, None, "") + assert points_from_specs(specs, ROLED) == "points" diff --git a/dotbot/tests/test_calibration_session.py b/dotbot/tests/test_calibration_session.py index a947b1c8..0c18cb9e 100644 --- a/dotbot/tests/test_calibration_session.py +++ b/dotbot/tests/test_calibration_session.py @@ -21,7 +21,11 @@ from dotbot.area import Area from dotbot.calibration import lighthouse2 from dotbot.calibration.driver import SessionDriver -from dotbot.calibration.lighthouse2 import LH2_CALIBRATION_MESSAGE_BYTES, message_site +from dotbot.calibration.lighthouse2 import ( + LH2_CALIBRATION_MESSAGE_BYTES, + message_site, + read_calibration_file, +) from dotbot.calibration.ota import ( ButtonCapture, CaptureSession, @@ -198,6 +202,33 @@ def test_a_point_carries_the_same_placement_text_collect_prints(): assert [p["how"] for p in session.as_dict()["points"]] == [p.how for p in printed] +def test_a_session_given_no_points_opens_on_the_fields_corners(): + site = Site( + name="c405-arena", + areas={ + "staging": Area(0, 2000, 2000, 2000, "staging", "staging"), + "field": Area(0, 0, 2000, 2000, "field", "field"), + }, + ) + session = CalibrationSession.resolve([], site=site) + assert session.at == "field:corners" + assert session.points_from == "field" + assert [p.mm for p in session.points][1] == (1953, 18.5) + assert session.placement().points_from == "field" + + +def test_a_session_records_how_its_points_were_chosen(): + assert CalibrationSession.resolve(["annex:corners"], site=C405).points_from == ( + "over annex" + ) + typed = ["47,18.5", "1953,18.5", "47,1981.5", "1953,1981.5"] + assert CalibrationSession.resolve(typed, site=C405).points_from == "points" + square = CalibrationSession.resolve( + ["750,750,500,500:corners"], site=C405, points_from="square 500" + ) + assert square.points_from == "square 500" + + def test_fewer_than_four_points_is_refused_with_the_span_rule(): with pytest.raises(SessionError) as exc: CalibrationSession.resolve(["arena:top-left"], site=C405) @@ -702,6 +733,14 @@ async def test_the_routes_walk_a_session_from_start_to_push( assert (await http.get("/controller/calibration/session/state")).json() is None +@pytest.mark.asyncio +async def test_a_start_with_no_points_opens_on_the_sites_field(rest): + http, _, _ = rest + body = (await http.post("/controller/calibration/session", json={})).json() + assert body["at"] == "arena:corners" + assert body["points"][0]["where"] == "top-left corner of arena" + + @pytest.mark.asyncio async def test_a_route_that_needs_a_session_refuses_without_one(rest): http, _, _ = rest @@ -1027,6 +1066,83 @@ def test_collect_with_a_device_still_takes_another_robot_s_button( assert client.pushed_to == ["ABCD", "FEED"] +ROLED = Site( + name="c405-arena", + extent_mm=(2000, 4000), + areas={ + "staging": Area(0, 2000, 2000, 2000, "staging", "staging"), + "field": Area(0, 0, 2000, 2000, "field", "field"), + "dev-corner": Area(1000, 0, 1000, 1000, "dev-corner", "corner"), + }, +) + + +def _collect_in(monkeypatch, tmp_path, site, *args): + """`collect` over `site` with its own point flags, captured on Enter.""" + from click.testing import CliRunner + + from dotbot.cli import swarm_lh2 + + monkeypatch.setattr(lighthouse2, "CALIBRATION_DIR", tmp_path) + monkeypatch.setattr(swarm_lh2, "_swarmit_client", lambda *a: _CollectClient()) + monkeypatch.setattr( + swarm_lh2, "site_from_context", lambda ctx, flag=None: (site, "the test") + ) + return CliRunner().invoke( + swarm_lh2.cmd, + ["collect", "--device=ABCD", "--reads=1", "--timeout=2", "--retries=0", *args], + input="\n" * 4, + ) + + +def _saved_points_from(tmp_path) -> str: + (path,) = (tmp_path / "calibrations" / "c405-arena").glob("*.toml") + return read_calibration_file(path).placements[0].points_from + + +def test_collect_with_no_point_flag_calibrates_over_the_field(monkeypatch, tmp_path): + result = _collect_in(monkeypatch, tmp_path, ROLED) + + assert result.exit_code == 0, result.output + assert "Points: field:corners (field)." in result.output + assert "top-left corner of field" in result.output + assert _saved_points_from(tmp_path) == "field" + + +def test_collect_over_an_area_takes_its_corners(monkeypatch, tmp_path): + result = _collect_in(monkeypatch, tmp_path, ROLED, "--over", "dev-corner") + + assert result.exit_code == 0, result.output + assert "top-left corner of dev-corner" in result.output + assert _saved_points_from(tmp_path) == "over dev-corner" + + +def test_collect_square_says_the_rest_of_the_field_is_extrapolated( + monkeypatch, tmp_path +): + result = _collect_in(monkeypatch, tmp_path, ROLED, "--square", "500") + + assert result.exit_code == 0, result.output + assert "Points: 750,750,500,500:corners (square 500)." in result.output + assert "rest of the 2000 x 2000 mm field is extrapolated" in result.output + assert _saved_points_from(tmp_path) == "square 500" + + +@pytest.mark.parametrize( + "flags", + [ + ["--over", "dev-corner", "--square", "500"], + ["--points", "field:corners", "--over", "dev-corner"], + ["--points", "field:corners", "--square", "500"], + ], +) +def test_collect_takes_one_way_of_choosing_the_points(monkeypatch, tmp_path, flags): + result = _collect_in(monkeypatch, tmp_path, ROLED, *flags) + + assert result.exit_code == 2 + assert "each choose the points; pass one" in result.output + + def test_collect_help_marks_the_enter_capture_deprecated(): from click.testing import CliRunner From a49d180b2207398eccb5ae45fa65b261928ba60e Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Tue, 29 Sep 2026 10:32:43 +0200 Subject: [PATCH 04/37] dotbot/cli: default camera collect --area to the site's field AI-assisted: Claude Opus 5.5 --- dotbot/cli/camera_calibrate.py | 14 +++++++++++--- dotbot/tests/test_camera_collect.py | 23 +++++++++++++++++++++-- 2 files changed, 32 insertions(+), 5 deletions(-) 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/tests/test_camera_collect.py b/dotbot/tests/test_camera_collect.py index 63683ac3..5a51fa99 100644 --- a/dotbot/tests/test_camera_collect.py +++ b/dotbot/tests/test_camera_collect.py @@ -15,6 +15,8 @@ and detector tests share. """ +import dataclasses + import cv2 import numpy as np import pytest @@ -846,8 +848,25 @@ def test_collect_refuses_an_area_the_site_does_not_define(tmp_path, monkeypatch) assert "defines: arena" in result.output -def test_collect_needs_an_area_to_derive_the_sheets_from(tmp_path, monkeypatch): +def test_collect_defaults_to_the_sites_field(tmp_path, monkeypatch, frame_path): + bench = dataclasses.replace(DEV_CORNER, name="bench", role="field") + staging = Area(0, 4000, 2000, 2000, "staging", "staging") + result = run_collect( + tmp_path, + monkeypatch, + ["--camera", str(frame_path), "--reads", "1"], + areas={"staging": staging, "bench": bench}, + ) + + assert result.exit_code == 0, result.output + assert "sheet 0 inside the top-left corner of bench" in result.output + written = sorted((tmp_path / SITE.name).glob("camera-*.toml")) + assert read_camera_calibration_file(written[0]).area == "bench" + + +def test_collect_needs_an_area_when_the_site_has_no_field(tmp_path, monkeypatch): """The bare group invokes collect with no flags, so the ask has to be said.""" - result = run_collect(tmp_path, monkeypatch, []) + result = run_collect(tmp_path, monkeypatch, [], areas={}) assert result.exit_code != 0 + assert "no field" in result.output assert "--area" in result.output From 50fa8d3db52f7b87e325ea4f21e3059fe2980c6a Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Tue, 29 Sep 2026 10:33:04 +0200 Subject: [PATCH 05/37] dotbot/console-web: open calibration on the field, hide corners on load Breaking: area visibility moves to a new browser key holding per-area choices over role defaults, so areas a browser had hidden show again once, and corner areas start hidden. AI-assisted: Claude Opus 5.5 --- dotbot/console-web/src/App.tsx | 27 ++++--- dotbot/console-web/src/areaLayer.test.tsx | 33 ++++++--- dotbot/console-web/src/areas.test.ts | 70 ++++++++++++++----- dotbot/console-web/src/areas.ts | 67 ++++++++++-------- .../console-web/src/calibrationSetup.test.ts | 24 ++++--- dotbot/console-web/src/calibrationSetup.ts | 10 +-- dotbot/console-web/src/setupCard.test.tsx | 3 +- dotbot/console-web/src/types.ts | 11 ++- 8 files changed, 162 insertions(+), 83 deletions(-) diff --git a/dotbot/console-web/src/App.tsx b/dotbot/console-web/src/App.tsx index 03dca3f7..46baa112 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, @@ -229,14 +235,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/areaLayer.test.tsx b/dotbot/console-web/src/areaLayer.test.tsx index 7cfb6050..336e5297 100644 --- a/dotbot/console-web/src/areaLayer.test.tsx +++ b/dotbot/console-web/src/areaLayer.test.tsx @@ -2,7 +2,7 @@ import React, { useState } from "react"; import { fireEvent, render, screen, within } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { saveHiddenAreas, toggleHidden } from "./areas"; +import { type AreaVisibility, hiddenAreaNames, saveAreaVisibility, toggleShown } from "./areas"; import { MapView } from "./MapView"; import { RightPane, RightTab } from "./RightPane"; import type { Area, Site } from "./types"; @@ -10,11 +10,12 @@ import type { Calibration } from "./useCalibration"; const ARENA: Area = { x: 0, y: 0, w: 2000, h: 2000, name: "arena" }; const ANNEX: Area = { x: 0, y: 2000, w: 2000, h: 2000, name: "annex" }; +const CORNER: Area = { x: 1000, y: 0, w: 1000, h: 1000, name: "dev-corner", role: "corner" }; const C405: Site = { name: "c405-arena", anchor: "the arena's top-left corner", extent_mm: [2000, 4000], - areas: [ARENA, ANNEX], + areas: [ARENA, ANNEX, CORNER], }; const VIEWPORT: Area = { x: -2000, y: -2000, w: 6000, h: 8000 }; @@ -32,7 +33,8 @@ const calibration = { // The map and the Layers tab over one hidden set, exactly as App wires them. const Harness: React.FC = () => { - const [hiddenAreas, setHiddenAreas] = useState>(new Set()); + const [visibility, setVisibility] = useState({}); + const hiddenAreas = hiddenAreaNames(C405.areas, visibility); const [tab, setTab] = useState("layers"); return ( <> @@ -73,9 +75,9 @@ const Harness: React.FC = () => { site={C405} hiddenAreas={hiddenAreas} onAreaToggle={(name) => - setHiddenAreas((prev) => { - const next = toggleHidden(prev, name); - saveHiddenAreas(next); + setVisibility((prev) => { + const next = toggleShown(prev, C405.areas, name); + saveAreaVisibility(next); return next; }) } @@ -123,6 +125,19 @@ describe("Layers > Areas", () => { expect(fetchSpy).not.toHaveBeenCalled(); }); + it("starts with a corner hidden and draws it once ticked", () => { + render(); + const map = screen.getByTestId("map"); + expect(within(map).queryByRole("img", { name: "dev-corner" })).not.toBeInTheDocument(); + + fireEvent.click(within(screen.getByTestId("pane")).getByText("dev-corner")); + + expect(within(map).getByRole("img", { name: "dev-corner" })).toBeInTheDocument(); + expect(window.localStorage.getItem("dotbot.console.areaVisibility")).toBe( + '{"dev-corner":true}', + ); + }); + it("draws each area in its own colour, the one its row carries", () => { render(); const map = screen.getByTestId("map"); @@ -149,11 +164,11 @@ describe("Layers > Areas", () => { ).toBe(before); }); - it("remembers the hidden set in this browser", () => { + it("remembers the choice in this browser", () => { render(); fireEvent.click(within(screen.getByTestId("pane")).getByText("annex")); - expect(window.localStorage.getItem("dotbot.console.hiddenAreas")).toBe( - '["annex"]', + expect(window.localStorage.getItem("dotbot.console.areaVisibility")).toBe( + '{"annex":false}', ); }); }); diff --git a/dotbot/console-web/src/areas.test.ts b/dotbot/console-web/src/areas.test.ts index d6b6d3a5..ca02724d 100644 --- a/dotbot/console-web/src/areas.test.ts +++ b/dotbot/console-web/src/areas.test.ts @@ -1,50 +1,82 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { loadHiddenAreas, saveHiddenAreas, toggleHidden } from "./areas"; +import { + hiddenAreaNames, + isShown, + loadAreaVisibility, + saveAreaVisibility, + toggleShown, +} from "./areas"; +import type { Area } from "./types"; + +const FIELD: Area = { x: 0, y: 0, w: 2000, h: 2000, name: "field", role: "field" }; +const STAGING: Area = { x: 0, y: 2000, w: 2000, h: 2000, name: "staging", role: "staging" }; +const CORNER: Area = { x: 1000, y: 0, w: 1000, h: 1000, name: "dev-corner", role: "corner" }; +const VIEW: Area = { x: 0, y: 0, w: 2000, h: 4000, name: "field+staging" }; +const AREAS = [FIELD, STAGING, VIEW, CORNER]; afterEach(() => { window.localStorage.clear(); vi.restoreAllMocks(); }); -describe("the areas Layers > Areas hides", () => { - it("starts with none hidden, so every outline is drawn", () => { - expect(loadHiddenAreas()).toEqual(new Set()); +describe("which areas are drawn with no choice made", () => { + it("hides corners and shows every other role, and no role", () => { + expect(hiddenAreaNames(AREAS, {})).toEqual(new Set(["dev-corner"])); + }); + + it("lets a choice override the role, either way", () => { + expect(isShown(CORNER, { "dev-corner": true })).toBe(true); + expect(isShown(FIELD, { field: false })).toBe(false); + }); +}); + +describe("the choices Layers > Areas remembers", () => { + it("starts with none", () => { + expect(loadAreaVisibility()).toEqual({}); }); - it("round-trips the hidden set through storage", () => { - saveHiddenAreas(new Set(["annex"])); - expect(loadHiddenAreas()).toEqual(new Set(["annex"])); + it("round-trips through storage", () => { + saveAreaVisibility({ "dev-corner": true, staging: false }); + expect(loadAreaVisibility()).toEqual({ "dev-corner": true, staging: false }); }); - it("draws every outline when storage refuses to answer", () => { + it("falls back to the roles when storage refuses to answer", () => { vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new Error("storage blocked"); }); - expect(loadHiddenAreas()).toEqual(new Set()); + expect(loadAreaVisibility()).toEqual({}); }); it("keeps working when storage refuses to remember", () => { vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("storage blocked"); }); - expect(() => saveHiddenAreas(new Set(["annex"]))).not.toThrow(); + expect(() => saveAreaVisibility({ staging: false })).not.toThrow(); }); - it("ignores a stored value that is not a list of names", () => { - window.localStorage.setItem("dotbot.console.hiddenAreas", '{"annex": true}'); - expect(loadHiddenAreas()).toEqual(new Set()); + it("drops entries that are not true or false", () => { + window.localStorage.setItem( + "dotbot.console.areaVisibility", + '{"staging": false, "field": "yes"}', + ); + expect(loadAreaVisibility()).toEqual({ staging: false }); }); }); describe("toggling one area", () => { - it("hides an area that was shown", () => { - expect(toggleHidden(new Set(), "arena")).toEqual(new Set(["arena"])); + it("hides an area shown by default", () => { + expect(toggleShown({}, AREAS, "field")).toEqual({ field: false }); }); - it("shows an area that was hidden, leaving the rest alone", () => { - expect(toggleHidden(new Set(["arena", "annex"]), "arena")).toEqual( - new Set(["annex"]), - ); + it("shows a corner hidden by default", () => { + expect(toggleShown({}, AREAS, "dev-corner")).toEqual({ "dev-corner": true }); + }); + + it("flips a choice already made, leaving the rest alone", () => { + expect(toggleShown({ field: false, staging: false }, AREAS, "field")).toEqual({ + field: true, + staging: false, + }); }); }); diff --git a/dotbot/console-web/src/areas.ts b/dotbot/console-web/src/areas.ts index b2ffa53b..3496a5a2 100644 --- a/dotbot/console-web/src/areas.ts +++ b/dotbot/console-web/src/areas.ts @@ -1,36 +1,47 @@ // Which area outlines the map draws, per browser. // // Every area of the site is an outline; Layers > Areas ticks which ones are -// visible. Nothing reaches the controller, so the set is remembered locally -// and a browser that refuses storage still renders every outline. - -import { store } from "./persisted"; - -const KEY = "dotbot.console.hiddenAreas"; - -/** The area names this browser hides, empty when storage says nothing. */ -export function loadHiddenAreas(): Set { - try { - const raw = window.localStorage.getItem(KEY); - if (!raw) return new Set(); - const parsed = JSON.parse(raw); - return new Set( - Array.isArray(parsed) ? parsed.filter((n) => typeof n === "string") : [], - ); - } catch { - return new Set(); - } +// visible. An area is shown unless its role is `corner`, and a tick in Layers +// overrides that per name. Nothing reaches the controller, so the overrides +// are remembered locally and a browser that refuses storage falls back to +// the role defaults. + +import { loadRecord, store } from "./persisted"; +import type { Area } from "./types"; + +const KEY = "dotbot.console.areaVisibility"; + +/** Per-name show/hide choices this browser made, `{name: shown}`. */ +export type AreaVisibility = Record; + +/** The choices storage holds, empty when it says nothing. */ +export function loadAreaVisibility(): AreaVisibility { + return loadRecord(KEY, (v): v is boolean => typeof v === "boolean"); +} + +/** Remember the choices; a browser that refuses storage just forgets them. */ +export function saveAreaVisibility(visibility: AreaVisibility): void { + store(KEY, visibility); +} + +/** Whether `area` is drawn: this browser's choice, else its role's default. */ +export function isShown(area: Area, visibility: AreaVisibility): boolean { + return visibility[area.name ?? ""] ?? area.role !== "corner"; } -/** Remember the hidden set; a browser that refuses storage just forgets it. */ -export function saveHiddenAreas(hidden: Set): void { - store(KEY, [...hidden]); +/** The names of the areas not drawn. */ +export function hiddenAreaNames(areas: Area[], visibility: AreaVisibility): Set { + return new Set( + areas.filter((a) => !isShown(a, visibility)).map((a) => a.name ?? ""), + ); } -/** The hidden set with `name` flipped. */ -export function toggleHidden(hidden: Set, name: string): Set { - const next = new Set(hidden); - if (next.has(name)) next.delete(name); - else next.add(name); - return next; +/** The choices with area `name` flipped from how it is drawn now. */ +export function toggleShown( + visibility: AreaVisibility, + areas: Area[], + name: string, +): AreaVisibility { + const area = areas.find((a) => a.name === name) ?? { x: 0, y: 0, w: 0, h: 0, name }; + return { ...visibility, [name]: !isShown(area, visibility) }; } diff --git a/dotbot/console-web/src/calibrationSetup.test.ts b/dotbot/console-web/src/calibrationSetup.test.ts index 4693b6b7..11c82370 100644 --- a/dotbot/console-web/src/calibrationSetup.test.ts +++ b/dotbot/console-web/src/calibrationSetup.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vitest"; import { - AREA_PREFERRED, TYPED_RECT, areaChoices, defaultChoice, @@ -16,26 +15,31 @@ const C405: Site = { anchor: "the arena's top-left corner", extent_mm: [2000, 4000], areas: [ - { x: 0, y: 2000, w: 2000, h: 2000, name: "annex" }, - { x: 0, y: 0, w: 2000, h: 2000, name: "arena" }, + { x: 0, y: 2000, w: 2000, h: 2000, name: "staging", role: "staging" }, + { x: 0, y: 0, w: 2000, h: 2000, name: "field", role: "field" }, + { x: 1000, y: 0, w: 1000, h: 1000, name: "dev-corner", role: "corner" }, ], + field: "field", }; describe("the rectangle picker", () => { it("offers the site's areas in the site's own order", () => { - expect(areaChoices(C405)).toEqual(["annex", "arena"]); + expect(areaChoices(C405)).toEqual(["staging", "field", "dev-corner"]); }); - it("opens on the arena when the site has one, whatever its order", () => { - expect(defaultChoice(C405)).toBe(AREA_PREFERRED); + it("opens on the field the controller names, whatever its order", () => { + expect(defaultChoice(C405)).toBe("field"); + expect(defaultChoice({ ...C405, field: "dev-corner" })).toBe("dev-corner"); }); - it("opens on the first area when the site has no arena", () => { - expect(defaultChoice({ ...C405, areas: [C405.areas[0]] })).toBe("annex"); + it("opens on the typed rectangle when the field is not one of the areas", () => { + expect(defaultChoice({ ...C405, areas: [], field: "0,0,2000,4000" })).toBe( + TYPED_RECT, + ); }); - it("opens on the typed rectangle when the site defines no areas", () => { - expect(defaultChoice({ ...C405, areas: [] })).toBe(TYPED_RECT); + it("opens on the typed rectangle when the site has no field", () => { + expect(defaultChoice({ ...C405, areas: [], field: null })).toBe(TYPED_RECT); expect(defaultChoice(null)).toBe(TYPED_RECT); }); }); diff --git a/dotbot/console-web/src/calibrationSetup.ts b/dotbot/console-web/src/calibrationSetup.ts index ddcaecf8..5af8c807 100644 --- a/dotbot/console-web/src/calibrationSetup.ts +++ b/dotbot/console-web/src/calibrationSetup.ts @@ -12,19 +12,15 @@ import type { Area, Site } from "./types"; /** The picker entry that means "a rectangle typed as x,y,w,h". */ export const TYPED_RECT = "typed"; -/** The area a site is opened on: `arena` when it has one, else its first. */ -export const AREA_PREFERRED = "arena"; - /** The area names the picker offers, in the site's own order. */ export function areaChoices(site: Site | null): string[] { return (site?.areas ?? []).map((a) => a.name ?? "").filter(Boolean); } -/** Which entry the picker opens on; the typed rectangle when there is no area. */ +/** Which entry the picker opens on: the site's field, else the typed rectangle. */ export function defaultChoice(site: Site | null): string { - const names = areaChoices(site); - if (names.includes(AREA_PREFERRED)) return AREA_PREFERRED; - return names[0] ?? TYPED_RECT; + const field = site?.field ?? ""; + return areaChoices(site).includes(field) ? field : TYPED_RECT; } /** A typed `x,y,w,h` in frame mm, or null when it is not four whole numbers. */ diff --git a/dotbot/console-web/src/setupCard.test.tsx b/dotbot/console-web/src/setupCard.test.tsx index ccde64ed..3a74f212 100644 --- a/dotbot/console-web/src/setupCard.test.tsx +++ b/dotbot/console-web/src/setupCard.test.tsx @@ -20,6 +20,7 @@ const site: Site = { { x: 0, y: 0, w: 3330, h: 2000, name: "arena" }, { x: 0, y: 2000, w: 3330, h: 2000, name: "annex" }, ], + field: "arena", }; // What the controller resolves `arena:corners` to: the four corners in CORNERS @@ -68,7 +69,7 @@ beforeEach(() => { }); describe("the Calibrate tab before a session", () => { - it("opens on the site's arena and asks the controller for its corners", async () => { + it("opens on the site's field and asks the controller for its corners", async () => { render(); expect(screen.getByLabelText("Rectangle")).toHaveValue("arena"); diff --git a/dotbot/console-web/src/types.ts b/dotbot/console-web/src/types.ts index eb6b0d84..ee66d67c 100644 --- a/dotbot/console-web/src/types.ts +++ b/dotbot/console-web/src/types.ts @@ -407,6 +407,11 @@ export interface ControllerConnection { gw_address: string; } +// What an area is for: `field` is where experiments happen and what gets +// calibrated, `staging` is where robots park and charge, `corner` is a small +// patch that starts hidden. +export type AreaRole = "field" | "staging" | "corner"; + // One named rectangle of the site, in frame mm. An area is a view of the // frame and carries no calibration, so showing or hiding one never touches // one. @@ -416,16 +421,20 @@ export interface Area { w: number; h: number; name?: string; + role?: AreaRole | null; } // GET /controller/site - the floor the controller works in. `extent_mm` is // [width, height] with zero at its top-left corner, which is where `anchor` -// points; a site with nothing measured yet reports none. +// points; a site with nothing measured yet reports none. `field` names the +// area calibration defaults to: an area's name, an `x,y,w,h` literal for a +// site with an extent and no areas, or null. export interface Site { name: string; anchor: string; extent_mm: [number, number] | null; areas: Area[]; + field?: string | null; } // GET /controller/cameras - one registered camera, one area. `width` and From e109de3beac094325c6c7415f8423f9abfa8905c Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Tue, 29 Sep 2026 11:15:56 +0200 Subject: [PATCH 06/37] dotbot/examples: name the simulated fleet's config dotbot.toml, site virtual-lab AI-assisted: Claude Opus 5.5 --- dotbot/examples/simulator_fleet/README.md | 32 +++++++++++---------- dotbot/examples/simulator_fleet/dotbot.toml | 27 +++++++++++++++++ dotbot/examples/simulator_fleet/site.toml | 26 ----------------- dotbot/tests/test_dotbot_simulator.py | 2 +- 4 files changed, 45 insertions(+), 42 deletions(-) create mode 100644 dotbot/examples/simulator_fleet/dotbot.toml delete mode 100644 dotbot/examples/simulator_fleet/site.toml diff --git a/dotbot/examples/simulator_fleet/README.md b/dotbot/examples/simulator_fleet/README.md index 99c15ced..93b21a23 100644 --- a/dotbot/examples/simulator_fleet/README.md +++ b/dotbot/examples/simulator_fleet/README.md @@ -1,8 +1,8 @@ -# A simulated fleet in a large hall +# A simulated fleet in a virtual lab A 20 x 30 m site with room for up to 1000 simulated DotBots. -`site.toml` defines the site `sim-hall`: its extent and three areas, a +`dotbot.toml` defines the site `virtual-lab`: its extent and three areas, a `staging` strip along the north wall, the 16 x 16 m `field` and a `charging` strip along the south wall, which `role = "staging"` makes a second staging area. @@ -16,15 +16,16 @@ and 47 mm tail to tail where the two halves meet. ## Run -From this folder, pick a fleet size: +From this folder, pick a fleet size. `dotbot` reads the `dotbot.toml` in the +current folder by itself, so no `-c` is needed: ```bash -BROWSER=true dotbot -c site.toml run simulator --site sim-hall --robots 10 --controller-http-port 8100 --headless -BROWSER=true dotbot -c site.toml run simulator --site sim-hall --robots 50 --controller-http-port 8100 --headless -BROWSER=true dotbot -c site.toml run simulator --site sim-hall --robots 100 --controller-http-port 8100 --headless -BROWSER=true dotbot -c site.toml run simulator --site sim-hall --robots 200 --controller-http-port 8100 --headless -BROWSER=true dotbot -c site.toml run simulator --site sim-hall --robots 500 --controller-http-port 8100 --headless -BROWSER=true dotbot -c site.toml run simulator --site sim-hall --robots 1000 --controller-http-port 8100 --headless +BROWSER=true dotbot run simulator --robots 10 --controller-http-port 8100 --headless +BROWSER=true dotbot run simulator --robots 50 --controller-http-port 8100 --headless +BROWSER=true dotbot run simulator --robots 100 --controller-http-port 8100 --headless +BROWSER=true dotbot run simulator --robots 200 --controller-http-port 8100 --headless +BROWSER=true dotbot run simulator --robots 500 --controller-http-port 8100 --headless +BROWSER=true dotbot run simulator --robots 1000 --controller-http-port 8100 --headless ``` Then open . Drop `BROWSER=true` and @@ -34,11 +35,12 @@ To move robots, change their headings or mix in Mari robots, write the fleet to a file, edit it, and run from it: ```bash -BROWSER=true dotbot -c site.toml run simulator --site sim-hall --robots 500 --write-init-state fleet.toml --controller-http-port 8100 --headless -BROWSER=true dotbot -c site.toml run simulator --site sim-hall --simulator-init-state fleet.toml --controller-http-port 8100 --headless +BROWSER=true dotbot run simulator --robots 500 --write-init-state fleet.toml --controller-http-port 8100 --headless +BROWSER=true dotbot run simulator --simulator-init-state fleet.toml --controller-http-port 8100 --headless ``` -`-c site.toml` makes this file the whole config, so no other `dotbot.toml` -applies. The console frames the whole site, where the robots are dots at -their true size; zoom in to see each one's body. Waypoints outside the site -are refused. +From another folder, pass the file with +`-c dotbot/examples/simulator_fleet/dotbot.toml`. Either way it is the whole +config, and your `~/.dotbot/config.toml` does not apply. The console frames +the whole site, where the robots are dots at their true size; zoom in to see +each one's body. Waypoints outside the site are refused. diff --git a/dotbot/examples/simulator_fleet/dotbot.toml b/dotbot/examples/simulator_fleet/dotbot.toml new file mode 100644 index 00000000..8261be05 --- /dev/null +++ b/dotbot/examples/simulator_fleet/dotbot.toml @@ -0,0 +1,27 @@ +# A virtual 20 x 30 m lab for simulating a large fleet. From this folder, +# `dotbot` picks this file up by itself. +# Zero is the top-left corner of the extent, x right, y down, millimetres. +site = "virtual-lab" + +[sites.virtual-lab] +anchor = "the north-west corner of the lab's floor marking" +extent_mm = [20000, 30000] + +[sites.virtual-lab.areas.staging] # a 2 m strip along the north wall +x = 0 +y = 0 +w = 20000 +h = 2000 + +[sites.virtual-lab.areas.field] # the field, where the robots start +x = 2000 +y = 10000 +w = 16000 +h = 16000 + +[sites.virtual-lab.areas.charging] # a 2 m strip along the south wall +role = "staging" +x = 0 +y = 28000 +w = 20000 +h = 2000 diff --git a/dotbot/examples/simulator_fleet/site.toml b/dotbot/examples/simulator_fleet/site.toml deleted file mode 100644 index 41bf7c74..00000000 --- a/dotbot/examples/simulator_fleet/site.toml +++ /dev/null @@ -1,26 +0,0 @@ -# A 20 x 30 m hall for fleet simulations, loaded with `dotbot -c site.toml`. -# Zero is the top-left corner of the extent, x right, y down, millimetres. -site = "sim-hall" - -[sites.sim-hall] -anchor = "the north-west corner of the hall's floor marking" -extent_mm = [20000, 30000] - -[sites.sim-hall.areas.staging] # a 2 m strip along the north wall -x = 0 -y = 0 -w = 20000 -h = 2000 - -[sites.sim-hall.areas.field] # the field, where the robots start -x = 2000 -y = 10000 -w = 16000 -h = 16000 - -[sites.sim-hall.areas.charging] # a 2 m strip along the south wall -role = "staging" -x = 0 -y = 28000 -w = 20000 -h = 2000 diff --git a/dotbot/tests/test_dotbot_simulator.py b/dotbot/tests/test_dotbot_simulator.py index de5e1b26..20d0ba71 100644 --- a/dotbot/tests/test_dotbot_simulator.py +++ b/dotbot/tests/test_dotbot_simulator.py @@ -262,7 +262,7 @@ def test_the_simulator_example_puts_a_thousand_robots_in_its_field(): from dotbot.config import load_config from dotbot.site import site_from_config - path = Path(dotbot.__file__).parent / "examples" / "simulator_fleet" / "site.toml" + path = Path(dotbot.__file__).parent / "examples" / "simulator_fleet" / "dotbot.toml" config = load_config(path) site = site_from_config(config, config.site) assert site.areas["charging"].role == "staging" From d15dadfe08609652fefcf8d7c1251a8145a72487 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Tue, 29 Sep 2026 11:40:20 +0200 Subject: [PATCH 07/37] dotbot/cli: write a default site from config init, sized by --field AI-assisted: Claude Opus 5.5 --- dotbot/cli/config_cmd.py | 182 ++++++++++++++++++++++++++++--- dotbot/tests/test_cli_config.py | 144 +++++++++++++++++++++++- dotbot/tests/test_cli_helpers.py | 2 +- 3 files changed, 309 insertions(+), 19 deletions(-) diff --git a/dotbot/cli/config_cmd.py b/dotbot/cli/config_cmd.py index de8713bf..0fe1c1e3 100644 --- a/dotbot/cli/config_cmd.py +++ b/dotbot/cli/config_cmd.py @@ -4,12 +4,14 @@ """`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 @@ -17,16 +19,130 @@ import tomlkit from dotbot.config import USER_CONFIG_PATH +from dotbot.site import SITE_DEFAULT _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 + +_SITE_NAME = re.compile(r"^[A-Za-z0-9_-]+$") +_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 +152,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 +177,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. """ + if not _SITE_NAME.match(site): + raise click.BadParameter( + f"{site!r}: use letters, digits, - and _", param_hint="'--site'" + ) + 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 +224,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() diff --git a/dotbot/tests/test_cli_config.py b/dotbot/tests/test_cli_config.py index c2e8e486..0fb3ff92 100644 --- a/dotbot/tests/test_cli_config.py +++ b/dotbot/tests/test_cli_config.py @@ -1,13 +1,17 @@ # SPDX-FileCopyrightText: 2026-present Inria # SPDX-License-Identifier: BSD-3-Clause -"""Phase-2 wiring: the root `-c/--config` + `--deployment` flags, and the -`fw`/`device` `--config` -> `--build-config` rename. Headless (CliRunner).""" +"""Phase-2 wiring: the root `-c/--config` + `--deployment` flags, the +`fw`/`device` `--config` -> `--build-config` rename, and the site `config init` +writes. Headless (CliRunner).""" + +from pathlib import Path import pytest from click.testing import CliRunner from dotbot.cli.main import cli +from dotbot.config import load_config @pytest.fixture @@ -108,3 +112,139 @@ def test_device_flash_uses_build_config(runner): result = runner.invoke(cli, ["device", "flash", "--help"]) assert result.exit_code == 0 assert "--build-config" in result.output + + +# --- config init: the default site ------------------------------------------ + + +@pytest.mark.parametrize( + "spec, size", + [ + ("1000x1000", (1000, 1000)), + ("1000", (1000, 1000)), + ("1000mm", (1000, 1000)), + ("1m", (1000, 1000)), + ("1.5m", (1500, 1500)), + ("1500", (1500, 1500)), + ("1.5x2m", (1500, 2000)), + ("2000x3000", (2000, 3000)), + ("1.5mx2000mm", (1500, 2000)), + ("2M", (2000, 2000)), + ], +) +def test_parse_field_size(spec, size): + from dotbot.cli.config_cmd import parse_field_size + + assert parse_field_size(spec) == size + + +@pytest.mark.parametrize( + "spec, error", + [ + ("1.5", "a 1.5 mm field is too small; did you mean 1.5m?"), + ("1.5x2", "did you mean 1.5x2m?"), + ("1.5mm", "did you mean 1.5m?"), + ("50", "a 50 mm field is too small"), + ("2000m", "a 2000 m field is too large; did you mean 2000mm?"), + ("150cm", "units are mm or m, not cm"), + ("1500.5", "whole numbers"), + ("2x3x4", "WxH"), + ("big", "a size is a number"), + ], +) +def test_parse_field_size_refuses(spec, error): + import click + + from dotbot.cli.config_cmd import parse_field_size + + with pytest.raises(click.BadParameter, match=error.replace("?", r"\?")): + parse_field_size(spec) + + +def _init(runner, *args): + result = runner.invoke(cli, ["config", "init", "--force", *args]) + assert result.exit_code == 0, result.output + return result + + +def test_config_init_writes_the_default_site(runner): + from dotbot.site import site_from_config + + with runner.isolated_filesystem(): + _init(runner) + loaded = load_config("dotbot.toml") + assert loaded.site == "default" + site = site_from_config(loaded, "default") + assert site.extent_mm == (5000, 5000) + assert site.field.as_dict() == { + "x": 1500, + "y": 1500, + "w": 2000, + "h": 2000, + "name": "field", + "role": "field", + } + staging = site.areas["staging"] + assert (staging.x, staging.y, staging.w, staging.h) == (1500, 3500, 2000, 600) + assert staging.role == "staging" + + +@pytest.mark.parametrize( + "field, extent, area", + [ + ("1000x1000", (4000, 4000), (1500, 1500, 1000, 1000)), + ("1m", (4000, 4000), (1500, 1500, 1000, 1000)), + ("1.5x2m", (4500, 5000), (1500, 1500, 1500, 2000)), + ], +) +def test_config_init_field_sizes_the_site(runner, field, extent, area): + from dotbot.site import site_from_config + + with runner.isolated_filesystem(): + _init(runner, "--field", field) + site = site_from_config(load_config("dotbot.toml"), "default") + assert site.extent_mm == extent + f = site.field + assert (f.x, f.y, f.w, f.h) == area + staging = site.areas["staging"] + assert (staging.y, staging.w) == (f.y_max, f.w) + + +def test_config_init_warns_about_coverage_only_on_a_large_field(runner): + with runner.isolated_filesystem(): + assert "Warning" not in _init(runner, "--field", "5m").output + assert "one LH2 base station" in _init(runner, "--field", "6000x6000").output + + +def test_config_init_refuses_a_bare_metre_value(runner): + with runner.isolated_filesystem(): + result = runner.invoke(cli, ["config", "init", "--field", "1.5"]) + assert result.exit_code != 0 + assert "did you mean 1.5m?" in result.output + assert not Path("dotbot.toml").exists() + + +def test_config_init_names_the_site(runner): + with runner.isolated_filesystem(): + _init(runner, "--site", "demo-dcoss-2026") + loaded = load_config("dotbot.toml") + assert loaded.site == "demo-dcoss-2026" + assert set(loaded.sites) == {"demo-dcoss-2026"} + + +def test_config_init_refuses_a_site_name_toml_cannot_hold(runner): + with runner.isolated_filesystem(): + result = runner.invoke(cli, ["config", "init", "--site", "my lab"]) + assert result.exit_code != 0 + assert "--site" in result.output + + +def test_config_init_global_writes_the_default_site(runner, tmp_path, monkeypatch): + import dotbot.cli.config_cmd as ccmd + + user = tmp_path / "home" / ".dotbot" / "config.toml" + monkeypatch.setattr(ccmd, "USER_CONFIG_PATH", user) + with runner.isolated_filesystem(): + _init(runner, "--global") + assert "default" in load_config(user).sites + diff --git a/dotbot/tests/test_cli_helpers.py b/dotbot/tests/test_cli_helpers.py index c7f271eb..e4227255 100644 --- a/dotbot/tests/test_cli_helpers.py +++ b/dotbot/tests/test_cli_helpers.py @@ -228,10 +228,10 @@ def test_config_init_writes_valid_starter(runner): assert result.exit_code == 0, result.output written = Path("dotbot.toml") assert written.is_file() - # The starter is all-commented, so it loads as a valid empty config. loaded = cfg.load_config(written) assert loaded.conn is None assert loaded.deployment == {} + assert loaded.site == "default" def test_config_init_refuses_overwrite_without_force(runner): From d2c3bdb35bda04fc3c17c8542677c6f58c8e06ab Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Tue, 29 Sep 2026 11:40:31 +0200 Subject: [PATCH 08/37] dotbot.example.toml: replace config_sample.toml with what config init writes AI-assisted: Claude Opus 5.5 --- config_sample.toml | 27 --------------------------- dotbot.example.toml | 13 +++++++++++++ dotbot/examples/motions/README.md | 11 +++++++++-- dotbot/tests/test_cli_config.py | 7 +++++++ 4 files changed, 29 insertions(+), 29 deletions(-) delete mode 100644 config_sample.toml create mode 100644 dotbot.example.toml 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/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/examples/motions/README.md b/dotbot/examples/motions/README.md index 9c315b0e..990a9aa1 100644 --- a/dotbot/examples/motions/README.md +++ b/dotbot/examples/motions/README.md @@ -18,12 +18,19 @@ This example moves a single DotBot through a predefined motion: either a geometr ## How to run (default: simulator) -### 1. Start the controller +### 1. Start the simulator + +In an empty folder, write a config holding a default site (a 2 x 2 m field), +then start the simulator from that folder; it opens the console in your +browser: ```bash -dotbot-controller --config-path config_sample.toml -a dotbot-simulator +dotbot config init +dotbot run simulator ``` +`dotbot.example.toml`, in the repository root, is the same file. + ### 2. Run a motion From the `PyDotBot/` root in a new terminal: diff --git a/dotbot/tests/test_cli_config.py b/dotbot/tests/test_cli_config.py index 0fb3ff92..3484ba1f 100644 --- a/dotbot/tests/test_cli_config.py +++ b/dotbot/tests/test_cli_config.py @@ -248,3 +248,10 @@ def test_config_init_global_writes_the_default_site(runner, tmp_path, monkeypatc _init(runner, "--global") assert "default" in load_config(user).sites + +def test_example_config_is_what_init_writes(runner): + """The example config in the repository root is what `config init` writes.""" + example = Path(__file__).parents[2] / "dotbot.example.toml" + with runner.isolated_filesystem(): + _init(runner) + assert example.read_text() == Path("dotbot.toml").read_text() From b9c90e29d08257eb3f8d6875fff4de91d39d4636 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Tue, 29 Sep 2026 11:43:58 +0200 Subject: [PATCH 09/37] dotbot/site: add the staging area and the field-or-fallback reader AI-assisted: Claude Opus 5.5 --- dotbot/dotbot_simulator.py | 14 +++----------- dotbot/site.py | 19 +++++++++++++++++++ dotbot/tests/test_cli_site.py | 31 ++++++++++++++++++++++++++++++- 3 files changed, 52 insertions(+), 12 deletions(-) diff --git a/dotbot/dotbot_simulator.py b/dotbot/dotbot_simulator.py index 71d1522c..14239886 100644 --- a/dotbot/dotbot_simulator.py +++ b/dotbot/dotbot_simulator.py @@ -42,7 +42,7 @@ FleetPlant, battery_discharge_model, ) -from dotbot.site import Site +from dotbot.site import Site, field_or_fallback SIMULATOR_STEP_DELTA_T = 0.01 # one app tick, 10 ms @@ -62,8 +62,6 @@ FLEET_PITCH_MM = 200 # Headings of a generated fleet's two halves, 0 facing +y (down) FLEET_FACING_UP, FLEET_FACING_DOWN = 180, 0 -# The square a site that measures neither an extent nor an area falls back to. -PLACEMENT_EXTENT_DEFAULT_MM = 2000 # Feature order must match utils/sim_to_real/train_gru.py FEATURE_COLS GRU_FEATURE_COLS = [ @@ -216,14 +214,8 @@ def resolve_init_state_path(path: str) -> str: def placement_area(site: Optional[Site] = None) -> Area: - """The rectangle a fleet is spread over: the site's field (`Site.field`), - else a 2 x 2 m square at the frame origin for a site that declares nothing. - """ - area = site.field if site is not None else None - if area is not None: - return area - side = PLACEMENT_EXTENT_DEFAULT_MM - return Area(0, 0, side, side) + """The rectangle a fleet is spread over (`field_or_fallback`).""" + return field_or_fallback(site) def _grid_shape(count: int) -> Tuple[int, int]: diff --git a/dotbot/site.py b/dotbot/site.py index 134479e4..56f99b2d 100644 --- a/dotbot/site.py +++ b/dotbot/site.py @@ -22,6 +22,9 @@ from dotbot.area import Area, AreaRegistry, area_role SITE_DEFAULT = "default" +# The side of the square, at the frame origin, a site that declares nothing +# works in. +FIELD_FALLBACK_MM = 2000 @dataclass @@ -81,11 +84,27 @@ def field(self) -> Area | None: return None return Area(0, 0, extent.w, extent.h, f"0,0,{extent.w},{extent.h}") + @property + def staging(self) -> Area | None: + """Where robots park and charge: the first area whose role is `staging`.""" + for area in self.areas.values(): + if area_role(area.name, area.role) == "staging": + return area + return None + def registry(self) -> AreaRegistry: """The resolver `--points` runs against.""" return AreaRegistry(named=dict(self.areas), site=self.name) +def field_or_fallback(site: Site | None) -> Area: + """The site's field, else a `FIELD_FALLBACK_MM` square at the frame origin.""" + area = site.field if site is not None else None + if area is not None: + return area + return Area(0, 0, FIELD_FALLBACK_MM, FIELD_FALLBACK_MM) + + def site_from_config(config: Any, name: str) -> Site: """The `[sites.]` table of a loaded config, else an empty site. diff --git a/dotbot/tests/test_cli_site.py b/dotbot/tests/test_cli_site.py index 48f26d17..2077c42a 100644 --- a/dotbot/tests/test_cli_site.py +++ b/dotbot/tests/test_cli_site.py @@ -9,9 +9,16 @@ import pytest +from dotbot.area import Area from dotbot.cli._site import resolve_site_name from dotbot.config import load_config_text, select_deployment -from dotbot.site import SITE_DEFAULT, Site, site_from_config +from dotbot.site import ( + FIELD_FALLBACK_MM, + SITE_DEFAULT, + Site, + field_or_fallback, + site_from_config, +) def test_no_config_falls_back_to_a_neutral_package_site(): @@ -146,3 +153,25 @@ def test_a_site_with_no_areas_takes_its_extent_as_the_field(): def test_a_site_that_declares_nothing_has_no_field(): assert Site().field is None + + +def test_staging_is_the_first_staging_area(): + site = Site( + areas={ + "field": Area(0, 0, 10, 10, "field", "field"), + "dock": Area(0, 10, 10, 5, "dock", "staging"), + "staging": Area(0, 15, 10, 5, "staging", "staging"), + } + ) + assert site.staging.name == "dock" + assert Site(areas={"field": Area(0, 0, 1, 1, "field", "field")}).staging is None + + +def test_field_or_fallback(): + assert ( + field_or_fallback(_site("field = { x = 1, y = 1, w = 10, h = 10 }\n")).name + == "field" + ) + fallback = Area(0, 0, FIELD_FALLBACK_MM, FIELD_FALLBACK_MM) + assert field_or_fallback(Site()) == fallback + assert field_or_fallback(None) == fallback From 70c1a86bcce48914445a72b139d35091761757c7 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Tue, 29 Sep 2026 11:43:58 +0200 Subject: [PATCH 10/37] dotbot/examples: place motions, charging and naming game from the site Breaking for scripts that call the examples: motions drops --arena-size for --area (default: the controller's field), and the charging example refuses a site with no staging area. AI-assisted: Claude Opus 5.5 --- .../charging_station/charging_station.py | 130 ++++++++++++------ .../controller_with_motion.py | 7 +- .../minimum_naming_game_with_motion.py | 7 +- .../minimum_naming_game/walk_avoid.py | 23 ++-- dotbot/examples/motions/README.md | 15 +- dotbot/examples/motions/motions.py | 104 ++++++++------ dotbot/tests/test_examples_site.py | 47 +++++++ .../tests/test_experiment_charging_station.py | 60 ++++++-- 8 files changed, 270 insertions(+), 123 deletions(-) create mode 100644 dotbot/tests/test_examples_site.py diff --git a/dotbot/examples/charging_station/charging_station.py b/dotbot/examples/charging_station/charging_station.py index ab44c215..dc71be50 100644 --- a/dotbot/examples/charging_station/charging_station.py +++ b/dotbot/examples/charging_station/charging_station.py @@ -1,6 +1,11 @@ +"""Queue robots on the border between the field and staging, charge them one +at a time at a charger on staging's far edge, then park them along the field's +opposite edge. Every position comes from the controller's site.""" + import asyncio import math import os +from dataclasses import dataclass from typing import Dict, List from dotbot.examples.common.orca import ( @@ -22,6 +27,7 @@ ) from dotbot.protocol import ApplicationType from dotbot.rest import RestClient, rest_client +from dotbot.site import Site from dotbot.websocket import DotBotWsClient THRESHOLD = 100 # Acceptable distance error to consider a waypoint reached @@ -31,21 +37,59 @@ BOT_RADIUS = 60 # Physical radius of a DotBot (unit), used for collision avoidance MAX_SPEED = 300 # Maximum allowed linear speed of a bot (mm/s) -CHARGER_X, CHARGER_Y = ( - 500, - 500, -) - -QUEUE_HEAD_X, QUEUE_HEAD_Y = ( - 500, - 1500, -) # World-frame (X, Y) position of the charging queue head -QUEUE_SPACING = ( - 300 # Spacing between consecutive bots in the charging queue (along X axis) -) - -PARK_X, PARK_Y = (1700, 500) # World-frame (X, Y) position of the parking area origin -PARK_SPACING = 300 # Spacing between parked bots (along Y axis) +QUEUE_HEAD_INSET = 500 # From staging's left edge to the queue head and charger +CHARGER_INSET = 100 # From staging's far edge to the charger +QUEUE_SPACING = 300 # Between consecutive bots in the queue, along x +PARK_INSET = 300 # From the field's edges to the first parking slot +PARK_SPACING = 300 # Between parked bots, along x +DISENGAGE_DISTANCE = 400 # How far a charged bot reverses off the charger + + +@dataclass(frozen=True) +class ChargingLayout: + """Where the queue, the charger and the parking row are, in frame mm. + + `away` is the sign of y a bot reverses along to leave the charger. + """ + + charger_x: int + charger_y: int + queue_head_x: int + queue_head_y: int + park_x: int + park_y: int + away: int + + +def layout_from_site(site: Site) -> ChargingLayout: + """The layout for a site with a field and a staging area above or below it.""" + field, staging = site.field, site.staging + if field is None or staging is None: + raise ValueError( + f"site {site.name!r} needs a field and a staging area; " + "`dotbot config init` writes a site with both" + ) + below = staging.centre[1] >= field.centre[1] + head_x = staging.x + min(QUEUE_HEAD_INSET, staging.w // 4) + if below: + return ChargingLayout( + charger_x=head_x, + charger_y=staging.y_max - CHARGER_INSET, + queue_head_x=head_x, + queue_head_y=staging.y, + park_x=field.x + PARK_INSET, + park_y=field.y + PARK_INSET, + away=-1, + ) + return ChargingLayout( + charger_x=head_x, + charger_y=staging.y + CHARGER_INSET, + queue_head_x=head_x, + queue_head_y=staging.y_max, + park_x=field.x + PARK_INSET, + park_y=field.y_max - PARK_INSET, + away=1, + ) async def queue_robots( @@ -53,9 +97,12 @@ async def queue_robots( ws: DotBotWsClient, dotbots: List[DotBotModel], params: OrcaParams, + layout: ChargingLayout, ) -> None: - sorted_bots = order_bots(dotbots, QUEUE_HEAD_X, QUEUE_HEAD_Y) - goals = assign_queue_goals(sorted_bots, QUEUE_HEAD_X, QUEUE_HEAD_Y, QUEUE_SPACING) + sorted_bots = order_bots(dotbots, layout.queue_head_x, layout.queue_head_y) + goals = assign_queue_goals( + sorted_bots, layout.queue_head_x, layout.queue_head_y, QUEUE_SPACING + ) await send_to_goal(client, ws, goals, params) @@ -69,9 +116,10 @@ async def charge_robots( client: RestClient, ws: DotBotWsClient, params: OrcaParams, + layout: ChargingLayout, ) -> None: dotbots = await fetch_active_dotbots(client) - remaining = order_bots(dotbots, QUEUE_HEAD_X, QUEUE_HEAD_Y) + remaining = order_bots(dotbots, layout.queue_head_x, layout.queue_head_y) total_count = len(dotbots) # The head of the remaining should park # Except on the first loop, where it should just queue. @@ -82,17 +130,15 @@ async def charge_robots( dotbots = await fetch_active_dotbots(client) dotbots = [b for b in dotbots if b.address in {r.address for r in remaining}] - remaining = order_bots(dotbots, QUEUE_HEAD_X, QUEUE_HEAD_Y) + remaining = order_bots(dotbots, layout.queue_head_x, layout.queue_head_y) # Assign charging + shift goals - goals = assign_charge_goals( - remaining, QUEUE_HEAD_X, QUEUE_HEAD_Y, QUEUE_SPACING - ) + goals = assign_charge_goals(remaining, layout) if park_dotbot is not None: goals[park_dotbot.address] = { - "x": PARK_X, - "y": PARK_Y + parked_count * PARK_SPACING, + "x": layout.park_x + parked_count * PARK_SPACING, + "y": layout.park_y, } await send_to_goal(client, ws, goals, params) @@ -117,7 +163,7 @@ async def charge_robots( await asyncio.sleep(10 * DT) # Reverse slightly to disengage the robot from the charging station - await disengage_from_charger(client, head.address) + await disengage_from_charger(client, head.address, layout.away) parked_count = total_count - len(remaining) @@ -127,23 +173,22 @@ async def charge_robots( remaining = remaining[1:] -async def disengage_from_charger(client: RestClient, dotbot_address: str): +async def disengage_from_charger(client: RestClient, dotbot_address: str, away: int): + """Reverse `DISENGAGE_DISTANCE` along `away` (a sign of y), then nudge forward.""" bots = await client.fetch_dotbots(query=DotBotQueryModel(address=dotbot_address)) if not bots: return dotbot = bots[0] initial_y = dotbot.lh2_position.y - # reverse until 400 units below initial position - y_after_reverse = initial_y + 400 - # forward a bit to recover direction - y_after_forward = y_after_reverse - 10 + def travelled(bot: DotBotModel) -> float: + return (bot.lh2_position.y - initial_y) * away while True: bots = await client.fetch_dotbots( query=DotBotQueryModel(address=dotbot_address) ) - if not bots or bots[0].lh2_position.y >= y_after_reverse: + if not bots or travelled(bots[0]) >= DISENGAGE_DISTANCE: break await client.send_move_raw_command( address=dotbot_address, @@ -158,7 +203,7 @@ async def disengage_from_charger(client: RestClient, dotbot_address: str): bots = await client.fetch_dotbots( query=DotBotQueryModel(address=dotbot_address) ) - if not bots or bots[0].lh2_position.y <= y_after_forward: + if not bots or travelled(bots[0]) <= DISENGAGE_DISTANCE - 10: break await client.send_move_raw_command( address=dotbot_address, @@ -268,10 +313,7 @@ def assign_queue_goals( def assign_charge_goals( - ordered: List[DotBotModel], - base_x: int, - base_y: int, - spacing: int, + ordered: List[DotBotModel], layout: ChargingLayout ) -> Dict[str, dict]: if len(ordered) == 0: return {} @@ -280,15 +322,15 @@ def assign_charge_goals( # Send the first one to the charger head = ordered[0] goals[head.address] = { - "x": CHARGER_X, - "y": CHARGER_Y, + "x": layout.charger_x, + "y": layout.charger_y, } # Remaining bots shift left in the queue for i, bot in enumerate(ordered[1:]): goals[bot.address] = { - "x": base_x + i * spacing, - "y": base_y, + "x": layout.queue_head_x + i * QUEUE_SPACING, + "y": layout.queue_head_y, } return goals @@ -344,6 +386,10 @@ async def main() -> None: port = os.getenv("DOTBOT_CONTROLLER_PORT", "8000") use_https = os.getenv("DOTBOT_CONTROLLER_USE_HTTPS", False) async with rest_client(url, port, use_https) as client: + try: + layout = layout_from_site(await client.fetch_site()) + except ValueError as exc: + raise SystemExit(str(exc)) from exc dotbots = await fetch_active_dotbots(client) ws = DotBotWsClient(url, port) @@ -365,10 +411,10 @@ async def main() -> None: ) # Phase 1: initial queue - await queue_robots(client, ws, dotbots, params) + await queue_robots(client, ws, dotbots, params, layout) # Phase 2: charging loop - await charge_robots(client, ws, params) + await charge_robots(client, ws, params, layout) except (asyncio.CancelledError, KeyboardInterrupt): active_dotbots = await fetch_active_dotbots(client) for dotbot in active_dotbots: diff --git a/dotbot/examples/minimum_naming_game/controller_with_motion.py b/dotbot/examples/minimum_naming_game/controller_with_motion.py index d47cbaa3..d4418451 100644 --- a/dotbot/examples/minimum_naming_game/controller_with_motion.py +++ b/dotbot/examples/minimum_naming_game/controller_with_motion.py @@ -1,6 +1,7 @@ import math import random +from dotbot.area import Area from dotbot.examples.common.sct import SCT from dotbot.examples.minimum_naming_game.walk_avoid import walk_avoid from dotbot.models import ( @@ -26,11 +27,11 @@ def __init__( address: str, path: str, max_speed: float, - arena_limits: tuple[float, float], + area: Area, ): self.address = address self.max_speed = max_speed - self.arena_limits = arena_limits + self.area = area self.position = DotBotLH2Position(x=0.0, y=0.0) # initial position self.direction = 0.0 # initial orientation @@ -80,7 +81,7 @@ def control_step(self): self.direction, self.neighbors, self.max_speed, - self.arena_limits, + self.area, ) # print(f'DotBot {self.address} Walk Vector: {self.vector}') diff --git a/dotbot/examples/minimum_naming_game/minimum_naming_game_with_motion.py b/dotbot/examples/minimum_naming_game/minimum_naming_game_with_motion.py index eae65957..4b48c4d6 100644 --- a/dotbot/examples/minimum_naming_game/minimum_naming_game_with_motion.py +++ b/dotbot/examples/minimum_naming_game/minimum_naming_game_with_motion.py @@ -19,6 +19,7 @@ ) from dotbot.protocol import ApplicationType from dotbot.rest import RestClient, rest_client +from dotbot.site import field_or_fallback from dotbot.websocket import DotBotWsClient COMM_RANGE = 250 @@ -28,9 +29,6 @@ BOT_RADIUS = 60 # Physical radius of a DotBot (unit), used for collision avoidance MAX_SPEED = 300 # Maximum allowed linear speed of a bot (mm/s) -ARENA_SIZE_X = 2000 # Width of the arena in mm -ARENA_SIZE_Y = 2000 # Height of the arena in mm - dotbot_controllers = dict() @@ -49,6 +47,7 @@ async def main() -> None: ) async with rest_client(url, port, use_https) as client: + field = field_or_fallback(await client.fetch_site()) dotbots = await fetch_active_dotbots(client) # print(len(dotbots), "dotbots connected.") @@ -61,7 +60,7 @@ async def main() -> None: dotbot.address, sct_path, 0.9 * MAX_SPEED, - arena_limits=(ARENA_SIZE_X, ARENA_SIZE_Y), + area=field, ) dotbot_controllers[dotbot.address] = controller # print(f'type of controller: {type(controller)} for DotBot {dotbot.address}') diff --git a/dotbot/examples/minimum_naming_game/walk_avoid.py b/dotbot/examples/minimum_naming_game/walk_avoid.py index 32454a30..1f83d76c 100644 --- a/dotbot/examples/minimum_naming_game/walk_avoid.py +++ b/dotbot/examples/minimum_naming_game/walk_avoid.py @@ -1,5 +1,6 @@ import math +from dotbot.area import Area from dotbot.models import DotBotModel @@ -9,11 +10,10 @@ def walk_avoid( direction: float, neighbors: list[DotBotModel], max_speed: float, - arena_limits: tuple[float, float], + area: Area, ) -> list[float]: """ - Walk straight while avoiding collisions and arena boundary. - Arena limits: x, y in [0.0, 1.0] + Walk straight while avoiding collisions and the edges of `area`. """ UNIT_SPEED = max_speed MARGIN = 0.1 # Trigger turn when within 10% of any edge @@ -23,15 +23,14 @@ def walk_avoid( if neighbors: neighbor_collision = True - # 2. Identify if any arena boundary is violated + # 2. Identify if any edge of the area is violated curr_x = position_x curr_y = position_y + x_low, x_high = area.x + MARGIN * area.w, area.x_max - MARGIN * area.w + y_low, y_high = area.y + MARGIN * area.h, area.y_max - MARGIN * area.h wall_collision = ( - curr_x < MARGIN * arena_limits[0] - or curr_x > (arena_limits[0] - MARGIN * arena_limits[0]) - or curr_y < MARGIN * arena_limits[1] - or curr_y > (arena_limits[1] - MARGIN * arena_limits[1]) + curr_x < x_low or curr_x > x_high or curr_y < y_low or curr_y > y_high ) # 3. Determine "Local" movement @@ -41,13 +40,13 @@ def walk_avoid( if wall_collision: # Decide direction of repulsion (Left or Right) - if curr_x < MARGIN * arena_limits[0]: + if curr_x < x_low: local_v[0] += UNIT_SPEED - if curr_x > (arena_limits[0] - MARGIN * arena_limits[0]): + if curr_x > x_high: local_v[0] += -UNIT_SPEED - if curr_y < MARGIN * arena_limits[1]: + if curr_y < y_low: local_v[1] += UNIT_SPEED - if curr_y > (arena_limits[1] - MARGIN * arena_limits[1]): + if curr_y > y_high: local_v[1] += -UNIT_SPEED if neighbor_collision: diff --git a/dotbot/examples/motions/README.md b/dotbot/examples/motions/README.md index 990a9aa1..6ee58ff2 100644 --- a/dotbot/examples/motions/README.md +++ b/dotbot/examples/motions/README.md @@ -7,11 +7,11 @@ This example moves a single DotBot through a predefined motion: either a geometr | Name | Type | Description | |---------------|------------|--------------------------------------------------| -| `square` | waypoints | Square path centred in the arena | -| `triangle` | waypoints | Equilateral triangle centred in the arena | -| `circle` | waypoints | Circular path centred in the arena | -| `infinity` | waypoints | Lemniscate (∞) path centred in the arena | -| `sawtooth` | waypoints | Boustrophedon sawtooth sweep across the arena | +| `square` | waypoints | Square path centred in the field | +| `triangle` | waypoints | Equilateral triangle centred in the field | +| `circle` | waypoints | Circular path centred in the field | +| `infinity` | waypoints | Lemniscate (∞) path centred in the field | +| `sawtooth` | waypoints | Boustrophedon sawtooth sweep across the field | | `speed_ramp` | move\_raw | Sinusoidal ramp from `-MAX_SPEED` to `+MAX_SPEED` | | `speed_steps` | move\_raw | Forward/backward motion stepping through discrete speed levels | | `speed_swing` | move\_raw | Alternating ±speed with increasing-then-decreasing magnitude | @@ -40,6 +40,7 @@ python -m dotbot.examples.motions.motions --motion ``` If `--address` is omitted, the script automatically picks the first available DotBot. +Shapes are centred in the controller's field; `--area` picks another area. ## Options @@ -49,7 +50,9 @@ If `--address` is omitted, the script automatically picks the first available Do Motion to execute. [required] -n, --repeat INTEGER Number of times to replay the motion. [default: 1] --scale FLOAT Shape scale in mm. [default: 400] - --arena-size INTEGER Arena size in mm (square arena). [default: 2000] + --area TEXT Area to centre the shapes in: a name, a `+`-joined + composite or x,y,w,h in mm. Defaults to the + controller's field. --num-points INTEGER Number of waypoints for circle and infinity motions. [default: 12] --waypoint-threshold INTEGER Proximity threshold in mm to consider a waypoint reached. Ignored for raw motions. [default: 100] diff --git a/dotbot/examples/motions/motions.py b/dotbot/examples/motions/motions.py index ea3fe164..d5a0a8ba 100644 --- a/dotbot/examples/motions/motions.py +++ b/dotbot/examples/motions/motions.py @@ -30,6 +30,7 @@ from rich.console import Console from rich.table import Table +from dotbot.area import Area from dotbot.models import ( DotBotLH2Position, DotBotMoveRawCommandModel, @@ -40,6 +41,7 @@ ) from dotbot.protocol import ApplicationType, ControlModeType from dotbot.rest import rest_client +from dotbot.site import Site, field_or_fallback from dotbot.websocket import DotBotWsClient # --------------------------------------------------------------------------- @@ -53,9 +55,6 @@ MAX_WAYPOINTS = 12 # maximum number of waypoints per batch (hardware limit) NUM_POINTS_DEFAULT = 12 # default number of waypoints for circle/infinity shapes (can be overridden via CLI) -# Arena size in mm -ARENA_SIZE_DEFAULT = 2000 - # Shape parameters (all distances in mm, matching the controller's coordinate space) SHAPE_SCALE_DEFAULT = 400 # radius / half-size in mm for all shapes WAYPOINT_THRESHOLD_DEFAULT = ( @@ -219,14 +218,14 @@ async def stop(ws: DotBotWsClient, address: str) -> None: # --------------------------------------------------------------------------- -def _center(arena_size: int) -> tuple[int, int]: - """Return the arena center coordinates.""" - return arena_size // 2, arena_size // 2 +def _center(area: Area) -> tuple[float, float]: + """Return the centre of the area the shapes are drawn in.""" + return area.centre -def square_waypoints(scale: float, arena_size: int, _) -> list[dict]: - """Return the 4 corners of a square centered in the arena, closed back to the start.""" - cx, cy = _center(arena_size) +def square_waypoints(scale: float, area: Area, _) -> list[dict]: + """Return the 4 corners of a square centred in the area, closed back to the start.""" + cx, cy = _center(area) h = scale / 2 return [ {"x": round(cx + h), "y": round(cy - h)}, @@ -237,9 +236,9 @@ def square_waypoints(scale: float, arena_size: int, _) -> list[dict]: ] -def triangle_waypoints(scale: float, arena_size: int, _) -> list[dict]: - """Return the 3 vertices of an equilateral triangle centered in the arena.""" - cx, cy = _center(arena_size) +def triangle_waypoints(scale: float, area: Area, _) -> list[dict]: + """Return the 3 vertices of an equilateral triangle centred in the area.""" + cx, cy = _center(area) r = scale points = [] for i in range(3): @@ -255,9 +254,9 @@ def triangle_waypoints(scale: float, arena_size: int, _) -> list[dict]: return points -def circle_waypoints(scale: float, arena_size: int, n_points: int) -> list[dict]: - """Approximate a circle with n_points waypoints centered in the arena.""" - cx, cy = _center(arena_size) +def circle_waypoints(scale: float, area: Area, n_points: int) -> list[dict]: + """Approximate a circle with n_points waypoints centred in the area.""" + cx, cy = _center(area) r = scale / 2 points = [] for i in range(n_points + 1): @@ -271,11 +270,11 @@ def circle_waypoints(scale: float, arena_size: int, n_points: int) -> list[dict] return points -def sawtooth_waypoints(scale: float, arena_size: int, _) -> list[dict]: +def sawtooth_waypoints(scale: float, area: Area, _) -> list[dict]: """ - Boustrophedon sawtooth sweep centered in the arena. + Boustrophedon sawtooth sweep centred in the area. - The robot sweeps left→right across 80% of the arena width with 4 teeth, + The robot sweeps left→right across 80% of the area's width with 4 teeth, then takes a single vertical step at the right edge, then sweeps right→left with teeth interleaved with the forward sweep. `scale` sets the peak-to-valley height of each tooth. @@ -295,8 +294,8 @@ def sawtooth_waypoints(scale: float, arena_size: int, _) -> list[dict]: V V V V V (y_low, x = 4t, 3t, 2t, t, 0) """ N_TEETH = 4 - cx, cy = _center(arena_size) - width = 0.8 * arena_size + cx, cy = _center(area) + width = 0.8 * area.w left_x = round(cx - width / 2) right_x = round(cx + width / 2) tooth_w = width / N_TEETH @@ -328,15 +327,15 @@ def sawtooth_waypoints(scale: float, arena_size: int, _) -> list[dict]: return points -def infinity_waypoints(scale: float, arena_size: int, n_points: int) -> list[dict]: +def infinity_waypoints(scale: float, area: Area, n_points: int) -> list[dict]: """ - Approximate a lemniscate of Bernoulli (infinity symbol) centered in the arena. + Approximate a lemniscate of Bernoulli (infinity symbol) centred in the area. Parametric form: x(t) = a * cos(t) / (1 + sin²(t)) y(t) = a * sin(t) * cos(t) / (1 + sin²(t)) n_points is kept low to avoid threshold-area overlaps near the crossing point. """ - cx, cy = _center(arena_size) + cx, cy = _center(area) a = scale / 2 # scale is the total width of the shape points = [] for i in range(n_points + 1): @@ -475,7 +474,7 @@ async def run_motion( address: str, motion_name: str, scale: float, - arena_size: int, + area: Area, num_points: int, waypoint_threshold: int = WAYPOINT_THRESHOLD_DEFAULT, duration: float = SPEED_PROFILE_DURATION_DEFAULT, @@ -492,7 +491,7 @@ async def run_motion( await ws.connect() try: if kind == "waypoints": - waypoints = fn(scale, arena_size, num_points) + waypoints = fn(scale, area, num_points) if reverse: waypoints = list(reversed(waypoints)) table = Table( @@ -514,6 +513,16 @@ async def run_motion( await ws.close() +def motion_area(site: Site, spec: str | None = None) -> Area: + """The area shapes are centred in: `spec` resolved in the site, else its field.""" + if spec is None: + return field_or_fallback(site) + try: + return site.registry().resolve(spec) + except ValueError as exc: + raise click.BadParameter(str(exc), param_hint="'--area'") from exc + + async def run_async( host, port, @@ -521,24 +530,32 @@ async def run_async( motion, repeat, scale, - arena_size, + area_spec, num_points, waypoint_threshold, duration, interval, reverse, ): - if address is None: - rprint("[yellow]No address provided — fetching available DotBots ...[/yellow]") - async with rest_client(host, port, False) as client: - dotbots = await client.fetch_dotbots() - if not dotbots: + async with rest_client(host, port, False) as client: + site = await client.fetch_site() + if address is None: rprint( - "[bold red]ERROR:[/bold red] No DotBots found. Is the controller running?" + "[yellow]No address provided — fetching available DotBots ...[/yellow]" ) - return - address = dotbots[0].address - rprint(f" Using first available DotBot: [bold cyan]{address}[/bold cyan]") + dotbots = await client.fetch_dotbots() + if not dotbots: + rprint( + "[bold red]ERROR:[/bold red] No DotBots found. Is the controller running?" + ) + return + address = dotbots[0].address + rprint(f" Using first available DotBot: [bold cyan]{address}[/bold cyan]") + area = motion_area(site, area_spec) + rprint( + f" Shapes centred in [bold]{area.name or 'the site'}[/bold] " + f"({area.w} x {area.h} mm at {area.x}, {area.y})" + ) for i in range(repeat): if repeat > 1: rprint(f"\n [bold]Iteration {i + 1}/{repeat}[/bold]") @@ -548,7 +565,7 @@ async def run_async( address, motion, scale, - arena_size, + area, num_points, waypoint_threshold, duration, @@ -607,11 +624,12 @@ async def run_async( help="Number of times to replay the motion.", ) @click.option( - "--arena-size", - type=int, - default=ARENA_SIZE_DEFAULT, - show_default=True, - help="Arena size in mm (square arena).", + "--area", + "area_spec", + type=str, + default=None, + help="Area to centre the shapes in: a name, a `+`-joined composite or " + "x,y,w,h in mm. Defaults to the controller's field.", ) @click.option( "--num-points", @@ -654,7 +672,7 @@ def main( motion, repeat, scale, - arena_size, + area_spec, num_points, waypoint_threshold, duration, @@ -670,7 +688,7 @@ def main( motion, repeat, scale, - arena_size, + area_spec, num_points, waypoint_threshold, duration, diff --git a/dotbot/tests/test_examples_site.py b/dotbot/tests/test_examples_site.py new file mode 100644 index 00000000..9f6a4e29 --- /dev/null +++ b/dotbot/tests/test_examples_site.py @@ -0,0 +1,47 @@ +# SPDX-FileCopyrightText: 2026-present Inria +# SPDX-License-Identifier: BSD-3-Clause + +"""The examples place their shapes and bounds from the controller's site.""" + +import click +import pytest + +from dotbot.area import Area +from dotbot.examples.minimum_naming_game.walk_avoid import walk_avoid +from dotbot.examples.motions.motions import motion_area, square_waypoints +from dotbot.site import Site + +SITE = Site( + name="hall", + extent_mm=(5000, 5000), + areas={ + "field": Area(1500, 1500, 2000, 2000, "field", "field"), + "staging": Area(1500, 3500, 2000, 600, "staging", "staging"), + }, +) + + +def test_motions_centre_on_the_field(): + area = motion_area(SITE) + corners = square_waypoints(400, area, 0) + xs = [p["x"] for p in corners] + ys = [p["y"] for p in corners] + assert (min(xs) + max(xs)) / 2 == 2500 + assert (min(ys) + max(ys)) / 2 == 2500 + + +def test_motions_area_resolves_in_the_site(): + assert motion_area(SITE, "staging").name == "staging" + assert motion_area(SITE, "0,0,1000,1000").centre == (500, 500) + with pytest.raises(click.BadParameter, match="field, staging"): + motion_area(SITE, "nowhere") + + +def test_walk_avoid_turns_back_at_the_edges_of_an_offset_area(): + field = SITE.areas["field"] + # Just inside the field's left edge, which starts 1500 mm from zero. + vx, vy = walk_avoid(1550, 2500, 0, [], 100, field) + assert vx > 0 and vy == 0 + # Mid-field, it walks on as it would with no edge in sight. + free = walk_avoid(2500, 2500, 0, [], 100, Area(0, 0, 5000, 5000)) + assert walk_avoid(2500, 2500, 0, [], 100, field) == free diff --git a/dotbot/tests/test_experiment_charging_station.py b/dotbot/tests/test_experiment_charging_station.py index ec271e03..9bc8241b 100644 --- a/dotbot/tests/test_experiment_charging_station.py +++ b/dotbot/tests/test_experiment_charging_station.py @@ -5,15 +5,13 @@ import pytest +from dotbot.area import Area from dotbot.examples.charging_station.charging_station import ( DT, PARK_SPACING, - PARK_X, - PARK_Y, - QUEUE_HEAD_X, - QUEUE_HEAD_Y, QUEUE_SPACING, charge_robots, + layout_from_site, queue_robots, ) from dotbot.examples.common.orca import OrcaParams @@ -27,9 +25,46 @@ WSMessage, ) from dotbot.protocol import ApplicationType +from dotbot.site import Site MOVE_RAW_SCALE = 10 # displacement per raw move step +# A 2 x 2 m field with a 2 x 2 m staging area below it. +C405 = Site( + name="c405", + areas={ + "field": Area(0, 0, 2000, 2000, "field", "field"), + "staging": Area(0, 2000, 2000, 2000, "staging", "staging"), + }, +) +LAYOUT = layout_from_site(C405) +QUEUE_HEAD_X, QUEUE_HEAD_Y = LAYOUT.queue_head_x, LAYOUT.queue_head_y + + +def test_layout_queues_on_the_border_and_charges_on_the_far_edge(): + assert (LAYOUT.queue_head_x, LAYOUT.queue_head_y) == (500, 2000) + assert (LAYOUT.charger_x, LAYOUT.charger_y) == (500, 3900) + assert (LAYOUT.park_x, LAYOUT.park_y) == (300, 300) + assert LAYOUT.away == -1 + + +def test_layout_with_staging_above_the_field(): + site = Site( + areas={ + "staging": Area(0, 0, 4000, 1000, "staging", "staging"), + "field": Area(0, 1000, 4000, 3000, "field", "field"), + } + ) + layout = layout_from_site(site) + assert (layout.queue_head_y, layout.charger_y, layout.park_y) == (1000, 100, 3700) + assert layout.away == 1 + + +def test_layout_needs_a_staging_area(): + site = Site(name="bare", areas={"field": Area(0, 0, 10, 10, "field", "field")}) + with pytest.raises(ValueError, match="config init"): + layout_from_site(site) + class FakeRestClient: """ @@ -208,7 +243,7 @@ async def test_queue_robots_converges_to_queue_positions(_): await ws.connect() params = OrcaParams(time_horizon=5 * DT, time_step=DT) - await queue_robots(client, ws, bots, params) + await queue_robots(client, ws, bots, params, LAYOUT) # Bots should be ordered A, B, C along the queue expected = { @@ -243,22 +278,21 @@ async def test_charge_robots_moves_all_bots_to_parking(_): await ws.connect() params = OrcaParams(time_horizon=5 * DT, time_step=DT) - await charge_robots(client, ws, params) + await charge_robots(client, ws, params, LAYOUT) # --- Assertions: all bots parked --- # Bots should be ordered A, B, C along the park slots expected = { - "A": PARK_Y + 0 * PARK_SPACING, - "B": PARK_Y + 1 * PARK_SPACING, - "C": PARK_Y + 2 * PARK_SPACING, + "A": LAYOUT.park_x + 0 * PARK_SPACING, + "B": LAYOUT.park_x + 1 * PARK_SPACING, + "C": LAYOUT.park_x + 2 * PARK_SPACING, } - for address, expected_y in expected.items(): + for address, expected_x in expected.items(): bot = client._dotbots[address] - # X, Y coordinate matches queue spacing - assert math.isclose(bot.lh2_position.x, PARK_X, abs_tol=100) - assert math.isclose(bot.lh2_position.y, expected_y, abs_tol=100) + assert math.isclose(bot.lh2_position.x, expected_x, abs_tol=100) + assert math.isclose(bot.lh2_position.y, LAYOUT.park_y, abs_tol=100) # LEDs were used during charging assert len(client.rgb_commands) >= 2 * len(bots) From 854ed225a7d2ea236805d653bfa0051249f32045 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Tue, 29 Sep 2026 11:53:11 +0200 Subject: [PATCH 11/37] dotbot: read sites from site packs found in site_dirs, inline tables first AI-assisted: Claude Opus 5.5 --- dotbot/cli/_site.py | 23 +++++- dotbot/cli/config_cmd.py | 15 +++- dotbot/config.py | 3 + dotbot/site.py | 27 ++++-- dotbot/site_packs.py | 120 +++++++++++++++++++++++++++ dotbot/tests/test_site_packs.py | 140 ++++++++++++++++++++++++++++++++ 6 files changed, 317 insertions(+), 11 deletions(-) create mode 100644 dotbot/site_packs.py create mode 100644 dotbot/tests/test_site_packs.py 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/config_cmd.py b/dotbot/cli/config_cmd.py index 0fe1c1e3..e884ea59 100644 --- a/dotbot/cli/config_cmd.py +++ b/dotbot/cli/config_cmd.py @@ -18,8 +18,9 @@ import click import tomlkit -from dotbot.config import USER_CONFIG_PATH +from dotbot.config import USER_CONFIG_PATH, ConfigError from dotbot.site import SITE_DEFAULT +from dotbot.site_packs import site_catalog _CONFIG_DOCS_URL = ( "https://pydotbot.readthedocs.io/en/latest/reference/configuration.html" @@ -270,7 +271,8 @@ def _prune(value: Any) -> Any: @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. """ @@ -284,6 +286,15 @@ def show(ctx): ) click.echo(f"source: {source}") click.echo(f"deployment: {deployment_name or '(none)'}") + try: + catalog = site_catalog(config, config_path) + except ConfigError as exc: + raise click.ClickException(str(exc)) from exc + 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}") click.echo("") if config is None: diff --git a/dotbot/config.py b/dotbot/config.py index b660a2e3..0c636d11 100644 --- a/dotbot/config.py +++ b/dotbot/config.py @@ -240,6 +240,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/site.py b/dotbot/site.py index 56f99b2d..0b7b9d08 100644 --- a/dotbot/site.py +++ b/dotbot/site.py @@ -9,14 +9,15 @@ of its own - the site's name identifies it, and `anchor` is the prose that re-establishes zero in the physical world. -Sites come from the `[sites.]` tables of a dotbot config file. The -package default is deliberately empty: a real site is measured, never -shipped. +Sites come from the `[sites.]` tables of a dotbot config file, or from +site packs (`dotbot.site_packs`). The package default is deliberately empty: +a real site is measured, never shipped. """ from __future__ import annotations from dataclasses import dataclass, field +from pathlib import Path from typing import Any from dotbot.area import Area, AreaRegistry, area_role @@ -25,6 +26,8 @@ # The side of the square, at the frame origin, a site that declares nothing # works in. FIELD_FALLBACK_MM = 2000 +# A site pack's folder of calibration files +PACK_CALIBRATIONS = "calibrations" @dataclass @@ -34,13 +37,14 @@ class Site: `extent_mm` is (width, height) in millimetres with zero at its top-left corner, which is the site's anchor. A calibration file records only `name` and `anchor`, so a site read back from one carries no extent and - no areas. + no areas. `pack` is the site pack folder the site was read from, if any. """ name: str = SITE_DEFAULT anchor: str = "" extent_mm: tuple[int, int] | None = None areas: dict[str, Area] = field(default_factory=dict) + pack: Path | None = None @property def extent(self) -> Area | None: @@ -84,6 +88,11 @@ def field(self) -> Area | None: return None return Area(0, 0, extent.w, extent.h, f"0,0,{extent.w},{extent.h}") + @property + def pack_calibrations(self) -> Path | None: + """The pack's calibration folder, looked in before the home one.""" + return self.pack / PACK_CALIBRATIONS if self.pack is not None else None + @property def staging(self) -> Area | None: """Where robots park and charge: the first area whose role is `staging`.""" @@ -112,12 +121,18 @@ def site_from_config(config: Any, name: str) -> Site: pydantic model. """ tables = getattr(config, "sites", None) or {} - table = tables.get(name) + return site_from_table(name, tables.get(name)) + + +def site_from_table(name: str, table: Any, pack: Path | None = None) -> Site: + """A site from one `[sites.]` table or pack `site.toml`; None is an + empty site.""" if table is None: - return Site(name=name) + return Site(name=name, pack=pack) extent = getattr(table, "extent_mm", None) return Site( name=name, + pack=pack, anchor=getattr(table, "anchor", None) or "", extent_mm=(int(extent[0]), int(extent[1])) if extent else None, areas={ diff --git a/dotbot/site_packs.py b/dotbot/site_packs.py new file mode 100644 index 00000000..74355468 --- /dev/null +++ b/dotbot/site_packs.py @@ -0,0 +1,120 @@ +# SPDX-FileCopyrightText: 2026-present Inria +# SPDX-License-Identifier: BSD-3-Clause + +"""Site packs: a site as a folder you can copy, commit or share. + +A pack is a folder named after its site holding `site.toml`, the keys a +`[sites.]` table holds, and optionally `calibrations/`, the site's LH2 +and camera files under their usual names. Packs are found in the `site_dirs` +folders, in order; an inline `[sites.]` table wins over a pack of the +same name. +""" + +from __future__ import annotations + +import tomllib +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from pydantic import ValidationError + +from dotbot.config import ConfigError, SiteSection +from dotbot.site import Site, site_from_table + +PACK_FILE = "site.toml" +SITE_DIRS_DEFAULT = ("sites", "~/.dotbot/sites") + + +def user_sites_dir() -> Path: + """Where `dotbot site add` puts packs.""" + return Path.home() / ".dotbot" / "sites" + + +@dataclass(frozen=True) +class SiteEntry: + """One site the config can name, and where it was read from. + + `pack` is the pack folder, None for an inline table; `shadows` is the pack + an inline table of the same name hides. + """ + + name: str + table: SiteSection + pack: Path | None = None + shadows: Path | None = None + + @property + def source(self) -> str: + if self.pack is not None: + return str(self.pack) + return "inline" + (f", shadowing {self.shadows}" if self.shadows else "") + + def site(self) -> Site: + return site_from_table(self.name, self.table, self.pack) + + +def site_dirs(config: Any, config_path: Path | None) -> list[Path]: + """The folders searched for packs; relative ones from the config's folder.""" + entries = getattr(config, "site_dirs", None) + if entries is None: + entries = SITE_DIRS_DEFAULT + base = config_path.parent if config_path is not None else Path.cwd() + folders = [] + for entry in entries: + folder = Path(entry).expanduser() + folders.append(folder if folder.is_absolute() else base / folder) + return folders + + +def find_packs(folders: list[Path]) -> dict[str, Path]: + """Every pack in `folders`, by site name; the first folder wins a clash.""" + packs: dict[str, Path] = {} + for folder in folders: + if not folder.is_dir(): + continue + for candidate in sorted(folder.iterdir()): + if (candidate / PACK_FILE).is_file(): + packs.setdefault(candidate.name, candidate) + return packs + + +def read_pack(folder: Path) -> SiteSection: + """A pack's `site.toml`, held to the same schema as an inline table.""" + path = folder / PACK_FILE + try: + with open(path, "rb") as handle: + data = tomllib.load(handle) + except (OSError, tomllib.TOMLDecodeError) as exc: + raise ConfigError(f"could not read site pack {path}: {exc}") from exc + try: + return SiteSection.model_validate(data) + except ValidationError as exc: + raise ConfigError(f"invalid site pack {path}:\n{exc}") from exc + + +def site_catalog(config: Any, config_path: Path | None) -> dict[str, SiteEntry]: + """Every site the config can name: its inline tables, then its packs.""" + packs = find_packs(site_dirs(config, config_path)) + inline = getattr(config, "sites", None) or {} + catalog = { + name: SiteEntry(name, table, shadows=packs.get(name)) + for name, table in inline.items() + } + for name, folder in packs.items(): + if name not in catalog: + catalog[name] = SiteEntry(name, read_pack(folder), pack=folder) + return catalog + + +def resolve_site_entry( + config: Any, config_path: Path | None, name: str +) -> SiteEntry | None: + """The site `name` names, reading only its own pack; None if unknown.""" + packs = find_packs(site_dirs(config, config_path)) + inline = getattr(config, "sites", None) or {} + if name in inline: + return SiteEntry(name, inline[name], shadows=packs.get(name)) + if name in packs: + return SiteEntry(name, read_pack(packs[name]), pack=packs[name]) + return None diff --git a/dotbot/tests/test_site_packs.py b/dotbot/tests/test_site_packs.py new file mode 100644 index 00000000..97b97c76 --- /dev/null +++ b/dotbot/tests/test_site_packs.py @@ -0,0 +1,140 @@ +# SPDX-FileCopyrightText: 2026-present Inria +# SPDX-License-Identifier: BSD-3-Clause + +"""Site packs: discovery and the inline-wins rule. Every folder is a +temporary one.""" + +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from dotbot.calibration import lighthouse2 +from dotbot.cli.main import cli +from dotbot.config import ConfigError, load_config, load_config_text +from dotbot.site_packs import ( + find_packs, + resolve_site_entry, + site_catalog, + site_dirs, +) +from dotbot.tests.lh2_wire_fixture import FIXTURE_ID, FIXTURE_TOML + +ANCHOR = "arena top-left corner, against the door wall of C405" +CALIBRATION_NAME = f"calibration-2026-09-10T09-12-00Z-{FIXTURE_ID[:8]}.toml" + + +def _pack(folder: Path, name: str, anchor: str = ANCHOR, calibration=False) -> Path: + pack = folder / name + pack.mkdir(parents=True) + (pack / "site.toml").write_text( + f'anchor = "{anchor}"\n' + "extent_mm = [2000, 4000]\n\n" + "[areas]\n" + "field = { x = 0, y = 0, w = 2000, h = 2000 }\n" + "staging = { x = 0, y = 2000, w = 2000, h = 2000 }\n" + ) + if calibration: + (pack / "calibrations").mkdir() + (pack / "calibrations" / CALIBRATION_NAME).write_text(FIXTURE_TOML) + return pack + + +@pytest.fixture +def home(tmp_path, monkeypatch): + """A scratch home: packs, calibrations and the user config live under it.""" + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setattr(lighthouse2, "CALIBRATION_DIR", home / ".dotbot") + monkeypatch.setattr("dotbot.config.USER_CONFIG_PATH", home / "nope.toml") + return home + + +@pytest.fixture +def runner(): + return CliRunner() + + +# --- discovery -------------------------------------------------------------- + + +def test_site_dirs_default_and_relative_entries_read_from_the_config_folder( + tmp_path, home +): + config_path = tmp_path / "lab" / "dotbot.toml" + assert site_dirs(load_config_text(""), config_path) == [ + tmp_path / "lab" / "sites", + home / ".dotbot" / "sites", + ] + config = load_config_text('site_dirs = ["packs", "/abs/packs"]') + assert site_dirs(config, config_path) == [ + tmp_path / "lab" / "packs", + Path("/abs/packs"), + ] + + +def test_the_first_site_dir_wins_a_name_clash(tmp_path): + first = _pack(tmp_path / "a", "c405-arena") + _pack(tmp_path / "b", "c405-arena") + _pack(tmp_path / "b", "aio") + (tmp_path / "b" / "not-a-pack").mkdir() + packs = find_packs([tmp_path / "missing", tmp_path / "a", tmp_path / "b"]) + assert packs == {"c405-arena": first, "aio": tmp_path / "b" / "aio"} + + +def test_a_pack_is_a_site(tmp_path): + pack = _pack(tmp_path / "sites", "c405-arena") + config_path = tmp_path / "dotbot.toml" + entry = resolve_site_entry(load_config_text(""), config_path, "c405-arena") + site = entry.site() + assert (site.name, site.anchor, site.extent_mm) == ( + "c405-arena", + ANCHOR, + (2000, 4000), + ) + assert site.field.name == "field" and site.staging.name == "staging" + assert site.pack == pack + assert entry.source == str(pack) + + +def test_an_inline_table_wins_over_a_pack_and_names_it(tmp_path): + pack = _pack(tmp_path / "sites", "c405-arena") + config = load_config_text( + "[sites.c405-arena.areas]\nfield = { x = 0, y = 0, w = 10, h = 10 }\n" + ) + entry = resolve_site_entry(config, tmp_path / "dotbot.toml", "c405-arena") + assert entry.pack is None and entry.shadows == pack + assert entry.site().field.w == 10 + assert entry.source == f"inline, shadowing {pack}" + + +def test_an_invalid_pack_fails_loud(tmp_path): + pack = _pack(tmp_path / "sites", "broken") + (pack / "site.toml").write_text("extent = [1, 2]\n") + with pytest.raises(ConfigError, match="invalid site pack"): + site_catalog(load_config_text(""), tmp_path / "dotbot.toml") + + +def test_the_active_site_comes_from_a_pack_with_a_notice_when_shadowed( + runner, tmp_path, home +): + _pack(tmp_path / "sites", "c405-arena") + config = tmp_path / "dotbot.toml" + config.write_text('site = "c405-arena"\n') + result = runner.invoke(cli, ["-c", str(config), "config", "show"]) + assert result.exit_code == 0, result.output + assert f"c405-arena {tmp_path / 'sites' / 'c405-arena'}" in result.output + + from dotbot.cli._site import site_from_context + + class Ctx: + obj = {"config": load_config(config), "config_path": config} + + site, _ = site_from_context(Ctx()) + assert site.pack == tmp_path / "sites" / "c405-arena" + + config.write_text('site = "c405-arena"\n[sites.c405-arena]\nanchor = "elsewhere"\n') + Ctx.obj = {"config": load_config(config), "config_path": config} + site, _ = site_from_context(Ctx()) + assert site.anchor == "elsewhere" and site.pack is None From 7c2e6e7826e6a5242e8fc7f5c8f4591f71605575 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Tue, 29 Sep 2026 11:53:19 +0200 Subject: [PATCH 12/37] dotbot/calibration: look in the site pack first, refuse another site's file Breaking: the controller now refuses an LH2 or camera calibration whose recorded site name differs from the active site's, or whose anchor differs when both record one, including a file passed by path. AI-assisted: Claude Opus 5.5 --- dotbot/calibration/lighthouse2.py | 130 ++++++++++++++++++++---------- dotbot/camera/registration.py | 17 ++-- dotbot/cli/swarm_lh2.py | 2 +- dotbot/controller.py | 15 ++-- dotbot/controller_app.py | 4 + dotbot/tests/test_site_packs.py | 42 +++++++++- 6 files changed, 151 insertions(+), 59 deletions(-) diff --git a/dotbot/calibration/lighthouse2.py b/dotbot/calibration/lighthouse2.py index 482a9033..bd00a450 100644 --- a/dotbot/calibration/lighthouse2.py +++ b/dotbot/calibration/lighthouse2.py @@ -23,7 +23,7 @@ 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 @@ -693,13 +693,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", @@ -707,10 +706,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, @@ -718,56 +732,82 @@ 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]: - if len(matches) > 1: - lines = [ - f" {files[path].get('id', '?')} " - f"{files[path].get(created_key, '?')} {path}" - for path in matches - ] - raise ValueError( - f"{what} {kind} {spec!r} matches several files:\n" + "\n".join(lines) - ) - return matches[0] if matches else None - - found = unique( - "tag", - [ - path - for path, data in files.items() - if str(data.get("tag", "")).lower() in {spec_lower, spec_slug} - {""} - ], - ) or unique( - "id prefix", - [ - path - for path, data in files.items() - if str(data.get("id", "")).lower().startswith(spec_lower) - ], + for folder, prefix in folders: + files = {path: metadata(path) for path in sorted(folder.glob(prefix + glob))} + + def unique(kind: str, matches: list) -> Optional[Path]: + if len(matches) > 1: + lines = [ + f" {files[path].get('id', '?')} " + f"{files[path].get(created_key, '?')} {path}" + for path in matches + ] + raise ValueError( + f"{what} {kind} {spec!r} matches several files:\n" + + "\n".join(lines) + ) + return matches[0] if matches else None + + found = unique( + "tag", + [ + path + for path, data in files.items() + if str(data.get("tag", "")).lower() in {spec_lower, spec_slug} - {""} + ], + ) or unique( + "id prefix", + [ + path + for path, data in files.items() + if str(data.get("id", "")).lower().startswith(spec_lower) + ], + ) + 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 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: @@ -780,10 +820,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/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/swarm_lh2.py b/dotbot/cli/swarm_lh2.py index 1b0f06b3..8927b55b 100644 --- a/dotbot/cli/swarm_lh2.py +++ b/dotbot/cli/swarm_lh2.py @@ -443,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/controller.py b/dotbot/controller.py index 488393b4..cd9c20b2 100644 --- a/dotbot/controller.py +++ b/dotbot/controller.py @@ -16,7 +16,7 @@ import time import webbrowser from dataclasses import dataclass -from typing import Callable, Dict, List, Optional, Tuple +from typing import Callable, Dict, List, Optional, Tuple, Union import serial import uvicorn @@ -129,19 +129,20 @@ ) -def load_calibration(spec: str, site: Optional[str] = None): - """The schema 2 calibration `spec` names: a file path or an id prefix. +def load_calibration(spec: str, site: Union[Site, str, None] = None): + """The schema 2 calibration `spec` names: a file path, a tag or an id prefix. Never the newest file on disk: a controller runs on the calibration it was told to run on, so that two bots reporting the same id are known to - carry the same numbers. An id prefix resolves under `site` only. + carry the same numbers. A tag or an id prefix resolves under `site` only, + and a `Site` refuses a file made in another. """ from dotbot.calibration.lighthouse2 import load_calibration as _load return _load(spec, site=site) -def load_camera_calibration(spec: str, site: Optional[str] = None): +def load_camera_calibration(spec: str, site: Union[Site, str, None] = None): """The camera registration `spec` names: a file path or an id prefix.""" from dotbot.camera.registration import load_camera_calibration as _load @@ -295,7 +296,7 @@ def __init__(self, settings: ControllerSettings): self.lh2_calibration = [] if settings.lh2_calibration: self.calibration = load_calibration( - settings.lh2_calibration, site=self.site.name + settings.lh2_calibration, site=self.site ) self.lh2_calibration = self.calibration.stations self.logger.info( @@ -340,7 +341,7 @@ def _start_camera(self, spec: str) -> None: controller without a camera is a missing layer, not a broken console. """ try: - calibration = load_camera_calibration(spec, site=self.site.name) + calibration = load_camera_calibration(spec, site=self.site) except (ValueError, OSError) as exc: self.logger.warning( "Camera calibration not loaded, so no camera layer is served", diff --git a/dotbot/controller_app.py b/dotbot/controller_app.py index 4f85c4b0..c7ea9cdb 100644 --- a/dotbot/controller_app.py +++ b/dotbot/controller_app.py @@ -566,7 +566,11 @@ def main( ["console", "file"], ) try: + # A calibration that cannot be found, or belongs to another site controller = Controller(controller_settings) + except ValueError as exc: + raise click.ClickException(str(exc)) from exc + try: asyncio.run(controller.run()) except serial.serialutil.SerialException as exc: sys.exit(f"Serial error: {exc}") diff --git a/dotbot/tests/test_site_packs.py b/dotbot/tests/test_site_packs.py index 97b97c76..0ecc7ee8 100644 --- a/dotbot/tests/test_site_packs.py +++ b/dotbot/tests/test_site_packs.py @@ -1,8 +1,8 @@ # SPDX-FileCopyrightText: 2026-present Inria # SPDX-License-Identifier: BSD-3-Clause -"""Site packs: discovery and the inline-wins rule. Every folder is a -temporary one.""" +"""Site packs: discovery, the inline-wins rule and pack-first calibration +lookup. Every folder is a temporary one.""" from pathlib import Path @@ -10,8 +10,10 @@ from click.testing import CliRunner from dotbot.calibration import lighthouse2 +from dotbot.calibration.lighthouse2 import load_calibration, resolve_calibration_path from dotbot.cli.main import cli from dotbot.config import ConfigError, load_config, load_config_text +from dotbot.site import Site from dotbot.site_packs import ( find_packs, resolve_site_entry, @@ -138,3 +140,39 @@ class Ctx: Ctx.obj = {"config": load_config(config), "config_path": config} site, _ = site_from_context(Ctx()) assert site.anchor == "elsewhere" and site.pack is None + + +# --- calibrations ----------------------------------------------------------- + + +def test_a_calibration_is_looked_for_in_the_pack_first(tmp_path, home): + pack = _pack(tmp_path / "sites", "c405-arena", calibration=True) + home_copy = home / ".dotbot" / "calibrations" / "c405-arena" / CALIBRATION_NAME + home_copy.parent.mkdir(parents=True) + home_copy.write_text(FIXTURE_TOML) + site = Site(name="c405-arena", anchor=ANCHOR, pack=pack) + + in_pack = pack / "calibrations" / CALIBRATION_NAME + assert resolve_calibration_path(FIXTURE_ID[:8], site=site) == in_pack + assert resolve_calibration_path("arena-relay", site=site) == in_pack + # By name alone, only the home folder is searched. + assert resolve_calibration_path(FIXTURE_ID[:8], site="c405-arena") == home_copy + # Without the pack's copy, the home one is found. + in_pack.unlink() + assert resolve_calibration_path(FIXTURE_ID[:8], site=site) == home_copy + + +def test_a_calibration_from_another_site_or_anchor_is_refused(tmp_path, home): + pack = _pack(tmp_path / "sites", "c405-arena", calibration=True) + site = Site(name="c405-arena", anchor=ANCHOR, pack=pack) + assert load_calibration(FIXTURE_ID[:8], site=site).id == FIXTURE_ID + + moved = Site(name="c405-arena", anchor="the window wall", pack=pack) + with pytest.raises(ValueError, match="records the anchor"): + load_calibration(FIXTURE_ID[:8], site=moved) + + path = str(pack / "calibrations" / CALIBRATION_NAME) + with pytest.raises(ValueError, match="made in site 'c405-arena', not 'aio'"): + load_calibration(path, site=Site(name="aio")) + # A site with no recorded anchor does not compare anchors. + assert load_calibration(path, site=Site(name="c405-arena")).id == FIXTURE_ID From e9c45553543c6b0f0a137298c93add98fca1226e Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Tue, 29 Sep 2026 11:53:19 +0200 Subject: [PATCH 13/37] dotbot/cli: add dotbot site add and export for site packs AI-assisted: Claude Opus 5.5 --- dotbot/cli/main.py | 5 + dotbot/cli/site_cmd.py | 191 ++++++++++++++++++++++++++++ dotbot/tests/test_cli_dispatcher.py | 1 + dotbot/tests/test_site_packs.py | 102 ++++++++++++++- 4 files changed, 297 insertions(+), 2 deletions(-) create mode 100644 dotbot/cli/site_cmd.py diff --git a/dotbot/cli/main.py b/dotbot/cli/main.py index 3ce7850b..fb9ddc47 100644 --- a/dotbot/cli/main.py +++ b/dotbot/cli/main.py @@ -66,6 +66,11 @@ "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.", + ), ) diff --git a/dotbot/cli/site_cmd.py b/dotbot/cli/site_cmd.py new file mode 100644 index 00000000..af821042 --- /dev/null +++ b/dotbot/cli/site_cmd.py @@ -0,0 +1,191 @@ +# 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 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 +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) -> tuple[Path, str]: + """The pack in an unpacked folder: the folder itself, or its one sub-folder.""" + if (folder / PACK_FILE).is_file(): + return folder, name + children = [child for child in folder.iterdir() if child.is_dir()] + 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}") + + +def _fetch(source: str, scratch: Path) -> tuple[Path, str]: + """The pack folder SOURCE names, and its site name.""" + if _is_git_url(source): + name = source.rstrip("/").rsplit("/", 1)[-1].removesuffix(".git") + target = scratch / name + url = source.removeprefix("git+") + result = subprocess.run( + ["git", "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()}" + ) + return _pack_in(target, name) + path = Path(source).expanduser() + if path.is_dir(): + return _pack_in(path, path.resolve().name) + if path.is_file() and zipfile.is_zipfile(path): + target = scratch / path.stem + with zipfile.ZipFile(path) as archive: + archive.extractall(target) + return _pack_in(target, path.stem) + raise click.ClickException( + f"{source} is neither a folder, a zip file nor a git URL" + ) + + +@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) 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)) + try: + read_pack(folder) + except ConfigError as exc: + raise click.ClickException(str(exc)) from exc + target = user_sites_dir() / name + if target.exists(): + if not force: + raise click.ClickException( + f"{target} already exists. Pass --force to replace it." + ) + shutil.rmtree(target) + target.mkdir(parents=True) + shutil.copy2(folder / PACK_FILE, target / PACK_FILE) + calibrations = folder / PACK_CALIBRATIONS + if calibrations.is_dir(): + shutil.copytree(calibrations, target / PACK_CALIBRATIONS) + count = len(list((target / PACK_CALIBRATIONS).glob("*.toml"))) + click.echo(f"Added site {name} to {target} ({count} calibration files)") + click.echo(f'Work in it with `site = "{name}"` in your config, or --site {name}.') + + +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}") + 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_text() + else: + site_toml = _site_toml(entry.table) + 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 {len(calibrations)} calibration files" if with_calibrations else "") + ) diff --git a/dotbot/tests/test_cli_dispatcher.py b/dotbot/tests/test_cli_dispatcher.py index 30dd338c..e069c6d0 100644 --- a/dotbot/tests/test_cli_dispatcher.py +++ b/dotbot/tests/test_cli_dispatcher.py @@ -28,6 +28,7 @@ "run", "config", "deployment", + "site", } # `run` groups the host-side processes (the former flat top-level verbs). diff --git a/dotbot/tests/test_site_packs.py b/dotbot/tests/test_site_packs.py index 0ecc7ee8..fc4aa437 100644 --- a/dotbot/tests/test_site_packs.py +++ b/dotbot/tests/test_site_packs.py @@ -1,9 +1,11 @@ # SPDX-FileCopyrightText: 2026-present Inria # SPDX-License-Identifier: BSD-3-Clause -"""Site packs: discovery, the inline-wins rule and pack-first calibration -lookup. Every folder is a temporary one.""" +"""Site packs: discovery, the inline-wins rule, pack-first calibration lookup, +and `dotbot site add` / `export`. Every folder is a temporary one.""" +import subprocess +import zipfile from pathlib import Path import pytest @@ -176,3 +178,99 @@ def test_a_calibration_from_another_site_or_anchor_is_refused(tmp_path, home): load_calibration(path, site=Site(name="aio")) # A site with no recorded anchor does not compare anchors. assert load_calibration(path, site=Site(name="c405-arena")).id == FIXTURE_ID + + +# --- dotbot site add / export ------------------------------------------------ + + +def test_export_an_inline_site_with_its_calibrations_then_add_it( + runner, tmp_path, home +): + calibrations = home / ".dotbot" / "calibrations" / "c405-arena" + calibrations.mkdir(parents=True) + (calibrations / CALIBRATION_NAME).write_text(FIXTURE_TOML) + config = tmp_path / "dotbot.toml" + config.write_text( + '[sites.c405-arena]\nanchor = "a corner"\nextent_mm = [2000, 4000]\n' + "[sites.c405-arena.areas]\n" + "field = { x = 0, y = 0, w = 2000, h = 2000 }\n" + 'bench = { x = 1000, y = 0, w = 1000, h = 1000, role = "corner" }\n' + ) + archive = tmp_path / "out" / "c405.zip" + result = runner.invoke( + cli, + [ + "-c", + str(config), + "site", + "export", + "c405-arena", + "--out", + str(archive), + "--with-calibrations", + ], + ) + assert result.exit_code == 0, result.output + assert "1 calibration files" in result.output + with zipfile.ZipFile(archive) as opened: + assert sorted(opened.namelist()) == [ + f"c405-arena/calibrations/{CALIBRATION_NAME}", + "c405-arena/site.toml", + ] + + result = runner.invoke(cli, ["-c", str(config), "site", "add", str(archive)]) + assert result.exit_code == 0, result.output + added = home / ".dotbot" / "sites" / "c405-arena" + assert (added / "calibrations" / CALIBRATION_NAME).is_file() + + # The added pack is a site the next config finds, bench role and all. + other = tmp_path / "elsewhere" / "dotbot.toml" + other.parent.mkdir() + other.write_text("") + result = runner.invoke(cli, ["-c", str(other), "config", "show"]) + assert f"c405-arena {added}" in result.output + site = resolve_site_entry(load_config(other), other, "c405-arena").site() + assert site.areas["bench"].role == "corner" + assert site.extent_mm == (2000, 4000) + + again = runner.invoke(cli, ["-c", str(config), "site", "add", str(archive)]) + assert again.exit_code != 0 and "--force" in again.output + forced = runner.invoke( + cli, ["-c", str(config), "site", "add", str(archive), "--force"] + ) + assert forced.exit_code == 0, forced.output + + +def test_add_a_pack_folder_and_a_git_repository(runner, tmp_path, home): + pack = _pack(tmp_path / "shared", "demo-dcoss-2026") + result = runner.invoke(cli, ["site", "add", str(pack)]) + assert result.exit_code == 0, result.output + assert (home / ".dotbot" / "sites" / "demo-dcoss-2026" / "site.toml").is_file() + + repo = _pack(tmp_path / "repos", "aio") + for command in ( + ["init", "-q"], + ["add", "site.toml"], + ["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "pack"], + ): + subprocess.run(["git", "-C", str(repo), *command], check=True) + result = runner.invoke(cli, ["site", "add", f"git+file://{repo}"]) + assert result.exit_code == 0, result.output + added = home / ".dotbot" / "sites" / "aio" + assert (added / "site.toml").is_file() and not (added / ".git").exists() + + +def test_add_refuses_what_is_not_a_pack(runner, tmp_path, home): + (tmp_path / "empty").mkdir() + result = runner.invoke(cli, ["site", "add", str(tmp_path / "empty")]) + assert result.exit_code != 0 and "no site.toml" in result.output + result = runner.invoke(cli, ["site", "add", str(tmp_path / "missing")]) + assert result.exit_code != 0 and "neither a folder" in result.output + + +def test_export_names_the_known_sites_when_asked_for_another(runner, tmp_path, home): + config = tmp_path / "dotbot.toml" + config.write_text("[sites.lab]\n") + result = runner.invoke(cli, ["-c", str(config), "site", "export", "nope"]) + assert result.exit_code != 0 + assert "known sites: lab" in result.output From 1b203fa1fbae76731d45752109ebd564e9dd623e Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Tue, 29 Sep 2026 12:04:10 +0200 Subject: [PATCH 14/37] dotbot/controller: warn on an old LH2 calibration and on unsolved stations AI-assisted: Claude Opus 5.5 --- dotbot/calibration/lighthouse2.py | 14 ++++++ dotbot/config.py | 2 + dotbot/controller.py | 48 +++++++++++++++++++++ dotbot/controller_app.py | 10 ++++- dotbot/tests/test_controller.py | 71 +++++++++++++++++++++++++++++++ 5 files changed, 144 insertions(+), 1 deletion(-) diff --git a/dotbot/calibration/lighthouse2.py b/dotbot/calibration/lighthouse2.py index bd00a450..b1c33726 100644 --- a/dotbot/calibration/lighthouse2.py +++ b/dotbot/calibration/lighthouse2.py @@ -788,6 +788,20 @@ def unique(kind: str, matches: list) -> Optional[Path]: ) +def calibration_age_days( + calibration: Calibration, now: Optional[datetime.datetime] = None +) -> Optional[float]: + """Days since `created_at`, or None when it is missing or unreadable.""" + try: + created = datetime.datetime.strptime( + calibration.created_at, "%Y-%m-%dT%H:%M:%SZ" + ).replace(tzinfo=datetime.timezone.utc) + except ValueError: + return None + 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. diff --git a/dotbot/config.py b/dotbot/config.py index 0c636d11..825ec885 100644 --- a/dotbot/config.py +++ b/dotbot/config.py @@ -197,6 +197,8 @@ 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) diff --git a/dotbot/controller.py b/dotbot/controller.py index cd9c20b2..23556dad 100644 --- a/dotbot/controller.py +++ b/dotbot/controller.py @@ -99,6 +99,8 @@ # A robot silent this long no longer names what a camera sees. CAMERA_PRIOR_MAX_AGE_S = 2.0 LH2_POSITION_DISTANCE_THRESHOLD = 20 # mm +# Older than this many days at load, an LH2 calibration is warned about +LH2_CALIBRATION_MAX_AGE_DAYS = 30 # A command the robot confirms in its advertisement is resent when an # advertisement this long after sending still does not show it; adverts come # every 0.1 to 1 s, twice that on a bench-telemetry build @@ -204,6 +206,7 @@ class ControllerSettings: controller_http_host: str = CONTROLLER_HTTP_HOST_DEFAULT site: Optional[Site] = None lh2_calibration: Optional[str] = None + lh2_calibration_max_age_days: int = LH2_CALIBRATION_MAX_AGE_DAYS camera_calibration: Optional[str] = None camera_detect: bool = True camera_max_robots: int = MAX_ROBOTS @@ -221,6 +224,13 @@ class ControllerSettings: mrta_url: Optional[str] = None # None: no MRTA server configured (opt-in only) +def _held_stations(calibrated: int) -> set[int]: + """The station indices an advertised `calibrated` bitmask holds.""" + return { + index for index in range(calibrated.bit_length()) if calibrated >> index & 1 + } + + def is_lh2_fix(position: DotBotLH2Position, dotbot: DotBotModel) -> bool: """Whether an advertised position is a real LH2 fix. @@ -307,11 +317,14 @@ def __init__(self, settings: ControllerSettings): tag=self.calibration.tag, stations=len(self.lh2_calibration), ) + self._warn_calibration_age(settings.lh2_calibration_max_age_days) else: self.logger.info( "No calibration selected: robots keep whatever they hold. " "Pass --lh2-calibration or set [run.controller] lh2_calibration." ) + # (robot, station) pairs already warned about, so each is warned once + self._unsolved_warned: set[tuple[str, int]] = set() self.cameras: List[CameraService] = [] # The warp each camera's last pushed detection came from, so a # console is told about a frame once. @@ -334,6 +347,39 @@ def __init__(self, settings: ControllerSettings): self._dotbot_twin_timestamps: Dict[str, float] = {} api.controller = self + def _warn_calibration_age(self, max_age_days: int) -> None: + """Warn when the calibration is older than `max_age_days` (0: never).""" + from dotbot.calibration.lighthouse2 import calibration_age_days + + age = calibration_age_days(self.calibration) + if not max_age_days or age is None or age <= max_age_days: + return + self.logger.warning( + "Calibration is older than lh2_calibration_max_age_days: it holds " + "only while no base station has moved since", + calibration_id=self.calibration.id, + created_at=self.calibration.created_at, + age_days=round(age, 1), + max_age_days=max_age_days, + ) + + def _warn_unsolved_stations(self, address: str, calibrated: int) -> None: + """Warn once per robot and station about a homography the loaded + calibration does not solve: that robot's positions from the station + come from some other calibration.""" + solved = {station.index for station in self.lh2_calibration} + for index in sorted(_held_stations(calibrated) - solved): + if (address, index) in self._unsolved_warned: + continue + self._unsolved_warned.add((address, index)) + self.logger.warning( + "Robot holds a station the calibration does not solve", + address=address, + station=index, + calibration_id=self.calibration.id, + solved=sorted(solved), + ) + def _start_camera(self, spec: str) -> None: """Open the camera layer one registration describes. @@ -737,6 +783,8 @@ def handle_received_frame( ) for name in self._waypoints_report(dotbot, payload): record.revs[name] = seq + if self.lh2_calibration: + self._warn_unsolved_stations(dotbot.address, dotbot.calibrated) is_fully_calibrated = all( dotbot.calibrated >> station.index & 0x01 for station in self.lh2_calibration diff --git a/dotbot/controller_app.py b/dotbot/controller_app.py index c7ea9cdb..2e95f9f5 100644 --- a/dotbot/controller_app.py +++ b/dotbot/controller_app.py @@ -31,7 +31,11 @@ from dotbot.cli._cfg import from_config from dotbot.cli._conn import ConnError, needs_swarm_id, parse_connection from dotbot.cli._site import site_from_context -from dotbot.controller import Controller, ControllerSettings +from dotbot.controller import ( + LH2_CALIBRATION_MAX_AGE_DAYS, + Controller, + ControllerSettings, +) from dotbot.logger import setup_logging # Old transport/identity config keys replaced by `conn` / `swarm_id`. @@ -475,6 +479,9 @@ def main( camera_detect_share, _ = _resolve_controller_key( "camera_detect_share", camera_detect_share, unified, DETECT_SHARE ) + max_age_days, _ = _resolve_controller_key( + "lh2_calibration_max_age_days", None, unified, LH2_CALIBRATION_MAX_AGE_DAYS + ) camera_max_robots = int(camera_max_robots) camera_detect_share = float(camera_detect_share) if camera_calibration: @@ -531,6 +538,7 @@ def main( "controller_http_host": controller_http_host, "site": site, "lh2_calibration": lh2_calibration, + "lh2_calibration_max_age_days": int(max_age_days), "camera_calibration": camera_calibration, "camera_detect": camera_detect, "camera_max_robots": camera_max_robots, diff --git a/dotbot/tests/test_controller.py b/dotbot/tests/test_controller.py index 77180327..b0b03751 100644 --- a/dotbot/tests/test_controller.py +++ b/dotbot/tests/test_controller.py @@ -1565,3 +1565,74 @@ async def test_a_resend_that_fails_drops_the_command(controller, clock): controller.handle_received_frame(_report(batch_id=3)) assert simulator.write.call_count == 2 assert not controller.pending_commands + + +# --- calibration warnings --------------------------------------------------- + + +def _old_calibration_settings(tmp_path, **kwargs): + """Settings loading the wire fixture, made 2026-09-10, by path.""" + from dotbot.tests.lh2_wire_fixture import FIXTURE_TOML + + path = tmp_path / "calibration.toml" + path.write_text(FIXTURE_TOML) + return ControllerSettings( + port="/dev/null", + baudrate=115200, + network_id="0", + gw_address="78", + site=Site(name="c405-arena"), + lh2_calibration=str(path), + **kwargs, + ) + + +def test_an_old_calibration_is_warned_about_at_load(tmp_path, serial_mock): + with capture_logs() as logs: + Controller(_old_calibration_settings(tmp_path, lh2_calibration_max_age_days=7)) + (entry,) = (e for e in logs if e["log_level"] == "warning") + assert "older than lh2_calibration_max_age_days" in entry["event"] + assert entry["max_age_days"] == 7 and entry["age_days"] > 7 + + +@pytest.mark.parametrize("max_age_days", [0, 100_000]) +def test_a_calibration_within_its_age_or_with_no_limit_is_not( + tmp_path, serial_mock, max_age_days +): + with capture_logs() as logs: + Controller( + _old_calibration_settings( + tmp_path, lh2_calibration_max_age_days=max_age_days + ) + ) + assert not [e for e in logs if e["log_level"] == "warning"] + + +def test_a_calibration_from_another_site_is_refused_at_load(tmp_path, serial_mock): + settings = _old_calibration_settings(tmp_path) + settings.site = Site(name="aio") + with pytest.raises(ValueError, match="made in site 'c405-arena', not 'aio'"): + Controller(settings) + + +def test_a_robot_holding_a_station_the_calibration_does_not_solve_is_warned_once( + tmp_path, serial_mock +): + controller = Controller( + _old_calibration_settings(tmp_path, lh2_calibration_max_age_days=0) + ) + solved = sorted(station.index for station in controller.lh2_calibration) + unsolved = min(set(range(8)) - set(solved)) + calibrated = sum(1 << index for index in solved) | 1 << unsolved + with capture_logs() as logs: + for _ in range(3): + controller.handle_received_frame( + _advertised( + BOT, calibrated=calibrated, direction=90, pos_x=1000, pos_y=1000 + ) + ) + warnings = [e for e in logs if e["log_level"] == "warning"] + assert [(e["address"], e["station"]) for e in warnings] == [ + (addr_to_hex(BOT), unsolved) + ] + assert warnings[0]["solved"] == solved From a1dacb060a26bb77347ab09d035965c932b1f78f Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Tue, 29 Sep 2026 12:04:10 +0200 Subject: [PATCH 15/37] dotbot/server: carry the loaded calibration's placements in the site AI-assisted: Claude Opus 5.5 --- dotbot/models.py | 44 ++++++++++++++++++++++++++++++++-- dotbot/server.py | 4 ++-- dotbot/tests/test_qrkey_app.py | 1 + dotbot/tests/test_server.py | 35 +++++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 4 deletions(-) diff --git a/dotbot/models.py b/dotbot/models.py index 92a5e809..774254f0 100644 --- a/dotbot/models.py +++ b/dotbot/models.py @@ -195,6 +195,39 @@ class DotBotAreaModel(BaseModel): role: Optional[Literal["field", "staging", "corner"]] = None +class DotBotPlacementSpanModel(BaseModel): + """One placement's points, in frame millimetres, and how they were chosen + (`field`, `over `, `square ` or `points`).""" + + points_mm: List[List[float]] + points_from: str = "" + + +class DotBotCalibrationSpanModel(BaseModel): + """The loaded LH2 calibration's placements, which span the part of the + site it was fitted over; positions outside them are extrapolated.""" + + id: str + tag: str = "" + created_at: str = "" + placements: List[DotBotPlacementSpanModel] = [] + + @classmethod + def from_calibration(cls, calibration: Any) -> "DotBotCalibrationSpanModel": + return cls( + id=calibration.id, + tag=calibration.tag, + created_at=calibration.created_at, + placements=[ + DotBotPlacementSpanModel( + points_mm=[list(point) for point in placement.points_mm], + points_from=placement.points_from, + ) + for placement in calibration.placements + ], + ) + + class DotBotSiteModel(BaseModel): """The site the controller works in, and the areas it defines. @@ -203,7 +236,8 @@ class DotBotSiteModel(BaseModel): `areas` are in the order the config declares them. `field` names the area experiments and calibration default to (`Site.field`): an area's name, an `x,y,w,h` literal for a site with an extent and no areas, or - None for a site that declares neither. + None for a site that declares neither. `calibration` is the LH2 + calibration the controller loaded, if any. """ name: str @@ -211,9 +245,10 @@ class DotBotSiteModel(BaseModel): extent_mm: Optional[List[int]] = None areas: List[DotBotAreaModel] = [] field: Optional[str] = None + calibration: Optional[DotBotCalibrationSpanModel] = None @classmethod - def from_site(cls, site: Site) -> "DotBotSiteModel": + def from_site(cls, site: Site, calibration: Any = None) -> "DotBotSiteModel": field = site.field return cls( name=site.name, @@ -221,6 +256,11 @@ def from_site(cls, site: Site) -> "DotBotSiteModel": extent_mm=list(site.extent_mm) if site.extent_mm else None, areas=[DotBotAreaModel(**a.as_dict()) for a in site.areas.values()], field=field.name if field is not None else None, + calibration=( + DotBotCalibrationSpanModel.from_calibration(calibration) + if calibration is not None + else None + ), ) def to_site(self) -> Site: diff --git a/dotbot/server.py b/dotbot/server.py index a6809d35..050e1729 100644 --- a/dotbot/server.py +++ b/dotbot/server.py @@ -562,12 +562,12 @@ async def device_poses(): @api.get( path="/controller/site", response_model=DotBotSiteModel, - summary="Return the site the controller works in, with its areas", + summary="Return the site the controller works in, its areas and calibrated span", tags=["controller"], ) async def site(): """Active site HTTP GET handler.""" - return DotBotSiteModel.from_site(api.controller.site) + return DotBotSiteModel.from_site(api.controller.site, api.controller.calibration) @api.get( diff --git a/dotbot/tests/test_qrkey_app.py b/dotbot/tests/test_qrkey_app.py index cc85d1e7..ae2f3f68 100644 --- a/dotbot/tests/test_qrkey_app.py +++ b/dotbot/tests/test_qrkey_app.py @@ -139,4 +139,5 @@ def test_a_site_request_replies_with_the_whole_site(site_client): {"x": 0, "y": 2000, "w": 2000, "h": 2000, "name": "annex", "role": None}, ], "field": "arena", + "calibration": None, } diff --git a/dotbot/tests/test_server.py b/dotbot/tests/test_server.py index 1ab6a727..36439c5d 100644 --- a/dotbot/tests/test_server.py +++ b/dotbot/tests/test_server.py @@ -60,6 +60,7 @@ def controller(): api.controller.settings.gw_address = "0000" api.controller.settings.network_id = "0000" api.controller.settings.site = None + api.controller.calibration = None # The robot state bookkeeping is the real one, over `dotbots` api.controller.seq = 0 api.controller.run_id = "0123456789ab" @@ -1375,6 +1376,39 @@ async def test_get_controller_site(): }, ], "field": "field", + "calibration": None, + } + + +@pytest.mark.asyncio +async def test_get_controller_site_carries_the_loaded_calibration_span(): + from dotbot.calibration.lighthouse2 import Calibration, Placement + + api.controller.site = Site(name="c405-arena", extent_mm=(2000, 4000)) + calibration = Calibration( + site=Site(name="c405-arena"), + placements=[ + Placement( + index=0, + points_mm=[(750, 750), (1250, 750), (750, 1250), (1250, 1250)], + points_from="square 500", + ) + ], + created_at="2026-09-10T09:12:00Z", + tag="demo", + ) + api.controller.calibration = calibration + response = await client.get("/controller/site") + assert response.json()["calibration"] == { + "id": calibration.id, + "tag": "demo", + "created_at": "2026-09-10T09:12:00Z", + "placements": [ + { + "points_mm": [[750, 750], [1250, 750], [750, 1250], [1250, 1250]], + "points_from": "square 500", + } + ], } @@ -1415,6 +1449,7 @@ async def test_get_controller_site_with_nothing_measured(): "extent_mm": None, "areas": [], "field": None, + "calibration": None, } From ece9501e4f9ca7b9440711baaba6e375522bd786 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Tue, 29 Sep 2026 12:04:10 +0200 Subject: [PATCH 16/37] dotbot/console-web: outline the calibrated span and hatch the rest AI-assisted: Claude Opus 5.5 --- dotbot/console-web/src/App.tsx | 1 + dotbot/console-web/src/MapView.tsx | 76 +++++++++++++++- dotbot/console-web/src/RightPane.tsx | 15 ++++ dotbot/console-web/src/areaLayer.test.tsx | 2 + dotbot/console-web/src/bodyColor.test.tsx | 1 + dotbot/console-web/src/botChrome.test.tsx | 1 + .../console-web/src/calibrationSpan.test.ts | 89 +++++++++++++++++++ dotbot/console-web/src/calibrationSpan.ts | 74 +++++++++++++++ .../src/calibrationSpanLayer.test.tsx | 89 +++++++++++++++++++ dotbot/console-web/src/cameraLayer.test.tsx | 1 + dotbot/console-web/src/gridLayer.test.tsx | 1 + dotbot/console-web/src/mapGestures.test.tsx | 1 + .../src/rightPaneCollapsed.test.tsx | 1 + dotbot/console-web/src/robotDrawing.test.tsx | 1 + dotbot/console-web/src/types.ts | 11 +++ dotbot/console-web/src/zoomSurface.test.tsx | 1 + 16 files changed, 364 insertions(+), 1 deletion(-) create mode 100644 dotbot/console-web/src/calibrationSpan.test.ts create mode 100644 dotbot/console-web/src/calibrationSpan.ts create mode 100644 dotbot/console-web/src/calibrationSpanLayer.test.tsx diff --git a/dotbot/console-web/src/App.tsx b/dotbot/console-web/src/App.tsx index 46baa112..9a63db38 100644 --- a/dotbot/console-web/src/App.tsx +++ b/dotbot/console-web/src/App.tsx @@ -212,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"); diff --git a/dotbot/console-web/src/MapView.tsx b/dotbot/console-web/src/MapView.tsx index 4fde74ef..a286b350 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,6 +355,12 @@ 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 ?? ""), ); @@ -1187,6 +1205,62 @@ export const MapView: React.FC = (props) => { {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 && ( = (props) => { )} + {props.site?.calibration && ( + <> +
Calibration
+ props.onLayerToggle("calibratedSpan")} + /> +
+ Outlined where the loaded LH2 calibration was fitted; positions + in the hatched rest of the site are extrapolated. +
+ + )} + {cameras.length > 0 && ( <>
Camera
diff --git a/dotbot/console-web/src/areaLayer.test.tsx b/dotbot/console-web/src/areaLayer.test.tsx index 336e5297..307d1c6f 100644 --- a/dotbot/console-web/src/areaLayer.test.tsx +++ b/dotbot/console-web/src/areaLayer.test.tsx @@ -54,6 +54,7 @@ const Harness: React.FC = () => { trails: false, crashedOnly: false, allWaypoints: false, + calibratedSpan: true, }} plannedMissions={[]} cam={{ scale: 1, tx: 0, ty: 0 }} @@ -89,6 +90,7 @@ const Harness: React.FC = () => { trails: false, crashedOnly: false, allWaypoints: false, + calibratedSpan: true, }} layerRows={[]} onLayerToggle={() => {}} diff --git a/dotbot/console-web/src/bodyColor.test.tsx b/dotbot/console-web/src/bodyColor.test.tsx index f29e3e20..b4c2adbc 100644 --- a/dotbot/console-web/src/bodyColor.test.tsx +++ b/dotbot/console-web/src/bodyColor.test.tsx @@ -111,6 +111,7 @@ const Harness: React.FC = () => { trails: false, crashedOnly: false, allWaypoints: false, + calibratedSpan: true, }} layerRows={[]} onLayerToggle={() => {}} diff --git a/dotbot/console-web/src/botChrome.test.tsx b/dotbot/console-web/src/botChrome.test.tsx index d4a4b319..63355446 100644 --- a/dotbot/console-web/src/botChrome.test.tsx +++ b/dotbot/console-web/src/botChrome.test.tsx @@ -126,6 +126,7 @@ const Harness: React.FC = ({ trails: false, crashedOnly: false, allWaypoints: false, + calibratedSpan: true, }} robotDrawing={robotDrawing} colorMode={colorMode} diff --git a/dotbot/console-web/src/calibrationSpan.test.ts b/dotbot/console-web/src/calibrationSpan.test.ts new file mode 100644 index 00000000..c1ad0d62 --- /dev/null +++ b/dotbot/console-web/src/calibrationSpan.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; + +import { + ageDays, + calibrationSpans, + convexHull, + describePointsFrom, + hatchBox, + spanTitle, +} from "./calibrationSpan"; +import type { SiteCalibration } from "./types"; + +// A 500 mm square at the centre of a 2 x 2 m field, in capture order. +const SQUARE: [number, number][] = [ + [750, 750], + [1250, 750], + [750, 1250], + [1250, 1250], +]; + +const CALIBRATION: SiteCalibration = { + id: "ac893d2d85e3068c", + tag: "demo", + created_at: "2026-09-10T09:12:00Z", + placements: [{ points_mm: SQUARE, points_from: "square 500" }], +}; + +describe("convexHull", () => { + it("orders four corners captured in Z order round the square", () => { + expect(convexHull(SQUARE)).toEqual([ + [750, 750], + [1250, 750], + [1250, 1250], + [750, 1250], + ]); + }); + + it("drops a point inside the others", () => { + expect(convexHull([...SQUARE, [1000, 1000]])).toHaveLength(4); + }); +}); + +describe("calibrationSpans", () => { + it("gives the span of four points, with how they were chosen", () => { + const [span] = calibrationSpans(CALIBRATION); + expect(span.points).toHaveLength(4); + expect(span.pointsFrom).toBe("square 500"); + }); + + it("skips a placement whose points do not span an area", () => { + const line = { + ...CALIBRATION, + placements: [{ points_mm: [[0, 0], [10, 0], [20, 0]] as [number, number][], points_from: "points" }], + }; + expect(calibrationSpans(line)).toEqual([]); + expect(calibrationSpans(null)).toEqual([]); + }); +}); + +describe("hatchBox", () => { + it("clips the hatch to the site's extent", () => { + expect(hatchBox([2000, 4000])).toEqual({ x: 0, y: 0, w: 2000, h: 4000 }); + }); + + it("draws no hatch for a site with no extent", () => { + expect(hatchBox(null)).toBeNull(); + }); +}); + +describe("the tooltip", () => { + it("says how the points were chosen", () => { + expect(describePointsFrom("field")).toBe("the field's corners"); + expect(describePointsFrom("over dev-corner")).toBe("the corners of dev-corner"); + expect(describePointsFrom("square 500")).toBe("a 500 mm square in the field"); + expect(describePointsFrom("points")).toBe("points given by hand"); + expect(describePointsFrom("")).toBe("its recorded points"); + }); + + it("names the calibration and its age", () => { + const now = new Date("2026-09-29T10:00:00Z"); + expect(ageDays(CALIBRATION.created_at, now)).toBe(19); + expect(ageDays("", now)).toBeNull(); + const [span] = calibrationSpans(CALIBRATION); + expect(spanTitle(CALIBRATION, span, now)).toBe( + "LH2 calibration demo (ac893d2d), 19 days old: calibrated over a 500 mm " + + "square in the field. Positions outside the outline are extrapolated.", + ); + }); +}); diff --git a/dotbot/console-web/src/calibrationSpan.ts b/dotbot/console-web/src/calibrationSpan.ts new file mode 100644 index 00000000..60a81ec3 --- /dev/null +++ b/dotbot/console-web/src/calibrationSpan.ts @@ -0,0 +1,74 @@ +import type { Area, SiteCalibration } from "./types"; + +// Where the loaded LH2 calibration was fitted, and what is extrapolated: the +// map outlines each placement's span and hatches the rest of the site. + +export type Point = [number, number]; + +export interface Span { + points: Point[]; + pointsFrom: string; +} + +const cross = (o: Point, a: Point, b: Point) => + (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); + +/** The convex hull of `points`, in order round it from the lowest x. */ +export function convexHull(points: Point[]): Point[] { + const sorted = [...points].sort((p, q) => p[0] - q[0] || p[1] - q[1]); + if (sorted.length < 3) return sorted; + const half = (pts: Point[]) => { + const hull: Point[] = []; + for (const p of pts) { + while (hull.length >= 2 && cross(hull[hull.length - 2], hull[hull.length - 1], p) <= 0) { + hull.pop(); + } + hull.push(p); + } + hull.pop(); + return hull; + }; + return [...half(sorted), ...half([...sorted].reverse())]; +} + +/** One outline per placement that spans an area: three points or more, not in a line. */ +export function calibrationSpans(calibration: SiteCalibration | null | undefined): Span[] { + return (calibration?.placements ?? []).flatMap((placement) => { + const points = convexHull(placement.points_mm); + return points.length >= 3 ? [{ points, pointsFrom: placement.points_from }] : []; + }); +} + +/** The rectangle the hatch covers: the whole site, or nothing when its extent is unknown. */ +export function hatchBox(extent: [number, number] | null): Area | null { + return extent ? { x: 0, y: 0, w: extent[0], h: extent[1] } : null; +} + +/** How a placement's points were chosen, as a phrase. */ +export function describePointsFrom(pointsFrom: string): string { + if (pointsFrom === "field") return "the field's corners"; + if (pointsFrom.startsWith("over ")) return `the corners of ${pointsFrom.slice(5)}`; + if (pointsFrom.startsWith("square ")) return `a ${pointsFrom.slice(7)} mm square in the field`; + if (pointsFrom === "points") return "points given by hand"; + return "its recorded points"; +} + +/** Whole days since `createdAt`, or null when it does not parse. */ +export function ageDays(createdAt: string, now: Date = new Date()): number | null { + const created = Date.parse(createdAt); + if (Number.isNaN(created)) return null; + return Math.floor((now.getTime() - created) / 86_400_000); +} + +/** The outline's tooltip: which calibration, how old, and what it covers. */ +export function spanTitle(calibration: SiteCalibration, span: Span, now?: Date): string { + const name = calibration.tag + ? `${calibration.tag} (${calibration.id.slice(0, 8)})` + : calibration.id.slice(0, 8); + const age = ageDays(calibration.created_at, now); + const old = age === null ? "" : age === 1 ? ", 1 day old" : `, ${age} days old`; + return ( + `LH2 calibration ${name}${old}: calibrated over ${describePointsFrom(span.pointsFrom)}. ` + + "Positions outside the outline are extrapolated." + ); +} diff --git a/dotbot/console-web/src/calibrationSpanLayer.test.tsx b/dotbot/console-web/src/calibrationSpanLayer.test.tsx new file mode 100644 index 00000000..fbb8ff0e --- /dev/null +++ b/dotbot/console-web/src/calibrationSpanLayer.test.tsx @@ -0,0 +1,89 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { type Layers, MapView } from "./MapView"; +import type { Site } from "./types"; + +const FIELD = { x: 0, y: 0, w: 2000, h: 2000, name: "field", role: "field" as const }; +const SITE: Site = { + name: "c405-arena", + anchor: "", + extent_mm: [2000, 4000], + areas: [FIELD], + field: "field", + calibration: { + id: "ac893d2d85e3068c", + tag: "", + created_at: "2026-09-10T09:12:00Z", + placements: [ + { + points_mm: [ + [750, 750], + [1250, 750], + [750, 1250], + [1250, 1250], + ], + points_from: "square 500", + }, + ], + }, +}; + +const LAYERS: Layers = { + batteryBars: true, + waypoints: true, + hotSpots: false, + dotBots: true, + trails: false, + crashedOnly: false, + allWaypoints: false, + calibratedSpan: true, +}; + +const renderMap = (site: Site, layers: Layers = LAYERS) => + render( + {}} + onGeom={() => {}} + onSelect={() => {}} + onAddWaypoint={() => {}} + site={site} + onZoom={() => {}} + />, + ); + +describe("the calibrated span on the map", () => { + it("outlines the span, hatches the site and says how it was calibrated", () => { + renderMap(SITE); + const outline = screen.getByTestId("calibration-span-0"); + expect(outline.getAttribute("points")?.split(" ")).toHaveLength(4); + expect(outline.textContent).toContain("a 500 mm square in the field"); + expect(screen.getByTestId("calibration-hatch")).toBeTruthy(); + }); + + it("outlines without a hatch on a site with no extent", () => { + renderMap({ ...SITE, extent_mm: null }); + expect(screen.getByTestId("calibration-span-0")).toBeTruthy(); + expect(screen.queryByTestId("calibration-hatch")).toBeNull(); + }); + + it("draws nothing when the layer is off or nothing is loaded", () => { + const { unmount } = renderMap(SITE, { ...LAYERS, calibratedSpan: false }); + expect(screen.queryByTestId("calibration-span")).toBeNull(); + unmount(); + renderMap({ ...SITE, calibration: null }); + expect(screen.queryByTestId("calibration-span")).toBeNull(); + }); +}); diff --git a/dotbot/console-web/src/cameraLayer.test.tsx b/dotbot/console-web/src/cameraLayer.test.tsx index f3e9ec39..46159375 100644 --- a/dotbot/console-web/src/cameraLayer.test.tsx +++ b/dotbot/console-web/src/cameraLayer.test.tsx @@ -115,6 +115,7 @@ const LAYERS = { trails: false, crashedOnly: false, allWaypoints: false, + calibratedSpan: true, }; // A robot standing where it says it is, which is all the map draws it from. diff --git a/dotbot/console-web/src/gridLayer.test.tsx b/dotbot/console-web/src/gridLayer.test.tsx index aa954e2c..6ed3a55f 100644 --- a/dotbot/console-web/src/gridLayer.test.tsx +++ b/dotbot/console-web/src/gridLayer.test.tsx @@ -36,6 +36,7 @@ const Harness: React.FC<{ cam: Camera; siteExtent?: Area | null }> = ({ trails: false, crashedOnly: false, allWaypoints: false, + calibratedSpan: true, }} plannedMissions={[]} cam={cam} diff --git a/dotbot/console-web/src/mapGestures.test.tsx b/dotbot/console-web/src/mapGestures.test.tsx index 99c54459..72157fef 100644 --- a/dotbot/console-web/src/mapGestures.test.tsx +++ b/dotbot/console-web/src/mapGestures.test.tsx @@ -102,6 +102,7 @@ const Harness: React.FC = ({ trails: false, crashedOnly: false, allWaypoints: false, + calibratedSpan: true, }} plannedMissions={planned} cam={cam} diff --git a/dotbot/console-web/src/rightPaneCollapsed.test.tsx b/dotbot/console-web/src/rightPaneCollapsed.test.tsx index ee81629f..2bdc2f38 100644 --- a/dotbot/console-web/src/rightPaneCollapsed.test.tsx +++ b/dotbot/console-web/src/rightPaneCollapsed.test.tsx @@ -83,6 +83,7 @@ const Harness: React.FC<{ session?: CalibrationSession | null }> = ({ trails: false, crashedOnly: false, allWaypoints: false, + calibratedSpan: true, }} layerRows={[]} onLayerToggle={() => {}} diff --git a/dotbot/console-web/src/robotDrawing.test.tsx b/dotbot/console-web/src/robotDrawing.test.tsx index d19683e2..c05efa4c 100644 --- a/dotbot/console-web/src/robotDrawing.test.tsx +++ b/dotbot/console-web/src/robotDrawing.test.tsx @@ -80,6 +80,7 @@ const Harness: React.FC = () => { trails: false, crashedOnly: false, allWaypoints: false, + calibratedSpan: true, }} layerRows={[]} onLayerToggle={() => {}} diff --git a/dotbot/console-web/src/types.ts b/dotbot/console-web/src/types.ts index ee66d67c..412cfc89 100644 --- a/dotbot/console-web/src/types.ts +++ b/dotbot/console-web/src/types.ts @@ -435,6 +435,17 @@ export interface Site { extent_mm: [number, number] | null; areas: Area[]; field?: string | null; + calibration?: SiteCalibration | null; +} + +// The LH2 calibration the controller loaded: each placement's points in +// frame mm, which span the part of the site it was fitted over, and how they +// were chosen (`field`, `over `, `square ` or `points`). +export interface SiteCalibration { + id: string; + tag: string; + created_at: string; + placements: { points_mm: [number, number][]; points_from: string }[]; } // GET /controller/cameras - one registered camera, one area. `width` and diff --git a/dotbot/console-web/src/zoomSurface.test.tsx b/dotbot/console-web/src/zoomSurface.test.tsx index ea778ff8..a74be833 100644 --- a/dotbot/console-web/src/zoomSurface.test.tsx +++ b/dotbot/console-web/src/zoomSurface.test.tsx @@ -46,6 +46,7 @@ const Harness: React.FC<{ onCam?: (c: Camera) => void; from?: Camera }> = ({ trails: false, crashedOnly: false, allWaypoints: false, + calibratedSpan: true, }} plannedMissions={[]} cam={cam} From 31f4c123a96d033aae1a4af1b9e07bfa17db9bf9 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Tue, 29 Sep 2026 12:28:25 +0200 Subject: [PATCH 17/37] dotbot/tests: point USERPROFILE at the scratch home in the site pack tests AI-assisted: Claude Opus 5.5 --- dotbot/tests/test_site_packs.py | 1 + 1 file changed, 1 insertion(+) diff --git a/dotbot/tests/test_site_packs.py b/dotbot/tests/test_site_packs.py index fc4aa437..5af26d1b 100644 --- a/dotbot/tests/test_site_packs.py +++ b/dotbot/tests/test_site_packs.py @@ -50,6 +50,7 @@ def home(tmp_path, monkeypatch): home = tmp_path / "home" home.mkdir() monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) monkeypatch.setattr(lighthouse2, "CALIBRATION_DIR", home / ".dotbot") monkeypatch.setattr("dotbot.config.USER_CONFIG_PATH", home / "nope.toml") return home From eb41d73c0d8fd0bb6a4e250ebe063b6827e4d9f3 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Tue, 29 Sep 2026 12:34:05 +0200 Subject: [PATCH 18/37] dotbot/tests: build Windows-safe absolute paths and file URLs in the site pack tests AI-assisted: Claude Opus 5.5 --- dotbot/tests/test_site_packs.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/dotbot/tests/test_site_packs.py b/dotbot/tests/test_site_packs.py index 5af26d1b..b329649d 100644 --- a/dotbot/tests/test_site_packs.py +++ b/dotbot/tests/test_site_packs.py @@ -72,11 +72,9 @@ def test_site_dirs_default_and_relative_entries_read_from_the_config_folder( tmp_path / "lab" / "sites", home / ".dotbot" / "sites", ] - config = load_config_text('site_dirs = ["packs", "/abs/packs"]') - assert site_dirs(config, config_path) == [ - tmp_path / "lab" / "packs", - Path("/abs/packs"), - ] + absolute = tmp_path / "abs" / "packs" + config = load_config_text(f'site_dirs = ["packs", "{absolute.as_posix()}"]') + assert site_dirs(config, config_path) == [tmp_path / "lab" / "packs", absolute] def test_the_first_site_dir_wins_a_name_clash(tmp_path): @@ -255,7 +253,7 @@ def test_add_a_pack_folder_and_a_git_repository(runner, tmp_path, home): ["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "pack"], ): subprocess.run(["git", "-C", str(repo), *command], check=True) - result = runner.invoke(cli, ["site", "add", f"git+file://{repo}"]) + result = runner.invoke(cli, ["site", "add", f"git+{repo.as_uri()}"]) assert result.exit_code == 0, result.output added = home / ".dotbot" / "sites" / "aio" assert (added / "site.toml").is_file() and not (added / ".git").exists() From 8d586f15db4e351a9ec0450835afe37bcc9bb7ea Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Tue, 29 Sep 2026 12:54:04 +0200 Subject: [PATCH 19/37] dotbot/tests: keep every test away from this machine's site packs AI-assisted: Claude Opus 5.5 --- dotbot/site_packs.py | 11 +++++++---- dotbot/tests/conftest.py | 11 +++++++++++ dotbot/tests/test_site_packs.py | 2 ++ 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/dotbot/site_packs.py b/dotbot/site_packs.py index 74355468..83766af6 100644 --- a/dotbot/site_packs.py +++ b/dotbot/site_packs.py @@ -23,12 +23,15 @@ from dotbot.site import Site, site_from_table PACK_FILE = "site.toml" -SITE_DIRS_DEFAULT = ("sites", "~/.dotbot/sites") +# Where `dotbot site add` puts packs, searched last when `site_dirs` is unset +USER_SITES_DIR = Path.home() / ".dotbot" / "sites" +# Searched first when `site_dirs` is unset, from the config file's folder +PROJECT_SITES_DIR = "sites" def user_sites_dir() -> Path: """Where `dotbot site add` puts packs.""" - return Path.home() / ".dotbot" / "sites" + return USER_SITES_DIR @dataclass(frozen=True) @@ -57,9 +60,9 @@ def site(self) -> Site: def site_dirs(config: Any, config_path: Path | None) -> list[Path]: """The folders searched for packs; relative ones from the config's folder.""" entries = getattr(config, "site_dirs", None) - if entries is None: - entries = SITE_DIRS_DEFAULT base = config_path.parent if config_path is not None else Path.cwd() + if entries is None: + return [base / PROJECT_SITES_DIR, user_sites_dir()] folders = [] for entry in entries: folder = Path(entry).expanduser() diff --git a/dotbot/tests/conftest.py b/dotbot/tests/conftest.py index c56a62bd..452210c7 100644 --- a/dotbot/tests/conftest.py +++ b/dotbot/tests/conftest.py @@ -14,3 +14,14 @@ def never_open_a_browser(monkeypatch): puts a tab on the developer's screen for every run. """ monkeypatch.setattr("webbrowser.open", lambda *args, **kwargs: True) + + +@pytest.fixture(scope="session") +def _empty_user_sites(tmp_path_factory): + return tmp_path_factory.mktemp("user-sites") + + +@pytest.fixture(autouse=True) +def no_user_site_packs(monkeypatch, _empty_user_sites): + """Keep every test away from this machine's ~/.dotbot/sites packs.""" + monkeypatch.setattr("dotbot.site_packs.USER_SITES_DIR", _empty_user_sites) diff --git a/dotbot/tests/test_site_packs.py b/dotbot/tests/test_site_packs.py index b329649d..936e72e2 100644 --- a/dotbot/tests/test_site_packs.py +++ b/dotbot/tests/test_site_packs.py @@ -11,6 +11,7 @@ import pytest from click.testing import CliRunner +from dotbot import site_packs from dotbot.calibration import lighthouse2 from dotbot.calibration.lighthouse2 import load_calibration, resolve_calibration_path from dotbot.cli.main import cli @@ -52,6 +53,7 @@ def home(tmp_path, monkeypatch): monkeypatch.setenv("HOME", str(home)) monkeypatch.setenv("USERPROFILE", str(home)) monkeypatch.setattr(lighthouse2, "CALIBRATION_DIR", home / ".dotbot") + monkeypatch.setattr(site_packs, "USER_SITES_DIR", home / ".dotbot" / "sites") monkeypatch.setattr("dotbot.config.USER_CONFIG_PATH", home / "nope.toml") return home From 3399c73d76bfdeaed5d310debe9d9bfcee2f92d9 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Tue, 29 Sep 2026 12:54:14 +0200 Subject: [PATCH 20/37] dotbot/cli: refuse unsafe names, links and ext:: URLs in site add The site name came from the URL or zip unchecked, so a git URL ending in `..` made the target ~/.dotbot itself, which --force then deleted. A link in a pack was followed on copy, pulling any local file into ~/.dotbot/sites. AI-assisted: Claude Opus 5.5 --- dotbot/cli/config_cmd.py | 11 ++--- dotbot/cli/site_cmd.py | 81 +++++++++++++++++++++++++-------- dotbot/site.py | 10 ++++ dotbot/tests/test_cli_config.py | 6 ++- dotbot/tests/test_site_packs.py | 57 +++++++++++++++++++++++ 5 files changed, 137 insertions(+), 28 deletions(-) diff --git a/dotbot/cli/config_cmd.py b/dotbot/cli/config_cmd.py index e884ea59..b5539212 100644 --- a/dotbot/cli/config_cmd.py +++ b/dotbot/cli/config_cmd.py @@ -19,7 +19,7 @@ import tomlkit from dotbot.config import USER_CONFIG_PATH, ConfigError -from dotbot.site import SITE_DEFAULT +from dotbot.site import SITE_DEFAULT, check_site_name from dotbot.site_packs import site_catalog _CONFIG_DOCS_URL = ( @@ -37,7 +37,6 @@ # Above this, one LH2 base station rarely covers the field well. FIELD_COVERAGE_MM = 5000 -_SITE_NAME = re.compile(r"^[A-Za-z0-9_-]+$") _FIELD_SIDE = re.compile(r"^(?P\d+(?:\.\d+)?)(?Pmm|m|[a-z]+)?$") @@ -202,10 +201,10 @@ def init(global_, force, conn, swarm_id, site, field_spec): margin of floor round both. `--field` sizes the field, and the rest follows from it. `--conn` / `--swarm-id` pre-fill those top-level keys. """ - if not _SITE_NAME.match(site): - raise click.BadParameter( - f"{site!r}: use letters, digits, - and _", param_hint="'--site'" - ) + 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: diff --git a/dotbot/cli/site_cmd.py b/dotbot/cli/site_cmd.py index af821042..e6abc158 100644 --- a/dotbot/cli/site_cmd.py +++ b/dotbot/cli/site_cmd.py @@ -9,6 +9,7 @@ `[sites.]` table. The same folders can be shared with git, cp or unzip. """ +import re import shutil import subprocess import tempfile @@ -19,7 +20,7 @@ import tomlkit from dotbot.config import ConfigError -from dotbot.site import PACK_CALIBRATIONS +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+") @@ -49,28 +50,42 @@ def _pack_in(folder: Path, name: str) -> tuple[Path, str]: raise click.ClickException(f"no {PACK_FILE} found in {name}") +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 _is_git_url(source): - name = source.rstrip("/").rsplit("/", 1)[-1].removesuffix(".git") - target = scratch / name url = source.removeprefix("git+") - result = subprocess.run( - ["git", "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()}" - ) + target = scratch / "clone" + _git_clone(url, target) + name = re.split(r"[/:]", url.rstrip("/"))[-1].removesuffix(".git") return _pack_in(target, name) path = Path(source).expanduser() if path.is_dir(): return _pack_in(path, path.resolve().name) if path.is_file() and zipfile.is_zipfile(path): - target = scratch / path.stem + target = scratch / "unzipped" with zipfile.ZipFile(path) as archive: archive.extractall(target) return _pack_in(target, path.stem) @@ -79,6 +94,31 @@ def _fetch(source: str, scratch: Path) -> tuple[Path, str]: ) +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 + + @cmd.command() @click.argument("source") @click.option("--force", "-f", is_flag=True, help="Replace a pack of the same name.") @@ -90,10 +130,7 @@ def add(source, force): """ with tempfile.TemporaryDirectory() as scratch: folder, name = _fetch(source, Path(scratch)) - try: - read_pack(folder) - except ConfigError as exc: - raise click.ClickException(str(exc)) from exc + _check_pack(folder, name) target = user_sites_dir() / name if target.exists(): if not force: @@ -161,6 +198,10 @@ def export(ctx, name, out_path, with_calibrations, force): 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( @@ -168,9 +209,9 @@ def export(ctx, name, out_path, with_calibrations, force): ) if entry.pack is not None: - site_toml = (entry.pack / PACK_FILE).read_text() + site_toml = (entry.pack / PACK_FILE).read_bytes() else: - site_toml = _site_toml(entry.table) + site_toml = _site_toml(entry.table).encode() calibrations: dict[str, Path] = {} if with_calibrations: site = entry.site() diff --git a/dotbot/site.py b/dotbot/site.py index 0b7b9d08..2a261ae0 100644 --- a/dotbot/site.py +++ b/dotbot/site.py @@ -16,6 +16,7 @@ from __future__ import annotations +import re from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -23,6 +24,8 @@ from dotbot.area import Area, AreaRegistry, area_role SITE_DEFAULT = "default" +# A site name is a bare TOML key, and names a site pack's folder +SITE_NAME = re.compile(r"[A-Za-z0-9_-]+") # The side of the square, at the frame origin, a site that declares nothing # works in. FIELD_FALLBACK_MM = 2000 @@ -106,6 +109,13 @@ def registry(self) -> AreaRegistry: return AreaRegistry(named=dict(self.areas), site=self.name) +def check_site_name(name: str) -> str: + """`name`, or ValueError when it is not letters, digits, `-` and `_`.""" + if not SITE_NAME.fullmatch(name): + raise ValueError(f"site name {name!r}: use letters, digits, - and _") + return name + + def field_or_fallback(site: Site | None) -> Area: """The site's field, else a `FIELD_FALLBACK_MM` square at the frame origin.""" area = site.field if site is not None else None diff --git a/dotbot/tests/test_cli_config.py b/dotbot/tests/test_cli_config.py index 3484ba1f..c6cb2452 100644 --- a/dotbot/tests/test_cli_config.py +++ b/dotbot/tests/test_cli_config.py @@ -232,11 +232,13 @@ def test_config_init_names_the_site(runner): assert set(loaded.sites) == {"demo-dcoss-2026"} -def test_config_init_refuses_a_site_name_toml_cannot_hold(runner): +@pytest.mark.parametrize("name", ["my lab", "lab\n", ""]) +def test_config_init_refuses_a_site_name_toml_cannot_hold(runner, name): with runner.isolated_filesystem(): - result = runner.invoke(cli, ["config", "init", "--site", "my lab"]) + result = runner.invoke(cli, ["config", "init", "--site", name]) assert result.exit_code != 0 assert "--site" in result.output + assert not Path("dotbot.toml").exists() def test_config_init_global_writes_the_default_site(runner, tmp_path, monkeypatch): diff --git a/dotbot/tests/test_site_packs.py b/dotbot/tests/test_site_packs.py index 936e72e2..138cdb2a 100644 --- a/dotbot/tests/test_site_packs.py +++ b/dotbot/tests/test_site_packs.py @@ -275,3 +275,60 @@ def test_export_names_the_known_sites_when_asked_for_another(runner, tmp_path, h result = runner.invoke(cli, ["-c", str(config), "site", "export", "nope"]) assert result.exit_code != 0 assert "known sites: lab" in result.output + + +def _commit(repo: Path) -> None: + for command in ( + ["init", "-q"], + ["add", "-A"], + ["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "pack"], + ): + subprocess.run(["git", "-C", str(repo), *command], check=True) + + +def test_add_refuses_a_git_url_that_names_no_usable_site(runner, tmp_path, home): + # The URL's last segment, `..`, would name the folder above the packs. + repo = _pack(tmp_path, "repo") + (repo / "sub").mkdir() + (repo / "sub" / "keep").write_text("") + _commit(repo) + kept = home / ".dotbot" / "config.toml" + kept.parent.mkdir() + kept.write_text("") + url = f"git+{repo.as_uri()}/sub/.." + result = runner.invoke(cli, ["site", "add", url, "--force"]) + assert result.exit_code != 0 + assert "site name '..'" in result.output + assert kept.is_file() + + +def test_add_refuses_a_zip_whose_name_is_not_a_site_name(runner, tmp_path, home): + archive = tmp_path / "my lab.zip" + with zipfile.ZipFile(archive, "w") as opened: + opened.writestr("site.toml", 'anchor = "a corner"\n') + result = runner.invoke(cli, ["site", "add", str(archive)]) + assert result.exit_code != 0 and "site name 'my lab'" in result.output + assert not (home / ".dotbot" / "sites").exists() + + +def test_add_refuses_a_pack_holding_links(runner, tmp_path, home): + secret = tmp_path / "secret.toml" + secret.write_text("private = true\n") + pack = _pack(tmp_path / "shared", "linked", calibration=True) + try: + (pack / "calibrations" / "stolen.toml").symlink_to(secret) + except OSError: + pytest.skip("this machine cannot create symbolic links") + result = runner.invoke(cli, ["site", "add", str(pack)]) + assert result.exit_code != 0 + assert "holds links" in result.output and "stolen.toml" in result.output + assert not (home / ".dotbot" / "sites" / "linked").exists() + + +def test_add_refuses_git_transports_that_run_commands(runner, tmp_path, home): + marker = tmp_path / "ran" + result = runner.invoke( + cli, ["site", "add", f"git+ext::sh -c touch% {marker.as_posix()}"] + ) + assert result.exit_code != 0 and "git clone" in result.output + assert not marker.exists() From dd9680124a9df3a09c4afda939ba7165c1b8ded7 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Tue, 29 Sep 2026 12:54:28 +0200 Subject: [PATCH 21/37] dotbot/calibration: define the spec ambiguity check once, not per folder AI-assisted: Claude Opus 5.5 --- dotbot/calibration/lighthouse2.py | 52 ++++++++++++++----------------- 1 file changed, 23 insertions(+), 29 deletions(-) diff --git a/dotbot/calibration/lighthouse2.py b/dotbot/calibration/lighthouse2.py index b1c33726..dafe5c75 100644 --- a/dotbot/calibration/lighthouse2.py +++ b/dotbot/calibration/lighthouse2.py @@ -746,37 +746,31 @@ def resolve_calibration_spec( spec_lower = spec.lower() spec_slug = slug_tag(spec).lower() + def unique(kind: str, matches: list[Path], files: dict) -> Optional[Path]: + if len(matches) > 1: + lines = [ + f" {files[path].get('id', '?')} " + f"{files[path].get(created_key, '?')} {path}" + for path in matches + ] + raise ValueError( + f"{what} {kind} {spec!r} matches several files:\n" + "\n".join(lines) + ) + return matches[0] if matches else None + for folder, prefix in folders: files = {path: metadata(path) for path in sorted(folder.glob(prefix + glob))} - - def unique(kind: str, matches: list) -> Optional[Path]: - if len(matches) > 1: - lines = [ - f" {files[path].get('id', '?')} " - f"{files[path].get(created_key, '?')} {path}" - for path in matches - ] - raise ValueError( - f"{what} {kind} {spec!r} matches several files:\n" - + "\n".join(lines) - ) - return matches[0] if matches else None - - found = unique( - "tag", - [ - path - for path, data in files.items() - if str(data.get("tag", "")).lower() in {spec_lower, spec_slug} - {""} - ], - ) or unique( - "id prefix", - [ - path - for path, data in files.items() - if str(data.get("id", "")).lower().startswith(spec_lower) - ], - ) + tags = [ + path + for path, data in files.items() + if str(data.get("tag", "")).lower() in {spec_lower, spec_slug} - {""} + ] + 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( From f097612463344269c55e81a3e4306a84b6434829 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Tue, 29 Sep 2026 12:54:28 +0200 Subject: [PATCH 22/37] dotbot/controller: read a calibration's age in any ISO zone, check the limit AI-assisted: Claude Opus 5.5 --- dotbot/calibration/lighthouse2.py | 9 +++++---- dotbot/controller_app.py | 19 +++++++++++++++++-- dotbot/tests/test_calibration_lighthouse2.py | 20 ++++++++++++++++++++ dotbot/tests/test_controller_app.py | 10 ++++++++++ 4 files changed, 52 insertions(+), 6 deletions(-) diff --git a/dotbot/calibration/lighthouse2.py b/dotbot/calibration/lighthouse2.py index dafe5c75..7287be09 100644 --- a/dotbot/calibration/lighthouse2.py +++ b/dotbot/calibration/lighthouse2.py @@ -785,13 +785,14 @@ def unique(kind: str, matches: list[Path], files: dict) -> Optional[Path]: def calibration_age_days( calibration: Calibration, now: Optional[datetime.datetime] = None ) -> Optional[float]: - """Days since `created_at`, or None when it is missing or unreadable.""" + """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.strptime( - calibration.created_at, "%Y-%m-%dT%H:%M:%SZ" - ).replace(tzinfo=datetime.timezone.utc) + 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 diff --git a/dotbot/controller_app.py b/dotbot/controller_app.py index 2e95f9f5..3668297a 100644 --- a/dotbot/controller_app.py +++ b/dotbot/controller_app.py @@ -72,6 +72,20 @@ def _resolve_controller_key(key, flag, config, default): return ([default] if isinstance(default, str) else default), "the default" +def _max_age_days(raw, source: str) -> int: + """`lh2_calibration_max_age_days` as a count of days, 0 or more.""" + try: + days = int(raw) + except ValueError: + days = -1 + if days < 0: + raise click.ClickException( + f"lh2_calibration_max_age_days from {source} is {raw!r}; give a " + "whole number of days, or 0 to never warn" + ) + return days + + def _conn_to_settings(conn, swarm_id, sim_is_dotbot): """Map `--conn` + `--swarm-id` into internal ControllerSettings fields. @@ -479,9 +493,10 @@ def main( camera_detect_share, _ = _resolve_controller_key( "camera_detect_share", camera_detect_share, unified, DETECT_SHARE ) - max_age_days, _ = _resolve_controller_key( + raw_max_age, max_age_source = _resolve_controller_key( "lh2_calibration_max_age_days", None, unified, LH2_CALIBRATION_MAX_AGE_DAYS ) + max_age_days = _max_age_days(raw_max_age, max_age_source) camera_max_robots = int(camera_max_robots) camera_detect_share = float(camera_detect_share) if camera_calibration: @@ -538,7 +553,7 @@ def main( "controller_http_host": controller_http_host, "site": site, "lh2_calibration": lh2_calibration, - "lh2_calibration_max_age_days": int(max_age_days), + "lh2_calibration_max_age_days": max_age_days, "camera_calibration": camera_calibration, "camera_detect": camera_detect, "camera_max_robots": camera_max_robots, diff --git a/dotbot/tests/test_calibration_lighthouse2.py b/dotbot/tests/test_calibration_lighthouse2.py index 196342d8..ae907332 100644 --- a/dotbot/tests/test_calibration_lighthouse2.py +++ b/dotbot/tests/test_calibration_lighthouse2.py @@ -903,3 +903,23 @@ def test_typed_points_are_recorded_as_points(): specs = ["47,18.5", "1953,18.5", "47,1981.5", "1953,1981.5"] assert collect_points(ROLED, specs) == (specs, None, "") assert points_from_specs(specs, ROLED) == "points" + + +@pytest.mark.parametrize( + "created_at, age", + [ + ("2026-09-10T09:00:00Z", 2.0), + ("2026-09-10T11:00:00+02:00", 2.0), + ("2026-09-10T09:00:00", 2.0), + ("", None), + ("last tuesday", None), + ], +) +def test_calibration_age_reads_any_iso_zone(created_at, age): + import datetime + + from dotbot.calibration.lighthouse2 import Calibration, calibration_age_days + + now = datetime.datetime(2026, 9, 12, 9, tzinfo=datetime.timezone.utc) + calibration = Calibration(created_at=created_at) + assert calibration_age_days(calibration, now) == age diff --git a/dotbot/tests/test_controller_app.py b/dotbot/tests/test_controller_app.py index ea359491..686b57a6 100644 --- a/dotbot/tests/test_controller_app.py +++ b/dotbot/tests/test_controller_app.py @@ -476,3 +476,13 @@ def test_robots_needs_a_dotbot_simulator(): ) assert result.exit_code == 2 assert "needs a DotBot simulator" in result.output + + +@pytest.mark.parametrize("value", ["abc", "-3", "1.5"]) +@patch("dotbot.controller.Controller.run") +def test_main_refuses_a_bad_calibration_max_age(run, value, monkeypatch): + monkeypatch.setenv("DOTBOT_RUN_CONTROLLER_LH2_CALIBRATION_MAX_AGE_DAYS", value) + result = CliRunner().invoke(main, ["--conn", "simulator"]) + assert result.exit_code != 0 + assert "whole number of days" in result.output + run.assert_not_called() From 0c6c3c5332a63f97c72ebefbfe2d1d1c2c4fc834 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Tue, 29 Sep 2026 12:54:28 +0200 Subject: [PATCH 23/37] dotbot/controller: collect the solved stations once, not per advertisement AI-assisted: Claude Opus 5.5 --- dotbot/controller.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dotbot/controller.py b/dotbot/controller.py index 23556dad..0d1955b2 100644 --- a/dotbot/controller.py +++ b/dotbot/controller.py @@ -323,6 +323,7 @@ def __init__(self, settings: ControllerSettings): "No calibration selected: robots keep whatever they hold. " "Pass --lh2-calibration or set [run.controller] lh2_calibration." ) + self._solved_stations = {station.index for station in self.lh2_calibration} # (robot, station) pairs already warned about, so each is warned once self._unsolved_warned: set[tuple[str, int]] = set() self.cameras: List[CameraService] = [] @@ -367,8 +368,7 @@ def _warn_unsolved_stations(self, address: str, calibrated: int) -> None: """Warn once per robot and station about a homography the loaded calibration does not solve: that robot's positions from the station come from some other calibration.""" - solved = {station.index for station in self.lh2_calibration} - for index in sorted(_held_stations(calibrated) - solved): + for index in sorted(_held_stations(calibrated) - self._solved_stations): if (address, index) in self._unsolved_warned: continue self._unsolved_warned.add((address, index)) @@ -377,7 +377,7 @@ def _warn_unsolved_stations(self, address: str, calibrated: int) -> None: address=address, station=index, calibration_id=self.calibration.id, - solved=sorted(solved), + solved=sorted(self._solved_stations), ) def _start_camera(self, spec: str) -> None: From 31afafb91810a72e49cc45f8cd9a821fb168b074 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Tue, 29 Sep 2026 12:54:28 +0200 Subject: [PATCH 24/37] dotbot/area: type roles as Role everywhere, derive ROLES from it AI-assisted: Claude Opus 5.5 --- dotbot/area.py | 8 ++++---- dotbot/models.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/dotbot/area.py b/dotbot/area.py index 816c9b66..699309fc 100644 --- a/dotbot/area.py +++ b/dotbot/area.py @@ -17,13 +17,13 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Literal +from typing import Literal, get_args Role = Literal["field", "staging", "corner"] -ROLES: tuple[str, ...] = ("field", "staging", "corner") +ROLES: tuple[str, ...] = get_args(Role) -def area_role(name: str, role: str | None = None) -> str | None: +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 @@ -43,7 +43,7 @@ class Area: w: int h: int name: str = "" - role: str | None = None + role: Role | None = None @property def x_max(self) -> int: diff --git a/dotbot/models.py b/dotbot/models.py index 774254f0..627837e7 100644 --- a/dotbot/models.py +++ b/dotbot/models.py @@ -14,7 +14,7 @@ from pydantic import BaseModel, BeforeValidator, Field, field_validator -from dotbot.area import Area +from dotbot.area import Area, Role from dotbot.protocol import ApplicationType, ControlModeType, WaypointsStatus from dotbot.robots import ROBOT_DEFAULT, BodyPose from dotbot.site import Site @@ -192,7 +192,7 @@ class DotBotAreaModel(BaseModel): w: int h: int name: str = "" - role: Optional[Literal["field", "staging", "corner"]] = None + role: Optional[Role] = None class DotBotPlacementSpanModel(BaseModel): From 90a369f81c38822dcada117dd0ce4ccb5d400de1 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Tue, 29 Sep 2026 12:54:28 +0200 Subject: [PATCH 25/37] dotbot/console-web: show a calibration ahead of the clock as 0 days old AI-assisted: Claude Opus 5.5 --- dotbot/console-web/src/calibrationSpan.test.ts | 1 + dotbot/console-web/src/calibrationSpan.ts | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/dotbot/console-web/src/calibrationSpan.test.ts b/dotbot/console-web/src/calibrationSpan.test.ts index c1ad0d62..d01f8a72 100644 --- a/dotbot/console-web/src/calibrationSpan.test.ts +++ b/dotbot/console-web/src/calibrationSpan.test.ts @@ -80,6 +80,7 @@ describe("the tooltip", () => { const now = new Date("2026-09-29T10:00:00Z"); expect(ageDays(CALIBRATION.created_at, now)).toBe(19); expect(ageDays("", now)).toBeNull(); + expect(ageDays("2099-01-01T00:00:00Z", now)).toBe(0); const [span] = calibrationSpans(CALIBRATION); expect(spanTitle(CALIBRATION, span, now)).toBe( "LH2 calibration demo (ac893d2d), 19 days old: calibrated over a 500 mm " + diff --git a/dotbot/console-web/src/calibrationSpan.ts b/dotbot/console-web/src/calibrationSpan.ts index 60a81ec3..c855fbe9 100644 --- a/dotbot/console-web/src/calibrationSpan.ts +++ b/dotbot/console-web/src/calibrationSpan.ts @@ -53,11 +53,12 @@ export function describePointsFrom(pointsFrom: string): string { return "its recorded points"; } -/** Whole days since `createdAt`, or null when it does not parse. */ +/** Whole days since `createdAt`, 0 for a time ahead of this browser's clock, + * or null when it does not parse. */ export function ageDays(createdAt: string, now: Date = new Date()): number | null { const created = Date.parse(createdAt); if (Number.isNaN(created)) return null; - return Math.floor((now.getTime() - created) / 86_400_000); + return Math.max(0, Math.floor((now.getTime() - created) / 86_400_000)); } /** The outline's tooltip: which calibration, how old, and what it covers. */ From a5b6f4caaa2d433279673e28712a326989c12f95 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Tue, 29 Sep 2026 12:54:28 +0200 Subject: [PATCH 26/37] dotbot/examples: drop an em dash from the motions address message AI-assisted: Claude Opus 5.5 --- dotbot/examples/motions/motions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotbot/examples/motions/motions.py b/dotbot/examples/motions/motions.py index d5a0a8ba..8671a1d6 100644 --- a/dotbot/examples/motions/motions.py +++ b/dotbot/examples/motions/motions.py @@ -541,7 +541,7 @@ async def run_async( site = await client.fetch_site() if address is None: rprint( - "[yellow]No address provided — fetching available DotBots ...[/yellow]" + "[yellow]No address provided, fetching available DotBots ...[/yellow]" ) dotbots = await client.fetch_dotbots() if not dotbots: From 77b634c724cdd92e7726384bef0ca3bbb20e17f1 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Tue, 29 Sep 2026 13:12:18 +0200 Subject: [PATCH 27/37] dotbot/calibration: record points_from as a table, not a string points_from is now an inline table in the calibration file, e.g. { kind = "over", area = "dev-corner" }, and an object in the site payload. A file carrying the old string form is refused. AI-assisted: Claude Opus 5.5 --- dotbot/calibration/lighthouse2.py | 31 +++++-- dotbot/calibration/points.py | 84 ++++++++++++++++--- dotbot/calibration/session.py | 5 +- .../console-web/src/calibrationSpan.test.ts | 20 +++-- dotbot/console-web/src/calibrationSpan.ts | 23 +++-- .../src/calibrationSpanLayer.test.tsx | 2 +- dotbot/console-web/src/types.ts | 11 ++- dotbot/models.py | 21 ++++- dotbot/tests/test_calibration_lighthouse2.py | 55 +++++++++--- dotbot/tests/test_calibration_session.py | 28 ++++--- dotbot/tests/test_server.py | 5 +- 11 files changed, 217 insertions(+), 68 deletions(-) diff --git a/dotbot/calibration/lighthouse2.py b/dotbot/calibration/lighthouse2.py index 7287be09..6750643c 100644 --- a/dotbot/calibration/lighthouse2.py +++ b/dotbot/calibration/lighthouse2.py @@ -27,6 +27,7 @@ import numpy as np +from dotbot.calibration.points import PointsFrom from dotbot.robots import ROBOT_DEFAULT from dotbot.site import SITE_DEFAULT, Site @@ -156,14 +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: `field`, `over `, - `square ` or `points`. + `points_from` says how the points were chosen. """ index: int points_mm: list[tuple[float, float]] at: str = "" - points_from: str = "" + points_from: PointsFrom | None = None captured_at: str = "" samples: list[Sample] = field(default_factory=list) @@ -552,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('"', '\\"') @@ -587,8 +600,10 @@ def render_calibration(calibration: Calibration) -> str: f'at = "{toml_escape(placement.at)}"', f"points_mm = {toml_points(placement.points_mm)}", ] - if placement.points_from: - out.append(f'points_from = "{toml_escape(placement.points_from)}"') + 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 = [", @@ -659,7 +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=raw.get("points_from", ""), + points_from=( + PointsFrom.from_dict(raw["points_from"]) + if "points_from" in raw + else None + ), captured_at=raw.get("captured_at", ""), samples=samples, ) diff --git a/dotbot/calibration/points.py b/dotbot/calibration/points.py index b8623e74..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: @@ -179,7 +236,7 @@ def collect_points( points: list[str], over: str | None = None, square: int | None = None, -) -> tuple[list[str], str | None, str]: +) -> 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 @@ -191,7 +248,7 @@ def collect_points( return points, None, "" if over is not None: area = site.registry().resolve(over) - return [f"{area.name}:corners"], f"over {area.name}", "" + return [f"{area.name}:corners"], PointsFrom("over", area=area.name), "" if square is None: return [field_corners(site)], None, "" field = _field(site) @@ -201,25 +258,28 @@ def collect_points( f"{field.w} x {field.h} mm field is extrapolated, and the error grows " "toward its corners." ) - return [f"{square_area.name}:corners"], f"square {square}", note + return ( + [f"{square_area.name}:corners"], + PointsFrom("square", side_mm=square), + note, + ) -def points_from_specs(specs: list[str] | tuple[str, ...], site: Site) -> str: - """How a placement's points were chosen, as its calibration file records it. +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. + `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 "field" + return PointsFrom("field") if name in site.areas: - return f"over {name}" - return "points" + return PointsFrom("over", area=name) + return PointsFrom("points") def _centre(area: Area) -> PointPlacement: diff --git a/dotbot/calibration/session.py b/dotbot/calibration/session.py index 03880a29..956f46ff 100644 --- a/dotbot/calibration/session.py +++ b/dotbot/calibration/session.py @@ -38,6 +38,7 @@ ) from dotbot.calibration.points import ( PointPlacement, + PointsFrom, field_corners, points_from_specs, resolve_placement_points, @@ -82,7 +83,7 @@ class CalibrationSession: points: list[SessionPoint] site: Site at: str = "" - points_from: str = "" + points_from: PointsFrom | None = None # The area the expected error will be evaluated over; "" means none. area: str = "" device: str = "" @@ -108,7 +109,7 @@ def resolve( specs: Sequence[str], site: Site | None = None, robot: str = ROBOT_DEFAULT, - points_from: str | None = None, + points_from: PointsFrom | None = None, **kwargs: Any, ) -> CalibrationSession: """A session over the points one `--points` specification stands for. diff --git a/dotbot/console-web/src/calibrationSpan.test.ts b/dotbot/console-web/src/calibrationSpan.test.ts index d01f8a72..a8328da5 100644 --- a/dotbot/console-web/src/calibrationSpan.test.ts +++ b/dotbot/console-web/src/calibrationSpan.test.ts @@ -22,7 +22,7 @@ const CALIBRATION: SiteCalibration = { id: "ac893d2d85e3068c", tag: "demo", created_at: "2026-09-10T09:12:00Z", - placements: [{ points_mm: SQUARE, points_from: "square 500" }], + placements: [{ points_mm: SQUARE, points_from: { kind: "square", side_mm: 500 } }], }; describe("convexHull", () => { @@ -44,13 +44,13 @@ describe("calibrationSpans", () => { it("gives the span of four points, with how they were chosen", () => { const [span] = calibrationSpans(CALIBRATION); expect(span.points).toHaveLength(4); - expect(span.pointsFrom).toBe("square 500"); + expect(span.pointsFrom).toEqual({ kind: "square", side_mm: 500 }); }); it("skips a placement whose points do not span an area", () => { const line = { ...CALIBRATION, - placements: [{ points_mm: [[0, 0], [10, 0], [20, 0]] as [number, number][], points_from: "points" }], + placements: [{ points_mm: [[0, 0], [10, 0], [20, 0]] as [number, number][], points_from: { kind: "points" } as const }], }; expect(calibrationSpans(line)).toEqual([]); expect(calibrationSpans(null)).toEqual([]); @@ -69,11 +69,15 @@ describe("hatchBox", () => { describe("the tooltip", () => { it("says how the points were chosen", () => { - expect(describePointsFrom("field")).toBe("the field's corners"); - expect(describePointsFrom("over dev-corner")).toBe("the corners of dev-corner"); - expect(describePointsFrom("square 500")).toBe("a 500 mm square in the field"); - expect(describePointsFrom("points")).toBe("points given by hand"); - expect(describePointsFrom("")).toBe("its recorded points"); + expect(describePointsFrom({ kind: "field" })).toBe("the field's corners"); + expect(describePointsFrom({ kind: "over", area: "dev-corner" })).toBe( + "the corners of dev-corner", + ); + expect(describePointsFrom({ kind: "square", side_mm: 500 })).toBe( + "a 500 mm square in the field", + ); + expect(describePointsFrom({ kind: "points" })).toBe("points given by hand"); + expect(describePointsFrom(null)).toBe("its recorded points"); }); it("names the calibration and its age", () => { diff --git a/dotbot/console-web/src/calibrationSpan.ts b/dotbot/console-web/src/calibrationSpan.ts index c855fbe9..144905d4 100644 --- a/dotbot/console-web/src/calibrationSpan.ts +++ b/dotbot/console-web/src/calibrationSpan.ts @@ -1,4 +1,4 @@ -import type { Area, SiteCalibration } from "./types"; +import type { Area, PointsFrom, SiteCalibration } from "./types"; // Where the loaded LH2 calibration was fitted, and what is extrapolated: the // map outlines each placement's span and hatches the rest of the site. @@ -7,7 +7,7 @@ export type Point = [number, number]; export interface Span { points: Point[]; - pointsFrom: string; + pointsFrom: PointsFrom | null; } const cross = (o: Point, a: Point, b: Point) => @@ -45,12 +45,19 @@ export function hatchBox(extent: [number, number] | null): Area | null { } /** How a placement's points were chosen, as a phrase. */ -export function describePointsFrom(pointsFrom: string): string { - if (pointsFrom === "field") return "the field's corners"; - if (pointsFrom.startsWith("over ")) return `the corners of ${pointsFrom.slice(5)}`; - if (pointsFrom.startsWith("square ")) return `a ${pointsFrom.slice(7)} mm square in the field`; - if (pointsFrom === "points") return "points given by hand"; - return "its recorded points"; +export function describePointsFrom(pointsFrom: PointsFrom | null): string { + switch (pointsFrom?.kind) { + case "field": + return "the field's corners"; + case "over": + return `the corners of ${pointsFrom.area}`; + case "square": + return `a ${pointsFrom.side_mm} mm square in the field`; + case "points": + return "points given by hand"; + default: + return "its recorded points"; + } } /** Whole days since `createdAt`, 0 for a time ahead of this browser's clock, diff --git a/dotbot/console-web/src/calibrationSpanLayer.test.tsx b/dotbot/console-web/src/calibrationSpanLayer.test.tsx index fbb8ff0e..dbad4bb2 100644 --- a/dotbot/console-web/src/calibrationSpanLayer.test.tsx +++ b/dotbot/console-web/src/calibrationSpanLayer.test.tsx @@ -24,7 +24,7 @@ const SITE: Site = { [750, 1250], [1250, 1250], ], - points_from: "square 500", + points_from: { kind: "square", side_mm: 500 }, }, ], }, diff --git a/dotbot/console-web/src/types.ts b/dotbot/console-web/src/types.ts index 412cfc89..6f47fa9d 100644 --- a/dotbot/console-web/src/types.ts +++ b/dotbot/console-web/src/types.ts @@ -440,12 +440,19 @@ export interface Site { // The LH2 calibration the controller loaded: each placement's points in // frame mm, which span the part of the site it was fitted over, and how they -// were chosen (`field`, `over `, `square ` or `points`). +// were chosen: the field's corners, the corners of `area`, a `side_mm` square +// centred in the field, or given by hand. Null when the file does not say. +export interface PointsFrom { + kind: "field" | "over" | "square" | "points"; + area?: string | null; + side_mm?: number | null; +} + export interface SiteCalibration { id: string; tag: string; created_at: string; - placements: { points_mm: [number, number][]; points_from: string }[]; + placements: { points_mm: [number, number][]; points_from: PointsFrom | null }[]; } // GET /controller/cameras - one registered camera, one area. `width` and diff --git a/dotbot/models.py b/dotbot/models.py index 627837e7..5378a84b 100644 --- a/dotbot/models.py +++ b/dotbot/models.py @@ -15,6 +15,7 @@ from pydantic import BaseModel, BeforeValidator, Field, field_validator from dotbot.area import Area, Role +from dotbot.calibration.points import PointsKind from dotbot.protocol import ApplicationType, ControlModeType, WaypointsStatus from dotbot.robots import ROBOT_DEFAULT, BodyPose from dotbot.site import Site @@ -195,12 +196,20 @@ class DotBotAreaModel(BaseModel): role: Optional[Role] = None +class DotBotPointsFromModel(BaseModel): + """How a placement's points were chosen: the field's corners, the corners + of `area`, a `side_mm` square centred in the field, or given by hand.""" + + kind: PointsKind + area: Optional[str] = None + side_mm: Optional[int] = None + + class DotBotPlacementSpanModel(BaseModel): - """One placement's points, in frame millimetres, and how they were chosen - (`field`, `over `, `square ` or `points`).""" + """One placement's points, in frame millimetres, and how they were chosen.""" points_mm: List[List[float]] - points_from: str = "" + points_from: Optional[DotBotPointsFromModel] = None class DotBotCalibrationSpanModel(BaseModel): @@ -221,7 +230,11 @@ def from_calibration(cls, calibration: Any) -> "DotBotCalibrationSpanModel": placements=[ DotBotPlacementSpanModel( points_mm=[list(point) for point in placement.points_mm], - points_from=placement.points_from, + points_from=( + None + if placement.points_from is None + else DotBotPointsFromModel(**placement.points_from.to_dict()) + ), ) for placement in calibration.placements ], diff --git a/dotbot/tests/test_calibration_lighthouse2.py b/dotbot/tests/test_calibration_lighthouse2.py index ae907332..7e63566b 100644 --- a/dotbot/tests/test_calibration_lighthouse2.py +++ b/dotbot/tests/test_calibration_lighthouse2.py @@ -28,6 +28,7 @@ resolve_calibration_path, ) from dotbot.calibration.points import ( + PointsFrom, centred_square, collect_header, collect_points, @@ -266,15 +267,45 @@ def test_schema_2_round_trips_and_re_solves_to_the_same_matrices_and_id( def test_points_from_round_trips_through_the_file(monkeypatch, tmp_path): corners = [(-0.25, -0.25), (0.25, -0.25), (-0.25, 0.25), (0.25, 0.25)] - placement = replace(_consistent_placement(corners), points_from="over dev-corner") + how = PointsFrom("over", area="dev-corner") + placement = replace(_consistent_placement(corners), points_from=how) monkeypatch.setattr(lighthouse2, "CALIBRATION_DIR", tmp_path) manager = LighthouseManager(placements=[placement]) manager.solve() path = manager.save_calibration() parsed = tomllib.loads(path.read_text()) - assert parsed["placement"][0]["points_from"] == "over dev-corner" - assert read_calibration_file(path).placements[0].points_from == "over dev-corner" + assert parsed["placement"][0]["points_from"] == { + "kind": "over", + "area": "dev-corner", + } + assert read_calibration_file(path).placements[0].points_from == how + + +def test_points_from_written_as_a_string_is_rejected(tmp_path): + path = tmp_path / "calibration-2026-01-01T00-00-00Z-deadbeef.toml" + path.write_text( + "schema_version = 2\n[[placement]]\nindex = 0\npoints_mm = []\n" + 'points_from = "over dev-corner"\n', + encoding="utf-8", + ) + with pytest.raises(ValueError, match="points_from is a table"): + read_calibration_file(path) + + +@pytest.mark.parametrize( + "raw", + [ + {"kind": "nearby"}, + {"kind": "over"}, + {"kind": "square"}, + {"kind": "field", "area": "field"}, + {"kind": "points", "side_mm": 500}, + ], +) +def test_points_from_carries_exactly_the_fields_of_its_kind(raw): + with pytest.raises(ValueError): + PointsFrom.from_dict(raw) def test_schema_1_file_is_rejected(tmp_path): @@ -305,7 +336,7 @@ def test_calibration_id_ignores_the_descriptive_fields(monkeypatch, tmp_path): original.tag = "another-session" original.robot = "dotbot-v9" original.placements[0].at = "typed by hand" - original.placements[0].points_from = "square 500" + original.placements[0].points_from = PointsFrom("square", side_mm=500) assert original.id == before @@ -856,13 +887,13 @@ def _five_point_placement(): def test_collect_defaults_to_the_fields_corners(): assert field_corners(ROLED) == "field:corners" assert collect_points(ROLED, []) == (["field:corners"], None, "") - assert points_from_specs(["field:corners"], ROLED) == "field" + assert points_from_specs(["field:corners"], ROLED) == PointsFrom("field") def test_a_site_with_only_an_extent_calibrates_over_the_extent(): site = Site(name="hall", extent_mm=(5000, 4000)) assert field_corners(site) == "0,0,5000,4000:corners" - assert points_from_specs([field_corners(site)], site) == "field" + assert points_from_specs([field_corners(site)], site) == PointsFrom("field") assert resolve_points(field_corners(site), site.registry())[3].mm == ( 5000 - 47.0, 4000 - 18.5, @@ -877,10 +908,12 @@ def test_a_site_with_no_field_names_the_fix(): def test_over_calibrates_another_areas_corners(): assert collect_points(ROLED, [], over="dev-corner") == ( ["dev-corner:corners"], - "over dev-corner", + PointsFrom("over", area="dev-corner"), "", ) - assert points_from_specs(["dev-corner:corners"], ROLED) == "over dev-corner" + assert points_from_specs(["dev-corner:corners"], ROLED) == PointsFrom( + "over", area="dev-corner" + ) with pytest.raises(ValueError, match="unknown area 'nowhere'"): collect_points(ROLED, [], over="nowhere") @@ -888,9 +921,9 @@ def test_over_calibrates_another_areas_corners(): def test_square_calibrates_a_centred_square_and_says_the_rest_is_extrapolated(): specs, how, note = collect_points(ROLED, [], square=500) assert specs == ["750,750,500,500:corners"] - assert how == "square 500" + assert how == PointsFrom("square", side_mm=500) assert "the rest of the 2000 x 2000 mm field is extrapolated" in note - assert points_from_specs(specs, ROLED) == "points" + assert points_from_specs(specs, ROLED) == PointsFrom("points") @pytest.mark.parametrize("side", [0, -5, 2001]) @@ -902,7 +935,7 @@ def test_a_square_that_is_empty_or_does_not_fit_is_refused(side): def test_typed_points_are_recorded_as_points(): specs = ["47,18.5", "1953,18.5", "47,1981.5", "1953,1981.5"] assert collect_points(ROLED, specs) == (specs, None, "") - assert points_from_specs(specs, ROLED) == "points" + assert points_from_specs(specs, ROLED) == PointsFrom("points") @pytest.mark.parametrize( diff --git a/dotbot/tests/test_calibration_session.py b/dotbot/tests/test_calibration_session.py index 0c18cb9e..7c850da6 100644 --- a/dotbot/tests/test_calibration_session.py +++ b/dotbot/tests/test_calibration_session.py @@ -31,7 +31,7 @@ CaptureSession, parse_capture_payload, ) -from dotbot.calibration.points import CORNERS +from dotbot.calibration.points import CORNERS, PointsFrom from dotbot.calibration.session import CalibrationSession, SessionError from dotbot.site import Site @@ -212,21 +212,25 @@ def test_a_session_given_no_points_opens_on_the_fields_corners(): ) session = CalibrationSession.resolve([], site=site) assert session.at == "field:corners" - assert session.points_from == "field" + assert session.points_from == PointsFrom("field") assert [p.mm for p in session.points][1] == (1953, 18.5) - assert session.placement().points_from == "field" + assert session.placement().points_from == PointsFrom("field") def test_a_session_records_how_its_points_were_chosen(): assert CalibrationSession.resolve(["annex:corners"], site=C405).points_from == ( - "over annex" + PointsFrom("over", area="annex") ) typed = ["47,18.5", "1953,18.5", "47,1981.5", "1953,1981.5"] - assert CalibrationSession.resolve(typed, site=C405).points_from == "points" + assert CalibrationSession.resolve(typed, site=C405).points_from == PointsFrom( + "points" + ) square = CalibrationSession.resolve( - ["750,750,500,500:corners"], site=C405, points_from="square 500" + ["750,750,500,500:corners"], + site=C405, + points_from=PointsFrom("square", side_mm=500), ) - assert square.points_from == "square 500" + assert square.points_from == PointsFrom("square", side_mm=500) def test_fewer_than_four_points_is_refused_with_the_span_rule(): @@ -1095,7 +1099,7 @@ def _collect_in(monkeypatch, tmp_path, site, *args): ) -def _saved_points_from(tmp_path) -> str: +def _saved_points_from(tmp_path) -> PointsFrom | None: (path,) = (tmp_path / "calibrations" / "c405-arena").glob("*.toml") return read_calibration_file(path).placements[0].points_from @@ -1106,7 +1110,7 @@ def test_collect_with_no_point_flag_calibrates_over_the_field(monkeypatch, tmp_p assert result.exit_code == 0, result.output assert "Points: field:corners (field)." in result.output assert "top-left corner of field" in result.output - assert _saved_points_from(tmp_path) == "field" + assert _saved_points_from(tmp_path) == PointsFrom("field") def test_collect_over_an_area_takes_its_corners(monkeypatch, tmp_path): @@ -1114,7 +1118,7 @@ def test_collect_over_an_area_takes_its_corners(monkeypatch, tmp_path): assert result.exit_code == 0, result.output assert "top-left corner of dev-corner" in result.output - assert _saved_points_from(tmp_path) == "over dev-corner" + assert _saved_points_from(tmp_path) == PointsFrom("over", area="dev-corner") def test_collect_square_says_the_rest_of_the_field_is_extrapolated( @@ -1123,9 +1127,9 @@ def test_collect_square_says_the_rest_of_the_field_is_extrapolated( result = _collect_in(monkeypatch, tmp_path, ROLED, "--square", "500") assert result.exit_code == 0, result.output - assert "Points: 750,750,500,500:corners (square 500)." in result.output + assert "Points: 750,750,500,500:corners (square 500 mm)." in result.output assert "rest of the 2000 x 2000 mm field is extrapolated" in result.output - assert _saved_points_from(tmp_path) == "square 500" + assert _saved_points_from(tmp_path) == PointsFrom("square", side_mm=500) @pytest.mark.parametrize( diff --git a/dotbot/tests/test_server.py b/dotbot/tests/test_server.py index 36439c5d..6db053d3 100644 --- a/dotbot/tests/test_server.py +++ b/dotbot/tests/test_server.py @@ -1383,6 +1383,7 @@ async def test_get_controller_site(): @pytest.mark.asyncio async def test_get_controller_site_carries_the_loaded_calibration_span(): from dotbot.calibration.lighthouse2 import Calibration, Placement + from dotbot.calibration.points import PointsFrom api.controller.site = Site(name="c405-arena", extent_mm=(2000, 4000)) calibration = Calibration( @@ -1391,7 +1392,7 @@ async def test_get_controller_site_carries_the_loaded_calibration_span(): Placement( index=0, points_mm=[(750, 750), (1250, 750), (750, 1250), (1250, 1250)], - points_from="square 500", + points_from=PointsFrom("square", side_mm=500), ) ], created_at="2026-09-10T09:12:00Z", @@ -1406,7 +1407,7 @@ async def test_get_controller_site_carries_the_loaded_calibration_span(): "placements": [ { "points_mm": [[750, 750], [1250, 750], [750, 1250], [1250, 1250]], - "points_from": "square 500", + "points_from": {"kind": "square", "area": None, "side_mm": 500}, } ], } From 09e863645a7800d4d78fcfdef5a7dc10e445fa5d Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Tue, 29 Sep 2026 13:13:38 +0200 Subject: [PATCH 28/37] dotbot/cli: keep the old pack when a forced site add fails A pack is staged in a hidden sibling folder and renamed into place, so site pack discovery skips folders whose name starts with a dot. AI-assisted: Claude Opus 5.5 --- dotbot/cli/site_cmd.py | 45 +++++++++++++++++++++++++-------- dotbot/site_packs.py | 2 ++ dotbot/tests/test_site_packs.py | 21 +++++++++++++++ 3 files changed, 57 insertions(+), 11 deletions(-) diff --git a/dotbot/cli/site_cmd.py b/dotbot/cli/site_cmd.py index e6abc158..d84038ef 100644 --- a/dotbot/cli/site_cmd.py +++ b/dotbot/cli/site_cmd.py @@ -119,6 +119,35 @@ def _check_pack(folder: Path, name: str) -> None: 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.") @@ -132,17 +161,11 @@ def add(source, force): folder, name = _fetch(source, Path(scratch)) _check_pack(folder, name) target = user_sites_dir() / name - if target.exists(): - if not force: - raise click.ClickException( - f"{target} already exists. Pass --force to replace it." - ) - shutil.rmtree(target) - target.mkdir(parents=True) - shutil.copy2(folder / PACK_FILE, target / PACK_FILE) - calibrations = folder / PACK_CALIBRATIONS - if calibrations.is_dir(): - shutil.copytree(calibrations, target / PACK_CALIBRATIONS) + 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} ({count} calibration files)") click.echo(f'Work in it with `site = "{name}"` in your config, or --site {name}.') diff --git a/dotbot/site_packs.py b/dotbot/site_packs.py index 83766af6..e48a68a7 100644 --- a/dotbot/site_packs.py +++ b/dotbot/site_packs.py @@ -77,6 +77,8 @@ def find_packs(folders: list[Path]) -> dict[str, Path]: if not folder.is_dir(): continue for candidate in sorted(folder.iterdir()): + if candidate.name.startswith("."): + continue if (candidate / PACK_FILE).is_file(): packs.setdefault(candidate.name, candidate) return packs diff --git a/dotbot/tests/test_site_packs.py b/dotbot/tests/test_site_packs.py index 138cdb2a..f159263e 100644 --- a/dotbot/tests/test_site_packs.py +++ b/dotbot/tests/test_site_packs.py @@ -14,6 +14,7 @@ from dotbot import site_packs from dotbot.calibration import lighthouse2 from dotbot.calibration.lighthouse2 import load_calibration, resolve_calibration_path +from dotbot.cli import site_cmd from dotbot.cli.main import cli from dotbot.config import ConfigError, load_config, load_config_text from dotbot.site import Site @@ -261,6 +262,26 @@ def test_add_a_pack_folder_and_a_git_repository(runner, tmp_path, home): assert (added / "site.toml").is_file() and not (added / ".git").exists() +def test_a_failed_forced_add_keeps_the_old_pack(runner, tmp_path, home, monkeypatch): + runner.invoke(cli, ["site", "add", str(_pack(tmp_path / "v1", "lab"))]) + sites = home / ".dotbot" / "sites" + newer = _pack(tmp_path / "v2", "lab", anchor="moved", calibration=True) + + def fail(*args, **kwargs): + raise OSError("disk full") + + monkeypatch.setattr(site_cmd.shutil, "copytree", fail) + result = runner.invoke(cli, ["site", "add", str(newer), "--force"]) + assert result.exit_code != 0 + assert ANCHOR in (sites / "lab" / "site.toml").read_text() + assert [path.name for path in sites.iterdir()] == ["lab"] + + +def test_a_hidden_folder_is_not_a_pack(tmp_path): + _pack(tmp_path, ".lab-x1y2") + assert find_packs([tmp_path]) == {} + + def test_add_refuses_what_is_not_a_pack(runner, tmp_path, home): (tmp_path / "empty").mkdir() result = runner.invoke(cli, ["site", "add", str(tmp_path / "empty")]) From 520ec957473c07b685dd28cebcd48525c4a22f3a Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Tue, 29 Sep 2026 13:14:20 +0200 Subject: [PATCH 29/37] dotbot/cli: read a site pack zip from stdin with site add - AI-assisted: Claude Opus 5.5 --- dotbot/cli/site_cmd.py | 57 +++++++++++++++++++++++++++------ dotbot/tests/test_site_packs.py | 44 +++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 10 deletions(-) diff --git a/dotbot/cli/site_cmd.py b/dotbot/cli/site_cmd.py index d84038ef..8eab0ac1 100644 --- a/dotbot/cli/site_cmd.py +++ b/dotbot/cli/site_cmd.py @@ -40,14 +40,46 @@ def _is_git_url(source: str) -> bool: ) -def _pack_in(folder: Path, name: str) -> tuple[Path, str]: - """The pack in an unpacked folder: the folder itself, or its one sub-folder.""" +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 - children = [child for child in folder.iterdir() if child.is_dir()] 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}") + 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: @@ -75,20 +107,24 @@ def _git_clone(url: str, target: Path) -> None: 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): - target = scratch / "unzipped" - with zipfile.ZipFile(path) as archive: - archive.extractall(target) - return _pack_in(target, path.stem) + return _unzip(path, scratch, path.stem) raise click.ClickException( f"{source} is neither a folder, a zip file nor a git URL" ) @@ -154,8 +190,9 @@ def _install(folder: Path, target: Path) -> None: 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) or a git - URL whose repository is one. The folder's name is the site's name. + 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)) diff --git a/dotbot/tests/test_site_packs.py b/dotbot/tests/test_site_packs.py index f159263e..1261838a 100644 --- a/dotbot/tests/test_site_packs.py +++ b/dotbot/tests/test_site_packs.py @@ -282,6 +282,50 @@ def test_a_hidden_folder_is_not_a_pack(tmp_path): assert find_packs([tmp_path]) == {} +def _zipped(pack: Path, root: Path) -> bytes: + archive = pack.parent / "pack.zip" + with zipfile.ZipFile(archive, "w") as opened: + for path in pack.rglob("*"): + if path.is_file(): + opened.write(path, path.relative_to(root).as_posix()) + return archive.read_bytes() + + +def test_add_reads_a_zip_from_stdin_named_by_its_top_folder(runner, tmp_path, home): + pack = _pack(tmp_path / "v1", "lab", calibration=True) + result = runner.invoke(cli, ["site", "add", "-"], input=_zipped(pack, pack.parent)) + assert result.exit_code == 0, result.output + added = home / ".dotbot" / "sites" / "lab" + assert (added / "calibrations" / CALIBRATION_NAME).is_file() + + again = runner.invoke(cli, ["site", "add", "-"], input=_zipped(pack, pack.parent)) + assert again.exit_code != 0 and "--force" in again.output + + bare = runner.invoke(cli, ["site", "add", "-"], input=_zipped(pack, pack)) + assert bare.exit_code != 0 and "nothing names its site" in bare.output + + junk = runner.invoke(cli, ["site", "add", "-"], input=b"not a zip") + assert junk.exit_code != 0 and "not a zip file" in junk.output + + +def test_add_from_a_terminal_stdin_says_to_pipe_a_zip(runner, home, monkeypatch): + class Terminal: + def isatty(self): + return True + + monkeypatch.setattr(site_cmd.click, "get_binary_stream", lambda name: Terminal()) + result = runner.invoke(cli, ["site", "add", "-"]) + assert result.exit_code != 0 + assert "curl -L | dotbot site add -" in result.output + + +def test_add_of_a_zip_url_says_to_download_or_pipe_it(runner, home): + url = "https://example.org/lab.zip" + result = runner.invoke(cli, ["site", "add", url]) + assert result.exit_code != 0 + assert f"curl -L {url} | dotbot site add -" in result.output + + def test_add_refuses_what_is_not_a_pack(runner, tmp_path, home): (tmp_path / "empty").mkdir() result = runner.invoke(cli, ["site", "add", str(tmp_path / "empty")]) From 29ed246117d2877590f19b73ec4c1388a4be040e Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Tue, 29 Sep 2026 15:22:33 +0200 Subject: [PATCH 30/37] dotbot/controller: add --area to run simulator, shape the fleet grid like it Every generated robot faces the same way: positions are reported at the photodiode, a lever arm ahead of the axle, so two halves facing apart drew 300 mm between them against 200 mm everywhere else. AI-assisted: Claude Opus 5.5 --- doc/cli/run.md | 15 +++-- doc/reference/configuration.md | 1 + dotbot/adapter.py | 7 ++- dotbot/config.py | 1 + dotbot/controller.py | 4 ++ dotbot/controller_app.py | 54 +++++++++++++++--- dotbot/dotbot_simulator.py | 81 ++++++++++++++++----------- dotbot/tests/test_controller_app.py | 64 ++++++++++++++++++++- dotbot/tests/test_dotbot_simulator.py | 64 +++++++++++++++++++-- 9 files changed, 234 insertions(+), 57 deletions(-) diff --git a/doc/cli/run.md b/doc/cli/run.md index 9371a6d7..1d672efb 100644 --- a/doc/cli/run.md +++ b/doc/cli/run.md @@ -72,15 +72,20 @@ 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` (else its first area, else its extent, else a 2 x 2 m square); 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/reference/configuration.md b/doc/reference/configuration.md index 6c01248c..60f5a91b 100644 --- a/doc/reference/configuration.md +++ b/doc/reference/configuration.md @@ -132,6 +132,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. | diff --git a/dotbot/adapter.py b/dotbot/adapter.py index 49d43a26..5cf03cfe 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,21 @@ def __init__( simulator_init_state: str = SIMULATOR_INIT_STATE_DEFAULT, site: Optional[Site] = None, robots: Optional[int] = None, + area: Optional[Area] = None, ): self.simulator_init_state = simulator_init_state self.site = site self.robots = robots + self.area = area 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 ) diff --git a/dotbot/config.py b/dotbot/config.py index 825ec885..f7d562a1 100644 --- a/dotbot/config.py +++ b/dotbot/config.py @@ -209,6 +209,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 diff --git a/dotbot/controller.py b/dotbot/controller.py index 0d1955b2..09dee2c7 100644 --- a/dotbot/controller.py +++ b/dotbot/controller.py @@ -45,6 +45,7 @@ SailBotSimulatorAdapter, SerialAdapter, ) +from dotbot.area import Area from dotbot.calibration.driver import SessionDriver from dotbot.calibration.lighthouse2 import homography_as_float32 from dotbot.camera.detection.robot import MAX_ROBOTS @@ -220,6 +221,8 @@ class ControllerSettings: simulator_init_state: str = SIMULATOR_INIT_STATE_DEFAULT # A generated fleet of this many robots, in place of the init-state file simulator_robots: Optional[int] = None + # Where the simulator places its robots; None: the site's field + simulator_area: Optional[Area] = None swarmit_url: Optional[str] = SWARMIT_URL_DEFAULT # None: no swarmit server mrta_url: Optional[str] = None # None: no MRTA server configured (opt-in only) @@ -1249,6 +1252,7 @@ async def _start_adapter(self): self.settings.simulator_init_state, self.site, robots=self.settings.simulator_robots, + area=self.settings.simulator_area, ) elif self.settings.adapter == "sailbot-simulator": self.adapter = SailBotSimulatorAdapter() diff --git a/dotbot/controller_app.py b/dotbot/controller_app.py index 3668297a..663d626d 100644 --- a/dotbot/controller_app.py +++ b/dotbot/controller_app.py @@ -176,8 +176,28 @@ def _maybe_scaffold_sim_state(explicit_init_state): click.echo(f"Created {target} — edit it to customize the simulated swarm.") -def _generated_fleet(robots, write_init_state, init_state, site, dotbot_simulator): - """Check `--robots` against the fleet's site and the other flags. +def _simulator_area(spec, source, site, dotbot_simulator): + """The area `--area` names, resolved in `site`; None for the field. + + A spec from the config applies to simulator runs only; on the command + line it needs one. + """ + if spec is None: + return None + if not dotbot_simulator: + if source == "the command line": + raise click.UsageError("--area needs a DotBot simulator connection.") + return None + try: + return site.registry().resolve(spec) + except ValueError as exc: + raise click.BadParameter(str(exc), param_hint="'--area'") from exc + + +def _generated_fleet( + robots, write_init_state, init_state, site, dotbot_simulator, area=None +): + """Check `--robots` against the fleet's area and the other flags. Returns the robot count left for the simulator to generate and the init-state path to run from: with `--write-init-state`, the count is @@ -203,10 +223,10 @@ def _generated_fleet(robots, write_init_state, init_state, site, dotbot_simulato ) try: - fleet = fleet_init_state(robots, site) + fleet = fleet_init_state(robots, site, area=area) except FleetDoesNotFit as exc: raise click.ClickException(str(exc)) from exc - area = placement_area(site) + area = placement_area(site, area) print( f"Simulated fleet: {robots} robots {FLEET_PITCH_MM} mm apart in " f"{area.name or 'the default area'} ({area.w} x {area.h} mm)" @@ -375,9 +395,19 @@ def _generated_fleet(robots, write_init_state, init_state, site, dotbot_simulato "--robots", type=click.IntRange(min=1), help=( - "With a simulator: start this many robots, 200 mm apart in a " - "near-square grid centred in the site's `field` area (else its first " - "area, else its extent). Not with --simulator-init-state." + "With a simulator: start this many robots, 200 mm apart in a grid " + "shaped like and centred in --area. Not with --simulator-init-state." + ), +) +@click.option( + "--area", + "simulator_area", + type=str, + default=None, + help=( + "With a simulator: the area its robots are placed in, a name from " + "the site's `[sites..areas.]` tables, `x,y,w,h` in " + "frame mm, or a `+`-joined composite. Defaults to the site's field." ), ) @click.option( @@ -435,6 +465,7 @@ def main( background_map, simulator_init_state, robots, + simulator_area, write_init_state, swarmit_url, mrta_url, @@ -530,12 +561,18 @@ def main( # implementation detail — the CLI never exposes it. conn_settings = _conn_to_settings(conn, swarm_id, sim_is_dotbot) + dotbot_simulator = conn_settings.get("adapter") == "dotbot-simulator" + area_spec, area_source = _resolve_controller_key( + "simulator_area", simulator_area, unified, None + ) + simulator_area = _simulator_area(area_spec, area_source, site, dotbot_simulator) robots, simulator_init_state = _generated_fleet( robots, write_init_state, simulator_init_state, site, - conn_settings.get("adapter") == "dotbot-simulator", + dotbot_simulator, + simulator_area, ) # For a simulator connection with no init-state set (CLI default is @@ -561,6 +598,7 @@ def main( "background_map": background_map, "simulator_init_state": simulator_init_state, "simulator_robots": robots, + "simulator_area": simulator_area, "swarmit_url": swarmit_url, "mrta_url": mrta_url, "headless": True if headless else None, diff --git a/dotbot/dotbot_simulator.py b/dotbot/dotbot_simulator.py index 14239886..e5a3525d 100644 --- a/dotbot/dotbot_simulator.py +++ b/dotbot/dotbot_simulator.py @@ -60,8 +60,8 @@ # How far apart `--robots N` puts a generated fleet FLEET_PITCH_MM = 200 -# Headings of a generated fleet's two halves, 0 facing +y (down) -FLEET_FACING_UP, FLEET_FACING_DOWN = 180, 0 +# The heading of every robot of a generated fleet: up (-y), 0 facing +y +FLEET_FACING = 180 # Feature order must match utils/sim_to_real/train_gru.py FEATURE_COLS GRU_FEATURE_COLS = [ @@ -213,14 +213,27 @@ def resolve_init_state_path(path: str) -> str: return path -def placement_area(site: Optional[Site] = None) -> Area: - """The rectangle a fleet is spread over (`field_or_fallback`).""" - return field_or_fallback(site) +def placement_area(site: Optional[Site] = None, area: Optional[Area] = None) -> Area: + """The rectangle a fleet is spread over: `area` when given, else + `field_or_fallback`.""" + return area if area is not None else field_or_fallback(site) -def _grid_shape(count: int) -> Tuple[int, int]: - """Columns and rows of the near-square grid `count` points fill.""" - columns = ceil(sqrt(count)) +def _grid_shape( + count: int, area: Area, max_columns: int = 0, max_rows: int = 0 +) -> Tuple[int, int]: + """Columns and rows of a grid of `count` points shaped like `area`. + + Rows run across the area's width. A non-zero `max_columns` / `max_rows` + caps the grid; with both, `count` fits whenever it is at most their + product. + """ + columns = ceil(sqrt(count * area.w / area.h)) if area.h > 0 else count + if max_rows: + columns = max(columns, ceil(count / max_rows)) + if max_columns: + columns = min(columns, max_columns) + columns = max(1, min(columns, count)) return columns, ceil(count / columns) @@ -232,7 +245,7 @@ def grid_positions(area: Area, count: int) -> List[Tuple[int, int]]: """ if count <= 0: return [] - cols, rows = _grid_shape(count) + cols, rows = _grid_shape(count, area) return [ ( int(area.x + (index % cols + 0.5) * area.w / cols), @@ -243,12 +256,14 @@ def grid_positions(area: Area, count: int) -> List[Tuple[int, int]]: def place_dotbots( - dotbots: List[SimulatedDotBotSettings], site: Optional[Site] = None + dotbots: List[SimulatedDotBotSettings], + site: Optional[Site] = None, + area: Optional[Area] = None, ) -> List[SimulatedDotBotSettings]: """Fill in the positions a world file left out. A robot that gives `pos_x` / `pos_y` keeps them; the rest take grid cells - of the site's placement area, in file order. + of the placement area (see `placement_area`), in file order. """ unplaced = [ index @@ -257,7 +272,7 @@ def place_dotbots( ] if not unplaced: return list(dotbots) - positions = grid_positions(placement_area(site), len(unplaced)) + positions = grid_positions(placement_area(site, area), len(unplaced)) placed = list(dotbots) for slot, index in enumerate(unplaced): bot, (x, y) = dotbots[index], positions[slot] @@ -275,36 +290,33 @@ class FleetDoesNotFit(ValueError): def fleet_capacity(area: Area, pitch_mm: int = FLEET_PITCH_MM) -> int: - """The most robots `fleet_init_state` fits in `area`.""" - max_columns, max_rows = area.w // pitch_mm, area.h // pitch_mm - best = 0 - for columns in range(1, max_columns + 1): - count = min(columns * columns, columns * max_rows) - if count > (columns - 1) ** 2: - best = count - return best + """The most robots `fleet_init_state` fits in `area`: one per pitch + square, with half a pitch of margin all round.""" + return (area.w // pitch_mm) * (area.h // pitch_mm) def fleet_init_state( - count: int, site: Optional[Site] = None, pitch_mm: int = FLEET_PITCH_MM + count: int, + site: Optional[Site] = None, + pitch_mm: int = FLEET_PITCH_MM, + area: Optional[Area] = None, ) -> InitStateToml: - """`count` robots in a near-square grid `pitch_mm` apart, centred in the - site's field (see `placement_area`). + """`count` robots `pitch_mm` apart in a grid shaped like the placement + area (see `placement_area`) and centred in it. - Rows fill left to right and a short last row is centred under the - others. The top half of the rows face up (-y), the rest down (+y). - Raises `FleetDoesNotFit` when the grid, with half a pitch of margin all - round, is larger than the area. + Rows fill left to right and wrap at the area's width; a short last row is + centred under the others. Every robot faces up (-y). Raises + `FleetDoesNotFit` when more robots are asked for than `fleet_capacity`. """ - area = placement_area(site) - columns, rows = _grid_shape(count) - if columns * pitch_mm > area.w or rows * pitch_mm > area.h: + area = placement_area(site, area) + capacity = fleet_capacity(area, pitch_mm) + if count > capacity: where = f"{area.name} " if area.name else "" raise FleetDoesNotFit( f"{count} robots do not fit in the {where}area ({area.w} x " - f"{area.h} mm) at {pitch_mm} mm apart; at most " - f"{fleet_capacity(area, pitch_mm)} do." + f"{area.h} mm) at {pitch_mm} mm apart; at most {capacity} do." ) + columns, rows = _grid_shape(count, area, area.w // pitch_mm, area.h // pitch_mm) left = area.x + (area.w - (columns - 1) * pitch_mm) // 2 top = area.y + (area.h - (rows - 1) * pitch_mm) // 2 dotbots = [] @@ -316,7 +328,7 @@ def fleet_init_state( address=f"DE{index:014X}", pos_x=left + shift + column * pitch_mm, pos_y=top + row * pitch_mm, - direction=FLEET_FACING_UP if row < rows // 2 else FLEET_FACING_DOWN, + direction=FLEET_FACING, ) ) return InitStateToml(dotbots=dotbots) @@ -451,6 +463,7 @@ def __init__( on_frame_received: Callable, simulator_init_state: "str | InitStateToml", site: Optional[Site] = None, + area: Optional[Area] = None, ): self.on_frame_received = on_frame_received self.ticks = 0 @@ -466,7 +479,7 @@ def __init__( ) ) self._network = init_state.network - settings = place_dotbots(init_state.dotbots, site) + settings = place_dotbots(init_state.dotbots, site, area) count = len(settings) self.addresses = [s.address.upper() for s in settings] headed = np.array([s.direction != DIRECTION_NONE for s in settings], bool) diff --git a/dotbot/tests/test_controller_app.py b/dotbot/tests/test_controller_app.py index 686b57a6..86dfa353 100644 --- a/dotbot/tests/test_controller_app.py +++ b/dotbot/tests/test_controller_app.py @@ -378,11 +378,31 @@ def test_run_simulator_keeps_the_site_tables(controller, _asyncio_run, tmp_path) """ -def _run_simulator(tmp_path, *args): +ARENA_CONFIG = """ +site = "arena" + +[sites.arena] +extent_mm = [2000, 4000] + +[sites.arena.areas.field] +x = 0 +y = 0 +w = 2000 +h = 2000 + +[sites.arena.areas.staging] +x = 0 +y = 2000 +w = 2000 +h = 2000 +""" + + +def _run_simulator(tmp_path, *args, config=FLEET_CONFIG): from dotbot.cli.main import cli config_file = tmp_path / "dotbot.toml" - config_file.write_text(FLEET_CONFIG) + config_file.write_text(config) return CliRunner().invoke(cli, ["-c", str(config_file), "run", "simulator", *args]) @@ -464,6 +484,46 @@ def test_write_init_state_writes_the_fleet_and_runs_from_it( assert controller.call_args.args[0].simulator_init_state == str(target) +@pytest.mark.skipif(sys.platform == "win32", reason="Doesn't work on Windows") +@patch("dotbot.controller_app.asyncio.run") +@patch("dotbot.controller_app.Controller") +def test_area_places_the_fleet_in_a_combined_area(controller, _asyncio_run, tmp_path): + too_many = _run_simulator(tmp_path, "--robots", "150", config=ARENA_CONFIG) + assert too_many.exit_code != 0 + assert "at most 100 do" in too_many.output + + result = _run_simulator( + tmp_path, "--robots", "150", "--area", "field+staging", config=ARENA_CONFIG + ) + assert result.exit_code == 0, result.output + area = controller.call_args.args[0].simulator_area + assert (area.x, area.y, area.w, area.h) == (0, 0, 2000, 4000) + assert "150 robots 200 mm apart in field+staging (2000 x 4000 mm)" in ( + result.output + ) + + +@pytest.mark.skipif(sys.platform == "win32", reason="Doesn't work on Windows") +@patch("dotbot.controller_app.asyncio.run") +@patch("dotbot.controller_app.Controller") +def test_area_comes_from_the_config_too(controller, _asyncio_run, tmp_path): + config = ARENA_CONFIG.replace( + "[sites.arena]", '[run.controller]\nsimulator_area = "staging"\n\n[sites.arena]' + ) + result = _run_simulator(tmp_path, "--robots", "10", config=config) + assert result.exit_code == 0, result.output + assert controller.call_args.args[0].simulator_area.name == "staging" + + +def test_an_unknown_area_is_refused_with_the_known_ones(tmp_path): + result = _run_simulator( + tmp_path, "--robots", "10", "--area", "field+pen", config=ARENA_CONFIG + ) + assert result.exit_code == 2 + assert "unknown area 'pen'" in result.output + assert "field, staging" in result.output + + def test_write_init_state_needs_robots(tmp_path): result = _run_simulator(tmp_path, "--write-init-state", str(tmp_path / "f.toml")) assert result.exit_code == 2 diff --git a/dotbot/tests/test_dotbot_simulator.py b/dotbot/tests/test_dotbot_simulator.py index 20d0ba71..44532786 100644 --- a/dotbot/tests/test_dotbot_simulator.py +++ b/dotbot/tests/test_dotbot_simulator.py @@ -225,9 +225,21 @@ def test_a_generated_fleet_is_centred_and_a_pitch_apart(): assert nearest == FLEET_PITCH_MM -def test_a_generated_fleets_top_half_faces_up_and_the_rest_down(): +def test_a_generated_fleet_all_faces_up(): bots = fleet_init_state(4, FIELD_SITE).dotbots - assert [bot.direction for bot in bots] == [180, 180, 0, 0] + assert [bot.direction for bot in bots] == [180, 180, 180, 180] + + +def test_a_generated_fleets_photodiodes_are_evenly_spaced(): + """Robots facing opposite ways put their photodiodes a lever arm closer or + further apart than their axles; the reported grid must stay one pitch.""" + fleet = fleet_init_state(100, FIELD_SITE) + sim = DotBotSimulatorCommunicationInterface(lambda frame: None, fleet) + px, py = sim.plant.photodiode() + rows = sorted({round(y) for y in py}) + assert {b - a for a, b in zip(rows, rows[1:])} == {FLEET_PITCH_MM} + columns = sorted({round(x) for x in px}) + assert {b - a for a, b in zip(columns, columns[1:])} == {FLEET_PITCH_MM} def test_without_a_field_the_fleet_goes_to_the_first_area_then_the_extent(): @@ -249,10 +261,50 @@ def test_a_fleet_that_does_not_fit_is_refused_with_how_many_do(site): fleet_init_state(101, site) -def test_the_capacity_of_a_narrow_area_counts_its_near_square_grid(): - # One row: a 2-column grid still has one row, a 3-column grid needs two - assert fleet_capacity(Area(0, 0, 2000, 200)) == 2 - fleet_init_state(2, Site(areas={"strip": Area(0, 0, 2000, 200, "strip")})) +def test_the_capacity_of_a_narrow_area_is_one_full_row(): + assert fleet_capacity(Area(0, 0, 2000, 200)) == 10 + bots = fleet_init_state( + 10, Site(areas={"strip": Area(0, 0, 2000, 200, "strip")}) + ).dotbots + assert {bot.pos_y for bot in bots} == {100} + + +RECTANGLE = Site( + name="arena", + extent_mm=(2000, 4000), + areas={ + "field": Area(0, 0, 2000, 2000, "field", "field"), + "staging": Area(0, 2000, 2000, 2000, "staging", "staging"), + }, +) + + +def test_the_capacity_of_a_rectangle_is_its_pitch_squares(): + area = RECTANGLE.registry().resolve("field+staging") + assert fleet_capacity(area) == 200 + assert fleet_capacity(Area(0, 0, 2050, 4199)) == 10 * 20 + + +@pytest.mark.parametrize("count", [150, 200]) +def test_a_generated_fleet_fills_a_tall_rectangle(count): + area = RECTANGLE.registry().resolve("field+staging") + bots = fleet_init_state(count, RECTANGLE, area=area).dotbots + assert len(bots) == count + assert all( + area.x < b.pos_x < area.x_max and area.y < b.pos_y < area.y_max for b in bots + ) + assert len({(b.pos_x, b.pos_y) for b in bots}) == count + assert len({b.pos_y for b in bots}) > len({b.pos_x for b in bots}) + with pytest.raises(FleetDoesNotFit, match="201 robots.*at most 200 do"): + fleet_init_state(201, RECTANGLE, area=area) + + +def test_a_generated_grid_takes_the_shape_of_its_area(): + tall = fleet_init_state(50, area=Area(0, 0, 2000, 8000)).dotbots + xs, ys = {b.pos_x for b in tall}, {b.pos_y for b in tall} + assert len(ys) > len(xs) + wide = fleet_init_state(50, area=Area(0, 0, 8000, 2000)).dotbots + assert len({b.pos_x for b in wide}) > len({b.pos_y for b in wide}) def test_the_simulator_example_puts_a_thousand_robots_in_its_field(): From c59d48b17a6e6a25c06e0cb287faf412e7dde38d Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Tue, 29 Sep 2026 15:22:33 +0200 Subject: [PATCH 31/37] dotbot/examples: keep one staging area in the simulator fleet example AI-assisted: Claude Opus 5.5 --- dotbot/examples/simulator_fleet/README.md | 22 ++++++++++----------- dotbot/examples/simulator_fleet/dotbot.toml | 7 ------- dotbot/tests/test_dotbot_simulator.py | 3 ++- 3 files changed, 13 insertions(+), 19 deletions(-) diff --git a/dotbot/examples/simulator_fleet/README.md b/dotbot/examples/simulator_fleet/README.md index 93b21a23..cd8e352c 100644 --- a/dotbot/examples/simulator_fleet/README.md +++ b/dotbot/examples/simulator_fleet/README.md @@ -2,17 +2,17 @@ A 20 x 30 m site with room for up to 1000 simulated DotBots. -`dotbot.toml` defines the site `virtual-lab`: its extent and three areas, a -`staging` strip along the north wall, the 16 x 16 m `field` and a `charging` -strip along the south wall, which `role = "staging"` makes a second staging -area. - -`--robots N` starts N robots in a near-square block centred on the field, -200 mm apart centre to centre, the top half of the rows facing the staging -strip and the bottom half the charging strip. The largest here, 1000 robots -in 32 rows of up to 32, spans 6.2 x 6.2 m. At 200 mm a v3 robot has about -105 mm of floor to the next one across and between rows facing the same way, -and 47 mm tail to tail where the two halves meet. +`dotbot.toml` defines the site `virtual-lab`: its extent and two areas, a +`staging` strip along the north wall, where robots park and charge, and the +16 x 16 m `field`. + +`--robots N` starts N robots in a block centred on the field, 200 mm apart +centre to centre and all facing the staging strip. The block takes the +field's shape, so on this square field the largest, 1000 robots in 32 rows of +up to 32, spans 6.2 x 6.2 m. At 200 mm a v3 robot has about 105 mm of floor to +the next one across and nose to tail. `--area` places the fleet in another +area instead, a name, `x,y,w,h` in mm or a `+`-joined composite such as +`field+staging`. ## Run diff --git a/dotbot/examples/simulator_fleet/dotbot.toml b/dotbot/examples/simulator_fleet/dotbot.toml index 8261be05..b53afab3 100644 --- a/dotbot/examples/simulator_fleet/dotbot.toml +++ b/dotbot/examples/simulator_fleet/dotbot.toml @@ -18,10 +18,3 @@ x = 2000 y = 10000 w = 16000 h = 16000 - -[sites.virtual-lab.areas.charging] # a 2 m strip along the south wall -role = "staging" -x = 0 -y = 28000 -w = 20000 -h = 2000 diff --git a/dotbot/tests/test_dotbot_simulator.py b/dotbot/tests/test_dotbot_simulator.py index 44532786..63a682b2 100644 --- a/dotbot/tests/test_dotbot_simulator.py +++ b/dotbot/tests/test_dotbot_simulator.py @@ -317,7 +317,8 @@ def test_the_simulator_example_puts_a_thousand_robots_in_its_field(): path = Path(dotbot.__file__).parent / "examples" / "simulator_fleet" / "dotbot.toml" config = load_config(path) site = site_from_config(config, config.site) - assert site.areas["charging"].role == "staging" + assert site.staging.name == "staging" + assert [a.name for a in site.areas.values() if a.role == "staging"] == ["staging"] field = site.field assert field.name == "field" bots = fleet_init_state(1000, site).dotbots From 035f92a1907b5ffb1b111ac027cb7ae6200ed65f Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Tue, 29 Sep 2026 15:22:41 +0200 Subject: [PATCH 32/37] dotbot/controller: warn once for unsolved stations, simulate only solved ones AI-assisted: Claude Opus 5.5 --- dotbot/adapter.py | 8 ++++- dotbot/controller.py | 49 ++++++++++++++++++--------- dotbot/dotbot_simulator.py | 17 ++++++++-- dotbot/tests/test_controller.py | 33 +++++++++++++----- dotbot/tests/test_dotbot_simulator.py | 27 +++++++++++++++ 5 files changed, 105 insertions(+), 29 deletions(-) diff --git a/dotbot/adapter.py b/dotbot/adapter.py index 5cf03cfe..67ea01cd 100644 --- a/dotbot/adapter.py +++ b/dotbot/adapter.py @@ -296,11 +296,13 @@ def __init__( 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 = ( @@ -309,7 +311,11 @@ def create_simulator(self, on_frame_received: callable): else fleet_init_state(self.robots, self.site, area=self.area) ) return DotBotSimulatorCommunicationInterface( - on_frame_received, init_state, self.site, self.area + on_frame_received, + init_state, + self.site, + self.area, + calibrated=self.calibrated, ) diff --git a/dotbot/controller.py b/dotbot/controller.py index 09dee2c7..fc7d5c11 100644 --- a/dotbot/controller.py +++ b/dotbot/controller.py @@ -227,6 +227,12 @@ class ControllerSettings: mrta_url: Optional[str] = None # None: no MRTA server configured (opt-in only) +def _station_mask(stations: set[int]) -> Optional[int]: + """The `calibrated` bitmask of robots holding exactly `stations`; None + for no stations.""" + return sum(1 << index for index in stations) or None + + def _held_stations(calibrated: int) -> set[int]: """The station indices an advertised `calibrated` bitmask holds.""" return { @@ -327,8 +333,9 @@ def __init__(self, settings: ControllerSettings): "Pass --lh2-calibration or set [run.controller] lh2_calibration." ) self._solved_stations = {station.index for station in self.lh2_calibration} - # (robot, station) pairs already warned about, so each is warned once - self._unsolved_warned: set[tuple[str, int]] = set() + # The stations each robot holds that the calibration does not solve + self._unsolved_held: Dict[str, frozenset[int]] = {} + self._unsolved_warned: frozenset[int] = frozenset() self.cameras: List[CameraService] = [] # The warp each camera's last pushed detection came from, so a # console is told about a frame once. @@ -368,20 +375,29 @@ def _warn_calibration_age(self, max_age_days: int) -> None: ) def _warn_unsolved_stations(self, address: str, calibrated: int) -> None: - """Warn once per robot and station about a homography the loaded - calibration does not solve: that robot's positions from the station - come from some other calibration.""" - for index in sorted(_held_stations(calibrated) - self._solved_stations): - if (address, index) in self._unsolved_warned: - continue - self._unsolved_warned.add((address, index)) - self.logger.warning( - "Robot holds a station the calibration does not solve", - address=address, - station=index, - calibration_id=self.calibration.id, - solved=sorted(self._solved_stations), - ) + """Warn when robots hold homographies for stations the loaded + calibration does not solve: their positions from those stations come + from some other calibration. One line, again only when the set of + such stations changes.""" + unsolved = frozenset(_held_stations(calibrated) - self._solved_stations) + if unsolved: + self._unsolved_held[address] = unsolved + else: + self._unsolved_held.pop(address, None) + stations = frozenset().union(*self._unsolved_held.values()) + if not stations or stations == self._unsolved_warned: + return + self._unsolved_warned = stations + robots = len(self._unsolved_held) + self.logger.warning( + f"{robots} {'robot holds' if robots == 1 else 'robots hold'} " + f"calibrations for stations {sorted(stations)} that the loaded " + "calibration does not solve", + robots=robots, + stations=sorted(stations), + calibration_id=self.calibration.id, + solved=sorted(self._solved_stations), + ) def _start_camera(self, spec: str) -> None: """Open the camera layer one registration describes. @@ -1253,6 +1269,7 @@ async def _start_adapter(self): self.site, robots=self.settings.simulator_robots, area=self.settings.simulator_area, + calibrated=_station_mask(self._solved_stations), ) elif self.settings.adapter == "sailbot-simulator": self.adapter = SailBotSimulatorAdapter() diff --git a/dotbot/dotbot_simulator.py b/dotbot/dotbot_simulator.py index e5a3525d..6a633da4 100644 --- a/dotbot/dotbot_simulator.py +++ b/dotbot/dotbot_simulator.py @@ -60,6 +60,9 @@ # How far apart `--robots N` puts a generated fleet FLEET_PITCH_MM = 200 +# The stations a simulated robot holds when neither its world file nor the +# controller's calibration says: all eight +CALIBRATED_ALL = 0xFF # The heading of every robot of a generated fleet: up (-y), 0 facing +y FLEET_FACING = 180 @@ -108,14 +111,17 @@ class SimulatedDotBotSettings(BaseModel): out asks for a placement inside the active site instead - see `place_dotbots`. A `direction` is the robot's heading, which its estimator starts out tracking; without one the robot faces +y and starts - with no heading, as a real robot does from boot. + with no heading, as a real robot does from boot. `calibrated` is the + bitmask of stations the robot holds a homography for; without one it + holds the stations of the controller's calibration, all eight when none + is loaded. """ address: str = Field(default_factory=_random_address) pos_x: Optional[int] = None pos_y: Optional[int] = None direction: int = DIRECTION_NONE - calibrated: int = 0xFF + calibrated: Optional[int] = None motor_left_error: float = 0 motor_right_error: float = 0 lh2_noise_mm: float = 0 @@ -464,6 +470,7 @@ def __init__( simulator_init_state: "str | InitStateToml", site: Optional[Site] = None, area: Optional[Area] = None, + calibrated: Optional[int] = None, ): self.on_frame_received = on_frame_received self.ticks = 0 @@ -511,7 +518,11 @@ def __init__( self._headers = [ Header(destination=gateway, source=int(a, 16)) for a in self.addresses ] - self._calibrated = [s.calibrated & 0xFF for s in settings] + calibrated = CALIBRATED_ALL if calibrated is None else calibrated + self._calibrated = [ + (calibrated if s.calibrated is None else s.calibrated) & 0xFF + for s in settings + ] self._dotbot_modes = [s.network_mode for s in settings] self._mari = None if any(m == SimulatedNetworkMode.MARI for m in self._dotbot_modes): diff --git a/dotbot/tests/test_controller.py b/dotbot/tests/test_controller.py index b0b03751..ab36fc25 100644 --- a/dotbot/tests/test_controller.py +++ b/dotbot/tests/test_controller.py @@ -1615,24 +1615,39 @@ def test_a_calibration_from_another_site_is_refused_at_load(tmp_path, serial_moc Controller(settings) -def test_a_robot_holding_a_station_the_calibration_does_not_solve_is_warned_once( +def test_robots_holding_stations_the_calibration_does_not_solve_are_warned_once( tmp_path, serial_mock ): controller = Controller( _old_calibration_settings(tmp_path, lh2_calibration_max_age_days=0) ) solved = sorted(station.index for station in controller.lh2_calibration) - unsolved = min(set(range(8)) - set(solved)) - calibrated = sum(1 << index for index in solved) | 1 << unsolved + first, second = sorted(set(range(8)) - set(solved))[:2] + held = sum(1 << index for index in solved) with capture_logs() as logs: - for _ in range(3): - controller.handle_received_frame( - _advertised( - BOT, calibrated=calibrated, direction=90, pos_x=1000, pos_y=1000 + for bot in (BOT, BOT + 1, BOT + 2): + for _ in range(3): + controller.handle_received_frame( + _advertised( + bot, + calibrated=held | 1 << first, + direction=90, + pos_x=1000, + pos_y=1000, + ) ) + controller.handle_received_frame( + _advertised( + BOT + 3, calibrated=held | 1 << second, direction=90, pos_x=1, pos_y=1 ) + ) warnings = [e for e in logs if e["log_level"] == "warning"] - assert [(e["address"], e["station"]) for e in warnings] == [ - (addr_to_hex(BOT), unsolved) + assert [(e["robots"], e["stations"]) for e in warnings] == [ + (1, [first]), + (4, [first, second]), ] + assert warnings[0]["event"] == ( + f"1 robot holds calibrations for stations [{first}] that the loaded " + "calibration does not solve" + ) assert warnings[0]["solved"] == solved diff --git a/dotbot/tests/test_dotbot_simulator.py b/dotbot/tests/test_dotbot_simulator.py index 63a682b2..a6de3157 100644 --- a/dotbot/tests/test_dotbot_simulator.py +++ b/dotbot/tests/test_dotbot_simulator.py @@ -19,6 +19,7 @@ SIMULATOR_STEP_DELTA_T, DotBotSimulatorCommunicationInterface, FleetDoesNotFit, + InitStateToml, SimulatedDotBotSettings, fleet_capacity, fleet_init_state, @@ -560,6 +561,32 @@ def test_a_robot_given_a_heading_starts_tracking_it_where_it_was_put(tmp_path): assert (advert.encoder_left, advert.encoder_right) == (0, 0) +@pytest.mark.parametrize( + "calibrated, expected", [(None, [0xFF, 0x05]), (0b11, [0b11, 0x05])] +) +def test_a_robot_holds_the_controllers_stations_unless_its_file_says( + calibrated, expected +): + fleet = InitStateToml( + dotbots=[ + SimulatedDotBotSettings(address="0000000000000001", pos_x=1, pos_y=1), + SimulatedDotBotSettings( + address="0000000000000002", pos_x=1, pos_y=1, calibrated=0x05 + ), + ] + ) + received = [] + interface = DotBotSimulatorCommunicationInterface( + received.append, fleet, calibrated=calibrated + ) + _step(interface, 0.5) + by_source = { + frame.header.source: advert + for frame, advert in zip(received, _adverts(received)) + } + assert [by_source[1].calibrated, by_source[2].calibrated] == expected + + def test_a_robot_without_a_heading_starts_with_none_and_no_position(tmp_path): interface, received = _interface( tmp_path, [{"address": "0000000000000001", "pos_x": 1000, "pos_y": 1000}] From c73df17f2e047ff860e08348a3e9f8664d302ba6 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Tue, 29 Sep 2026 15:22:50 +0200 Subject: [PATCH 33/37] dotbot/console-web: colour areas by role and tag each row with it AI-assisted: Claude Opus 5.5 --- dotbot/console-web/src/MapView.tsx | 18 +++++++----- dotbot/console-web/src/Minimap.tsx | 2 +- dotbot/console-web/src/RightPane.tsx | 30 +++++++++++++++---- dotbot/console-web/src/areaColor.test.ts | 31 ++++++++++++++++++++ dotbot/console-web/src/areaColor.ts | 35 ++++++++++++++++------- dotbot/console-web/src/areaLayer.test.tsx | 16 +++++++++++ 6 files changed, 109 insertions(+), 23 deletions(-) create mode 100644 dotbot/console-web/src/areaColor.test.ts diff --git a/dotbot/console-web/src/MapView.tsx b/dotbot/console-web/src/MapView.tsx index a286b350..f64263ea 100644 --- a/dotbot/console-web/src/MapView.tsx +++ b/dotbot/console-web/src/MapView.tsx @@ -364,8 +364,7 @@ export const MapView: React.FC = (props) => { 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. @@ -1170,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 @@ -1197,9 +1197,13 @@ 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} diff --git a/dotbot/console-web/src/Minimap.tsx b/dotbot/console-web/src/Minimap.tsx index b8bedcc6..8d934c20 100644 --- a/dotbot/console-web/src/Minimap.tsx +++ b/dotbot/console-web/src/Minimap.tsx @@ -189,7 +189,7 @@ export const Minimap: React.FC = ({ 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 297e5037..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 && (