From 639c22013d96d750c53e5acbcaec7fff0df29af8 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Fri, 2 Oct 2026 14:40:28 +0200 Subject: [PATCH 01/17] dotbot/calibration: suggest where robots should spin in the next round AI-assisted: Claude Opus 5.5 --- dotbot/calibration/multi_station.py | 112 ++++++++++++++++++ .../tests/test_calibration_multi_station.py | 76 ++++++++++++ 2 files changed, 188 insertions(+) diff --git a/dotbot/calibration/multi_station.py b/dotbot/calibration/multi_station.py index f4573afa..8fad00a3 100644 --- a/dotbot/calibration/multi_station.py +++ b/dotbot/calibration/multi_station.py @@ -56,6 +56,7 @@ "StationGraph", "error_map_from_calibration", "multi_station_report", + "next_round", "solve_joint", "station_graph", ] @@ -1188,6 +1189,117 @@ def appendable_rounds(calibration: Calibration) -> int: return max(t.round for t in calibration.tracks) + 1 +# --- Active collection ------------------------------------------------------ + +# Least distance between two suggested spins, mm: closer ones tell the +# solve little more than one. +NEXT_ROUND_SPACING_MM = 400.0 +# How far inside an overlap its suggested ends sit, mm. +NEXT_ROUND_INSET_MM = 150.0 + + +@dataclass(frozen=True) +class SpinTarget: + """Where one robot should spin in the next round, and why.""" + + point_mm: tuple[float, float] + stations: tuple[int, ...] + why: str + + +def _covering( + rectangles: Mapping[int, Sequence[int]], p, inset: float = 0.0 +) -> tuple[int, ...]: + """The stations whose rectangle, shrunk by `inset`, holds `p`.""" + return tuple( + s + for s, (x0, y0, x1, y1) in sorted(rectangles.items()) + if x0 + inset <= p[0] <= x1 - inset and y0 + inset <= p[1] <= y1 - inset + ) + + +def next_round( + error_map: ErrorMap, + rectangles: Mapping[int, Sequence[int]], + links: Sequence[LinkRecord], + robots: int = 4, + spacing_mm: float = NEXT_ROUND_SPACING_MM, +) -> list[SpinTarget]: + """Where `robots` robots should spin next to strengthen the calibration. + + First the two far ends of every overlap whose link is under the advised + strength (or missing), since a spread pair is what ties two stations; + then the cells the error map predicts worst, no two closer than + `spacing_mm` and none on a rectangle's edge, where a spinning robot + would leave it. Points are in the calibration's frame. + """ + targets: list[SpinTarget] = [] + + def free(p) -> bool: + return all(math.dist(p, t.point_mm) >= spacing_mm for t in targets) + + strength = {(k.a, k.b): k for k in links} + stations = sorted(rectangles) + for i, a in enumerate(stations): + for b in stations[i + 1 :]: + ra, rb = rectangles[a], rectangles[b] + ox0, oy0 = max(ra[0], rb[0]), max(ra[1], rb[1]) + ox1, oy1 = min(ra[2], rb[2]), min(ra[3], rb[3]) + if ( + ox1 - ox0 <= 2 * NEXT_ROUND_INSET_MM + or oy1 - oy0 <= 2 * NEXT_ROUND_INSET_MM + ): + continue + link = strength.get((a, b)) + if link is not None and link_advised(link): + continue + inset = NEXT_ROUND_INSET_MM + if ox1 - ox0 >= oy1 - oy0: + mid = (oy0 + oy1) / 2 + ends = [(ox0 + inset, mid), (ox1 - inset, mid)] + else: + mid = (ox0 + ox1) / 2 + ends = [(mid, oy0 + inset), (mid, oy1 - inset)] + why = f"ties {station_label(a)} and {station_label(b)}, " + ( + f"now {link.shared} circle(s) over {link.spread_mm:.0f} mm" + if link is not None + else "not tied yet" + ) + for p in ends: + if len(targets) < robots and free(p): + targets.append(SpinTarget(p, _covering(rectangles, p), why)) + sigma = error_map.sigma_mm + order = np.argsort(np.where(np.isnan(sigma), -np.inf, sigma), axis=None)[::-1] + for flat in order: + if len(targets) >= robots: + break + row, col = np.unravel_index(flat, sigma.shape) + value = sigma[row, col] + if np.isnan(value): + break + p = error_map.centre(int(row), int(col)) + covering = _covering(rectangles, p, NEXT_ROUND_INSET_MM) + if covering and free(p): + targets.append( + SpinTarget(p, covering, f"predicted error {value:.1f} mm here") + ) + return targets + + +def next_round_lines(targets: Sequence[SpinTarget]) -> list[str]: + """The suggestions as the operator reads them.""" + if not targets: + return ["next round: nothing to suggest"] + lines = [f"next round, {len(targets)} spin(s), in this calibration's frame:"] + for t in targets: + seen = ", ".join(str(s) for s in t.stations) + lines.append( + f" ({t.point_mm[0]:6.0f}, {t.point_mm[1]:6.0f}) mm, seen by station(s) " + f"{seen}: {t.why}" + ) + return lines + + # --- Report ----------------------------------------------------------------- diff --git a/dotbot/tests/test_calibration_multi_station.py b/dotbot/tests/test_calibration_multi_station.py index 77828ec6..7e54dda7 100644 --- a/dotbot/tests/test_calibration_multi_station.py +++ b/dotbot/tests/test_calibration_multi_station.py @@ -4,6 +4,7 @@ `lh2_multi_fixture`), so every number here is in floor millimetres. """ +import math import tomllib from pathlib import Path @@ -645,3 +646,78 @@ def test_anchored_multi_refuses_anchors_that_put_a_station_below_zero(): ] with pytest.raises(ValueError, match="below the site's zero"): ms.solve_joint(tracks, anchors=anchors) + + +def _weak_tie(): + """Two stations tied by two spins only 350 mm apart, six spins each of their own.""" + a, b = two_stations() + own_a = [(x, y) for x in (200, 600) for y in (900, 1500, 2100)] + own_b = [(x, y) for x in (3200, 3600) for y in (900, 1500, 2100)] + tie = [(1900, 1300), (1900, 1650)] + return a, b, own_a + own_b + tie + + +def _to_floor(joint, stations, p): + for st in stations: + x0, y0, x1, y1 = joint.rectangles[st.index] + if x0 <= p[0] <= x1 and y0 <= p[1] <= y1: + cam = apply(np.linalg.inv(joint.homographies[st.index]), np.array([p])) + return tuple(apply(st.cam_to_floor, cam)[0]) + raise AssertionError(f"{p} is in no rectangle") + + +def test_active_next_round_ties_the_weak_link_first(): + a, b, centres = _weak_tie() + joint = ms.solve_joint(F.spins([a, b], centres, noise=1.0, seed=1)) + (link,) = joint.graph.links + assert ms.link_holds(link) and not ms.link_advised(link) + targets = ms.next_round( + joint.error_map, joint.rectangles, joint.graph.links, robots=4 + ) + assert len(targets) == 4 + assert targets[0].stations == targets[1].stations == (0, 1) + assert "ties station 0 (channel 1) and station 1 (channel 2)" in targets[0].why + assert math.dist(targets[0].point_mm, targets[1].point_mm) >= 600 + for t in targets: + assert all( + math.dist(t.point_mm, u.point_mm) >= ms.NEXT_ROUND_SPACING_MM + for u in targets + if u is not t + ) + + +def test_active_spins_where_suggested_beat_random_spins(): + """In simulation: the worst predicted error after a second round.""" + a, b, centres = _weak_tie() + first = F.spins([a, b], centres, noise=1.0, seed=1) + joint = ms.solve_joint(first) + + def after(points): + second = F.spins( + [a, b], + points, + noise=1.0, + seed=5, + round_=1, + names=[f"N{i}" for i in range(len(points))], + ) + both = {s: first.get(s, []) + second.get(s, []) for s in (0, 1)} + return ms.solve_joint(both).error_map.worst_mm + + targets = ms.next_round( + joint.error_map, joint.rectangles, joint.graph.links, robots=4 + ) + planned = after([_to_floor(joint, [a, b], t.point_mm) for t in targets]) + seen = [ + (x, y) + for x in range(-300, 3600, 50) + for y in range(0, 3000, 50) + if a.sees((x, y)) or b.sees((x, y)) + ] + rng = np.random.default_rng(0) + random = [ + after([seen[i] for i in rng.choice(len(seen), 4, replace=False)]) + for _ in range(5) + ] + assert planned < joint.error_map.worst_mm + assert planned < np.mean(random) From c676dbf63a6725463b6490282d9f8a8a5fdb5356 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Fri, 2 Oct 2026 14:40:28 +0200 Subject: [PATCH 02/17] dotbot/cli: add calibrate-lh2 show --next-round AI-assisted: Claude Opus 5.5 --- dotbot/cli/swarm_lh2.py | 36 +++++++++++++++++++++++++-- dotbot/tests/test_calibration_spin.py | 13 ++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/dotbot/cli/swarm_lh2.py b/dotbot/cli/swarm_lh2.py index 94495e25..600e55d3 100644 --- a/dotbot/cli/swarm_lh2.py +++ b/dotbot/cli/swarm_lh2.py @@ -946,15 +946,32 @@ def _gated_push(client, calibration, site_changed=False, devices=None, stop=Fals default=None, help="The site to look the id up under. Defaults to `site` in the dotbot config.", ) +@click.option( + "--next-round", + "next_robots", + type=click.IntRange(min=1, max=64), + default=None, + metavar="N", + help=( + "Also say where N robots should spin in another round (collect " + "--spin --append) to strengthen the weakest ties and the worst " + "predicted error; points in this calibration's frame." + ), +) @click.pass_context -def _show(ctx, calibration, site_name): +def _show(ctx, calibration, site_name, next_robots): if _devices(ctx): raise click.UsageError("show reads a file, so it takes no `dotbot swarm -d`") from dotbot.calibration.lighthouse2 import ( read_calibration_file, resolve_calibration_path, ) - from dotbot.calibration.multi_station import multi_station_report + from dotbot.calibration.multi_station import ( + error_map_from_calibration, + multi_station_report, + next_round, + next_round_lines, + ) site, _ = site_from_context(ctx, site_name) try: @@ -976,6 +993,21 @@ def _show(ctx, calibration, site_name): ) for line in multi_station_report(loaded): click.echo(line) + if next_robots: + error_map = error_map_from_calibration(loaded) + if error_map is None: + raise click.ClickException( + f"{loaded.id8} is not a spin calibration, so it has no error map " + "to plan a round from" + ) + targets = next_round( + error_map, + {s.index: s.valid_mm for s in loaded.stations}, + loaded.links, + robots=next_robots, + ) + for line in next_round_lines(targets): + click.echo(line) def _parse_shift(_ctx, _param, value): diff --git a/dotbot/tests/test_calibration_spin.py b/dotbot/tests/test_calibration_spin.py index b6d04231..d5f4b58a 100644 --- a/dotbot/tests/test_calibration_spin.py +++ b/dotbot/tests/test_calibration_spin.py @@ -815,3 +815,16 @@ def test_an_anchor_that_would_not_spin_is_refused_before_the_spin(monkeypatch, l assert "B0B0B0B0B0B0B0B0 would not spin" in result.output assert addrs[0] not in result.output.split("would not spin")[0] assert "start" not in [c for c, _ in fleet.calls] + + +def test_show_next_round_says_where_to_spin(monkeypatch, lab): + a, b = _two_stations() + _, fleet = _two_station_fleet([a, b], _floor_grid([a, b])[:40]) + result = _collect(monkeypatch, lab, fleet, "--tag", "two") + assert result.exit_code == 0, result.output + shown = CliRunner().invoke( + swarm_lh2.cmd, ["show", "two", "--next-round", "3"], obj={"config": lab} + ) + assert shown.exit_code == 0, shown.output + assert "next round, 3 spin(s), in this calibration's frame:" in shown.output + assert shown.output.count("mm, seen by station(s)") == 3 From 8e46d471cd2e01630af6c36992c64144ee4e9ae8 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Sun, 4 Oct 2026 03:58:23 +0200 Subject: [PATCH 03/17] dotbot/calibration: name next-round stations by index and channel AI-assisted: Claude Opus 5.5 --- dotbot/calibration/multi_station.py | 6 +++--- dotbot/tests/test_calibration_spin.py | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/dotbot/calibration/multi_station.py b/dotbot/calibration/multi_station.py index 8fad00a3..8d6e3acb 100644 --- a/dotbot/calibration/multi_station.py +++ b/dotbot/calibration/multi_station.py @@ -1292,10 +1292,10 @@ def next_round_lines(targets: Sequence[SpinTarget]) -> list[str]: return ["next round: nothing to suggest"] lines = [f"next round, {len(targets)} spin(s), in this calibration's frame:"] for t in targets: - seen = ", ".join(str(s) for s in t.stations) + seen = ", ".join(station_label(s) for s in t.stations) lines.append( - f" ({t.point_mm[0]:6.0f}, {t.point_mm[1]:6.0f}) mm, seen by station(s) " - f"{seen}: {t.why}" + f" ({t.point_mm[0]:6.0f}, {t.point_mm[1]:6.0f}) mm, seen by {seen}: " + f"{t.why}" ) return lines diff --git a/dotbot/tests/test_calibration_spin.py b/dotbot/tests/test_calibration_spin.py index d5f4b58a..73ab9857 100644 --- a/dotbot/tests/test_calibration_spin.py +++ b/dotbot/tests/test_calibration_spin.py @@ -827,4 +827,5 @@ def test_show_next_round_says_where_to_spin(monkeypatch, lab): ) assert shown.exit_code == 0, shown.output assert "next round, 3 spin(s), in this calibration's frame:" in shown.output - assert shown.output.count("mm, seen by station(s)") == 3 + assert shown.output.count("mm, seen by station ") == 3 + From 704bab3a3d95469220f2ec8e305fab1ca0696e7f Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Sun, 4 Oct 2026 03:58:23 +0200 Subject: [PATCH 04/17] dotbot/cli: refuse show --next-round for a calibration a round cannot join AI-assisted: Claude Opus 5.5 --- dotbot/cli/swarm_lh2.py | 7 +++++++ dotbot/tests/test_calibration_spin.py | 12 ++++++++++++ 2 files changed, 19 insertions(+) diff --git a/dotbot/cli/swarm_lh2.py b/dotbot/cli/swarm_lh2.py index 600e55d3..4a13936a 100644 --- a/dotbot/cli/swarm_lh2.py +++ b/dotbot/cli/swarm_lh2.py @@ -967,6 +967,7 @@ def _show(ctx, calibration, site_name, next_robots): resolve_calibration_path, ) from dotbot.calibration.multi_station import ( + appendable_rounds, error_map_from_calibration, multi_station_report, next_round, @@ -994,6 +995,12 @@ def _show(ctx, calibration, site_name, next_robots): for line in multi_station_report(loaded): click.echo(line) if next_robots: + try: + appendable_rounds(loaded) + except ValueError as exc: + raise click.ClickException( + f"--next-round plans a round for collect --spin --append: {exc}" + ) from exc error_map = error_map_from_calibration(loaded) if error_map is None: raise click.ClickException( diff --git a/dotbot/tests/test_calibration_spin.py b/dotbot/tests/test_calibration_spin.py index 73ab9857..9f7823f3 100644 --- a/dotbot/tests/test_calibration_spin.py +++ b/dotbot/tests/test_calibration_spin.py @@ -829,3 +829,15 @@ def test_show_next_round_says_where_to_spin(monkeypatch, lab): assert "next round, 3 spin(s), in this calibration's frame:" in shown.output assert shown.output.count("mm, seen by station ") == 3 + +def test_show_next_round_refuses_a_calibration_a_round_cannot_join(lab, tmp_path): + from dotbot.tests.lh2_wire_fixture import FIXTURE_TOML + + path = tmp_path / "corner.toml" + path.write_text(FIXTURE_TOML, encoding="utf-8") + shown = CliRunner().invoke( + swarm_lh2.cmd, ["show", str(path), "--next-round", "2"], obj={"config": lab} + ) + assert shown.exit_code != 0 + assert "--next-round plans a round for collect --spin --append" in shown.output + assert "not a free-mode spin calibration" in shown.output From 67f39031386368b25fd3bdfa4c46d64fa080b555 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Wed, 14 Oct 2026 11:41:52 -0300 Subject: [PATCH 05/17] dotbot/calibration: use the site extent as a placed spin solve's rectangles A station that never sees part of the site yields no sweep pair there, so the extent drops nothing a station could solve, whereas the box around its spins dropped areas such as a staging strip the robots never spun in. AI-assisted: Claude Opus 5.5 --- dotbot/calibration/conics.py | 21 ++++++++++--------- dotbot/tests/test_calibration_conics.py | 11 ++-------- .../tests/test_calibration_multi_station.py | 18 ++++++++++++++++ 3 files changed, 31 insertions(+), 19 deletions(-) diff --git a/dotbot/calibration/conics.py b/dotbot/calibration/conics.py index 31425a0c..c8fbd2b5 100644 --- a/dotbot/calibration/conics.py +++ b/dotbot/calibration/conics.py @@ -728,8 +728,13 @@ def solve_calibration( ) solved_from = "conics-joint" if joint.joint else "conics-free" anchor = FREE_FRAME_ANCHOR + rectangles, valid_mm = dict(joint.rectangles), joint.valid_mm if joint.anchored: solved_from, anchor = "conics-anchored", site.anchor + # In the site's frame, every station's rectangle is the site extent + if site.valid_mm is not None: + valid_mm = site.valid_mm + rectangles = {station: valid_mm for station in rectangles} stations = [ StationSolution( index=station, @@ -737,14 +742,14 @@ def solve_calibration( points=sum(t.points for t in joint.solutions[station].tracks), residual_mm=joint.station_rms_mm[station], solved_from=solved_from, - valid_mm=joint.rectangles[station], + valid_mm=rectangles[station], ) for station, H in sorted(joint.homographies.items()) ] now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") calibration = Calibration( site=Site(name=site.name, anchor=anchor), - valid_mm=joint.valid_mm, + valid_mm=valid_mm, stations=stations, tracks=list(samples), links=sorted(joint.graph.links, key=lambda k: (k.a, k.b)), @@ -792,8 +797,9 @@ def self_defined_site( The site is the starter site (`starter_layout`) around the robots' field (`spin_field`): a margin of floor round it, or a `size_mm` site with it centred, and a staging strip below it. The calibration is - shifted by the field's offset, each station's rectangle with it, and a - tag gets `-` appended, so the two files answer to different tags. + shifted by the field's offset, each station's rectangle becomes the + site extent, and a tag gets `-` appended, so the two files answer + to different tags. """ if ( not calibration.stations @@ -828,12 +834,7 @@ def self_defined_site( points=st.points, residual_mm=st.residual_mm, solved_from=st.solved_from, - valid_mm=( - st.valid_mm[0] + dx, - st.valid_mm[1] + dy, - st.valid_mm[2] + dx, - st.valid_mm[3] + dy, - ), + valid_mm=(0, 0, int(extent[0]), int(extent[1])), ) ) site = Site( diff --git a/dotbot/tests/test_calibration_conics.py b/dotbot/tests/test_calibration_conics.py index c436aa9e..6bc40b4b 100644 --- a/dotbot/tests/test_calibration_conics.py +++ b/dotbot/tests/test_calibration_conics.py @@ -284,13 +284,7 @@ def test_a_self_defined_site_centres_the_field(): staging = site.areas["staging"] assert (staging.x, staging.y, staging.w) == (field.x, field.y_max, field.w) assert staging.y_max <= 4000 - x0, y0, x1, y1 = calibration.stations[0].valid_mm - assert placed.stations[0].valid_mm == ( - x0 + field.x, - y0 + field.y, - x1 + field.x, - y1 + field.y, - ) + assert placed.stations[0].valid_mm == (0, 0, 3000, 4000) assert placed.valid_mm == placed.stations[0].valid_mm assert ( placed.site.anchor @@ -321,8 +315,7 @@ def test_a_self_defined_site_is_the_starter_site_around_the_field(): assert site.extent_mm == (field_w + 3000, field_h + 3000) assert (site.field.x, site.field.y) == (1500, 1500) assert site.staging.y == site.field.y_max - x0, y0, x1, y1 = placed.valid_mm - assert (0, 0) < (x0, y0) and x1 <= site.extent_mm[0] and y1 <= site.extent_mm[1] + assert placed.valid_mm == (0, 0, *site.extent_mm) def test_a_self_defined_site_must_hold_the_staging_strip_too(): diff --git a/dotbot/tests/test_calibration_multi_station.py b/dotbot/tests/test_calibration_multi_station.py index 7e54dda7..d13aacb9 100644 --- a/dotbot/tests/test_calibration_multi_station.py +++ b/dotbot/tests/test_calibration_multi_station.py @@ -721,3 +721,21 @@ def after(points): ] assert planned < joint.error_map.worst_mm assert planned < np.mean(random) + + +def test_anchored_calibration_rectangles_are_the_site_extent(): + a, b = two_stations() + centres = floor_grid([a, b]) + tracks = F.spins([a, b], centres, noise=0.5, seed=7) + corners = [0, len(centres) - 1, 3, len(centres) - 4] + anchors = [ms.Anchor(ms.CircleKey(f"R{i:02d}", 0), tuple(centres[i])) for i in corners] + site = Site(name="arena", anchor="door corner", extent_mm=(6000, 4000)) + calibration, joint, _ = conics.solve_calibration(F.samples(tracks), site, anchors=anchors) + assert joint.rectangles[a.index] != (0, 0, 6000, 4000) + assert {s.valid_mm for s in calibration.stations} == {(0, 0, 6000, 4000)} + assert calibration.valid_mm == (0, 0, 6000, 4000) + # a free solve has no site frame, so it keeps the box around its spins + free, free_joint, _ = conics.solve_calibration(F.samples(tracks), site) + assert [s.valid_mm for s in free.stations] == [ + free_joint.rectangles[s.index] for s in free.stations + ] From e721d017a7fc3bcfd09ed9ed4ae34de73be01cd9 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Wed, 14 Oct 2026 11:45:52 -0300 Subject: [PATCH 06/17] dotbot/config: record a site's LH2 base stations by serial and channel AI-assisted: Claude Opus 5.5 --- dotbot/config.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/dotbot/config.py b/dotbot/config.py index 10074b49..eeccca90 100644 --- a/dotbot/config.py +++ b/dotbot/config.py @@ -29,6 +29,7 @@ import difflib import os +import re import tomllib from dataclasses import dataclass from pathlib import Path @@ -217,6 +218,24 @@ class SiteSection(_Strict): extent_mm: tuple[int, int] | None = None connection: ConnectionSection | None = None areas: dict[str, AreaSection] = Field(default_factory=dict) + # LH2 base stations by serial, each with the channel it is set to + stations: dict[str, int] = Field(default_factory=dict) + + @model_validator(mode="after") + def _stations(self) -> SiteSection: + for serial, channel in self.stations.items(): + if not re.fullmatch(r"[0-9A-F]{8}", serial): + raise ValueError(f"station serial {serial!r} is not 8 upper-case hex digits") + if not 1 <= channel <= 16: + raise ValueError(f"station {serial} on channel {channel}, outside 1 to 16") + channels = list(self.stations.values()) + shared = sorted({c for c in channels if channels.count(c) > 1}) + if shared: + raise ValueError( + "two stations on one channel merge into one index: channel " + + ", ".join(str(c) for c in shared) + ) + return self @model_validator(mode="after") def _one_field(self) -> SiteSection: From 9e565016135ab80bb69bd4fb61922af354db433f Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Wed, 14 Oct 2026 13:41:19 -0300 Subject: [PATCH 07/17] dotbot/controller: hold a batch until its robot has a heading A robot with no heading first gets a 40 mm probe move toward its first point, and its batch once it has a heading and the probe is done. While waypoints_awaiting_heading is set, the waypoint report and the mode are the probe's, so a client watching mode alone sees an early arrival. AI-assisted: Claude Opus 5.5 --- dotbot/controller.py | 143 +++++++++++++++ dotbot/examples/labyrinth/labyrinth.py | 5 +- dotbot/examples/motions/motions.py | 5 +- dotbot/models.py | 3 + dotbot/tests/scenario_harness.py | 6 +- dotbot/tests/test_controller.py | 237 +++++++++++++++++++++++++ dotbot/tests/test_scenarios.py | 24 +++ 7 files changed, 419 insertions(+), 4 deletions(-) diff --git a/dotbot/controller.py b/dotbot/controller.py index cae3c854..9e00ca37 100644 --- a/dotbot/controller.py +++ b/dotbot/controller.py @@ -84,8 +84,10 @@ PayloadControlMode, PayloadDotBotAdvertisement, PayloadLh2CalibrationHomography, + PayloadLH2Location, PayloadLH2Waypoints, PayloadType, + PayloadWaypointHeading, WaypointsAbortReason, WaypointsFailReason, WaypointsStatus, @@ -118,6 +120,12 @@ PayloadControlMode, ) GPS_POSITION_DISTANCE_THRESHOLD = 5 # meters +# A robot with no heading is first sent this far toward its first waypoint, +# from its fix, and gets its batch once it has a heading and that move is done +PROBE_MM = 40 +PROBE_THRESHOLD_MM = 20 +# How long a held batch waits for its robot's heading before it is dropped +HOLD_TIMEOUT_S = 30.0 IGNORED_PAYLOAD_TYPES = ( PayloadType.CMD_MOVE_RAW, PayloadType.CMD_RGB_LED, @@ -171,6 +179,15 @@ class PendingCommand: batch_ids: frozenset = frozenset() +@dataclass +class HeldBatch: + """A batch waiting for its robot's heading, and the probe move sent first.""" + + payload: PayloadLH2Waypoints + probe_batch_id: int + since: float + + @dataclass class RobotRecord: """What the controller keeps about a robot beside its model. @@ -383,6 +400,9 @@ def __init__(self, settings: ControllerSettings): self.pending_commands: Dict[tuple[str, str], PendingCommand] = {} self.batch_ids: Dict[str, int] = {} self.advertised_batch_ids: Dict[str, int] = {} + self.held_batches: Dict[str, HeldBatch] = {} + # Released held batches the robot has not advertised yet, by batch id + self.released_batches: Dict[str, int] = {} self.site = settings.site or Site() self.calibration = None self.lh2_calibration = [] @@ -783,6 +803,8 @@ def forget(self, address: str, now: Optional[float] = None) -> None: self._dotbot_twin_timestamps.pop(address, None) self._unsolved_held.pop(address, None) self.advertised_batch_ids.pop(address, None) + self.held_batches.pop(address, None) + self.released_batches.pop(address, None) def _record(self, address: str) -> RobotRecord: """The record kept beside `address`'s model, created on first use.""" @@ -947,6 +969,7 @@ def handle_received_frame( self._set(dotbot, record, "battery", payload.battery / 1000.0, seq) # mV self._set(dotbot, record, "mode", ControlModeType(payload.mode), seq) + self._release_held(dotbot, record, payload, seq) if debug: self.logger.debug( "Advertisement Data", @@ -1132,6 +1155,122 @@ def send_confirmed( return self.send_payload(int(address, 16), payload) def send_waypoints(self, address: str, payload: PayloadLH2Waypoints) -> bool: + """Send a waypoint batch, or hold it while the robot has no heading. + + A held batch goes out once the robot has a heading and has finished a + probe move of `PROBE_MM` toward the first point, sent in its place. + Any batch replaces a held one. False when nothing was sent, and a + batch held before is then kept. + """ + replaced = self.held_batches.pop(address, None) + probe = self._probe(address, payload) + if not self._send_batch(address, probe or payload): + if replaced is not None: + self.held_batches[address] = replaced + return False + if probe is not None: + self.released_batches.pop(address, None) + self.held_batches[address] = HeldBatch( + payload, probe.batch_id, time.monotonic() + ) + self.logger.info( + "Waypoints held until the robot has a heading", + address=address, + probe=(probe.waypoints[0].pos_x, probe.waypoints[0].pos_y), + ) + if (probe is not None) != (replaced is not None): + self.update_dotbot(address, waypoints_awaiting_heading=probe is not None) + return True + + def _probe( + self, address: str, payload: PayloadLH2Waypoints + ) -> Optional[PayloadLH2Waypoints]: + """The probe move to send instead of `payload`, or None when the robot + has a heading, has no fix, or its first point is within `PROBE_MM`.""" + dotbot = self.dotbots[address] + fix = dotbot.lh2_position + if payload.count == 0 or dotbot.direction is not None or fix is None: + return None + first = payload.waypoints[0] + dx, dy = first.pos_x - fix.x, first.pos_y - fix.y + distance = math.hypot(dx, dy) + if distance <= PROBE_MM: + return None + scale = PROBE_MM / distance + return PayloadLH2Waypoints( + threshold=PROBE_THRESHOLD_MM, + count=1, + waypoints=[ + PayloadLH2Location( + pos_x=round(fix.x + dx * scale), pos_y=round(fix.y + dy * scale) + ) + ], + headings=[PayloadWaypointHeading()], + ) + + def _release_held( + self, + dotbot: DotBotModel, + record: RobotRecord, + advert: PayloadDotBotAdvertisement, + seq: int, + ) -> None: + """Send a held batch once the robot has a heading and its probe move is + done, or the probe never reached it; drop it, clearing the robot's + waypoints, when it has no heading by then or after `HOLD_TIMEOUT_S`. + The robot stays awaiting its heading until it advertises the released + batch, or that batch is no longer pending.""" + address = dotbot.address + released = self.released_batches.get(address) + if released is not None and ( + (advert.has_report and advert.batch_id == released) + or (address, PayloadLH2Waypoints.__name__) not in self.pending_commands + ): + del self.released_batches[address] + self._set(dotbot, record, "waypoints_awaiting_heading", False, seq) + held = self.held_batches.get(address) + if held is None: + return + # Gave up on, or superseded by another controller's batch + lost = ( + advert.has_report + and advert.batch_id != held.probe_batch_id + and (address, PayloadLH2Waypoints.__name__) not in self.pending_commands + ) + probing = ( + advert.has_report + and not lost + and ( + advert.batch_id != held.probe_batch_id + or advert.waypoints_status == WaypointsStatus.IN_PROGRESS + ) + ) + timed_out = time.monotonic() - held.since > HOLD_TIMEOUT_S + if probing and not timed_out: + return + if dotbot.direction is None and not (advert.has_report or timed_out): + return + del self.held_batches[address] + if dotbot.direction is not None and not timed_out: + self.logger.info( + "Heading acquired, sending the held waypoints", address=address + ) + if self._send_batch(address, held.payload): + self.released_batches[address] = held.payload.batch_id + return + self._set(dotbot, record, "waypoints_awaiting_heading", False, seq) + self._set(dotbot, record, "waypoints", [], seq) + self.logger.warning( + "Held waypoints dropped", + address=address, + heading=dotbot.direction is not None, + probe_lost=lost, + timed_out=timed_out, + probe_status=dotbot.waypoints_status, + probe_reason=dotbot.waypoints_reason, + ) + + def _send_batch(self, address: str, payload: PayloadLH2Waypoints) -> bool: """Send a waypoint batch under a new batch id, resent until the robot advertises that id or another controller's newer batch. False when it was not sent.""" @@ -1251,6 +1390,10 @@ def send_payload(self, destination: int, payload: Payload) -> bool: if isinstance(payload, DIRECT_COMMANDS): # The robot drops its batch on these, so a resend would restart it self.pending_commands.pop((dest_str, PayloadLH2Waypoints.__name__), None) + held = self.held_batches.pop(dest_str, None) + released = self.released_batches.pop(dest_str, None) + if held is not None or released is not None: + self.update_dotbot(dest_str, waypoints_awaiting_heading=False) try: self.adapter.send_payload(destination, payload=payload) except Exception: # pylint: disable=broad-exception-caught diff --git a/dotbot/examples/labyrinth/labyrinth.py b/dotbot/examples/labyrinth/labyrinth.py index d11c672c..25edc3a2 100644 --- a/dotbot/examples/labyrinth/labyrinth.py +++ b/dotbot/examples/labyrinth/labyrinth.py @@ -106,7 +106,10 @@ async def _wait_for_completion(address: str, host: str, port: int) -> None: bots = await client.fetch_dotbots(query=DotBotQueryModel(address=address)) if bots: bot = bots[0] - if not bot.waypoints or bot.mode == ControlModeType.MANUAL: + if not bot.waypoints or ( + bot.mode == ControlModeType.MANUAL + and not bot.waypoints_awaiting_heading + ): break await asyncio.sleep(0.5) diff --git a/dotbot/examples/motions/motions.py b/dotbot/examples/motions/motions.py index 39cedce4..6b372592 100644 --- a/dotbot/examples/motions/motions.py +++ b/dotbot/examples/motions/motions.py @@ -145,7 +145,10 @@ async def _wait_for_waypoints_done( bot = dotbots[0] if not bot.waypoints: break - if bot.mode == ControlModeType.MANUAL: + if ( + bot.mode == ControlModeType.MANUAL + and not bot.waypoints_awaiting_heading + ): break await asyncio.sleep(WAYPOINT_POLL_INTERVAL) diff --git a/dotbot/models.py b/dotbot/models.py index ff3c8d37..1e364273 100644 --- a/dotbot/models.py +++ b/dotbot/models.py @@ -677,6 +677,9 @@ class DotBotModel(BaseModel): # The waypoint report, from apps that send one (the sandbox dotbot app) waypoints_status: Optional[WaypointsStatus] = None waypoints_reason: Optional[str] = None # why FAILED or ABORTED + # The controller holds the batch until the robot has a heading; the + # waypoint report meanwhile is the probe move's + waypoints_awaiting_heading: bool = False waypoint_index: Optional[int] = ( None # the point being driven to; the count once arrived ) diff --git a/dotbot/tests/scenario_harness.py b/dotbot/tests/scenario_harness.py index cc202dd8..0e33fb6a 100644 --- a/dotbot/tests/scenario_harness.py +++ b/dotbot/tests/scenario_harness.py @@ -137,10 +137,12 @@ async def run( return until is None def reported(self, address: str, status: str) -> bool: - """Whether the controller holds `status` for the last batch it sent.""" + """Whether the controller holds `status` for the last batch it sent, + and no batch is held behind it.""" dotbot = self.dotbot(address) return ( - self.controller.advertised_batch_ids.get(address) + not dotbot.waypoints_awaiting_heading + and self.controller.advertised_batch_ids.get(address) == self.controller.batch_ids.get(address) and dotbot.waypoints_status is not None and dotbot.waypoints_status.name == status diff --git a/dotbot/tests/test_controller.py b/dotbot/tests/test_controller.py index 88fba246..c7486ec6 100644 --- a/dotbot/tests/test_controller.py +++ b/dotbot/tests/test_controller.py @@ -19,7 +19,10 @@ from dotbot.area import Area from dotbot.controller import ( FORGET_AFTER_S, + HOLD_TIMEOUT_S, LOST_AFTER_S, + PROBE_MM, + PROBE_THRESHOLD_MM, STALE_AFTER_S, Controller, ControllerSettings, @@ -1863,3 +1866,237 @@ async def test_console_url_is_printed_headless_or_not( await controller._open_webbrowser() assert "Console: http://localhost:8123/console\n" in capsys.readouterr().out assert opened.called is not headless + + +# --- a batch held until its robot has a heading ------------------------------ + + +def _headingless(**fields) -> Frame: + return _report(direction=DIRECTION_NONE, **fields) + + +@pytest.mark.asyncio +async def test_a_robot_with_no_heading_gets_a_probe_move_first(controller, clock): + controller.send_payload = MagicMock() + controller.handle_received_frame(_headingless(batch_id=3)) + address = addr_to_hex(BOT) + batch = _batch() + assert controller.send_waypoints(address, batch) + + probe = controller.send_payload.call_args.args[1] + assert probe is not batch and probe.count == 1 + assert probe.threshold == PROBE_THRESHOLD_MM + # From the fix at (1000, 1000), toward the first point at (1000, 500) + assert (probe.waypoints[0].pos_x, probe.waypoints[0].pos_y) == ( + 1000, + 1000 - PROBE_MM, + ) + assert controller.dotbots[address].waypoints_awaiting_heading + + +@pytest.mark.asyncio +async def test_a_held_batch_waits_for_a_heading_and_the_probe_end(controller, clock): + controller.send_payload = MagicMock() + controller.handle_received_frame(_headingless(batch_id=3)) + address = addr_to_hex(BOT) + batch = _batch() + controller.send_waypoints(address, batch) + probe_id = controller.send_payload.call_args.args[1].batch_id + + in_progress = dict(batch_id=probe_id, waypoints_status=WaypointsStatus.IN_PROGRESS) + controller.handle_received_frame(_headingless(**in_progress)) + controller.handle_received_frame(_report(**in_progress)) + assert controller.send_payload.call_count == 1 # tracking, still probing + + controller.handle_received_frame( + _report(batch_id=probe_id, waypoints_status=WaypointsStatus.ARRIVED) + ) + assert controller.send_payload.call_args.args == (BOT, batch) + assert batch.batch_id == probe_id + 1 + assert not controller.held_batches + + +@pytest.mark.asyncio +async def test_a_released_batch_awaits_until_the_robot_advertises_it(controller, clock): + controller.send_payload = MagicMock() + controller.handle_received_frame(_headingless(batch_id=3)) + address = addr_to_hex(BOT) + batch = _batch() + controller.send_waypoints(address, batch) + probe_id = controller.send_payload.call_args.args[1].batch_id + + controller.handle_received_frame( + _report(batch_id=probe_id, waypoints_status=WaypointsStatus.ARRIVED) + ) + dotbot = controller.dotbots[address] + assert controller.send_payload.call_args.args == (BOT, batch) + # The probe is done and the robot in MANUAL, but the batch has not started + assert dotbot.mode == ControlModeType.MANUAL + assert dotbot.waypoints_awaiting_heading + controller.handle_received_frame( + _report(batch_id=probe_id, waypoints_status=WaypointsStatus.ARRIVED) + ) + assert dotbot.waypoints_awaiting_heading + + controller.handle_received_frame( + _report(batch_id=batch.batch_id, waypoints_status=WaypointsStatus.IN_PROGRESS) + ) + assert not dotbot.waypoints_awaiting_heading + assert not controller.released_batches + + +@pytest.mark.asyncio +async def test_a_direct_command_clears_a_released_batch_s_wait(controller, clock): + controller.adapter = MagicMock() + controller.handle_received_frame(_headingless(batch_id=3)) + address = addr_to_hex(BOT) + controller.send_waypoints(address, _batch()) + probe_id = controller.adapter.send_payload.call_args.kwargs["payload"].batch_id + controller.handle_received_frame( + _report(batch_id=probe_id, waypoints_status=WaypointsStatus.ARRIVED) + ) + assert controller.dotbots[address].waypoints_awaiting_heading + controller.send_payload(BOT, PayloadCommandMoveRaw(left_y=50, right_y=50)) + assert not controller.dotbots[address].waypoints_awaiting_heading + assert not controller.released_batches + + +@pytest.mark.asyncio +async def test_a_probe_ending_without_a_heading_drops_the_batch(controller, clock): + controller.send_payload = MagicMock() + controller.handle_received_frame(_headingless(batch_id=3)) + address = addr_to_hex(BOT) + controller.send_waypoints(address, _batch()) + probe_id = controller.send_payload.call_args.args[1].batch_id + + controller.handle_received_frame( + _headingless( + batch_id=probe_id, + waypoints_status=WaypointsStatus.FAILED, + waypoints_reason=1, + ) + ) + dotbot = controller.dotbots[address] + assert not dotbot.waypoints_awaiting_heading + assert dotbot.waypoints_reason == "NO_HEADING" + # A heading that comes later does not revive it + controller.handle_received_frame(_report(batch_id=probe_id)) + assert controller.send_payload.call_count == 1 + + +@pytest.mark.asyncio +async def test_a_robot_with_a_heading_or_a_short_first_leg_is_not_held( + controller, clock +): + controller.send_payload = MagicMock() + address = addr_to_hex(BOT) + controller.handle_received_frame(_report(batch_id=3)) + batch = _batch() + controller.send_waypoints(address, batch) + assert controller.send_payload.call_args.args == (BOT, batch) + + controller.handle_received_frame(_headingless(batch_id=3, pos_x=1000, pos_y=530)) + batch = _batch() + controller.send_waypoints(address, batch) + assert controller.send_payload.call_args.args == (BOT, batch) + assert not controller.dotbots[address].waypoints_awaiting_heading + + +@pytest.mark.asyncio +async def test_a_new_batch_replaces_a_held_one(controller, clock): + controller.send_payload = MagicMock() + controller.handle_received_frame(_headingless(batch_id=3)) + address = addr_to_hex(BOT) + controller.send_waypoints(address, _batch()) + stop = _batch(count=0) + controller.send_waypoints(address, stop) + assert controller.send_payload.call_args.args == (BOT, stop) + assert not controller.held_batches + assert not controller.dotbots[address].waypoints_awaiting_heading + + +@pytest.mark.asyncio +async def test_a_robot_without_a_report_is_released_by_its_heading(controller, clock): + controller.send_payload = MagicMock() + address = addr_to_hex(BOT) + fix = dict(pos_x=1000, pos_y=1000) + controller.handle_received_frame(_advertised(BOT, direction=DIRECTION_NONE, **fix)) + batch = _batch() + controller.send_waypoints(address, batch) + controller.handle_received_frame(_advertised(BOT, direction=DIRECTION_NONE, **fix)) + assert controller.send_payload.call_count == 1 + controller.handle_received_frame(_advertised(BOT, direction=180, **fix)) + assert controller.send_payload.call_args.args == (BOT, batch) + + +@pytest.mark.asyncio +async def test_a_held_batch_is_dropped_after_the_timeout(controller, clock): + controller.send_payload = MagicMock() + address = addr_to_hex(BOT) + fix = dict(pos_x=1000, pos_y=1000) + controller.handle_received_frame(_advertised(BOT, direction=DIRECTION_NONE, **fix)) + controller.send_waypoints(address, _batch()) + clock.now += HOLD_TIMEOUT_S + 1 + controller.handle_received_frame(_advertised(BOT, direction=DIRECTION_NONE, **fix)) + assert not controller.held_batches + assert not controller.dotbots[address].waypoints_awaiting_heading + + +@pytest.mark.asyncio +async def test_a_report_of_the_batch_before_the_probe_does_not_release( + controller, clock +): + controller.send_payload = MagicMock() + controller.handle_received_frame(_headingless(batch_id=3)) + address = addr_to_hex(BOT) + batch = _batch() + controller.send_waypoints(address, batch) + probe_id = controller.send_payload.call_args.args[1].batch_id + controller.handle_received_frame( + _report(batch_id=3, waypoints_status=WaypointsStatus.ARRIVED) + ) + assert controller.send_payload.call_count == 1 + controller.handle_received_frame( + _report(batch_id=probe_id, waypoints_status=WaypointsStatus.ARRIVED) + ) + assert controller.send_payload.call_args.args == (BOT, batch) + + +@pytest.mark.asyncio +async def test_a_probe_superseded_by_another_controller_releases_on_a_heading( + controller, clock +): + controller.send_payload = MagicMock() + controller.handle_received_frame(_headingless(batch_id=3)) + address = addr_to_hex(BOT) + batch = _batch() + controller.send_waypoints(address, batch) + controller.handle_received_frame(_report(batch_id=99)) + assert controller.send_payload.call_args.args == (BOT, batch) + assert not controller.held_batches + + +@pytest.mark.asyncio +async def test_a_dropped_batch_leaves_no_waypoints_behind(controller, clock): + controller.send_payload = MagicMock() + controller.handle_received_frame(_headingless(batch_id=3)) + address = addr_to_hex(BOT) + controller.send_waypoints(address, _batch()) + controller.update_dotbot(address, waypoints=[DotBotLH2Position(x=1000, y=500)]) + probe_id = controller.send_payload.call_args.args[1].batch_id + controller.handle_received_frame( + _headingless(batch_id=probe_id, waypoints_status=WaypointsStatus.ARRIVED) + ) + dotbot = controller.dotbots[address] + assert dotbot.waypoints == [] and not dotbot.waypoints_awaiting_heading + + +@pytest.mark.asyncio +async def test_a_direct_command_drops_a_held_batch(controller, clock): + controller.adapter = MagicMock() + controller.handle_received_frame(_headingless(batch_id=3)) + address = addr_to_hex(BOT) + controller.send_waypoints(address, _batch()) + controller.send_payload(BOT, PayloadCommandMoveRaw(left_y=50, right_y=50)) + assert not controller.held_batches + assert not controller.dotbots[address].waypoints_awaiting_heading diff --git a/dotbot/tests/test_scenarios.py b/dotbot/tests/test_scenarios.py index 4738d9e5..56584b5a 100644 --- a/dotbot/tests/test_scenarios.py +++ b/dotbot/tests/test_scenarios.py @@ -120,6 +120,30 @@ def watch(): assert axle_error_mm(robot, 900, 900) <= 20 +async def test_a_batch_waits_near_the_start_until_the_robot_has_a_heading(scenario): + """Guards the controller's hold: a robot with no heading drives only the + probe move until it has one, then the whole batch.""" + s = scenario([_bot()]) + await s.run(1.0) + robot = s.robots[A] + start = (robot.pos_x, robot.pos_y) + held_travel = [] + + def watch(): + if s.dotbot(A).waypoints_awaiting_heading: + held_travel.append(math.dist((robot.pos_x, robot.pos_y), start)) + + await s.waypoints(A, [(1500, 500), (1500, 1000)], threshold=20) + assert s.dotbot(A).waypoints_awaiting_heading + assert await s.run_until_reported([A], "ARRIVED", seconds=30, each_tick=watch) + + assert held_travel and max(held_travel) < 100 + assert axle_error_mm(robot, 1500, 1000) <= 20 + # The stream told the console it was held, then that it no longer is + held = [p["waypoints_awaiting_heading"] for p in s.socket.robot_states() if "waypoints_awaiting_heading" in p] + assert True in held and held[-1] is False + + async def test_a_robot_that_never_sees_lh2_fails_no_heading(scenario): """Guards the NO_HEADING timeout and its FAILED report.""" s = scenario([_bot()]) From 958bc289a360abab90ab29bc5f30e88340ca5cb6 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Wed, 14 Oct 2026 13:41:20 -0300 Subject: [PATCH 08/17] dotbot/console-web: show a batch held for a heading as under way AI-assisted: Claude Opus 5.5 --- dotbot/console-web/src/Footer.tsx | 4 +++- dotbot/console-web/src/heldWaypoints.test.ts | 6 ++++++ dotbot/console-web/src/heldWaypoints.ts | 1 + dotbot/console-web/src/missions.test.ts | 9 +++++++++ dotbot/console-web/src/types.ts | 7 +++++++ 5 files changed, 26 insertions(+), 1 deletion(-) diff --git a/dotbot/console-web/src/Footer.tsx b/dotbot/console-web/src/Footer.tsx index 598a5b33..9ff83757 100644 --- a/dotbot/console-web/src/Footer.tsx +++ b/dotbot/console-web/src/Footer.tsx @@ -336,7 +336,9 @@ const ControlDock: React.FC<{ ? isGroup ? "⚠ Not drivable - no DBP in selection" : `⚠ Not drivable - ${notDrivableReason(single)}` - : anyAuto + : single?.mission?.code === "AWAITING_HEADING" + ? "▶ Waiting for a heading · a short probe move first" + : anyAuto ? single?.mission?.state === "in_progress" && single.mission.index !== null ? `▶ Navigating · waypoint ${Math.min(single.mission.index + 1, activeCount)} of ${activeCount}` : `▶ Navigating · ${activeCount} waypoint${activeCount === 1 ? "" : "s"} left` diff --git a/dotbot/console-web/src/heldWaypoints.test.ts b/dotbot/console-web/src/heldWaypoints.test.ts index a9b0e893..c14386ce 100644 --- a/dotbot/console-web/src/heldWaypoints.test.ts +++ b/dotbot/console-web/src/heldWaypoints.test.ts @@ -35,6 +35,12 @@ describe("heldWaypoints", () => { expect(rows.map((r) => r.status)).toEqual(["under way", "done"]); }); + it("reads a batch held for a heading as under way, even between moves", () => { + const waiting = { state: "in_progress" as const, index: 0, reason: "waiting for a heading", code: "AWAITING_HEADING" }; + const rows = heldWaypoints([bot("A", [start, start], { mission: waiting })]); + expect(rows[0]).toMatchObject({ status: "waiting for a heading", tone: "run" }); + }); + it("picks the rows of a selection", () => { expect(heldAmong(heldWaypoints(bots), new Set(["D", "A"])).map((r) => r.id)).toEqual(["D"]); }); diff --git a/dotbot/console-web/src/heldWaypoints.ts b/dotbot/console-web/src/heldWaypoints.ts index 2965f10c..bc4a63cc 100644 --- a/dotbot/console-web/src/heldWaypoints.ts +++ b/dotbot/console-web/src/heldWaypoints.ts @@ -14,6 +14,7 @@ export interface HeldRow { function statusOf(b: UnifiedBot, n: number): Pick { const m = b.mission; + if (m?.code === "AWAITING_HEADING") return { status: m.reason ?? m.code, tone: "run" }; if (b.nav === "auto") { const at = m?.state === "in_progress" && m.index !== null ? Math.min(m.index + 1, n) : null; return { status: at === null ? "under way" : `to ${at} of ${n}`, tone: "run" }; diff --git a/dotbot/console-web/src/missions.test.ts b/dotbot/console-web/src/missions.test.ts index be7f535c..8e06c799 100644 --- a/dotbot/console-web/src/missions.test.ts +++ b/dotbot/console-web/src/missions.test.ts @@ -135,6 +135,15 @@ describe("missionReport", () => { expect(missionReport(undefined)).toBeNull(); }); + it("reports a batch the controller holds for a heading as under way", () => { + expect(missionReport({ waypoints_status: 2, waypoints_awaiting_heading: true })).toEqual({ + state: "in_progress", + index: 0, + reason: "waiting for a heading", + code: "AWAITING_HEADING", + }); + }); + it("names every reason the controller reports in words", () => { const reasons = ["NO_HEADING", "TURN", "PROGRESS", "HEADING_LOST", "HOLD", "SETTLE", "STOP", "DIRECT", "CONTROL_MODE"]; for (const code of reasons) { diff --git a/dotbot/console-web/src/types.ts b/dotbot/console-web/src/types.ts index 2d43c334..a15405f6 100644 --- a/dotbot/console-web/src/types.ts +++ b/dotbot/console-web/src/types.ts @@ -114,6 +114,9 @@ export interface PyDotBot { // one: 0 NONE, 1 IN_PROGRESS, 2 ARRIVED, 3 FAILED, 4 ABORTED. waypoints_status?: number | null; waypoints_reason?: string | null; + // The controller holds the batch until the robot has a heading, and the + // report meanwhile is a short probe move's. + waypoints_awaiting_heading?: boolean; waypoint_index?: number | null; rgb_led?: RgbLed; battery?: number; // volts @@ -362,6 +365,7 @@ const MISSION_REASONS: Record = { STOP: "stopped", DIRECT: "driven by hand", CONTROL_MODE: "mode changed", + AWAITING_HEADING: "waiting for a heading", }; const MISSION_STATES: Record = { @@ -373,6 +377,9 @@ const MISSION_STATES: Record = { /** The robot's report on its batch, or null when it sends none. */ export function missionReport(py: Partial | undefined): MissionReport | null { + if (py?.waypoints_awaiting_heading) { + return { state: "in_progress", index: 0, reason: MISSION_REASONS.AWAITING_HEADING, code: "AWAITING_HEADING" }; + } const state = MISSION_STATES[py?.waypoints_status ?? 0]; if (!state) return null; const code = py?.waypoints_reason ?? null; From 56ec8b27a5ca7ebd64ba61f5b7cb5d442a651bce Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Wed, 14 Oct 2026 13:42:51 -0300 Subject: [PATCH 09/17] dotbot/config: add a site's spin_radius_mm for spin calibration AI-assisted: Claude Opus 5.5 --- dotbot/cli/swarm_lh2.py | 13 ++++++++----- dotbot/config.py | 2 ++ dotbot/models.py | 3 +++ dotbot/site.py | 3 +++ dotbot/tests/test_calibration_spin.py | 13 +++++++++++++ dotbot/tests/test_cli_site.py | 7 +++++++ dotbot/tests/test_qrkey_app.py | 1 + dotbot/tests/test_server.py | 3 +++ 8 files changed, 40 insertions(+), 5 deletions(-) diff --git a/dotbot/cli/swarm_lh2.py b/dotbot/cli/swarm_lh2.py index 4a13936a..7c5feea7 100644 --- a/dotbot/cli/swarm_lh2.py +++ b/dotbot/cli/swarm_lh2.py @@ -301,10 +301,10 @@ def _await_point(session, stream, arrivals: queue.Queue): metavar="MM", help=( "With --spin: radius of the photodiode's circle in a spin, in mm, " - "which sets the scale of every distance. Default: the robot model's " - "measured value (51.4 mm on a DotBot v3, under the 53.5 mm from " - "photodiode to axle because the caster drags the turning point " - "forward). Re-measure it on a different floor." + "which sets the scale of every distance. Default: the site's " + "spin_radius_mm, else the robot model's measured value (51.4 mm on " + "a DotBot v3, under the 53.5 mm from photodiode to axle because the " + "caster drags the turning point forward). It changes with the floor." ), ) @click.option( @@ -652,7 +652,10 @@ def _collect_spin( f"{round_} earlier round(s))." ) radius_from = "--spin-radius" - if spin_radius is None: + if spin_radius is None and site.spin_radius_mm is not None: + spin_radius = site.spin_radius_mm + radius_from = f"site {site.name}" + elif spin_radius is None: spin_radius = robot_geometry(ROBOT_DEFAULT).spin_radius_mm radius_from = f"{ROBOT_DEFAULT}'s measured value" devices = _devices(ctx) diff --git a/dotbot/config.py b/dotbot/config.py index eeccca90..4a3ee930 100644 --- a/dotbot/config.py +++ b/dotbot/config.py @@ -220,6 +220,8 @@ class SiteSection(_Strict): areas: dict[str, AreaSection] = Field(default_factory=dict) # LH2 base stations by serial, each with the channel it is set to stations: dict[str, int] = Field(default_factory=dict) + # The photodiode's circle when a robot spins in place on this floor + spin_radius_mm: float | None = Field(default=None, ge=1.0, le=200.0) @model_validator(mode="after") def _stations(self) -> SiteSection: diff --git a/dotbot/models.py b/dotbot/models.py index 1e364273..758c6bfb 100644 --- a/dotbot/models.py +++ b/dotbot/models.py @@ -259,6 +259,7 @@ class DotBotSiteModel(BaseModel): areas: List[DotBotAreaModel] = [] field: Optional[str] = None calibration: Optional[DotBotCalibrationSpanModel] = None + spin_radius_mm: Optional[float] = None @classmethod def from_site(cls, site: Site, calibration: Any = None) -> "DotBotSiteModel": @@ -269,6 +270,7 @@ def from_site(cls, site: Site, calibration: Any = None) -> "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, + spin_radius_mm=site.spin_radius_mm, calibration=( DotBotCalibrationSpanModel.from_calibration(calibration) if calibration is not None @@ -286,6 +288,7 @@ def to_site(self) -> Site: areas={ a.name: Area(a.x, a.y, a.w, a.h, a.name, a.role) for a in self.areas }, + spin_radius_mm=self.spin_radius_mm, ) diff --git a/dotbot/site.py b/dotbot/site.py index 769bf79f..075b1da1 100644 --- a/dotbot/site.py +++ b/dotbot/site.py @@ -51,6 +51,8 @@ class Site: extent_mm: tuple[int, int] | None = None areas: dict[str, Area] = field(default_factory=dict) pack: Path | None = None + # A spin's photodiode radius measured on this floor, if one was + spin_radius_mm: float | None = None @property def extent(self) -> Area | None: @@ -160,6 +162,7 @@ def site_from_table(name: str, table: Any, pack: Path | None = None) -> Site: pack=pack, anchor=getattr(table, "anchor", None) or "", extent_mm=(int(extent[0]), int(extent[1])) if extent else None, + spin_radius_mm=getattr(table, "spin_radius_mm", None), areas={ area_name: Area( x=area.x, diff --git a/dotbot/tests/test_calibration_spin.py b/dotbot/tests/test_calibration_spin.py index 9f7823f3..cbd6f380 100644 --- a/dotbot/tests/test_calibration_spin.py +++ b/dotbot/tests/test_calibration_spin.py @@ -287,6 +287,19 @@ def test_spins_end_to_end_into_a_self_defined_site(monkeypatch, lab): ) +def test_the_site_spin_radius_is_used_unless_the_flag_overrides_it( + monkeypatch, lab +): + toml = lab.project_dir / "sites" / "c405" / "site.toml" + toml.write_text(toml.read_text() + "spin_radius_mm = 53.0\n") + _, fleet = _fleet() + result = _collect(monkeypatch, lab, fleet) + assert "spin radius 53 mm (from site c405)" in result.output + _, fleet = _fleet() + result = _collect(monkeypatch, lab, fleet, "--spin-radius", "50") + assert "spin radius 50 mm (from --spin-radius)" in result.output + + def test_site_init_refuses_an_existing_name(monkeypatch, lab): _, fleet = _fleet() result = _collect(monkeypatch, lab, fleet) diff --git a/dotbot/tests/test_cli_site.py b/dotbot/tests/test_cli_site.py index df90f7cc..8ed2dea5 100644 --- a/dotbot/tests/test_cli_site.py +++ b/dotbot/tests/test_cli_site.py @@ -79,6 +79,13 @@ def test_a_site_table_becomes_its_anchor_extent_and_areas(): } +def test_a_site_table_carries_its_floor_spin_radius(): + assert site_from_table("tile", _table("spin_radius_mm = 53.0\n")).spin_radius_mm == 53.0 + assert site_from_table("tile", _table("")).spin_radius_mm is None + with pytest.raises(ValueError): + _table("spin_radius_mm = 0.5\n") + + def test_a_named_site_with_no_table_is_empty_rather_than_an_error(): """The default site is exactly this: a name, and nothing measured yet.""" site = site_from_table("bench", None) diff --git a/dotbot/tests/test_qrkey_app.py b/dotbot/tests/test_qrkey_app.py index ae2f3f68..dfe9a92b 100644 --- a/dotbot/tests/test_qrkey_app.py +++ b/dotbot/tests/test_qrkey_app.py @@ -140,4 +140,5 @@ def test_a_site_request_replies_with_the_whole_site(site_client): ], "field": "arena", "calibration": None, + "spin_radius_mm": None, } diff --git a/dotbot/tests/test_server.py b/dotbot/tests/test_server.py index 42276130..6dd1adfc 100644 --- a/dotbot/tests/test_server.py +++ b/dotbot/tests/test_server.py @@ -1383,6 +1383,7 @@ async def test_get_controller_site(): ], "field": "field", "calibration": None, + "spin_radius_mm": None, } @@ -1426,6 +1427,7 @@ def test_the_site_payload_reads_back_as_the_same_site(): name="hall", anchor="north-west corner", extent_mm=(5000, 4000), + spin_radius_mm=53.0, areas={ "pen": Area(0, 0, 10, 10, "pen", "corner"), "main": Area(5, 5, 10, 10, "main", "field"), @@ -1457,6 +1459,7 @@ async def test_get_controller_site_with_nothing_measured(): "areas": [], "field": None, "calibration": None, + "spin_radius_mm": None, } From 74820fa505a152c81f7964fd79108bb502cf779f Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Wed, 14 Oct 2026 14:37:00 -0300 Subject: [PATCH 10/17] dotbot/console-web: show the site and id of the robot's LH2 calibration AI-assisted: Claude Opus 5.5 --- dotbot/console-web/src/Inspector.tsx | 11 +++++++++++ dotbot/console-web/src/inspector.test.ts | 17 +++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/dotbot/console-web/src/Inspector.tsx b/dotbot/console-web/src/Inspector.tsx index 3d628f75..76af33f1 100644 --- a/dotbot/console-web/src/Inspector.tsx +++ b/dotbot/console-web/src/Inspector.tsx @@ -30,6 +30,14 @@ export function formatLh2(bot: UnifiedBot): string { return bot.swarmit?.info?.lh2_summary ?? "unknown (no device info)"; } +/** The site the held calibration was made for and its short id; null without device info. */ +export function formatLh2Site(bot: UnifiedBot): string | null { + const info = bot.swarmit?.info; + if (!info) return null; + const id = (info.lh2_calibration_id ?? "").slice(0, 8); + return `${info.lh2_site_name || "none"} (id ${id || "none"})`; +} + /** * The body heading, and where it came from. A travel bearing is the robot's * direction of motion, so it is its heading only while it drives straight; @@ -99,6 +107,8 @@ export function infoText(bot: UnifiedBot): string { } out.push(""); out.push(`LH2 calibration ${formatLh2(bot)}`); + const lh2Site = formatLh2Site(bot); + if (lh2Site !== null) out.push(` site ${lh2Site}`); if (sw?.reset_reason !== undefined) { out.push(""); out.push(`Last reset ${bot.resetCause}`); @@ -215,6 +225,7 @@ const Card: React.FC<{ bot: UnifiedBot; camera: CameraRobot | null; cameras: boo
+ {formatLh2Site(bot) !== null && } {sw?.reset_reason !== undefined && ( <> diff --git a/dotbot/console-web/src/inspector.test.ts b/dotbot/console-web/src/inspector.test.ts index d5e241af..b916c4ae 100644 --- a/dotbot/console-web/src/inspector.test.ts +++ b/dotbot/console-web/src/inspector.test.ts @@ -4,6 +4,7 @@ import { cameraRobotFor, formatCameraHeading, formatLh2, + formatLh2Site, formatUptime, infoText, } from "./Inspector"; @@ -68,6 +69,22 @@ describe("formatLh2", () => { }); }); +describe("formatLh2Site", () => { + const info = { bl_version: "", net_version: "", boot_count: 0, uptime_s: 0, image_name: "", image_version: "", image_digest: "" }; + const withInfo = (over: object) => bot({ swarmit: node({ info: { ...info, ...over } }) }); + + it("names the site and the short calibration id the robot holds", () => { + expect(formatLh2Site(withInfo({ lh2_site_name: "virmond", lh2_calibration_id: "288a810d1234abcd" }))).toBe( + "virmond (id 288a810d)", + ); + }); + + it("says none for a robot holding no calibration, and nothing without device info", () => { + expect(formatLh2Site(withInfo({}))).toBe("none (id none)"); + expect(formatLh2Site(bot({ swarmit: null }))).toBeNull(); + }); +}); + describe("infoText", () => { it("is plain text carrying the identity and the reset cause", () => { const t = infoText( From c15bd51805f2f7d51818759e3d1dc7a83822cd08 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Wed, 14 Oct 2026 14:40:44 -0300 Subject: [PATCH 11/17] dotbot/console-web: name the calibration's site on the full grid card AI-assisted: Claude Opus 5.5 --- dotbot/console-web/src/GridView.tsx | 4 ++-- dotbot/console-web/src/botFacts.ts | 8 ++++++++ dotbot/console-web/src/fleetViews.test.tsx | 8 ++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/dotbot/console-web/src/GridView.tsx b/dotbot/console-web/src/GridView.tsx index cc051c13..b7f1d982 100644 --- a/dotbot/console-web/src/GridView.tsx +++ b/dotbot/console-web/src/GridView.tsx @@ -5,7 +5,7 @@ import { areaLabel, bootloaderLabel, calibrationDiffers, - calibrationLabel, + calibrationSiteLabel, detailText, FleetContext, firmwareTooOld, @@ -274,7 +274,7 @@ export const GridView: React.FC = ({ bots, selection, onSelect, c ) } > - {calibrationLabel(b) || dash} + {calibrationSiteLabel(b) || dash} {b.deviceType} {b.severity !== "normal" && {b.resetCause ?? b.severity}} diff --git a/dotbot/console-web/src/botFacts.ts b/dotbot/console-web/src/botFacts.ts index a47cd6ca..bf4295db 100644 --- a/dotbot/console-web/src/botFacts.ts +++ b/dotbot/console-web/src/botFacts.ts @@ -43,6 +43,14 @@ export function calibrationLabel(bot: UnifiedBot): string { return (bot.swarmit?.info?.lh2_calibration_id ?? "").slice(0, 8); } +/** "288a810d · virmond": the held calibration's short id and its site, "" for none or unknown. */ +export function calibrationSiteLabel(bot: UnifiedBot): string { + const id = calibrationLabel(bot); + if (!id) return ""; + const site = bot.swarmit?.info?.lh2_site_name ?? ""; + return site ? `${id} · ${site}` : id; +} + /** Whether the robot reports a calibration other than the one the controller serves. */ export function calibrationDiffers(bot: UnifiedBot, calibrationId: string): boolean { if (!calibrationId || firmwareTooOld(bot)) return false; diff --git a/dotbot/console-web/src/fleetViews.test.tsx b/dotbot/console-web/src/fleetViews.test.tsx index efd5d8de..64c910d3 100644 --- a/dotbot/console-web/src/fleetViews.test.tsx +++ b/dotbot/console-web/src/fleetViews.test.tsx @@ -7,6 +7,7 @@ import { appLabel, areaLabel, calibrationDiffers, + calibrationSiteLabel, detailText, FleetContext, firmwareTooOld, @@ -96,6 +97,13 @@ describe("botFacts", () => { expect(calibrationDiffers(other, "")).toBe(false); }); + it("names the held calibration's short id and its site", () => { + const withSite = bot("A", { swarmit: { ...bot("A").swarmit!, info: info({ lh2_site_name: "virmond" }) } }); + expect(calibrationSiteLabel(withSite)).toBe("abcd1234 · virmond"); + expect(calibrationSiteLabel(bot("A"))).toBe("abcd1234"); + expect(calibrationSiteLabel(bot("A", { swarmit: null }))).toBe(""); + }); + it("picks the smallest area the robot stands in", () => { expect(areaLabel(bot("A"), site)).toBe("field"); expect(areaLabel(bot("A", { position: { x: 100, y: 100 } }), site)).toBe("bench"); From 7f60a215c7bb1ce01744d9bd73e2844f21019a17 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Wed, 14 Oct 2026 14:47:16 -0300 Subject: [PATCH 12/17] dotbot/console-web: name the served calibration's site in its fleet coverage AI-assisted: Claude Opus 5.5 --- dotbot/console-web/src/LocalizationPanel.tsx | 3 ++- dotbot/console-web/src/localization.ts | 6 ++++++ dotbot/console-web/src/pushTarget.test.tsx | 16 +++++++++++++++- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/dotbot/console-web/src/LocalizationPanel.tsx b/dotbot/console-web/src/LocalizationPanel.tsx index 175c14a6..2b94c98a 100644 --- a/dotbot/console-web/src/LocalizationPanel.tsx +++ b/dotbot/console-web/src/LocalizationPanel.tsx @@ -6,6 +6,7 @@ import { cameraStatusRows, coverageLabel, extentLabel, + servedCalibrationLabel, stationRows, stationsSummary, } from "./localization"; @@ -186,7 +187,7 @@ export const LocalizationPanel: React.FC = ({
- {coverage.unknown ? "not compared" : coverage.id.slice(0, 8)} + {coverage.unknown ? "not compared" : servedCalibrationLabel(coverage, session?.site || site?.name || "")}
{coverage.unknown diff --git a/dotbot/console-web/src/localization.ts b/dotbot/console-web/src/localization.ts index e04bbafa..e3eb8473 100644 --- a/dotbot/console-web/src/localization.ts +++ b/dotbot/console-web/src/localization.ts @@ -60,6 +60,12 @@ export function calibrationCoverage(bots: UnifiedBot[], savedId: string): Covera return { id: savedId, carrying, total: bots.length, stale, unchecked, unknown: !wanted }; } +/** "288a810d (virmond)": the served calibration's short id and the site it was captured on. */ +export function servedCalibrationLabel(coverage: Coverage, site: string): string { + const id = coverage.id.slice(0, 8); + return site ? `${id} (${site})` : id; +} + /** "11 of 12 bots", or the absence when the fleet is empty. */ export function coverageLabel(coverage: Coverage): string { if (coverage.total === 0) return "no bots on the control plane"; diff --git a/dotbot/console-web/src/pushTarget.test.tsx b/dotbot/console-web/src/pushTarget.test.tsx index a747f7a9..acc4fa14 100644 --- a/dotbot/console-web/src/pushTarget.test.tsx +++ b/dotbot/console-web/src/pushTarget.test.tsx @@ -87,12 +87,13 @@ const renderPanel = ( selection: Set, onPush = vi.fn(), busy = false, + session: CalibrationSession = saved, ) => { render( {}} @@ -148,3 +149,16 @@ describe("the Localization tab's push row", () => { expect(onPush).not.toHaveBeenCalled(); }); }); + +describe("the Localization tab's calibration on the fleet", () => { + it("names the served calibration's id and its site beside its coverage", () => { + renderPanel([bot("AA", SAVED), bot("BB", "")], new Set(), vi.fn(), false, { ...saved, site: "virmond" }); + expect(screen.getByText("3f9a1c07 (virmond)")).toBeTruthy(); + expect(screen.getByText("on 1 of 2 bots")).toBeTruthy(); + }); + + it("names only the id when the session carries no site", () => { + renderPanel([bot("AA", SAVED)], new Set()); + expect(screen.getByText("3f9a1c07")).toBeTruthy(); + }); +}); From 2c4c70f75adc30450fd8cd1e086cb9086c1ec419 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Wed, 14 Oct 2026 14:47:16 -0300 Subject: [PATCH 13/17] dotbot/console-web: flag a robot calibrated for another site AI-assisted: Claude Opus 5.5 --- dotbot/console-web/src/BotMarker.tsx | 28 ++++++++++ dotbot/console-web/src/GridView.tsx | 21 +++---- dotbot/console-web/src/ListView.tsx | 14 ++--- dotbot/console-web/src/MapView.tsx | 2 + dotbot/console-web/src/botChrome.test.tsx | 27 +++++++++ dotbot/console-web/src/botFacts.ts | 44 ++++++++++++++- dotbot/console-web/src/fleetViews.test.tsx | 65 ++++++++++++++++++++++ dotbot/console-web/src/viewChrome.tsx | 46 +++++++++++++-- 8 files changed, 219 insertions(+), 28 deletions(-) diff --git a/dotbot/console-web/src/BotMarker.tsx b/dotbot/console-web/src/BotMarker.tsx index fdfb0240..a49114df 100644 --- a/dotbot/console-web/src/BotMarker.tsx +++ b/dotbot/console-web/src/BotMarker.tsx @@ -154,6 +154,8 @@ export interface BotMarkerProps { batteryBars: boolean; /** What the body is filled with: the swarmit state, or the LED colour. */ colorMode: BodyColorMode; + /** The site of the robot's calibration when it is not the map's, "" otherwise. */ + otherSite?: string; attach: (id: string, el: HTMLElement | null) => void; onPointerDown: (e: React.PointerEvent, id: string) => void; onHover: React.Dispatch>; @@ -174,6 +176,7 @@ export const BotMarker = React.memo(function BotMarker({ botCount, batteryBars, colorMode, + otherSite = "", attach, onPointerDown, onHover, @@ -235,6 +238,30 @@ export const BotMarker = React.memo(function BotMarker({ >
+ {/* calibrated for another site: the position is in another frame */} + {otherSite && ( +
+ ! +
+ )} {/* selection ring, hugging the board */} {selected && (
= ({ bot, ctx }) => { const slipping = bot.link === "stale" || bot.link === "lost"; - const differs = calibrationDiffers(bot, ctx.calibrationId); + const differs = calibrationMismatch(bot, ctx) !== "none"; const old = firmwareTooOld(bot); const bootloader = bootloaderLabel(bot); if (!bootloader && !slipping && !differs && !old) return null; @@ -104,11 +106,7 @@ const Chips: React.FC<{ bot: UnifiedBot; ctx: FleetContext }> = ({ bot, ctx }) = {bootloader && bl {bootloader}} {slipping && {reportsLabel(bot, ctx.now)}} {old && } - {differs && ( - - cal differs - - )} + {differs && }
); }; @@ -266,13 +264,8 @@ export const GridView: React.FC = ({ bots, selection, onSelect, c - differs - - ) - } + title={calibrationDetail(b) || undefined} + extra={} > {calibrationSiteLabel(b) || dash} diff --git a/dotbot/console-web/src/ListView.tsx b/dotbot/console-web/src/ListView.tsx index 0ce78055..9bfd55e1 100644 --- a/dotbot/console-web/src/ListView.tsx +++ b/dotbot/console-web/src/ListView.tsx @@ -4,7 +4,7 @@ import { appLabel, areaLabel, bootloaderLabel, - calibrationDiffers, + calibrationDetail, calibrationLabel, detailText, FleetContext, @@ -19,8 +19,8 @@ import { rowOpacity } from "./link"; import { store } from "./persisted"; import { UnifiedBot } from "./types"; import { - Badge, BatteryCell, + CalibrationBadge, FilterBar, HeadingGlyph, LedDot, @@ -118,12 +118,10 @@ export const COLUMNS: Column[] = [ shown: true, cell: (b, ctx) => ( - {calibrationLabel(b) || dash} - {calibrationDiffers(b, ctx.calibrationId) && ( - - differs - - )} + + {calibrationLabel(b) || dash} + + ), }, diff --git a/dotbot/console-web/src/MapView.tsx b/dotbot/console-web/src/MapView.tsx index ee72431b..29ad6b25 100644 --- a/dotbot/console-web/src/MapView.tsx +++ b/dotbot/console-web/src/MapView.tsx @@ -31,6 +31,7 @@ import { ticksInSite, } from "./grid"; import { hasHeading } from "./BotGlyph"; +import { calibrationOtherSite } from "./botFacts"; import { BotMarker, MARKER_SCALE_VAR, WAYPOINT_MIN_PX, botDraw as drawBot } from "./BotMarker"; import { useMarkerPerMm } from "./markerScale"; import { BodyColorMode, DEFAULT_BODY_COLOR_MODE } from "./bodyColor"; @@ -1569,6 +1570,7 @@ export const MapView: React.FC = (props) => { botCount={props.bots.length} batteryBars={props.layers.batteryBars} colorMode={colorMode} + otherSite={calibrationOtherSite(b, props.site)} attach={attachBot} onPointerDown={onBotPointerDown} onHover={setHoverId} diff --git a/dotbot/console-web/src/botChrome.test.tsx b/dotbot/console-web/src/botChrome.test.tsx index 100172df..a0a6b393 100644 --- a/dotbot/console-web/src/botChrome.test.tsx +++ b/dotbot/console-web/src/botChrome.test.tsx @@ -521,3 +521,30 @@ describe("the possible footprint", () => { expect(layer("a", "reach")!.getAttribute("fill")).not.toBe("none"); }); }); + +describe("a robot calibrated for another site", () => { + const held = (site: string) => + ({ + device: "DotBotV3", + status: "Running", + battery: 2900, + pos_x: 0, + pos_y: 0, + info: { info_version: 4, lh2_calibration_id: "288a810d1234abcd", lh2_site_name: site }, + }) as UnifiedBot["swarmit"]; + + it("is marked on the map, and a robot of this site or of no known site is not", () => { + render( + , + ); + expect(screen.getByTestId("other-site-away").getAttribute("title")).toContain("workshop"); + expect(screen.queryByTestId("other-site-home")).toBeNull(); + expect(screen.queryByTestId("other-site-none")).toBeNull(); + }); +}); diff --git a/dotbot/console-web/src/botFacts.ts b/dotbot/console-web/src/botFacts.ts index bf4295db..9b07f0c7 100644 --- a/dotbot/console-web/src/botFacts.ts +++ b/dotbot/console-web/src/botFacts.ts @@ -59,6 +59,46 @@ export function calibrationDiffers(bot: UnifiedBot, calibrationId: string): bool return (info.lh2_calibration_id ?? "").toLowerCase() !== calibrationId.toLowerCase(); } +/** The site of the calibration the robot holds when it is not `site`, "" otherwise or when either is unknown. */ +export function calibrationOtherSite(bot: UnifiedBot, site: Site | null): string { + const info = bot.swarmit?.info; + if (!info || firmwareTooOld(bot) || !info.lh2_calibration_id) return ""; + const held = info.lh2_site_name ?? ""; + const active = site?.name ?? ""; + if (!held || !active || held.toLowerCase() === active.toLowerCase()) return ""; + return held; +} + +/** How the robot's calibration differs from what the controller serves: a + * calibration of another site puts its positions in another frame, which + * outranks a different id of the same site. */ +export type CalibrationMismatch = "none" | "other-site" | "other-id"; + +export function calibrationMismatch(bot: UnifiedBot, ctx: Pick): CalibrationMismatch { + if (calibrationOtherSite(bot, ctx.site)) return "other-site"; + if (calibrationDiffers(bot, ctx.calibrationId)) return "other-id"; + return "none"; +} + +/** The stations whose homography the robot holds, from its station mask: [0, 1]. */ +export function calibrationStations(bot: UnifiedBot): number[] | null { + const mask = bot.swarmit?.info?.lh2_station_mask; + if (mask === undefined || mask === null) return null; + const out: number[] = []; + for (let i = 0; i < 32; i++) if (mask & (1 << i)) out.push(i); + return out; +} + +/** "virmond · 288a810d1234abcd · stations 0, 1": the held calibration in full, "" for none or unknown. */ +export function calibrationDetail(bot: UnifiedBot): string { + const info = bot.swarmit?.info; + if (!info?.lh2_calibration_id) return ""; + const parts = [info.lh2_site_name ?? "", info.lh2_calibration_id]; + const stations = calibrationStations(bot); + if (stations) parts.push(stations.length ? `stations ${stations.join(", ")}` : "no stations"); + return parts.filter(Boolean).join(" · "); +} + /** "1204, 877 mm", or "" with no fix. */ export function positionLabel(bot: UnifiedBot): string { if (!bot.position) return ""; @@ -147,7 +187,9 @@ export function reportsDetail(bot: UnifiedBot, now: number): string { export function warnings(bot: UnifiedBot, ctx: FleetContext): string[] { const out: string[] = []; if (firmwareTooOld(bot)) out.push("bootloader too old for this controller: reflash swarmit-sandbox"); - else if (calibrationDiffers(bot, ctx.calibrationId)) { + else if (calibrationOtherSite(bot, ctx.site)) { + out.push(`holds a calibration of site ${calibrationOtherSite(bot, ctx.site)}: its position is in another frame`); + } else if (calibrationDiffers(bot, ctx.calibrationId)) { const held = calibrationLabel(bot) || "none"; out.push(`holds calibration ${held}, not ${ctx.calibrationId.slice(0, 8)}: push it`); } diff --git a/dotbot/console-web/src/fleetViews.test.tsx b/dotbot/console-web/src/fleetViews.test.tsx index 64c910d3..7802ac52 100644 --- a/dotbot/console-web/src/fleetViews.test.tsx +++ b/dotbot/console-web/src/fleetViews.test.tsx @@ -6,7 +6,9 @@ import { ageLabel, appLabel, areaLabel, + calibrationDetail, calibrationDiffers, + calibrationMismatch, calibrationSiteLabel, detailText, FleetContext, @@ -239,3 +241,66 @@ describe("GridView", () => { spy.mockRestore(); }); }); + +describe("a calibration other than the one served", () => { + beforeEach(() => window.localStorage.clear()); + afterEach(cleanup); + + const held = (over: Partial) => + ({ swarmit: { ...bot("A").swarmit!, info: info({ lh2_site_name: "lab", ...over }) } }) as Partial; + const matching = bot("CCCC000000000001", held({})); + const otherId = bot("CCCC000000000002", held({ lh2_calibration_id: "99999999eeee" })); + const otherSite = bot("CCCC000000000003", held({ lh2_site_name: "workshop", lh2_calibration_id: "99999999eeee" })); + const fleet = [matching, otherId, otherSite]; + const rowOf = (id: string) => screen.getAllByRole("row").find((r) => r.textContent?.includes(id))!; + + it("ranks another site above another id, and needs both site names to compare", () => { + expect(calibrationMismatch(matching, ctx)).toBe("none"); + expect(calibrationMismatch(otherId, ctx)).toBe("other-id"); + expect(calibrationMismatch(otherSite, ctx)).toBe("other-site"); + // Same id, other site: still another frame. + expect(calibrationMismatch(bot("A", held({ lh2_site_name: "workshop" })), ctx)).toBe("other-site"); + expect(calibrationMismatch(bot("A", held({ lh2_site_name: "" })), ctx)).toBe("none"); + expect(calibrationMismatch(otherSite, { ...ctx, site: null })).toBe("other-id"); + expect(calibrationMismatch(bot("A", held({ lh2_site_name: "LAB" })), ctx)).toBe("none"); + expect(calibrationMismatch(bot("A", held({ info_version: 2, lh2_site_name: "workshop" })), ctx)).toBe("none"); + expect(detailText(otherSite, ctx)).toContain("! holds a calibration of site workshop"); + expect(detailText(otherSite, ctx)).not.toContain("push it"); + }); + + it("spells the held calibration out in full, with its stations", () => { + expect(calibrationDetail(bot("A", held({ lh2_station_mask: 0b11 })))).toBe("lab · abcd1234ffff · stations 0, 1"); + expect(calibrationDetail(bot("A", held({ lh2_station_mask: 0 })))).toBe("lab · abcd1234ffff · no stations"); + expect(calibrationDetail(bot("A", held({ lh2_site_name: "" })))).toBe("abcd1234ffff"); + expect(calibrationDetail(bot("A", held({ lh2_calibration_id: "" })))).toBe(""); + }); + + it("is flagged once per robot in the list, the site louder than the id", () => { + render( {}} ctx={ctx} />); + const m = rowOf("CCCC000000000001"); + expect(within(m).queryByText("differs")).toBeNull(); + expect(within(m).queryByText(/other site/)).toBeNull(); + const i = rowOf("CCCC000000000002"); + expect(within(i).getByText("differs").getAttribute("data-tone")).toBe("warn"); + expect(within(i).queryByText(/other site/)).toBeNull(); + const s = rowOf("CCCC000000000003"); + expect(within(s).getByText("other site: workshop").getAttribute("data-tone")).toBe("alert"); + expect(within(s).queryByText("differs")).toBeNull(); + }); + + it("carries the full calibration as the list cell's tooltip", () => { + const withMask = bot("CCCC000000000004", held({ lh2_station_mask: 0b11 })); + render( {}} ctx={ctx} />); + expect(screen.getByText("abcd1234").getAttribute("title")).toBe("lab · abcd1234ffff · stations 0, 1"); + }); + + it("is flagged the same way on compact and full grid cards", () => { + render( {}} ctx={ctx} />); + expect(screen.getAllByText("cal differs")).toHaveLength(1); + expect(screen.getAllByText("other site: workshop")).toHaveLength(1); + fireEvent.click(screen.getByRole("button", { name: "Full" })); + expect(screen.getAllByText("differs")).toHaveLength(1); + expect(screen.getAllByText("other site: workshop")).toHaveLength(1); + expect(screen.getByText("99999999 · workshop").getAttribute("title")).toBe("workshop · 99999999eeee"); + }); +}); diff --git a/dotbot/console-web/src/viewChrome.tsx b/dotbot/console-web/src/viewChrome.tsx index fb5cd79e..010b0ea2 100644 --- a/dotbot/console-web/src/viewChrome.tsx +++ b/dotbot/console-web/src/viewChrome.tsx @@ -1,6 +1,15 @@ import React, { useMemo, useState } from "react"; -import { appLabel, areaLabel, bootloaderLabel, calibrationLabel, FleetContext, headingLabel } from "./botFacts"; +import { + appLabel, + areaLabel, + bootloaderLabel, + calibrationLabel, + calibrationMismatch, + calibrationOtherSite, + FleetContext, + headingLabel, +} from "./botFacts"; import { TRAVEL_BODY_OPACITY } from "./BotGlyph"; import { headingToGlyphRotation } from "./frame"; import { BotState, STATE_ORDER, UnifiedBot } from "./types"; @@ -322,14 +331,15 @@ export function useQueriedBots(bots: UnifiedBot[], q: ViewQuery, ctx: FleetConte return useMemo(() => applyQuery(bots, q, ctx), [bots, q, ctx]); } -/** A small pill: a fact, or a warning when `tone` is "warn". */ -export const Badge: React.FC<{ children: React.ReactNode; title?: string; tone?: "warn" | "plain" }> = ({ +/** A small pill: a fact, a warning when `tone` is "warn", a filled one when "alert". */ +export const Badge: React.FC<{ children: React.ReactNode; title?: string; tone?: "warn" | "alert" | "plain" }> = ({ children, title, tone = "plain", }) => ( {children} ); +/** The badge on a robot whose calibration is not the one served: "other site: workshop" + * when it is another site's, `differs` when it is another id of this site, nothing otherwise. */ +export const CalibrationBadge: React.FC<{ bot: UnifiedBot; ctx: FleetContext; differs?: string }> = ({ + bot, + ctx, + differs = "differs", +}) => { + const mismatch = calibrationMismatch(bot, ctx); + if (mismatch === "other-site") { + return ( + + other site: {calibrationOtherSite(bot, ctx.site)} + + ); + } + if (mismatch === "other-id") { + return ( + + {differs} + + ); + } + return null; +}; + /** * The robot's heading as the map draws it: a circle with a radius toward the * way it faces, dimmed for a travel bearing, and an empty dashed circle while From d8b057203a2fde35b41d9ddfbb0be2f7b2ccce1c Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Wed, 14 Oct 2026 14:55:48 -0300 Subject: [PATCH 14/17] dotbot/console-web: treat an empty station mask as no calibration AI-assisted: Claude Opus 5.5 --- dotbot/console-web/src/Inspector.tsx | 6 +++--- dotbot/console-web/src/botFacts.ts | 21 +++++++++++++++------ dotbot/console-web/src/fleetViews.test.tsx | 5 ++++- dotbot/console-web/src/inspector.test.ts | 3 ++- dotbot/console-web/src/localization.ts | 2 +- 5 files changed, 25 insertions(+), 12 deletions(-) diff --git a/dotbot/console-web/src/Inspector.tsx b/dotbot/console-web/src/Inspector.tsx index 76af33f1..f554b6ef 100644 --- a/dotbot/console-web/src/Inspector.tsx +++ b/dotbot/console-web/src/Inspector.tsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; -import { reportsWire } from "./botFacts"; +import { heldCalibrationId, reportsWire } from "./botFacts"; import { stateLabel } from "./viewChrome"; import { CameraDetection, CameraRobot, REPORTS_LABEL, UnifiedBot } from "./types"; @@ -34,8 +34,8 @@ export function formatLh2(bot: UnifiedBot): string { export function formatLh2Site(bot: UnifiedBot): string | null { const info = bot.swarmit?.info; if (!info) return null; - const id = (info.lh2_calibration_id ?? "").slice(0, 8); - return `${info.lh2_site_name || "none"} (id ${id || "none"})`; + const id = heldCalibrationId(bot).slice(0, 8); + return id ? `${info.lh2_site_name || "none"} (id ${id})` : "none"; } /** diff --git a/dotbot/console-web/src/botFacts.ts b/dotbot/console-web/src/botFacts.ts index 9b07f0c7..2b2ac8ad 100644 --- a/dotbot/console-web/src/botFacts.ts +++ b/dotbot/console-web/src/botFacts.ts @@ -38,9 +38,18 @@ export function firmwareTooOld(bot: UnifiedBot): boolean { return !!info && (info.info_version ?? 0) < DEVICE_INFO_VERSION_MIN; } +/** The full LH2 calibration id the robot holds, "" for none or unknown. An + * empty station mask means no homography is held, whatever id the device + * info still carries. */ +export function heldCalibrationId(bot: UnifiedBot): string { + const info = bot.swarmit?.info; + if (!info || info.lh2_station_mask === 0) return ""; + return info.lh2_calibration_id ?? ""; +} + /** The LH2 calibration id the robot holds: its first 8 characters, "" for none or unknown. */ export function calibrationLabel(bot: UnifiedBot): string { - return (bot.swarmit?.info?.lh2_calibration_id ?? "").slice(0, 8); + return heldCalibrationId(bot).slice(0, 8); } /** "288a810d · virmond": the held calibration's short id and its site, "" for none or unknown. */ @@ -56,13 +65,13 @@ export function calibrationDiffers(bot: UnifiedBot, calibrationId: string): bool if (!calibrationId || firmwareTooOld(bot)) return false; const info = bot.swarmit?.info; if (!info) return false; - return (info.lh2_calibration_id ?? "").toLowerCase() !== calibrationId.toLowerCase(); + return heldCalibrationId(bot).toLowerCase() !== calibrationId.toLowerCase(); } /** The site of the calibration the robot holds when it is not `site`, "" otherwise or when either is unknown. */ export function calibrationOtherSite(bot: UnifiedBot, site: Site | null): string { const info = bot.swarmit?.info; - if (!info || firmwareTooOld(bot) || !info.lh2_calibration_id) return ""; + if (!info || firmwareTooOld(bot) || !heldCalibrationId(bot)) return ""; const held = info.lh2_site_name ?? ""; const active = site?.name ?? ""; if (!held || !active || held.toLowerCase() === active.toLowerCase()) return ""; @@ -91,9 +100,9 @@ export function calibrationStations(bot: UnifiedBot): number[] | null { /** "virmond · 288a810d1234abcd · stations 0, 1": the held calibration in full, "" for none or unknown. */ export function calibrationDetail(bot: UnifiedBot): string { - const info = bot.swarmit?.info; - if (!info?.lh2_calibration_id) return ""; - const parts = [info.lh2_site_name ?? "", info.lh2_calibration_id]; + const id = heldCalibrationId(bot); + if (!id) return ""; + const parts = [bot.swarmit?.info?.lh2_site_name ?? "", id]; const stations = calibrationStations(bot); if (stations) parts.push(stations.length ? `stations ${stations.join(", ")}` : "no stations"); return parts.filter(Boolean).join(" · "); diff --git a/dotbot/console-web/src/fleetViews.test.tsx b/dotbot/console-web/src/fleetViews.test.tsx index 7802ac52..cab21780 100644 --- a/dotbot/console-web/src/fleetViews.test.tsx +++ b/dotbot/console-web/src/fleetViews.test.tsx @@ -104,6 +104,9 @@ describe("botFacts", () => { expect(calibrationSiteLabel(withSite)).toBe("abcd1234 · virmond"); expect(calibrationSiteLabel(bot("A"))).toBe("abcd1234"); expect(calibrationSiteLabel(bot("A", { swarmit: null }))).toBe(""); + const emptied = bot("A", { swarmit: { ...bot("A").swarmit!, info: info({ lh2_station_mask: 0, lh2_site_name: "virmond" }) } }); + expect(calibrationSiteLabel(emptied)).toBe(""); + expect(calibrationDiffers(emptied, ctx.calibrationId)).toBe(true); }); it("picks the smallest area the robot stands in", () => { @@ -270,7 +273,7 @@ describe("a calibration other than the one served", () => { it("spells the held calibration out in full, with its stations", () => { expect(calibrationDetail(bot("A", held({ lh2_station_mask: 0b11 })))).toBe("lab · abcd1234ffff · stations 0, 1"); - expect(calibrationDetail(bot("A", held({ lh2_station_mask: 0 })))).toBe("lab · abcd1234ffff · no stations"); + expect(calibrationDetail(bot("A", held({ lh2_station_mask: 0 })))).toBe(""); expect(calibrationDetail(bot("A", held({ lh2_site_name: "" })))).toBe("abcd1234ffff"); expect(calibrationDetail(bot("A", held({ lh2_calibration_id: "" })))).toBe(""); }); diff --git a/dotbot/console-web/src/inspector.test.ts b/dotbot/console-web/src/inspector.test.ts index b916c4ae..e03111d1 100644 --- a/dotbot/console-web/src/inspector.test.ts +++ b/dotbot/console-web/src/inspector.test.ts @@ -80,7 +80,8 @@ describe("formatLh2Site", () => { }); it("says none for a robot holding no calibration, and nothing without device info", () => { - expect(formatLh2Site(withInfo({}))).toBe("none (id none)"); + expect(formatLh2Site(withInfo({}))).toBe("none"); + expect(formatLh2Site(withInfo({ lh2_station_mask: 0, lh2_site_name: "virmond", lh2_calibration_id: "288a810d1234abcd" }))).toBe("none"); expect(formatLh2Site(bot({ swarmit: null }))).toBeNull(); }); }); diff --git a/dotbot/console-web/src/localization.ts b/dotbot/console-web/src/localization.ts index e3eb8473..e0e3d816 100644 --- a/dotbot/console-web/src/localization.ts +++ b/dotbot/console-web/src/localization.ts @@ -53,7 +53,7 @@ export function calibrationCoverage(bots: UnifiedBot[], savedId: string): Covera for (const bot of bots) { const info = bot.swarmit?.info; if (!info || (info.info_version ?? 0) < DEVICE_INFO_VERSION_MIN) unchecked.push(bot.id); - else if ((info.lh2_calibration_id ?? "").toLowerCase() === wanted) carrying += 1; + else if (info.lh2_station_mask !== 0 && (info.lh2_calibration_id ?? "").toLowerCase() === wanted) carrying += 1; else stale.push(bot.id); } } From d4c2c633a806a6519128ff44a219ef4fc1de4c37 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Wed, 14 Oct 2026 15:30:09 -0300 Subject: [PATCH 15/17] dotbot/console-web: name a failed LH2 sensor in the inspector position AI-assisted: Claude Opus 5.5 --- dotbot/console-web/src/Inspector.tsx | 17 ++++++++++++----- dotbot/console-web/src/inspector.test.ts | 11 +++++++++++ 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/dotbot/console-web/src/Inspector.tsx b/dotbot/console-web/src/Inspector.tsx index f554b6ef..0173a869 100644 --- a/dotbot/console-web/src/Inspector.tsx +++ b/dotbot/console-web/src/Inspector.tsx @@ -30,6 +30,16 @@ export function formatLh2(bot: UnifiedBot): string { return bot.swarmit?.info?.lh2_summary ?? "unknown (no device info)"; } +/** Matches swarmit's LH2_FLAG_SENSOR_FAILED: the bot's LH2 receiver did not initialise this boot. */ +const LH2_FLAG_SENSOR_FAILED = 1 << 2; + +/** "1204, 877", or why there is no position. */ +export function formatPosition(bot: UnifiedBot): string { + if (bot.position) return `${Math.round(bot.position.x)}, ${Math.round(bot.position.y)}`; + if ((bot.swarmit?.info?.lh2_flags ?? 0) & LH2_FLAG_SENSOR_FAILED) return "LH2 sensor failed"; + return "no fix"; +} + /** The site the held calibration was made for and its short id; null without device info. */ export function formatLh2Site(bot: UnifiedBot): string | null { const info = bot.swarmit?.info; @@ -89,7 +99,7 @@ export function infoText(bot: UnifiedBot): string { out.push(`Reports ${REPORTS_LABEL[bot.link]} (${reportsWire(bot)})`); out.push(`Battery ${bot.battery.toFixed(2)}V`); out.push( - `Position ${bot.position ? `${Math.round(bot.position.x)}, ${Math.round(bot.position.y)}` : "no fix"}`, + `Position ${formatPosition(bot)}`, ); out.push(`Heading ${formatHeading(bot)}`); if (info) { @@ -198,10 +208,7 @@ const Card: React.FC<{ bot: UnifiedBot; camera: CameraRobot | null; cameras: boo - + {cameras && } diff --git a/dotbot/console-web/src/inspector.test.ts b/dotbot/console-web/src/inspector.test.ts index e03111d1..5d292c4c 100644 --- a/dotbot/console-web/src/inspector.test.ts +++ b/dotbot/console-web/src/inspector.test.ts @@ -5,6 +5,7 @@ import { formatCameraHeading, formatLh2, formatLh2Site, + formatPosition, formatUptime, infoText, } from "./Inspector"; @@ -164,3 +165,13 @@ describe("the camera's heading for the selected robot", () => { expect(formatCameraHeading(robot)).toBe("not seen"); }); }); + +describe("formatPosition", () => { + const info = { bl_version: "", net_version: "", boot_count: 0, uptime_s: 0, image_name: "", image_version: "", image_digest: "" }; + it("names a failed LH2 sensor instead of no fix", () => { + const failed = bot({ position: null, swarmit: node({ info: { ...info, lh2_station_mask: 3, lh2_flags: 0b111 } }) }); + expect(formatPosition(failed)).toBe("LH2 sensor failed"); + expect(formatPosition(bot({ position: null, swarmit: node({ info: { ...info, lh2_flags: 0b011 } }) }))).toBe("no fix"); + expect(formatPosition(bot())).toBe("1552, 267"); + }); +}); From fbaba59bdf116fe79a296779b81ad52da761b592 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Wed, 14 Oct 2026 15:53:35 -0300 Subject: [PATCH 16/17] dotbot/calibration: confirm a push by a new boot reporting its stations AI-assisted: Claude Opus 5.5 --- dotbot/calibration/driver.py | 6 +++- dotbot/calibration/push.py | 40 ++++++++++++++++------ dotbot/cli/swarm_lh2.py | 2 +- dotbot/tests/test_calibration_push.py | 43 ++++++++++++++++++++++-- dotbot/tests/test_calibration_session.py | 10 ++++-- 5 files changed, 83 insertions(+), 18 deletions(-) diff --git a/dotbot/calibration/driver.py b/dotbot/calibration/driver.py index 032f9c20..0bdb32d4 100644 --- a/dotbot/calibration/driver.py +++ b/dotbot/calibration/driver.py @@ -198,7 +198,11 @@ async def push( client.send_lh2_calibration, payload, check.send_to ) stale = await asyncio.to_thread( - push_worklist, client, session.saved, check.addresses + push_worklist, + client, + session.saved, + check.addresses, + check.info_gens, ) finally: await asyncio.to_thread(self._listen_for_buttons, "") diff --git a/dotbot/calibration/push.py b/dotbot/calibration/push.py index 9492fbf0..4b7bca6e 100644 --- a/dotbot/calibration/push.py +++ b/dotbot/calibration/push.py @@ -10,11 +10,13 @@ named robot is in its app, since the net core takes a calibration only in the bootloader; a push to the whole fleet leaves such robots out instead, unless that leaves none. After the push, the robots whose reported calibration id is -not the file's are the worklist. +not the file's, with its stations, from a boot after the push, are the +worklist. Everything here takes the `status()` mapping of a swarmit client, duck-typed: address to an object with `status`, `info_gen` and `info`, `info` carrying -`info_version`, `lh2_site_name` and `lh2_calibration_id`, or None. +`info_version`, `info_gen`, `lh2_site_name`, `lh2_calibration_id` and +`lh2_station_mask`, or None. """ from __future__ import annotations @@ -23,7 +25,7 @@ from dataclasses import dataclass, field from typing import Any, Mapping -from dotbot.calibration.lighthouse2 import Calibration, pushed_id +from dotbot.calibration.lighthouse2 import Calibration, pushed_id, station_mask DEVICE_INFO_VERSION_MIN = 4 # Seconds a pushed robot gets to reset, rejoin and report the pushed id. @@ -74,6 +76,7 @@ class PushCheck: other_site: dict[str, str] = field(default_factory=dict) running: list[str] = field(default_factory=list) stale: list[str] = field(default_factory=list) + info_gens: dict[str, int] = field(default_factory=dict) fleet: bool = False @property @@ -140,6 +143,7 @@ def check_push(status: Mapping[str, Any], calibration: Calibration) -> PushCheck else: check.unanswered.append(addr) continue + check.info_gens[addr] = getattr(info, "info_gen", None) if int(getattr(info, "info_version", 0)) < DEVICE_INFO_VERSION_MIN: check.old_firmware.append(addr) continue @@ -188,23 +192,37 @@ def gate_push( def push_worklist( - client: Any, calibration: Calibration, addresses: list[str] + client: Any, + calibration: Calibration, + addresses: list[str], + info_gens: Mapping[str, int] | None = None, ) -> list[str]: - """The pushed robots that do not report the file's id after the push. - - A push resets every robot that takes it, so each gets PUSH_REJOIN_TIMEOUT - to rejoin and report; one not heard by then is listed too. Nothing is - requested here: a rejoin moves the robot's device-info generation, and - swarmit then refreshes its device info by one broadcast. + """The pushed robots that do not report the file's id and stations after + the push. + + A robot that takes a push commits it and resets, so its device info must + come from a later generation than `info_gens`, the one read before the + push; one already on the file's id would otherwise pass without having + stored anything. Each robot gets PUSH_REJOIN_TIMEOUT to rejoin and + report; one not heard by then is listed too. Nothing is requested here: a + rejoin moves the robot's device-info generation, and swarmit then + refreshes its device info by one broadcast. """ wanted_id = pushed_id(calibration) + wanted_mask = station_mask(calibration) + before = {addr.upper(): gen for addr, gen in (info_gens or {}).items()} pending = {addr.upper() for addr in addresses} deadline = time.monotonic() + PUSH_REJOIN_TIMEOUT while pending: status = client.status() for addr in list(pending): info = getattr(status.get(addr), "info", None) - if info is not None and _reported_id(info) == wanted_id: + if ( + info is not None + and _reported_id(info) == wanted_id + and getattr(info, "lh2_station_mask", 0) == wanted_mask + and (addr not in before or getattr(info, "info_gen", None) != before[addr]) + ): pending.discard(addr) if not pending or time.monotonic() >= deadline: break diff --git a/dotbot/cli/swarm_lh2.py b/dotbot/cli/swarm_lh2.py index 7c5feea7..05874dec 100644 --- a/dotbot/cli/swarm_lh2.py +++ b/dotbot/cli/swarm_lh2.py @@ -923,7 +923,7 @@ def _gated_push(client, calibration, site_changed=False, devices=None, stop=Fals ) client.send_lh2_calibration(payload, check.send_to) click.echo("Sent. Waiting for the robots to report the new id...") - stale = push_worklist(client, calibration, check.targets) + stale = push_worklist(client, calibration, check.targets, check.info_gens) if stale: click.echo( f"Still not on {calibration.id8} ({len(stale)}), push again: " diff --git a/dotbot/tests/test_calibration_push.py b/dotbot/tests/test_calibration_push.py index a3f889c7..fa080257 100644 --- a/dotbot/tests/test_calibration_push.py +++ b/dotbot/tests/test_calibration_push.py @@ -47,9 +47,13 @@ def _lab(tmp_path): LAB = load_discovered(environ={}, start_dir=lab) -def _info(version=4, site="", calibration_id="", gen=1): +def _info(version=4, site="", calibration_id="", gen=1, mask=0): info = SimpleNamespace( - info_version=version, lh2_site_name=site, lh2_calibration_id=calibration_id + info_version=version, + info_gen=gen, + lh2_site_name=site, + lh2_calibration_id=calibration_id, + lh2_station_mask=mask, ) return SimpleNamespace(info_gen=gen, info=info) @@ -57,8 +61,9 @@ def _info(version=4, site="", calibration_id="", gen=1): class _Fleet: """A swarmit client whose robots commit whatever they are sent.""" - def __init__(self, nodes): + def __init__(self, nodes, mask=None): self.nodes = nodes + self.mask = mask self.pushed: list[bytes] = [] self.pushed_to: list[list[str] | None] = [] self.refreshed: list[list[str] | None] = [] @@ -86,6 +91,10 @@ def send_lh2_calibration(self, payload, devices=None): node.info.lh2_site_name, node.info.lh2_calibration_id = message_site( payload[:LH2_CALIBRATION_MESSAGE_BYTES] ) + node.info.lh2_station_mask = ( + int.from_bytes(payload[:4], "little") if self.mask is None else self.mask + ) + node.info.info_gen += 1 @pytest.fixture @@ -179,6 +188,34 @@ def test_a_robot_not_heard_after_the_push_is_listed(calibration_file): assert push.push_worklist(fleet, calibration, check.addresses) == ["GONE"] +def test_a_robot_already_on_the_id_that_does_not_reboot_is_listed(calibration_file): + """A dropped push leaves the old device info, id included, in place.""" + calibration = read_calibration_file(calibration_file) + mask = lighthouse2.station_mask(calibration) + held = lighthouse2.pushed_id(calibration) + fleet = _Fleet({"SAME": _info(calibration_id=held, gen=7, mask=mask)}) + check = gate_push(fleet, calibration) + assert push.push_worklist(fleet, calibration, check.addresses, check.info_gens) == [ + "SAME" + ] + fleet.send_lh2_calibration( + lighthouse2.calibration_payload(calibration), check.send_to + ) + assert push.push_worklist(fleet, calibration, check.addresses, check.info_gens) == [] + + +def test_a_robot_reporting_other_stations_is_listed(calibration_file): + calibration = read_calibration_file(calibration_file) + fleet = _Fleet({"A": _info()}, mask=0) + check = gate_push(fleet, calibration) + fleet.send_lh2_calibration( + lighthouse2.calibration_payload(calibration), check.send_to + ) + assert push.push_worklist(fleet, calibration, check.addresses, check.info_gens) == [ + "A" + ] + + def test_push_to_another_site_is_refused_without_site_changed( monkeypatch, calibration_file ): diff --git a/dotbot/tests/test_calibration_session.py b/dotbot/tests/test_calibration_session.py index 83bbd110..2a0d5079 100644 --- a/dotbot/tests/test_calibration_session.py +++ b/dotbot/tests/test_calibration_session.py @@ -81,7 +81,11 @@ def _press(lh_index: int, count1: int, count2: int, n: int = 1) -> ButtonCapture def _info(version=4, site="", calibration_id=""): """A robot's device info as swarmit decodes it.""" return SimpleNamespace( - info_version=version, lh2_site_name=site, lh2_calibration_id=calibration_id + info_version=version, + info_gen=1, + lh2_site_name=site, + lh2_calibration_id=calibration_id, + lh2_station_mask=0, ) @@ -111,7 +115,7 @@ def request_lh2_capture(self, device: str) -> None: def send_lh2_calibration(self, payload: bytes, devices=None) -> None: self.pushed.append(payload) self.pushed_to = devices - # The bot commits the push and reports its site and id from then on. + # The bot commits the push, resets and reports its site, id and stations. for addr, info in self.infos.items(): if devices is not None and addr not in devices: continue @@ -119,6 +123,8 @@ def send_lh2_calibration(self, payload: bytes, devices=None) -> None: info.lh2_site_name, info.lh2_calibration_id = message_site( payload[:LH2_CALIBRATION_MESSAGE_BYTES] ) + info.lh2_station_mask = int.from_bytes(payload[:4], "little") + info.info_gen += 1 def refresh_device_info(self, devices=None) -> None: pass From 7a768402019053f6a391e15c5d5891b51a0e91f3 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Fri, 16 Oct 2026 11:23:24 -0300 Subject: [PATCH 17/17] dotbot: apply black to the calibration push and site changes AI-assisted: Claude Opus 5.5 --- dotbot/calibration/push.py | 5 ++++- dotbot/config.py | 8 ++++++-- dotbot/tests/test_calibration_multi_station.py | 8 ++++++-- dotbot/tests/test_calibration_push.py | 8 ++++++-- dotbot/tests/test_calibration_spin.py | 4 +--- dotbot/tests/test_cli_site.py | 5 ++++- dotbot/tests/test_scenarios.py | 6 +++++- 7 files changed, 32 insertions(+), 12 deletions(-) diff --git a/dotbot/calibration/push.py b/dotbot/calibration/push.py index 4b7bca6e..baf6a7b9 100644 --- a/dotbot/calibration/push.py +++ b/dotbot/calibration/push.py @@ -221,7 +221,10 @@ def push_worklist( info is not None and _reported_id(info) == wanted_id and getattr(info, "lh2_station_mask", 0) == wanted_mask - and (addr not in before or getattr(info, "info_gen", None) != before[addr]) + and ( + addr not in before + or getattr(info, "info_gen", None) != before[addr] + ) ): pending.discard(addr) if not pending or time.monotonic() >= deadline: diff --git a/dotbot/config.py b/dotbot/config.py index 4a3ee930..89c084f4 100644 --- a/dotbot/config.py +++ b/dotbot/config.py @@ -227,9 +227,13 @@ class SiteSection(_Strict): def _stations(self) -> SiteSection: for serial, channel in self.stations.items(): if not re.fullmatch(r"[0-9A-F]{8}", serial): - raise ValueError(f"station serial {serial!r} is not 8 upper-case hex digits") + raise ValueError( + f"station serial {serial!r} is not 8 upper-case hex digits" + ) if not 1 <= channel <= 16: - raise ValueError(f"station {serial} on channel {channel}, outside 1 to 16") + raise ValueError( + f"station {serial} on channel {channel}, outside 1 to 16" + ) channels = list(self.stations.values()) shared = sorted({c for c in channels if channels.count(c) > 1}) if shared: diff --git a/dotbot/tests/test_calibration_multi_station.py b/dotbot/tests/test_calibration_multi_station.py index d13aacb9..caa70a13 100644 --- a/dotbot/tests/test_calibration_multi_station.py +++ b/dotbot/tests/test_calibration_multi_station.py @@ -728,9 +728,13 @@ def test_anchored_calibration_rectangles_are_the_site_extent(): centres = floor_grid([a, b]) tracks = F.spins([a, b], centres, noise=0.5, seed=7) corners = [0, len(centres) - 1, 3, len(centres) - 4] - anchors = [ms.Anchor(ms.CircleKey(f"R{i:02d}", 0), tuple(centres[i])) for i in corners] + anchors = [ + ms.Anchor(ms.CircleKey(f"R{i:02d}", 0), tuple(centres[i])) for i in corners + ] site = Site(name="arena", anchor="door corner", extent_mm=(6000, 4000)) - calibration, joint, _ = conics.solve_calibration(F.samples(tracks), site, anchors=anchors) + calibration, joint, _ = conics.solve_calibration( + F.samples(tracks), site, anchors=anchors + ) assert joint.rectangles[a.index] != (0, 0, 6000, 4000) assert {s.valid_mm for s in calibration.stations} == {(0, 0, 6000, 4000)} assert calibration.valid_mm == (0, 0, 6000, 4000) diff --git a/dotbot/tests/test_calibration_push.py b/dotbot/tests/test_calibration_push.py index fa080257..5068d5f9 100644 --- a/dotbot/tests/test_calibration_push.py +++ b/dotbot/tests/test_calibration_push.py @@ -92,7 +92,9 @@ def send_lh2_calibration(self, payload, devices=None): payload[:LH2_CALIBRATION_MESSAGE_BYTES] ) node.info.lh2_station_mask = ( - int.from_bytes(payload[:4], "little") if self.mask is None else self.mask + int.from_bytes(payload[:4], "little") + if self.mask is None + else self.mask ) node.info.info_gen += 1 @@ -201,7 +203,9 @@ def test_a_robot_already_on_the_id_that_does_not_reboot_is_listed(calibration_fi fleet.send_lh2_calibration( lighthouse2.calibration_payload(calibration), check.send_to ) - assert push.push_worklist(fleet, calibration, check.addresses, check.info_gens) == [] + assert ( + push.push_worklist(fleet, calibration, check.addresses, check.info_gens) == [] + ) def test_a_robot_reporting_other_stations_is_listed(calibration_file): diff --git a/dotbot/tests/test_calibration_spin.py b/dotbot/tests/test_calibration_spin.py index cbd6f380..04a64261 100644 --- a/dotbot/tests/test_calibration_spin.py +++ b/dotbot/tests/test_calibration_spin.py @@ -287,9 +287,7 @@ def test_spins_end_to_end_into_a_self_defined_site(monkeypatch, lab): ) -def test_the_site_spin_radius_is_used_unless_the_flag_overrides_it( - monkeypatch, lab -): +def test_the_site_spin_radius_is_used_unless_the_flag_overrides_it(monkeypatch, lab): toml = lab.project_dir / "sites" / "c405" / "site.toml" toml.write_text(toml.read_text() + "spin_radius_mm = 53.0\n") _, fleet = _fleet() diff --git a/dotbot/tests/test_cli_site.py b/dotbot/tests/test_cli_site.py index 8ed2dea5..8dc4a9eb 100644 --- a/dotbot/tests/test_cli_site.py +++ b/dotbot/tests/test_cli_site.py @@ -80,7 +80,10 @@ def test_a_site_table_becomes_its_anchor_extent_and_areas(): def test_a_site_table_carries_its_floor_spin_radius(): - assert site_from_table("tile", _table("spin_radius_mm = 53.0\n")).spin_radius_mm == 53.0 + assert ( + site_from_table("tile", _table("spin_radius_mm = 53.0\n")).spin_radius_mm + == 53.0 + ) assert site_from_table("tile", _table("")).spin_radius_mm is None with pytest.raises(ValueError): _table("spin_radius_mm = 0.5\n") diff --git a/dotbot/tests/test_scenarios.py b/dotbot/tests/test_scenarios.py index 56584b5a..b92016a9 100644 --- a/dotbot/tests/test_scenarios.py +++ b/dotbot/tests/test_scenarios.py @@ -140,7 +140,11 @@ def watch(): assert held_travel and max(held_travel) < 100 assert axle_error_mm(robot, 1500, 1000) <= 20 # The stream told the console it was held, then that it no longer is - held = [p["waypoints_awaiting_heading"] for p in s.socket.robot_states() if "waypoints_awaiting_heading" in p] + held = [ + p["waypoints_awaiting_heading"] + for p in s.socket.robot_states() + if "waypoints_awaiting_heading" in p + ] assert True in held and held[-1] is False