From a4ca9c55c8e0b0b9884cae6a6cd2b91250e7d186 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Wed, 14 Oct 2026 15:40:38 -0300 Subject: [PATCH 1/5] dotbot/calibration: take corner captures from the robot's button only Breaking: removes collect --device, --reads, --timeout and --retries, and POST /controller/calibration/session/capture. The bootloader's READY-mode capture they triggered is gone from swarmit, which drops request_lh2_capture and LH2_CALIB_TAG, so the two land together. On the simulator the simulated robot now presses its button at each point. AI-assisted: Claude Opus 5.5 --- dotbot/calibration/driver.py | 99 ++--- dotbot/calibration/ota.py | 124 +----- dotbot/calibration/points.py | 37 +- dotbot/calibration/session.py | 34 +- dotbot/calibration/simulated.py | 98 +++-- dotbot/cli/swarm_lh2.py | 99 +---- dotbot/controller.py | 14 +- dotbot/models.py | 6 - dotbot/server.py | 14 - dotbot/tests/test_calibration_lighthouse2.py | 19 +- dotbot/tests/test_calibration_ota.py | 177 ++------- dotbot/tests/test_calibration_session.py | 389 +++++++------------ 12 files changed, 283 insertions(+), 827 deletions(-) diff --git a/dotbot/calibration/driver.py b/dotbot/calibration/driver.py index 0bdb32d4..02e2e7bb 100644 --- a/dotbot/calibration/driver.py +++ b/dotbot/calibration/driver.py @@ -4,15 +4,10 @@ """The controller's side of a calibration session: one at a time, with events. The capture loop itself is `CalibrationSession`; this drives it from async -routes. It owns the swarmit client, built when a session starts and rebuilt -when the robot changes, and a start with no fleet in reach still opens; it -turns every state change into exactly one WebSocket notification, in the -order the changes happened; and it routes a capture that arrives from the -robot's own button to the outstanding point. - -The blocking capture runs in a worker thread. Progress is published from -there by handing the coroutine back to the loop and waiting for it, so the -notifications keep the order the reads arrived in. +routes. It owns the swarmit client, built when a session starts, and a start +with no fleet in reach still opens; it turns every state change into exactly +one WebSocket notification, in the order the changes happened; and it routes +a capture that arrives from a robot's button to the outstanding point. """ from __future__ import annotations @@ -34,33 +29,19 @@ # Seconds between two checks for a button press given up incomplete. EXPIRED_PRESS_POLL_INTERVAL = 1.0 -# The swarmit log-event tag a raw-count capture carries. Imported lazily so -# the swarmit protocol registry stays out of PyDotBot test collection. -_CAPTURE_TAG: int | None = None - - -def capture_tag() -> int: - global _CAPTURE_TAG - if _CAPTURE_TAG is None: - from swarmit.testbed.protocol import LH2_CALIB_TAG - - _CAPTURE_TAG = LH2_CALIB_TAG - return _CAPTURE_TAG - - class SessionDriver: """One calibration session at a time, with its transport and its events. - `client_factory` takes the device address and returns a swarmit client, - injected so a test drives the whole loop without a fleet. + `client_factory` returns a swarmit client, injected so a test drives the + whole loop without a fleet. """ def __init__( self, - client_factory: Callable[[str], Any], + client_factory: Callable[[], Any], notify: Callable[[dict | None], Any], site: Site | None = None, - stream_factory: Callable[[Any, str, Callable], Any] | None = None, + stream_factory: Callable[[Any, Callable], Any] | None = None, ): self._client_factory = client_factory self._notify = notify @@ -70,7 +51,6 @@ def __init__( self.logger = LOGGER.bind(context=__name__) self._client: Any = None self._stream: Any = None - self._device = "" self._lock = asyncio.Lock() self._loop: asyncio.AbstractEventLoop | None = None self._expiry_watch: asyncio.Task | None = None @@ -119,39 +99,13 @@ async def start( self.session = session self._loop = asyncio.get_running_loop() # Button captures come unrequested from any robot, so the stream - # is listening from the start rather than from the first capture. - await asyncio.to_thread(self._listen_for_buttons, session.device) + # is listening from the start. + await asyncio.to_thread(self._listen_for_buttons) if self._expiry_watch is None or self._expiry_watch.done(): self._expiry_watch = asyncio.create_task(self._watch_expired_presses()) await self._emit() return session.as_dict() - async def capture(self, device: str = "") -> dict: - """Take the outstanding point's reads from `device`, then advance.""" - async with self._lock: - session = self._require() - if device: - session.device = device.upper() - if not session.device: - raise SessionError( - "no robot chosen: click one on the map, or name it in the " - "capture request" - ) - loop = asyncio.get_running_loop() - self._loop = loop - stream = await asyncio.to_thread(self._ensure_stream, session.device) - - def on_progress(_point) -> None: - asyncio.run_coroutine_threadsafe(self._emit(), loop).result() - - try: - await asyncio.to_thread(session.capture, stream, on_progress) - session.error = "" - except TimeoutError as exc: - session.error = str(exc) - await self._emit() - return session.as_dict() - async def redo(self) -> dict: """Discard the last captured point's reads and re-open it.""" async with self._lock: @@ -186,7 +140,7 @@ async def push( async with self._lock: session = self._require() payload = session.push_payload() - client = await asyncio.to_thread(self._ensure_client, "") + client = await asyncio.to_thread(self._ensure_client) try: try: check = await asyncio.to_thread( @@ -205,7 +159,7 @@ async def push( check.info_gens, ) finally: - await asyncio.to_thread(self._listen_for_buttons, "") + await asyncio.to_thread(self._listen_for_buttons) await self._emit() return { "id": session.saved_id, @@ -301,30 +255,25 @@ def _require(self) -> CalibrationSession: ) return self.session - def _ensure_client(self, device: str) -> Any: - if self._client is None or device != self._device: - self._close_stream() - if self._client is not None: - self._client.__exit__(None, None, None) - self._client = None - self._client = self._client_factory(device) + def _ensure_client(self) -> Any: + if self._client is None: + self._client = self._client_factory() self._client.__enter__() - self._device = device return self._client - def _ensure_stream(self, device: str) -> Any: - client = self._ensure_client(device) + def _ensure_stream(self) -> Any: + client = self._ensure_client() if self._stream is None: - self._stream = self._stream_factory(client, device, self.on_button_capture) + self._stream = self._stream_factory(client, self.on_button_capture) self._stream.__enter__() return self._stream - def _listen_for_buttons(self, device: str) -> None: + def _listen_for_buttons(self) -> None: try: - self._ensure_stream(device) + self._ensure_stream() except Exception as exc: # a fleet out of reach is not a failure self.logger.warning( - "Not listening for button captures until a capture reaches the fleet", + "Not listening for button captures: the fleet is out of reach", error=str(exc), ) @@ -338,9 +287,7 @@ def bind_loop(self, loop: asyncio.AbstractEventLoop) -> None: self._loop = loop -def _default_stream(client: Any, device: str, on_button_capture: Callable) -> Any: +def _default_stream(client: Any, on_button_capture: Callable) -> Any: from dotbot.calibration.ota import CaptureSession - return CaptureSession( - client, device, capture_tag(), on_button_capture=on_button_capture - ) + return CaptureSession(client, on_button_capture=on_button_capture) diff --git a/dotbot/calibration/ota.py b/dotbot/calibration/ota.py index 5051b950..83850c0e 100644 --- a/dotbot/calibration/ota.py +++ b/dotbot/calibration/ota.py @@ -3,13 +3,9 @@ """Over-the-air LH2 capture collection (swarmit transport). -A capture comes either from the calibrate app, taken on the robot's own -button, or from the secure bootloader, which samples its raw LH2 counts on -request (READY mode only). Both arrive inside a SWARMIT_EVENT_LOG. This -module decodes the records and triggers the bootloader captures; the solve -and the file live in `lighthouse2`. - -DEPRECATED: the bootloader capture request, in favour of the calibrate app. +A capture is taken by the calibrate app on the robot's own button and +arrives inside SWARMIT_EVENT_LOGs. This module decodes and assembles the +records; the solve and the file live in `lighthouse2`. A capture is n reads per point, not one: the per-point error is the placement sigma and the lighthouse's own over sqrt(n) in quadrature, so a @@ -21,7 +17,6 @@ from __future__ import annotations -import queue import threading import time from collections.abc import Callable @@ -30,8 +25,6 @@ from dotbot.calibration.lighthouse2 import LH_PERIODS, LH2CalibrationSample, Sample from dotbot.calibration.points import CORNERS # noqa: F401 - the capture order -CAPTURE_TIMEOUT_DEFAULT = 5.0 -CAPTURE_RETRIES_DEFAULT = 3 CAPTURE_READS_DEFAULT = 25 # Each raw sample inside the LOG payload is [lh_index:1][count1:4 LE][count2:4 LE]. @@ -55,18 +48,6 @@ STATION_PRESENCE_RATIO_MIN = 0.5 -def parse_capture_payload(data: bytes, tag: int) -> list[LH2CalibrationSample]: - """Decode a SWARMIT_EVENT_LOG payload of raw LH2 samples. - - Layout (mirrors the swarmit bootloader): a 1-byte `tag`, then N - fixed-size records. Returns [] for any payload that is not a capture - (regular text log lines do not carry `tag` as their first byte). - """ - if len(data) < 1 or data[0] != tag: - return [] - return _parse_records(data[1:]) - - def _parse_records(body: bytes) -> list[LH2CalibrationSample]: samples: list[LH2CalibrationSample] = [] for off in range(0, len(body) - _SAMPLE_SIZE + 1, _SAMPLE_SIZE): @@ -307,25 +288,15 @@ class CaptureSession: A capture from the calibrate app's button can come from any device and at any time; its chunks are assembled into one `ButtonCapture` per press and handed to `on_button_capture`. - - With a `device`, `capture()` also triggers that device's READY-mode - capture (DEPRECATED, in favour of the button) and waits for the reply, - re-triggering on timeout because the trigger send is best-effort. The - bootloader only answers a trigger, so one stream serves every point. """ def __init__( self, client, - device: str | None, - tag: int, on_button_capture: Callable[[ButtonCapture], None] | None = None, button_timeout: float = BUTTON_CAPTURE_TIMEOUT_DEFAULT, ): self._client = client - self._device = (device or "").upper() - self._tag = tag - self._queue: queue.Queue = queue.Queue() self._stop = threading.Event() self._on_button_capture = on_button_capture self._assembler = ButtonAssembler(timeout=button_timeout) @@ -340,23 +311,12 @@ def __exit__(self, *exc) -> None: self._stop.set() def _reader(self) -> None: - try: - for event in self._client.watch_log_events(): - if self._stop.is_set(): - break - addr = str(event.get("addr", "")).upper() - data = bytes.fromhex(event.get("data_hex", "")) - chunk = parse_button_payload(data) - if chunk is not None: - self._on_button_chunk(addr, chunk) - continue - if not self._device or addr != self._device: - continue - decoded = parse_capture_payload(data, self._tag) - if decoded: - self._queue.put(decoded) - except Exception as exc: # surfaced on the next capture() get() - self._queue.put(exc) + for event in self._client.watch_log_events(): + if self._stop.is_set(): + break + chunk = parse_button_payload(bytes.fromhex(event.get("data_hex", ""))) + if chunk is not None: + self._on_button_chunk(str(event.get("addr", "")).upper(), chunk) def _on_button_chunk(self, addr: str, chunk: ButtonChunk) -> None: if self._on_button_capture is None: @@ -366,73 +326,7 @@ def _on_button_chunk(self, addr: str, chunk: ButtonChunk) -> None: if capture is not None: self._on_button_capture(capture) - @property - def device(self) -> str: - """The device `capture()` triggers, or "" for a button-only stream.""" - return self._device - def expired_presses(self) -> list[tuple[str, int]]: """Button presses given up on since the last call: (device, press).""" with self._assembler_lock: return self._assembler.expired() - - def capture( - self, - timeout: float, - retries: int, - on_attempt: Callable[[int, int], None] | None = None, - ) -> list[LH2CalibrationSample]: - """Trigger one capture and return every station's record from the reply. - - Raises TimeoutError if nothing arrives within `retries + 1` triggers, - and ValueError on a stream opened without a device. - """ - if not self._device: - raise ValueError( - "no device to trigger: this stream only takes button captures" - ) - # Discard anything left over from the previous point. - while not self._queue.empty(): - self._queue.get_nowait() - - attempts = retries + 1 - for attempt in range(attempts): - if on_attempt is not None: - on_attempt(attempt + 1, attempts) - self._client.request_lh2_capture(self._device) - deadline = time.monotonic() + timeout - remaining = deadline - time.monotonic() - while remaining > 0: - try: - item = self._queue.get(timeout=remaining) - except queue.Empty: - break - if isinstance(item, Exception): - raise item - return item - - raise TimeoutError( - f"no LH2 samples from {self._device} after {retries + 1} attempt(s); " - f"is the DotBot in READY (app stopped) and in view of a lighthouse?" - ) - - def capture_point( - self, - point: int, - reads: int = CAPTURE_READS_DEFAULT, - timeout: float = CAPTURE_TIMEOUT_DEFAULT, - retries: int = CAPTURE_RETRIES_DEFAULT, - on_attempt: Callable[[int, int], None] | None = None, - on_read: ( - Callable[[int, int, list[list[LH2CalibrationSample]]], None] | None - ) = None, - ) -> PointCapture: - """Take `reads` captures at one point and group them per station.""" - collected: list[list[LH2CalibrationSample]] = [] - for index in range(reads): - collected.append( - self.capture(timeout=timeout, retries=retries, on_attempt=on_attempt) - ) - if on_read is not None: - on_read(index + 1, reads, collected) - return samples_from_reads(collected, point) diff --git a/dotbot/calibration/points.py b/dotbot/calibration/points.py index d87a79d9..305d898a 100644 --- a/dotbot/calibration/points.py +++ b/dotbot/calibration/points.py @@ -287,9 +287,7 @@ def _centre(area: Area) -> PointPlacement: return PointPlacement(mm=area.centre, area=area.name) -def point_prompt( - index: int, total: int, point: PointPlacement, trigger: str = "Enter" -) -> str: +def point_prompt(index: int, total: int, point: PointPlacement) -> str: """What the operator reads before one capture. The corner and the pose come first and the coordinate last: the operator @@ -301,48 +299,31 @@ def point_prompt( if point.corner is None: return ( f"{head}: photodiode on ({x:g}, {y:g}) mm. " - f"Press {trigger} when it is still." + "Press the robot's button when it is still." ) return ( f"{head}, {point.where}: {point.how}. " - f"Photodiode lands at ({x:g}, {y:g}) mm. Press {trigger} when it is still." + f"Photodiode lands at ({x:g}, {y:g}) mm. Press the robot's button when it " + "is still." ) -def collect_header( - site: Site, source: str, total: int, reads: int, device: str = "" -) -> str: +def collect_header(site: Site, source: str, total: int) -> str: """The paragraph printed once, before the first capture prompt.""" - target = f" from {device.upper()}" if device else "" zero = ( site.anchor or "the site's anchor, which this config does not describe (add " f"`anchor` to [sites.{site.name}])" ) return ( - f"\nCollecting LH2 calibration{target} in site {site.name} " + f"\nCollecting LH2 calibration in site {site.name} " f"(from {source}). Coordinates are millimetres in the site's frame: " f"x grows right, y grows down, and zero is {zero}. A corner point is " "where the photodiode lands with the robot inside the rectangle, its " "PCB edges resting on the rectangle's edge lines and its nose toward " "the nearest top or bottom edge.\n" - f"{_trigger_text(device)}\n" - f"{total} point(s), {_reads_text(device, reads)}, in the order listed.\n" + "Run the calibrate app on the robot and press its button to capture; " + "a capture from any robot takes the outstanding point.\n" + f"{total} point(s), the calibrate app's reads each, in the order listed.\n" ) - -def _reads_text(device: str, reads: int) -> str: - return f"{reads} reads each" if device else "the calibrate app's reads each" - - -def _trigger_text(device: str) -> str: - if not device: - return ( - "Run the calibrate app on the robot and press its button to " - "capture; a capture from any robot takes the outstanding point." - ) - return ( - f"Enter captures from {device.upper()} with its app stopped (READY; " - "deprecated in favour of the calibrate app); with the calibrate app " - "running, the robot's own button captures instead." - ) diff --git a/dotbot/calibration/session.py b/dotbot/calibration/session.py index d5c309d7..765df240 100644 --- a/dotbot/calibration/session.py +++ b/dotbot/calibration/session.py @@ -11,14 +11,14 @@ for the same reads. Nothing here talks to a transport. The caller hands in a `PointCapture`, or -a swarmit `CaptureSession` to take one with. +the reads of a button capture. """ from __future__ import annotations import datetime from dataclasses import dataclass, field -from typing import Any, Callable, Sequence +from typing import Any, Sequence from dotbot.calibration.lighthouse2 import ( VALID_MM_DEFAULT, @@ -31,8 +31,6 @@ ) from dotbot.calibration.ota import ( CAPTURE_READS_DEFAULT, - CAPTURE_RETRIES_DEFAULT, - CAPTURE_TIMEOUT_DEFAULT, PointCapture, samples_from_reads, ) @@ -89,8 +87,6 @@ class CalibrationSession: device: str = "" robot: str = ROBOT_DEFAULT reads: int = CAPTURE_READS_DEFAULT - timeout: float = CAPTURE_TIMEOUT_DEFAULT - retries: int = CAPTURE_RETRIES_DEFAULT tag: str = "" stations: list[StationSolution] = field(default_factory=list) unsolved: list[tuple[int, int]] = field(default_factory=list) @@ -196,32 +192,6 @@ def store_reads(self, reads: list, device: str = "") -> SessionPoint | None: return None return self.store(point.index, samples_from_reads(reads, point.index), device) - def capture(self, stream, on_progress: Callable | None = None) -> SessionPoint: - """Take the outstanding point's reads over `stream`, a `CaptureSession`.""" - point = self.outstanding - if point is None: - raise SessionError("every point of this session is already captured") - point.reads_target = self.reads - point.reads_per_station = {} - - def on_read(done: int, total: int, collected: list) -> None: - counts: dict[int, int] = {} - for read in collected: - for record in read: - counts[record.lh_index] = counts.get(record.lh_index, 0) + 1 - point.reads_per_station = counts - if on_progress is not None: - on_progress(point) - - capture = stream.capture_point( - point=point.index, - reads=self.reads, - timeout=self.timeout, - retries=self.retries, - on_read=on_read, - ) - return self.store(point.index, capture, stream.device) - def redo(self) -> SessionPoint: """Discard the last captured point's reads and re-open it.""" captured = [p for p in self.points if p.captured] diff --git a/dotbot/calibration/simulated.py b/dotbot/calibration/simulated.py index d755b145..56da3ea3 100644 --- a/dotbot/calibration/simulated.py +++ b/dotbot/calibration/simulated.py @@ -4,11 +4,12 @@ """A capture client for a controller running on the simulator. There is no fleet behind `--conn simulator`, so a calibration session would -otherwise stall on its first capture and the console's calibration mode -could not be walked at all. This answers each capture request with the -counts a station would report for the point the session is asking about, by -inverting one plausible station matrix, so a rehearsal produces a real solve -with real residuals rather than a screen that never advances. +otherwise stall on its first point and the console's calibration mode could +not be walked at all. A simulated robot presses its calibrate app's button +at each outstanding point, `PRESS_DELAY_S` after the point opens, with the +counts a station would report there, by inverting one plausible station +matrix, so a rehearsal produces a real solve with real residuals rather than +a screen that never advances. It is a rehearsal surface, not hardware validation: the counts are computed from the declared point, so they agree with it by construction. @@ -16,11 +17,18 @@ from __future__ import annotations +import time from types import SimpleNamespace from typing import Any, Callable import numpy as np +from dotbot.calibration.ota import ( + BUTTON_CAPTURE_TAG, + BUTTON_CHUNK_RECORDS, + BUTTON_PRESS_MODULUS, +) + from dotbot.calibration.lighthouse2 import ( LH2_CALIBRATION_MESSAGE_BYTES, apply_homography, @@ -39,18 +47,9 @@ ) SIMULATED_STATIONS = (0, 1) - -# What a capture reply carries, mirroring the bootloader's log payload. -_SAMPLE_TAG: int | None = None - - -def _tag() -> int: - global _SAMPLE_TAG - if _SAMPLE_TAG is None: - from swarmit.testbed.protocol import LH2_CALIB_TAG - - _SAMPLE_TAG = LH2_CALIB_TAG - return _SAMPLE_TAG +SIMULATED_DEVICE = "SIMULATED" +PRESS_DELAY_S = 2.0 +PRESS_READS = 5 def counts_at(point_mm: tuple[float, float], station: int) -> tuple[int, int]: @@ -65,20 +64,22 @@ def counts_at(point_mm: tuple[float, float], station: int) -> tuple[int, int]: class SimulatedCaptureClient: """The swarmit client surface a calibration session uses, simulated. - `point_provider` returns the frame coordinates the session is capturing, - so each reply matches the prompt the operator is answering. + `point_provider` returns the index and frame coordinates of the point + the session is capturing, so each press matches the prompt the operator + is answering. """ def __init__( self, - device: str, - point_provider: Callable[[], tuple[float, float] | None], + point_provider: Callable[[], tuple[int, tuple[float, float]] | None], stations: tuple[int, ...] = SIMULATED_STATIONS, + press_delay_s: float = PRESS_DELAY_S, ): - self.device = (device or "SIMULATED").upper() + self.device = SIMULATED_DEVICE self._point_provider = point_provider self._stations = stations - self._pending: list[dict] = [] + self._press_delay_s = press_delay_s + self._press = 0 self.pushed: list[bytes] = [] def __enter__(self) -> SimulatedCaptureClient: @@ -87,17 +88,30 @@ def __enter__(self) -> SimulatedCaptureClient: def __exit__(self, *exc: Any) -> bool: return False - def request_lh2_capture(self, device: str) -> None: - point = self._point_provider() - if point is None: - return - body = bytearray([_tag()]) - for station in self._stations: - count1, count2 = counts_at(point, station) - body.append(station) - body += count1.to_bytes(4, "little") - body += count2.to_bytes(4, "little") - self._pending.append({"addr": self.device, "data_hex": bytes(body).hex()}) + def press(self, point: tuple[float, float]) -> list[dict]: + """The log events of one button press at `point`.""" + records = bytearray() + for _ in range(PRESS_READS): + for station in self._stations: + count1, count2 = counts_at(point, station) + records.append(station) + records += count1.to_bytes(4, "little") + records += count2.to_bytes(4, "little") + size = 9 * BUTTON_CHUNK_RECORDS + # A chunk short of BUTTON_CHUNK_RECORDS ends the press, so a press of + # whole chunks ends with an empty one. + chunks = [records[off : off + size] for off in range(0, len(records), size)] + if len(records) % size == 0: + chunks.append(bytearray()) + header = (self._press % BUTTON_PRESS_MODULUS) << 3 + self._press += 1 + return [ + { + "addr": self.device, + "data_hex": bytes([BUTTON_CAPTURE_TAG, header | index, *chunk]).hex(), + } + for index, chunk in enumerate(chunks) + ] def send_lh2_calibration( self, payload: bytes, devices: list[str] | None = None @@ -120,10 +134,14 @@ def status(self) -> dict[str, Any]: return {self.device: SimpleNamespace(info_gen=1, info=info)} def watch_log_events(self): - import time - + """One press per opening of a point, so a redone point is pressed again.""" + seen, opened_at, pressed = None, 0.0, False while True: - if self._pending: - yield self._pending.pop(0) - else: - time.sleep(0.02) + point = self._point_provider() + if point != seen: + seen, opened_at, pressed = point, time.monotonic(), False + if point is not None and not pressed and time.monotonic() - opened_at >= self._press_delay_s: + pressed = True + yield from self.press(point[1]) + continue + time.sleep(0.02) diff --git a/dotbot/cli/swarm_lh2.py b/dotbot/cli/swarm_lh2.py index 05874dec..487a9e22 100644 --- a/dotbot/cli/swarm_lh2.py +++ b/dotbot/cli/swarm_lh2.py @@ -33,7 +33,6 @@ import queue import sys -import threading import time import click @@ -116,17 +115,12 @@ def cmd() -> None: def _await_point(session, stream, arrivals: queue.Queue): - """Capture the outstanding point from whichever trigger comes first. - - A capture from any robot's button is stored as the point directly. Enter - runs the READY-mode capture of the stream's device (DEPRECATED, in favour - of the button); it only arrives when collect has a --device. - """ + """Store the next button capture, from any robot, as the outstanding point.""" from dotbot.calibration.session import SessionError while True: try: - kind, capture = arrivals.get(timeout=0.5) + capture = arrivals.get(timeout=0.5) except queue.Empty: for addr, press in stream.expired_presses(): click.echo( @@ -135,14 +129,6 @@ def _await_point(session, stream, arrivals: queue.Queue): err=True, ) continue - if kind == "eof": - raise click.Abort() - if kind == "enter": - try: - return session.capture(stream) - except TimeoutError as exc: - click.echo(f" ! {exc}", err=True) - raise click.Abort() if capture.lost: click.echo( f" ! {capture.device}: {capture.lost} capture(s) lost before " @@ -165,21 +151,12 @@ def _await_point(session, stream, arrivals: queue.Queue): help=( "Collect an LH2 calibration over the air (no serial cable). Walks " "you through the points of one placement, takes each point's reads " - "from the robot's button (calibrate app running) or, with --device, " - "from Enter, solves every visible station, and saves the " + "from the robot's button (calibrate app running), solves every " + "visible station, and saves the " "calibration. With --spin, the robots spin in place instead " "(experimental)." ), ) -@click.option( - "--device", - default=None, - help=( - "DotBot link-layer address in hex (e.g. BC3D3C8A2A6F8E68) that Enter " - "captures from, with its app stopped (READY). Deprecated in favour of " - "the calibrate app's button, which needs no address." - ), -) @click.option( "-n", "--conn", @@ -243,28 +220,6 @@ def _await_point(session, stream, arrivals: queue.Queue): "calibration is saved under. Defaults to `site` in the dotbot config." ), ) -@click.option( - "--reads", - default=None, - type=int, - help=( - "Captures averaged per point on Enter (--device); the calibrate app " - "sets its own. A single read costs about 60 % of the accuracy at " - "every point of the field." - ), -) -@click.option( - "--timeout", - default=None, - type=float, - help="Seconds to wait for each Enter capture before re-triggering.", -) -@click.option( - "--retries", - default=None, - type=int, - help="Re-trigger an Enter capture this many times before giving up.", -) @click.option( "--tag", default=None, @@ -278,7 +233,7 @@ def _await_point(session, stream, arrivals: queue.Queue): is_flag=True, help=( "Send the computed calibration over the air to the robots whose " - "captures built it (and to --device, when given)." + "captures built it." ), ) @click.option( @@ -360,16 +315,12 @@ def _await_point(session, stream, arrivals: queue.Queue): @click.pass_context def _collect( ctx, - device, conn, swarm_id, points, over, square, site_name, - reads, - timeout, - retries, tag, push, spin, @@ -383,13 +334,9 @@ def _collect( given = [ flag for flag, value in ( - ("--device", device), ("--points", points), ("--over", over), ("--square", square), - ("--reads", reads), - ("--timeout", timeout), - ("--retries", retries), ) if value not in (None, ()) ] @@ -426,14 +373,7 @@ def _collect( "corner collect takes its captures from whichever robot's button" ) try: - from swarmit.testbed.protocol import LH2_CALIB_TAG - - from dotbot.calibration.ota import ( - CAPTURE_READS_DEFAULT, - CAPTURE_RETRIES_DEFAULT, - CAPTURE_TIMEOUT_DEFAULT, - CaptureSession, - ) + from dotbot.calibration.ota import CaptureSession from dotbot.calibration.points import ( collect_header, collect_points, @@ -464,10 +404,6 @@ def _collect( specs, site=site, points_from=how, - device=device or "", - reads=reads if reads is not None else CAPTURE_READS_DEFAULT, - timeout=timeout if timeout is not None else CAPTURE_TIMEOUT_DEFAULT, - retries=retries if retries is not None else CAPTURE_RETRIES_DEFAULT, ) except (SessionError, ValueError) as exc: raise click.ClickException(str(exc)) from exc @@ -482,33 +418,15 @@ def _collect( arrivals: queue.Queue = queue.Queue() - def on_button_capture(capture) -> None: - arrivals.put(("button", capture)) - - def read_enter() -> None: - for _ in iter(sys.stdin.readline, ""): - arrivals.put(("enter", None)) - arrivals.put(("eof", None)) - with client: - with CaptureSession( - client, device, LH2_CALIB_TAG, on_button_capture=on_button_capture - ) as stream: + with CaptureSession(client, on_button_capture=arrivals.put) as stream: # Give the transport's own connect/subscribe log lines a beat to # print before our prompts, so the two don't interleave on screen. time.sleep(0.2) - click.echo( - collect_header( - site, site_source, len(session.points), session.reads, device or "" - ) - ) + click.echo(collect_header(site, site_source, len(session.points))) click.echo(f"Points: {session.at} ({session.points_from}).") if note: click.echo(note) - trigger = "the robot's button" - if device: - trigger = "Enter" - threading.Thread(target=read_enter, daemon=True).start() while not session.complete: outstanding = session.outstanding click.echo( @@ -517,7 +435,6 @@ def read_enter() -> None: outstanding.index, len(session.points), outstanding.placement, - trigger, ) ) point = _await_point(session, stream, arrivals) diff --git a/dotbot/controller.py b/dotbot/controller.py index 9e00ca37..a5f716ec 100644 --- a/dotbot/controller.py +++ b/dotbot/controller.py @@ -1099,17 +1099,17 @@ def _lh2_fix( pose=robot_body(dotbot.model, dotbot.pose), ) - def _swarmit_client(self, device: str = ""): + def _swarmit_client(self): """A swarmit client on the same connection the controller runs on. - There is no fleet behind a simulator adapter, so a capture would - stall on its first request; the simulated client answers it instead, - which is what makes calibration mode walkable without hardware. + There is no fleet behind a simulator adapter; the simulated client's + robot presses its button at each point instead, which is what makes + calibration mode walkable without hardware. """ if self.settings.adapter in ("dotbot-simulator", "sailbot-simulator"): from dotbot.calibration.simulated import SimulatedCaptureClient - return SimulatedCaptureClient(device, self._outstanding_point) + return SimulatedCaptureClient(self._outstanding_point) return build_swarmit_client( conn_string(self.settings), self.settings.network_id, @@ -1118,10 +1118,10 @@ def _swarmit_client(self, device: str = ""): ) def _outstanding_point(self): - """The frame coordinates the session is capturing, for the simulator.""" + """The index and frame coordinates the session is capturing, for the simulator.""" session = self.calibration_session.session point = session.outstanding if session else None - return None if point is None else point.mm + return None if point is None else (point.index, point.mm) async def _notify_calibration_session(self, state): """A `calibration_session` event per session state change. diff --git a/dotbot/models.py b/dotbot/models.py index 758c6bfb..911ccda5 100644 --- a/dotbot/models.py +++ b/dotbot/models.py @@ -468,12 +468,6 @@ class DotBotCalibrationPreviewModel(BaseModel): reads: int = 0 -class DotBotCalibrationCaptureModel(BaseModel): - """Which robot takes the outstanding point's reads.""" - - device: str = "" - - class DotBotCalibrationSaveModel(BaseModel): """An optional session label, written into the file's metadata.""" diff --git a/dotbot/server.py b/dotbot/server.py index 07a713cf..15b8f89f 100644 --- a/dotbot/server.py +++ b/dotbot/server.py @@ -35,7 +35,6 @@ DotBotBackgroundMapModel, DotBotBodyModel, DotBotBuildModel, - DotBotCalibrationCaptureModel, DotBotCalibrationPreviewModel, DotBotCalibrationPushedModel, DotBotCalibrationPushModel, @@ -673,19 +672,6 @@ async def calibration_session_state(): return api.controller.calibration_session.state() -@api.post( - path="/controller/calibration/session/capture", - response_model=DotBotCalibrationSessionModel, - summary="Capture the outstanding point from one robot", - tags=["calibration"], -) -async def calibration_session_capture(request: DotBotCalibrationCaptureModel): - """Calibration-capture HTTP POST handler.""" - return await _calibration( - api.controller.calibration_session.capture(request.device) - ) - - @api.post( path="/controller/calibration/session/redo", response_model=DotBotCalibrationSessionModel, diff --git a/dotbot/tests/test_calibration_lighthouse2.py b/dotbot/tests/test_calibration_lighthouse2.py index d76e6163..556c6e06 100644 --- a/dotbot/tests/test_calibration_lighthouse2.py +++ b/dotbot/tests/test_calibration_lighthouse2.py @@ -610,26 +610,26 @@ def test_every_corner_describes_the_pose_before_the_coordinate(): assert prompts[0] == ( "point 0 of 4, top-left corner of arena: robot inside the rectangle, " "left edge on the left line, front edge on the top line, nose toward " - "the top. Photodiode lands at (47, 18.5) mm. Press Enter when it is " - "still." + "the top. Photodiode lands at (47, 18.5) mm. Press the robot's button " + "when it is still." ) assert prompts[1] == ( "point 1 of 4, top-right corner of arena: robot inside the rectangle, " "right edge on the right line, front edge on the top line, nose " - "toward the top. Photodiode lands at (1953, 18.5) mm. Press Enter " - "when it is still." + "toward the top. Photodiode lands at (1953, 18.5) mm. Press the " + "robot's button when it is still." ) assert prompts[2] == ( "point 2 of 4, bottom-left corner of arena: robot inside the " "rectangle, left edge on the left line, front edge on the bottom " "line, nose toward the bottom. Photodiode lands at (47, 1981.5) mm. " - "Press Enter when it is still." + "Press the robot's button when it is still." ) assert prompts[3] == ( "point 3 of 4, bottom-right corner of arena: robot inside the " "rectangle, right edge on the right line, front edge on the bottom " "line, nose toward the bottom. Photodiode lands at (1953, 1981.5) " - "mm. Press Enter when it is still." + "mm. Press the robot's button when it is still." ) @@ -638,12 +638,13 @@ def test_a_typed_point_instructs_nothing_beyond_the_coordinate(): assert point.corner is None assert point.where == "" and point.how == "" assert point_prompt(2, 5, point) == ( - "point 2 of 5: photodiode on (1500, 2500) mm. Press Enter when it is " "still." + "point 2 of 5: photodiode on (1500, 2500) mm. Press the robot's button " + "when it is still." ) def test_the_header_states_the_site_the_orientation_and_the_rule(): - header = collect_header(C405, "the config file", 4, 10) + header = collect_header(C405, "the config file", 4) assert "site c405-arena (from the config file)" in header assert "x grows right, y grows down" in header assert f"zero is {C405.anchor}" in header @@ -651,7 +652,7 @@ def test_the_header_states_the_site_the_orientation_and_the_rule(): def test_the_header_names_the_missing_anchor_key(): - header = collect_header(Site(), "the default", 4, 10) + header = collect_header(Site(), "the default", 4) assert "add `anchor` to [sites.default]" in header diff --git a/dotbot/tests/test_calibration_ota.py b/dotbot/tests/test_calibration_ota.py index 4253bb5d..e43932fa 100644 --- a/dotbot/tests/test_calibration_ota.py +++ b/dotbot/tests/test_calibration_ota.py @@ -3,8 +3,8 @@ """Tests for the over-the-air LH2 capture decoding + collection logic. -These exercise our host-side orchestration (payload decode, trigger/wait/ -retry, per-station accumulation) with a fake client. They are NOT a +These exercise our host-side orchestration (payload decode, press assembly, +per-station accumulation) with a fake client. They are NOT a substitute for hardware-in-the-loop validation of the actual swarmit transport - the fake stands in only for the SwarmitClient surface, never for Mari/MQTT/serial behavior. @@ -12,18 +12,15 @@ import threading -from dotbot.calibration.lighthouse2 import LH_PERIODS +from dotbot.calibration.lighthouse2 import LH_PERIODS, LH2CalibrationSample from dotbot.calibration.ota import ( BUTTON_CAPTURE_TIMEOUT_DEFAULT, ButtonAssembler, CaptureSession, parse_button_payload, - parse_capture_payload, samples_from_reads, ) -_TAG = 0xCA - def _record(lh_index: int, count1: int, count2: int) -> bytes: return ( @@ -31,124 +28,19 @@ def _record(lh_index: int, count1: int, count2: int) -> bytes: ) -def _payload(*records: bytes) -> bytes: - return bytes([_TAG]) + b"".join(records) - - -def test_parse_empty_or_untagged_returns_nothing(): - assert parse_capture_payload(b"", _TAG) == [] - # A regular text log line: first byte is not the tag. - assert parse_capture_payload(b"hello world", _TAG) == [] - - -def test_parse_single_sample(): - samples = parse_capture_payload(_payload(_record(0, 49341, 85887)), _TAG) - assert len(samples) == 1 - assert samples[0].lh_index == 0 - assert samples[0].count1 == 49341 - assert samples[0].count2 == 85887 - - -def test_parse_multiple_samples(): - samples = parse_capture_payload( - _payload(_record(0, 1, 2), _record(1, 3, 4), _record(2, 5, 6)), - _TAG, - ) - assert [(s.lh_index, s.count1, s.count2) for s in samples] == [ - (0, 1, 2), - (1, 3, 4), - (2, 5, 6), +def _read(*records: bytes) -> list[LH2CalibrationSample]: + return [ + LH2CalibrationSample( + r[0], int.from_bytes(r[1:5], "little"), int.from_bytes(r[5:9], "little") + ) + for r in records ] -def test_parse_ignores_trailing_partial_record(): - # Tag + one full 9-byte record + 3 stray bytes that can't form a record. - data = _payload(_record(0, 7, 8)) + b"\x01\x02\x03" - samples = parse_capture_payload(data, _TAG) - assert len(samples) == 1 - assert (samples[0].count1, samples[0].count2) == (7, 8) - - -class _FakeClient: - """Minimal SwarmitClient stand-in: emits one tagged event per trigger. - - Mirrors the real firmware contract (samples only arrive in reply to a - capture request), so CaptureSession's drain-then-trigger ordering is - exercised the same way it is against a bot. - """ - - def __init__(self, device: str, records: bytes): - self._device = device.upper() - self._records = records - self._triggered = threading.Event() - self.triggers = 0 - - def request_lh2_capture(self, device: str) -> None: - self.triggers += 1 - self._triggered.set() - - def watch_log_events(self): - while True: - if self._triggered.wait(timeout=0.05): - self._triggered.clear() - yield { - "addr": self._device, - "data_hex": _payload(self._records).hex(), - } - - -def test_capture_session_returns_triggered_sample(): - client = _FakeClient("ABCD", _record(0, 111, 222)) - with CaptureSession(client, "abcd", _TAG) as session: - samples = session.capture(timeout=2.0, retries=2) - assert len(samples) == 1 - assert samples[0].lh_index == 0 - assert (samples[0].count1, samples[0].count2) == (111, 222) - - -def test_capture_session_ignores_other_devices(): - # Event addressed to a different bot must not satisfy the capture. - client = _FakeClient("FFFF", _record(0, 1, 2)) - with CaptureSession(client, "ABCD", _TAG) as session: - try: - session.capture(timeout=0.3, retries=0) - except TimeoutError: - pass - else: - raise AssertionError("expected TimeoutError for mismatched addr") - - -def test_a_two_station_capture_payload_yields_two_samples(): - """Every visible station's records are kept, which is the seam evidence.""" - client = _FakeClient("ABCD", _record(0, 111, 222) + _record(1, 333, 444)) - with CaptureSession(client, "ABCD", _TAG) as session: - samples = session.capture_point( - point=0, reads=1, timeout=2.0, retries=0 - ).samples - - assert [s.station for s in samples] == [0, 1] - assert [s.point for s in samples] == [0, 0] - assert samples[0].count1 == [111] and samples[0].count2 == [222] - assert samples[1].count1 == [333] and samples[1].count2 == [444] - - -def test_n_reads_accumulate_per_station(): - client = _FakeClient("ABCD", _record(0, 10, 20) + _record(1, 30, 40)) - with CaptureSession(client, "ABCD", _TAG) as session: - samples = session.capture_point( - point=2, reads=5, timeout=2.0, retries=0 - ).samples - - assert client.triggers == 5 - assert [s.reads for s in samples] == [5, 5] - assert all(s.point == 2 for s in samples) - assert samples[0].mean_counts().count1 == 10.0 - - def test_samples_from_reads_tolerates_a_station_a_capture_missed(): reads = [ - parse_capture_payload(_payload(_record(0, 1, 2), _record(1, 3, 4)), _TAG), - parse_capture_payload(_payload(_record(0, 5, 6)), _TAG), + _read(_record(0, 1, 2), _record(1, 3, 4)), + _read(_record(0, 5, 6)), ] capture = samples_from_reads(reads, point=1) assert {s.station: s.reads for s in capture.samples} == {0: 2, 1: 1} @@ -163,25 +55,14 @@ def test_samples_from_reads_tolerates_a_station_a_capture_missed(): # end of the sweep. The host cannot fix the decode, but it must not solve from # it. -_PHANTOM_BYTES = bytes.fromhex("ca04b1fa0100b1fa0100") _PHANTOM_COUNT = 129713 - - -def test_the_bench_phantom_parses_faithfully(): - """The wire bytes of the phantom decode as sent - the parser is not at fault.""" - samples = parse_capture_payload(_PHANTOM_BYTES, _TAG) - assert len(samples) == 1 - assert samples[0].lh_index == 4 - assert samples[0].count1 == _PHANTOM_COUNT - assert samples[0].count2 == _PHANTOM_COUNT - # And it is not a count any sweep of station 4 could report. - assert _PHANTOM_COUNT > LH_PERIODS[4] // 8 +_PHANTOM = _record(4, _PHANTOM_COUNT, _PHANTOM_COUNT) def test_the_bench_phantom_is_dropped_and_creates_no_station(): """23 good station-0 reads + the 2 phantom reads, as captured on the bench.""" - reads = [parse_capture_payload(_payload(_record(0, 43166, 75717)), _TAG)] * 23 - reads += [parse_capture_payload(_PHANTOM_BYTES, _TAG)] * 2 + reads = [_read(_record(0, 43166, 75717))] * 23 + reads += [_read(_PHANTOM)] * 2 capture = samples_from_reads(reads, point=0) assert [s.station for s in capture.samples] == [0] @@ -191,7 +72,7 @@ def test_the_bench_phantom_is_dropped_and_creates_no_station(): def test_equal_counts_are_dropped_even_inside_the_sweep(): - reads = [parse_capture_payload(_payload(_record(0, 5000, 5000)), _TAG)] + reads = [_read(_record(0, 5000, 5000))] capture = samples_from_reads(reads, point=0) assert capture.samples == [] assert capture.dropped == {0: 1} @@ -199,14 +80,14 @@ def test_equal_counts_are_dropped_even_inside_the_sweep(): def test_a_count_past_the_end_of_the_sweep_is_dropped(): over = LH_PERIODS[0] // 8 + 1 - reads = [parse_capture_payload(_payload(_record(0, 1000, over)), _TAG)] + reads = [_read(_record(0, 1000, over))] capture = samples_from_reads(reads, point=0) assert capture.samples == [] assert capture.dropped == {0: 1} def test_a_station_with_no_known_period_is_dropped(): - reads = [parse_capture_payload(_payload(_record(len(LH_PERIODS), 10, 20)), _TAG)] + reads = [_read(_record(len(LH_PERIODS), 10, 20))] capture = samples_from_reads(reads, point=0) assert capture.samples == [] assert capture.dropped == {len(LH_PERIODS): 1} @@ -214,11 +95,9 @@ def test_a_station_with_no_known_period_is_dropped(): def test_a_station_seen_in_too_few_reads_is_dropped(): """Station 1 is decoded in 2 of 25 reads: too rare to be in view.""" - reads = [parse_capture_payload(_payload(_record(0, 100, 200)), _TAG)] * 23 + reads = [_read(_record(0, 100, 200))] * 23 reads += [ - parse_capture_payload( - _payload(_record(0, 100, 200), _record(1, 300, 400)), _TAG - ) + _read(_record(0, 100, 200), _record(1, 300, 400)) ] * 2 capture = samples_from_reads(reads, point=0) @@ -229,7 +108,7 @@ def test_a_station_seen_in_too_few_reads_is_dropped(): def test_a_station_seen_in_every_read_is_kept(): - reads = [parse_capture_payload(_payload(_record(0, 100, 200)), _TAG)] * 25 + reads = [_read(_record(0, 100, 200))] * 25 capture = samples_from_reads(reads, point=0) assert [s.station for s in capture.samples] == [0] assert capture.samples[0].reads == 25 @@ -238,9 +117,7 @@ def test_a_station_seen_in_every_read_is_kept(): def test_a_genuine_second_station_in_every_read_is_kept(): reads = [ - parse_capture_payload( - _payload(_record(0, 100, 200), _record(1, 300, 400)), _TAG - ) + _read(_record(0, 100, 200), _record(1, 300, 400)) ] * 25 capture = samples_from_reads(reads, point=0) assert {s.station: s.reads for s in capture.samples} == {0: 25, 1: 25} @@ -308,8 +185,8 @@ def test_parse_button_payload_reads_press_chunk_and_records(): chunk = parse_button_payload(events[0]) assert (chunk.press, chunk.chunk) == (3, 0) assert [(r.lh_index, r.count1, r.count2) for r in chunk.records] == [(0, 10, 20)] - # The READY tag, a text line and a truncated record are not button chunks. - assert parse_button_payload(_payload(_record(0, 1, 2))) is None + # Another tag, a text line and a truncated record are not button chunks. + assert parse_button_payload(bytes([0xCA]) + _record(0, 1, 2)) is None assert parse_button_payload(b"R12 n30/60") is None assert parse_button_payload(events[0][:-1]) is None @@ -434,7 +311,7 @@ def test_an_app_restart_reusing_press_0_is_a_new_press_with_nothing_lost(): class _ButtonClient: - """Emits one press's events, three copies, from a device the session did not name.""" + """Emits one press's events, three copies.""" def __init__(self, events: list[bytes]): self._events = events @@ -445,7 +322,7 @@ def watch_log_events(self): threading.Event().wait() -def test_capture_session_hands_a_button_press_from_any_device_over_once(): +def test_capture_session_hands_a_button_press_over_once(): got = [] done = threading.Event() @@ -454,8 +331,6 @@ def on_button(capture): done.set() events = _button_events(2, _TWO_STATIONS) * 3 - with CaptureSession( - _ButtonClient(events), "ABCD", _TAG, on_button_capture=on_button - ): + with CaptureSession(_ButtonClient(events), on_button_capture=on_button): assert done.wait(timeout=2.0) assert [(c.device, c.press, len(c.reads)) for c in got] == [("FEED", 2, 25)] diff --git a/dotbot/tests/test_calibration_session.py b/dotbot/tests/test_calibration_session.py index 2a0d5079..657d67a4 100644 --- a/dotbot/tests/test_calibration_session.py +++ b/dotbot/tests/test_calibration_session.py @@ -13,7 +13,6 @@ import asyncio import queue import re -import threading from types import SimpleNamespace import pytest @@ -23,20 +22,15 @@ from dotbot.calibration.driver import SessionDriver from dotbot.calibration.lighthouse2 import ( LH2_CALIBRATION_MESSAGE_BYTES, + LH2CalibrationSample, message_site, read_calibration_file, ) -from dotbot.calibration.ota import ( - ButtonCapture, - CaptureSession, - parse_capture_payload, -) +from dotbot.calibration.ota import ButtonCapture, CaptureSession from dotbot.calibration.points import CORNERS, PointsFrom from dotbot.calibration.session import CalibrationSession, SessionError from dotbot.site import Site -_TAG = 0xCA - C405 = Site( name="c405-arena", anchor="the arena's top-left corner, against the door wall of C405", @@ -62,22 +56,24 @@ def _record(lh_index: int, count1: int, count2: int) -> bytes: ) -def _payload(*records: bytes) -> bytes: - return bytes([_TAG]) + b"".join(records) - +def _reads(lh_index: int, count1: int, count2: int) -> list: + """One read: one station's record.""" + return [LH2CalibrationSample(lh_index, count1, count2)] -def _reads(lh_index: int, count1: int, count2: int, n: int = 1) -> list: - """One capture's worth of decoded records, `n` of them.""" - return parse_capture_payload(_payload(_record(lh_index, count1, count2)), _TAG) - -def _press(lh_index: int, count1: int, count2: int, n: int = 1) -> ButtonCapture: +def _press( + lh_index: int, count1: int, count2: int, n: int = 1, device: str = "ABCD" +) -> ButtonCapture: """One button capture of `n` identical reads from one station.""" return ButtonCapture( - device="ABCD", press=0, reads=[_reads(lh_index, count1, count2)] * n + device=device, press=0, reads=[_reads(lh_index, count1, count2)] * n ) +async def _store(driver, capture: ButtonCapture) -> None: + await asyncio.wrap_future(driver.on_button_capture(capture)) + + def _info(version=4, site="", calibration_id=""): """A robot's device info as swarmit decodes it.""" return SimpleNamespace( @@ -90,28 +86,14 @@ def _info(version=4, site="", calibration_id=""): class _FakeClient: - """Emits one tagged event per trigger, for whichever counts are set. - - Mirrors the firmware contract: raw counts only arrive in reply to a - request, so the drain-then-trigger ordering is exercised as on a bot. - """ + """The swarmit client surface a session pushes and listens through.""" def __init__(self, device: str = "ABCD"): self.device = device.upper() - self.records = _record(0, *CORNER_COUNTS[0]) - self.triggers = 0 self.pushed: list[bytes] = [] self.entered = 0 - self._triggered = threading.Event() self.infos = {self.device: _info()} - def at_corner(self, index: int, station: int = 0) -> None: - self.records = _record(station, *CORNER_COUNTS[index]) - - def request_lh2_capture(self, device: str) -> None: - self.triggers += 1 - self._triggered.set() - def send_lh2_calibration(self, payload: bytes, devices=None) -> None: self.pushed.append(payload) self.pushed_to = devices @@ -136,10 +118,7 @@ def status(self): } def watch_log_events(self): - while True: - if self._triggered.wait(timeout=0.05): - self._triggered.clear() - yield {"addr": self.device, "data_hex": _payload(self.records).hex()} + return iter(()) def __enter__(self): self.entered += 1 @@ -157,24 +136,20 @@ async def record(state): seen.append(state) driver = SessionDriver( - client_factory=lambda device: client, + client_factory=lambda: client, notify=notify or record, site=site, - stream_factory=lambda c, device, on_button: CaptureSession( - c, device, _TAG, on_button_capture=on_button + stream_factory=lambda c, on_button: CaptureSession( + c, on_button_capture=on_button ), ) return driver, client, seen -async def _walk(driver, client, reads=1): - """Capture all four corners, one per corner's counts.""" - driver.session.reads = reads - driver.session.timeout = 2.0 - driver.session.retries = 0 +async def _walk(driver): + """Capture all four corners from the button, one per corner's counts.""" for index in range(4): - client.at_corner(index) - await driver.capture("ABCD") + await _store(driver, _press(0, *CORNER_COUNTS[index])) # --- the points a session opens on ------------------------------------------ @@ -250,16 +225,13 @@ def test_fewer_than_four_points_is_refused_with_the_span_rule(): @pytest.mark.asyncio -async def test_capture_stores_the_reads_and_advances_to_the_next_point(): - driver, client, _ = _driver() +async def test_a_capture_stores_the_reads_and_advances_to_the_next_point(): + driver, _, _ = _driver() await driver.start(["arena:corners"]) - driver.session.reads = 25 - driver.session.timeout = 2.0 - driver.session.retries = 0 - state = await driver.capture("abcd") + await _store(driver, _press(0, *CORNER_COUNTS[0], n=25)) - assert client.triggers == 25 + state = driver.state() assert state["outstanding"] == 1 assert state["captured"] == 1 assert state["device"] == "ABCD" @@ -269,34 +241,31 @@ async def test_capture_stores_the_reads_and_advances_to_the_next_point(): @pytest.mark.asyncio -async def test_capture_keeps_every_visible_station_and_drops_the_impossible(): +async def test_a_capture_keeps_every_visible_station_and_drops_the_impossible(): """Two stations in view are two sample entries; a phantom is not a third.""" - driver, client, _ = _driver() + driver, _, _ = _driver() await driver.start(["arena:corners"]) - driver.session.reads = 4 - driver.session.timeout = 2.0 - driver.session.retries = 0 # Station 1 is real; station 4's two sweeps share an LFSR index, which no # real pair of sweeps can produce. - client.records = ( - _record(0, 41290, 51728) + _record(1, 88102, 97640) + _record(4, 129713, 129713) - ) + read = [ + LH2CalibrationSample(0, 41290, 51728), + LH2CalibrationSample(1, 88102, 97640), + LH2CalibrationSample(4, 129713, 129713), + ] - state = await driver.capture("ABCD") + await _store(driver, ButtonCapture(device="ABCD", press=0, reads=[read] * 4)) + state = driver.state() assert [r["station"] for r in state["points"][0]["reads"]] == [0, 1] assert state["points"][0]["dropped"] == 4 @pytest.mark.asyncio async def test_redo_discards_the_last_point_and_re_opens_it(): - driver, client, _ = _driver() + driver, _, _ = _driver() await driver.start(["arena:corners"]) - driver.session.reads = 1 - driver.session.timeout = 2.0 - driver.session.retries = 0 - await driver.capture("ABCD") - await driver.capture("ABCD") + await _store(driver, _press(0, *CORNER_COUNTS[0])) + await _store(driver, _press(0, *CORNER_COUNTS[1])) assert driver.state()["outstanding"] == 2 state = await driver.redo() @@ -315,33 +284,6 @@ async def test_redo_with_nothing_captured_is_refused(): await driver.redo() -@pytest.mark.asyncio -async def test_a_capture_with_no_robot_chosen_is_refused(): - driver, _, _ = _driver() - await driver.start(["arena:corners"]) - with pytest.raises(SessionError) as exc: - await driver.capture() - assert "no robot chosen" in str(exc.value) - - -@pytest.mark.asyncio -async def test_a_capture_timeout_is_reported_as_a_line_not_an_exception(): - class _Silent(_FakeClient): - def request_lh2_capture(self, device): - self.triggers += 1 - - driver, _, _ = _driver(client=_Silent()) - await driver.start(["arena:corners"]) - driver.session.reads = 1 - driver.session.timeout = 0.2 - driver.session.retries = 0 - - state = await driver.capture("ABCD") - - assert state["outstanding"] == 0 - assert "no LH2 samples" in state["error"] - - # --- the robot's own trigger ------------------------------------------------ @@ -364,15 +306,12 @@ async def test_a_session_listens_for_the_button_before_any_capture(): @pytest.mark.asyncio async def test_a_capture_arriving_while_point_k_is_outstanding_is_point_k(): - driver, client, _ = _driver() + driver, _, _ = _driver() await driver.start(["arena:corners"]) - driver.session.reads = 1 - driver.session.timeout = 2.0 - driver.session.retries = 0 - await driver.capture("ABCD") # point 0 the requested way + await _store(driver, _press(0, *CORNER_COUNTS[0])) assert driver.state()["outstanding"] == 1 - await asyncio.wrap_future(driver.on_button_capture(_press(0, *CORNER_COUNTS[1]))) + await _store(driver, _press(0, *CORNER_COUNTS[1])) state = driver.state() assert state["outstanding"] == 2 @@ -413,7 +352,7 @@ def test_a_capture_arriving_with_no_session_is_dropped(): async def test_a_capture_arriving_with_every_point_captured_is_dropped(): driver, client, _ = _driver() await driver.start(["arena:corners"]) - await _walk(driver, client) + await _walk(driver) before = driver.state() await asyncio.wrap_future(driver.on_button_capture(_press(0, 44444, 55555))) @@ -431,7 +370,7 @@ async def test_four_points_carry_a_residual_per_station_and_no_expected_error( monkeypatch.setattr(lighthouse2, "CALIBRATION_DIR", tmp_path) driver, client, _ = _driver() await driver.start(["arena:corners"]) - await _walk(driver, client) + await _walk(driver) saved = await driver.save() state = saved["session"] @@ -455,7 +394,7 @@ async def test_save_writes_a_schema_4_file_under_the_site_directory( monkeypatch.setattr(lighthouse2, "CALIBRATION_DIR", tmp_path) driver, client, _ = _driver() await driver.start(["arena:corners"]) - await _walk(driver, client) + await _walk(driver) saved = await driver.save(tag="arena-relay") @@ -469,12 +408,9 @@ async def test_save_writes_a_schema_4_file_under_the_site_directory( @pytest.mark.asyncio async def test_solving_before_every_point_is_captured_is_refused(): - driver, client, _ = _driver() + driver, _, _ = _driver() await driver.start(["arena:corners"]) - driver.session.reads = 1 - driver.session.timeout = 2.0 - driver.session.retries = 0 - await driver.capture("ABCD") + await _store(driver, _press(0, *CORNER_COUNTS[0])) with pytest.raises(SessionError) as exc: driver.session.solve() assert "1 of 4 points captured" in str(exc.value) @@ -487,7 +423,7 @@ async def test_push_sends_the_float32_messages_and_returns_the_stale_worklist( monkeypatch.setattr(lighthouse2, "CALIBRATION_DIR", tmp_path) driver, client, _ = _driver() await driver.start(["arena:corners"]) - await _walk(driver, client) + await _walk(driver) await driver.save() pushed = await driver.push() @@ -506,7 +442,7 @@ async def test_a_console_push_to_a_robot_of_another_site_is_refused( driver, client, _ = _driver() client.infos["ABCD"] = _info(site="demo-dcoss-2026", calibration_id="00" * 8) await driver.start(["arena:corners"]) - await _walk(driver, client) + await _walk(driver) await driver.save() with pytest.raises(SessionError) as exc: @@ -528,7 +464,7 @@ async def test_abandoning_leaves_no_session_and_writes_nothing(monkeypatch, tmp_ monkeypatch.setattr(lighthouse2, "CALIBRATION_DIR", tmp_path) driver, client, _ = _driver() await driver.start(["arena:corners"]) - await _walk(driver, client) + await _walk(driver) await driver.abandon() @@ -553,19 +489,15 @@ async def test_collect_and_a_controller_session_write_the_same_file_for_the_same driver, client, _ = _driver() await driver.start(["arena:corners"]) - await _walk(driver, client) + await _walk(driver) from_console = await driver.save() console_body = _undated(open(driver.session.saved_path, encoding="utf-8").read()) # The same reads, taken the way `collect` takes them: one session object, - # one CaptureSession stream, the same prompt order. + # the same prompt order. direct = CalibrationSession.resolve(["arena:corners"], site=C405) - direct.reads, direct.timeout, direct.retries = 1, 2.0, 0 - cli_client = _FakeClient() - with CaptureSession(cli_client, "ABCD", _TAG) as stream: - for index in range(4): - cli_client.at_corner(index) - direct.capture(stream) + for index in range(4): + direct.store_reads(_press(0, *CORNER_COUNTS[index]).reads, "ABCD") from_cli = direct.save() cli_body = _undated(open(direct.saved_path, encoding="utf-8").read()) @@ -594,30 +526,14 @@ async def test_every_state_change_is_one_notification_in_order(monkeypatch, tmp_ async def notify(state): sent.append(state) - driver, client, _ = _driver(notify=notify) + driver, _, _ = _driver(notify=notify) await driver.start(["arena:corners"]) - driver.session.reads = 2 - driver.session.timeout = 2.0 - driver.session.retries = 0 - client.at_corner(0) - await driver.capture("ABCD") + await _store(driver, _press(0, *CORNER_COUNTS[0], n=2)) await driver.redo() await driver.abandon() - # start, two read-progress events, the finished capture, redo, abandon. - assert [_phase(s) for s in sent] == [ - (0, 0), - (0, 0), - (0, 0), - (1, 1), - (0, 0), - None, - ] - # The progress events carry the reads as they land, never out of order. - assert [s["points"][0]["reads"] for s in sent[1:3]] == [ - [{"station": 0, "reads": 1, "target": 2}], - [{"station": 0, "reads": 2, "target": 2}], - ] + # start, the capture, redo, abandon. + assert [_phase(s) for s in sent] == [(0, 0), (1, 1), (0, 0), None] def _phase(state): @@ -625,16 +541,17 @@ def _phase(state): @pytest.mark.asyncio -async def test_the_session_survives_a_client_that_is_built_only_once(): - """The transport is built at start, for the button, and reused by captures.""" +async def test_the_session_survives_a_client_that_is_built_only_once( + monkeypatch, tmp_path +): + """The transport is built at start, for the button, and reused by a push.""" + monkeypatch.setattr(lighthouse2, "CALIBRATION_DIR", tmp_path) driver, client, _ = _driver() await driver.start(["arena:corners"], device="ABCD") assert client.entered == 1 - driver.session.reads = 1 - driver.session.timeout = 2.0 - driver.session.retries = 0 - await driver.capture("ABCD") - await driver.capture("ABCD") + await _walk(driver) + await driver.save() + await driver.push() assert client.entered == 1 @@ -643,14 +560,14 @@ async def test_the_session_survives_a_client_that_is_built_only_once(): @pytest.mark.asyncio async def test_two_captures_at_once_do_not_share_a_point(): - """The session serialises: the second call takes the next point, not the same.""" - driver, client, _ = _driver() + """The session serialises: the second press takes the next point, not the same.""" + driver, _, _ = _driver() await driver.start(["arena:corners"]) - driver.session.reads = 1 - driver.session.timeout = 2.0 - driver.session.retries = 0 - await asyncio.gather(driver.capture("ABCD"), driver.capture("ABCD")) + await asyncio.gather( + _store(driver, _press(0, *CORNER_COUNTS[0])), + _store(driver, _press(0, *CORNER_COUNTS[1])), + ) state = driver.state() assert state["captured"] == 2 @@ -705,16 +622,8 @@ async def test_the_routes_walk_a_session_from_start_to_push( assert body["expected_error_mm"] is None assert body["points"][0]["where"] == "top-left corner of arena" - controller.calibration_session.session.reads = 1 - controller.calibration_session.session.timeout = 2.0 - controller.calibration_session.session.retries = 0 - for index in range(4): - client.at_corner(index) - body = ( - await http.post( - "/controller/calibration/session/capture", json={"device": "ABCD"} - ) - ).json() + await _walk(controller.calibration_session) + body = (await http.get("/controller/calibration/session/state")).json() assert body["outstanding"] is None assert body["captured"] == 4 @@ -754,7 +663,7 @@ async def test_a_start_with_no_points_opens_on_the_sites_field(rest): @pytest.mark.asyncio async def test_a_route_that_needs_a_session_refuses_without_one(rest): http, _, _ = rest - for path in ("capture", "redo", "save", "push"): + for path in ("redo", "save", "push"): response = await http.post(f"/controller/calibration/session/{path}", json={}) assert response.status_code == 409 assert "no calibration session is open" in response.json()["detail"] @@ -864,7 +773,7 @@ async def test_a_session_takes_the_reads_per_point_it_is_started_with(rest): @pytest.mark.asyncio -async def test_the_simulated_client_answers_the_point_the_session_is_asking_about( +async def test_the_simulated_robot_presses_at_the_point_the_session_is_asking_about( monkeypatch, tmp_path ): """A controller on the simulator walks the whole mode and solves. @@ -877,30 +786,32 @@ async def test_the_simulated_client_answers_the_point_the_session_is_asking_abou holder: dict = {} driver = SessionDriver( - client_factory=lambda device: SimulatedCaptureClient( - device, + client_factory=lambda: SimulatedCaptureClient( lambda: ( - holder["driver"].session.outstanding.mm + ( + holder["driver"].session.outstanding.index, + holder["driver"].session.outstanding.mm, + ) if holder["driver"].session and holder["driver"].session.outstanding is not None else None ), + press_delay_s=0.0, ), notify=_noop, site=C405, - stream_factory=lambda c, device, on_button: CaptureSession( - c, device, _TAG, on_button_capture=on_button + stream_factory=lambda c, on_button: CaptureSession( + c, on_button_capture=on_button ), ) holder["driver"] = driver - monkeypatch.setattr("dotbot.calibration.simulated._SAMPLE_TAG", _TAG) await driver.start(["arena:corners"]) - driver.session.reads = 3 - driver.session.timeout = 2.0 - driver.session.retries = 0 - for _ in range(4): - await driver.capture("ABCD") + for _ in range(200): + if driver.session.complete: + break + await asyncio.sleep(0.02) + assert driver.state()["device"] == "SIMULATED" saved = await driver.save() stations = saved["session"]["stations"] @@ -912,14 +823,33 @@ async def _noop(_state): return None +def test_the_simulated_robot_presses_once_per_opening_of_a_point(): + """A point re-opened by a redo is pressed again.""" + from dotbot.calibration.ota import parse_button_payload + from dotbot.calibration.simulated import SimulatedCaptureClient + + opened = [(0, (47.0, 18.5))] + client = SimulatedCaptureClient(lambda: opened[-1], press_delay_s=0.0) + events = client.watch_log_events() + + def next_press(): + chunk = parse_button_payload(bytes.fromhex(next(events)["data_hex"])) + assert chunk.chunk == 0 and len(chunk.records) < 13 + return chunk.press + + assert next_press() == 0 + opened.append((1, (1953.0, 18.5))) + assert next_press() == 1 + opened.append((0, (47.0, 18.5))) + assert next_press() == 2 + + def test_a_button_capture_missing_a_station_an_earlier_point_saw_is_stored(): session = CalibrationSession.resolve(["arena:corners"], site=C405) - two = parse_capture_payload( - _payload(_record(0, 41290, 51728), _record(1, 30000, 40000)), _TAG - ) + two = [LH2CalibrationSample(0, 41290, 51728), LH2CalibrationSample(1, 30000, 40000)] assert session.store_reads([two] * 3).index == 0 - # A station occluded at one point is the solver's concern, as with Enter. + # A station occluded at one point is the solver's concern. assert session.store_reads([_reads(0, *CORNER_COUNTS[1])] * 3).index == 1 assert session.outstanding.index == 2 @@ -936,10 +866,10 @@ async def test_a_button_press_given_up_incomplete_is_reported(monkeypatch): expired_presses=lambda: expired.pop() if expired else [], ) driver = SessionDriver( - client_factory=lambda device: _FakeClient(), + client_factory=lambda: _FakeClient(), notify=_noop, site=C405, - stream_factory=lambda client, device, on_button: stream, + stream_factory=lambda client, on_button: stream, ) await driver.start(["arena:corners"]) for _ in range(100): @@ -959,11 +889,8 @@ def test_collect_takes_a_button_press_as_the_outstanding_point(capsys): stream = SimpleNamespace(expired_presses=lambda: [("FEED", 7)]) arrivals: queue.Queue = queue.Queue() arrivals.put( - ( - "button", - ButtonCapture( - device="FEED", press=2, reads=[_reads(0, *CORNER_COUNTS[0])] * 3, lost=1 - ), + ButtonCapture( + device="FEED", press=2, reads=[_reads(0, *CORNER_COUNTS[0])] * 3, lost=1 ) ) @@ -975,7 +902,7 @@ def test_collect_takes_a_button_press_as_the_outstanding_point(capsys): assert "1 capture(s) lost before press 2" in captured.err -# --- collect, with and without --device ------------------------------------- +# --- collect ----------------------------------------------------------------- _CORNER_POINTS = [ "--points=47,18.5", @@ -986,12 +913,12 @@ def test_collect_takes_a_button_press_as_the_outstanding_point(capsys): class _CollectClient(_FakeClient): - """Button presses queued up front, and READY replies to each trigger.""" + """Button presses at the four corners, queued up front.""" - def __init__(self, presses=(), device="ABCD"): - super().__init__(device) - # Enter captures take the corners the button presses left. - self.first_corner = len(presses) + def __init__( + self, presses=(("FEED", 0), ("FEED", 1), ("FEED", 2), ("FEED", 3)) + ): + super().__init__() self.events: queue.Queue = queue.Queue() for press, (addr, corner) in enumerate(presses): record = _record(0, *CORNER_COUNTS[corner]) @@ -1000,11 +927,6 @@ def __init__(self, presses=(), device="ABCD"): ) self.infos[addr] = _info() - def request_lh2_capture(self, device: str) -> None: - self.triggers += 1 - record = _record(0, *CORNER_COUNTS[self.first_corner + self.triggers - 1]) - self.events.put({"addr": self.device, "data_hex": _payload(record).hex()}) - def watch_log_events(self): while True: try: @@ -1013,21 +935,17 @@ def watch_log_events(self): continue -def _collect(monkeypatch, tmp_path, client, *args, stdin=""): +def _collect(monkeypatch, tmp_path, client, *args): from click.testing import CliRunner from dotbot.cli import swarm_lh2 monkeypatch.setattr(lighthouse2, "CALIBRATION_DIR", tmp_path) monkeypatch.setattr(swarm_lh2, "_swarmit_client", lambda *a: client) - return CliRunner().invoke( - swarm_lh2.cmd, - ["collect", *_CORNER_POINTS, "--reads=1", "--timeout=2", "--retries=0", *args], - input=stdin, - ) + return CliRunner().invoke(swarm_lh2.cmd, ["collect", *_CORNER_POINTS, *args]) -def test_collect_without_a_device_takes_every_point_from_the_button( +def test_collect_takes_every_point_from_the_button_and_pushes_to_its_robots( monkeypatch, tmp_path ): client = _CollectClient( @@ -1037,43 +955,20 @@ def test_collect_without_a_device_takes_every_point_from_the_button( result = _collect(monkeypatch, tmp_path, client, "--push") assert result.exit_code == 0, result.output - assert client.triggers == 0 assert "Press the robot's button when it is still." in result.output - assert "Press Enter" not in result.output assert "received from BEEF as point 3" in result.output assert client.pushed_to == ["BEEF", "FEED"] -def test_collect_with_a_device_captures_on_enter_and_pushes_to_it( - monkeypatch, tmp_path -): - client = _CollectClient() - - result = _collect( - monkeypatch, tmp_path, client, "--device=abcd", "--push", stdin="\n" * 4 - ) - - assert result.exit_code == 0, result.output - assert client.triggers == 4 - assert "Enter captures from ABCD" in result.output - assert "Press Enter when it is still." in result.output - assert client.pushed_to == ["ABCD"] - +def test_collect_has_no_device_capture(): + from click.testing import CliRunner -def test_collect_with_a_device_still_takes_another_robot_s_button( - monkeypatch, tmp_path -): - # The press is read while collect prints its header, before stdin is. - client = _CollectClient(presses=[("FEED", 0)]) + from dotbot.cli import swarm_lh2 - result = _collect( - monkeypatch, tmp_path, client, "--device=ABCD", "--push", stdin="\n" * 3 - ) + result = CliRunner().invoke(swarm_lh2.cmd, ["collect", "--device=ABCD"]) - assert result.exit_code == 0, result.output - assert "received from FEED as point 0" in result.output - assert client.triggers == 3 - assert client.pushed_to == ["ABCD", "FEED"] + assert result.exit_code == 2 + assert "No such option '--device'" in result.output ROLED = Site( @@ -1088,7 +983,7 @@ def test_collect_with_a_device_still_takes_another_robot_s_button( def _collect_in(monkeypatch, tmp_path, site, *args): - """`collect` over `site` with its own point flags, captured on Enter.""" + """`collect` over `site` with its own point flags, captured on the button.""" from click.testing import CliRunner from dotbot.cli import swarm_lh2 @@ -1098,11 +993,7 @@ def _collect_in(monkeypatch, tmp_path, site, *args): monkeypatch.setattr( swarm_lh2, "site_from_context", lambda ctx, flag=None: (site, "the test") ) - return CliRunner().invoke( - swarm_lh2.cmd, - ["collect", "--device=ABCD", "--reads=1", "--timeout=2", "--retries=0", *args], - input="\n" * 4, - ) + return CliRunner().invoke(swarm_lh2.cmd, ["collect", *args]) def _saved_points_from(tmp_path) -> PointsFrom | None: @@ -1153,24 +1044,6 @@ def test_collect_takes_one_way_of_choosing_the_points(monkeypatch, tmp_path, fla assert "each choose the points; pass one" in result.output -def test_collect_help_marks_the_enter_capture_deprecated(): - from click.testing import CliRunner - - from dotbot.cli import swarm_lh2 - - result = CliRunner().invoke(swarm_lh2.cmd, ["collect", "--help"]) - - assert "Deprecated in favour of the calibrate app's button" in " ".join( - result.output.split() - ) - - -def test_a_button_only_stream_refuses_a_triggered_capture(): - with CaptureSession(_FakeClient(), None, _TAG) as stream: - with pytest.raises(ValueError, match="only takes button captures"): - stream.capture(timeout=0.1, retries=0) - - async def _button_session(driver, device: str) -> None: await driver.start(["arena:corners"]) for corner in range(4): From 0e7ce7de0fa018d13c24d3585f3cdf9c6e01b7f3 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Wed, 14 Oct 2026 15:40:38 -0300 Subject: [PATCH 2/5] dotbot/console-web: drop the Capture button, the robot's button captures AI-assisted: Claude Opus 5.5 --- dotbot/console-web/src/App.tsx | 11 +---- dotbot/console-web/src/MapView.tsx | 7 +--- dotbot/console-web/src/RightPane.tsx | 5 --- dotbot/console-web/src/SetupCard.tsx | 5 +-- dotbot/console-web/src/StepCard.tsx | 41 ++++-------------- dotbot/console-web/src/api.test.ts | 8 ---- dotbot/console-web/src/api.ts | 9 ---- dotbot/console-web/src/areaLayer.test.tsx | 3 -- dotbot/console-web/src/bodyColor.test.tsx | 2 - .../console-web/src/calibrationTab.test.tsx | 42 +++++-------------- dotbot/console-web/src/cameraLayer.test.tsx | 3 -- dotbot/console-web/src/goKey.test.tsx | 1 - .../src/heldWaypointsList.test.tsx | 1 - .../console-web/src/mrtaVisibility.test.tsx | 1 - dotbot/console-web/src/panelKeys.test.tsx | 1 - dotbot/console-web/src/poseQueue.test.tsx | 1 - dotbot/console-web/src/redoMission.test.tsx | 1 - .../src/rightPaneCollapsed.test.tsx | 3 -- dotbot/console-web/src/robotDrawing.test.tsx | 2 - dotbot/console-web/src/savedView.test.tsx | 1 - dotbot/console-web/src/setupCard.test.tsx | 19 ++++----- .../console-web/src/shortcutsPanel.test.tsx | 1 - dotbot/console-web/src/spreadMany.test.tsx | 1 - dotbot/console-web/src/spreadMission.test.tsx | 1 - dotbot/console-web/src/testbedKeys.test.tsx | 1 - dotbot/console-web/src/unknownRobots.test.tsx | 1 - dotbot/console-web/src/useCalibration.test.ts | 1 - dotbot/console-web/src/useCalibration.ts | 18 -------- dotbot/console-web/src/zoomAgreement.test.tsx | 1 - 29 files changed, 30 insertions(+), 162 deletions(-) diff --git a/dotbot/console-web/src/App.tsx b/dotbot/console-web/src/App.tsx index 0475962d..597ab2bc 100644 --- a/dotbot/console-web/src/App.tsx +++ b/dotbot/console-web/src/App.tsx @@ -75,7 +75,7 @@ import { Waypoint, } from "./types"; import type { FleetContext } from "./botFacts"; -import { useCalibration, useCapturer } from "./useCalibration"; +import { useCalibration } from "./useCalibration"; import { useFleet } from "./useFleet"; import { useMrta } from "./useMrta"; import { useOrchestration } from "./useOrchestration"; @@ -184,9 +184,6 @@ export const App: React.FC = () => { () => ({ calibrationId: session?.saved_id ?? "", site, now: Date.now() / 1000 }), [session?.saved_id, site, bots], ); - // The robot chosen to capture: clicked on the map, typed in the card, or - // the one whose button took the last point. - const [capturer, setCapturer] = useCapturer(session); const preselRef = useRef(false); React.useEffect(() => { if (preselRef.current || bots.length === 0) return; @@ -882,8 +879,6 @@ export const App: React.FC = () => { session={session} calibration={calibration} areaNames={session.area ? [session.area] : []} - device={capturer} - onDeviceChange={setCapturer} phone onDone={() => calibration.abandon()} /> @@ -891,7 +886,6 @@ export const App: React.FC = () => { setRightTab("layers")} /> )} @@ -1112,7 +1106,6 @@ export const App: React.FC = () => { onPoseMode={setPoseMode} waypointSettings={wpSettings} session={session} - onPickCapturer={(id) => setCapturer(id.toUpperCase())} site={site} onZoom={zoomTo} onShortcuts={() => setShortcuts(true)} @@ -1225,8 +1218,6 @@ export const App: React.FC = () => { onRobotOpacity={onRobotOpacity} session={session} calibration={calibration} - device={capturer} - onDeviceChange={setCapturer} onCalibrationDone={() => calibration.abandon()} /> diff --git a/dotbot/console-web/src/MapView.tsx b/dotbot/console-web/src/MapView.tsx index 29ad6b25..3dde9b8e 100644 --- a/dotbot/console-web/src/MapView.tsx +++ b/dotbot/console-web/src/MapView.tsx @@ -175,9 +175,7 @@ interface MapViewProps { // The radii a mission is sent with, for the waypoints' tooltips. waypointSettings?: WaypointSettings; // Calibration mode: the rectangle being calibrated, drawn over the map. - // While it is open, clicking a robot chooses it as the capturer. session?: CalibrationSession | null; - onPickCapturer?: (id: string) => void; // The named zooms: the site the menu lists, and what a pick applies to. site: Site | null; onZoom: (name: string) => void; @@ -880,10 +878,7 @@ export const MapView: React.FC = (props) => { beginGesture(e.clientX, e.clientY, b); return; } - if (props.session && props.onPickCapturer) { - props.onPickCapturer(id); - props.onSelect([id], "replace"); - } else if (role === "select") { + if (role === "select") { props.onSelect([id], "toggle"); } else if (props.selection.has(id) && props.selection.size === 1) { props.onSelect([], "replace"); // click the sole selected bot again = deselect diff --git a/dotbot/console-web/src/RightPane.tsx b/dotbot/console-web/src/RightPane.tsx index 56284d42..45cfe1f5 100644 --- a/dotbot/console-web/src/RightPane.tsx +++ b/dotbot/console-web/src/RightPane.tsx @@ -533,8 +533,6 @@ interface RightPaneProps { onRobotOpacity?: (area: string, value: number) => void; session: CalibrationSession | null; calibration: Calibration; - device: string; - onDeviceChange: (device: string) => void; onCalibrationDone: () => void; } @@ -626,15 +624,12 @@ export const RightPane: React.FC = (props) => { session={props.session} calibration={props.calibration} areaNames={props.session.area ? [props.session.area] : []} - device={props.device} - onDeviceChange={props.onDeviceChange} onDone={props.onCalibrationDone} /> ) : ( ))} diff --git a/dotbot/console-web/src/SetupCard.tsx b/dotbot/console-web/src/SetupCard.tsx index 05bc655f..281b82a6 100644 --- a/dotbot/console-web/src/SetupCard.tsx +++ b/dotbot/console-web/src/SetupCard.tsx @@ -44,8 +44,6 @@ const field: React.CSSProperties = { interface SetupCardProps { site: Site | null; calibration: Calibration; - /** The robot chosen to capture, clicked on the map or typed. */ - device: string; /** Leave the card. Given where it is the whole screen and no tab strip is. */ onLeave?: () => void; } @@ -53,7 +51,6 @@ interface SetupCardProps { export const SetupCard: React.FC = ({ site, calibration, - device, onLeave, }) => { const names = areaChoices(site); @@ -201,7 +198,7 @@ export const SetupCard: React.FC = ({ // A typed rectangle names no area, so the expected error it would // be evaluated over is left unchosen rather than named after it. const area = choice === TYPED_RECT ? "" : choice; - calibration.start([spec!], area, device, readsValue!); + calibration.start([spec!], area, "", readsValue!); }} style={{ display: "flex", diff --git a/dotbot/console-web/src/StepCard.tsx b/dotbot/console-web/src/StepCard.tsx index 3a60a4fb..cfe35fe7 100644 --- a/dotbot/console-web/src/StepCard.tsx +++ b/dotbot/console-web/src/StepCard.tsx @@ -87,9 +87,6 @@ interface StepCardProps { session: CalibrationSession; calibration: Calibration; areaNames: string[]; - /** The robot chosen to capture, clicked on the map or typed. */ - device: string; - onDeviceChange: (device: string) => void; phone?: boolean; onDone: () => void; } @@ -98,8 +95,6 @@ export const StepCard: React.FC = ({ session, calibration, areaNames, - device, - onDeviceChange, phone = false, onDone, }) => { @@ -144,24 +139,14 @@ export const StepCard: React.FC = ({ {!done && ( <> -
Capturing
- onDeviceChange(e.target.value)} - placeholder="click a robot on the map, or type its address" - style={{ - ...mono, - fontSize: 12, - width: "100%", - boxSizing: "border-box", - minWidth: 0, - padding: "7px 9px", - borderRadius: 7, - border: "1px solid var(--hairline)", - background: "var(--elevated)", - color: "var(--text)", - }} - /> +
+ Press the calibrate app's button on the robot when it is still. +
+ {session.device && ( +
+ last capture from {session.device} +
+ )} {point && point.reads.length > 0 && (
Reads per station
@@ -171,16 +156,6 @@ export const StepCard: React.FC = ({
)}
-
!calibration.busy && device && calibration.capture(device)} - style={{ - ...button("accent", calibration.busy || !device), - padding: phone ? "16px 14px" : "9px 14px", - fontSize: phone ? 16 : 13, - }} - > - {calibration.busy ? "Capturing…" : "Capture"} -
!calibration.busy && session.captured > 0 && calibration.redo() diff --git a/dotbot/console-web/src/api.test.ts b/dotbot/console-web/src/api.test.ts index b2d70c3a..fd2aeb00 100644 --- a/dotbot/console-web/src/api.test.ts +++ b/dotbot/console-web/src/api.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { - captureCalibrationPoint, clearWaypoints, fetchCalibrationSession, fetchMrtaStatus, @@ -116,13 +115,6 @@ describe("the calibration session", () => { }); }); - it("names the robot in the capture request", async () => { - const calls = stubFetch(200, { outstanding: 1 }); - await captureCalibrationPoint("ABCD"); - expect(calls[0].url).toBe("/controller/calibration/session/capture"); - expect(calls[0].body).toEqual({ device: "ABCD" }); - }); - it("reads the session back, and null means there is none", async () => { stubFetch(200, null); expect(await fetchCalibrationSession()).toBeNull(); diff --git a/dotbot/console-web/src/api.ts b/dotbot/console-web/src/api.ts index 6145a644..55226a58 100644 --- a/dotbot/console-web/src/api.ts +++ b/dotbot/console-web/src/api.ts @@ -211,15 +211,6 @@ export async function startCalibration( }); } -export async function captureCalibrationPoint( - device: string, -): Promise { - return controllerJson(`${SESSION}/capture`, { - method: "POST", - body: { device }, - }); -} - export async function redoCalibrationPoint(): Promise { return controllerJson(`${SESSION}/redo`, { method: "POST" }); } diff --git a/dotbot/console-web/src/areaLayer.test.tsx b/dotbot/console-web/src/areaLayer.test.tsx index 15a81415..8ba4aa83 100644 --- a/dotbot/console-web/src/areaLayer.test.tsx +++ b/dotbot/console-web/src/areaLayer.test.tsx @@ -24,7 +24,6 @@ const calibration = { error: "", pushed: "", start: vi.fn(), - capture: vi.fn(), redo: vi.fn(), save: vi.fn(), push: vi.fn(), @@ -98,8 +97,6 @@ const Harness: React.FC = () => { onLayerToggle={() => {}} session={null} calibration={calibration} - device="" - onDeviceChange={() => {}} onCalibrationDone={() => {}} />
diff --git a/dotbot/console-web/src/bodyColor.test.tsx b/dotbot/console-web/src/bodyColor.test.tsx index 66b1ab07..0fcf0ff0 100644 --- a/dotbot/console-web/src/bodyColor.test.tsx +++ b/dotbot/console-web/src/bodyColor.test.tsx @@ -120,8 +120,6 @@ const Harness: React.FC = () => { onBodyColorMode={setMode} session={null} calibration={{} as Calibration} - device="" - onDeviceChange={() => {}} onCalibrationDone={() => {}} /> diff --git a/dotbot/console-web/src/calibrationTab.test.tsx b/dotbot/console-web/src/calibrationTab.test.tsx index 947c713c..c99c16a0 100644 --- a/dotbot/console-web/src/calibrationTab.test.tsx +++ b/dotbot/console-web/src/calibrationTab.test.tsx @@ -54,7 +54,6 @@ vi.mock("./api", () => ({ putWaypointBatches: vi.fn(async () => {}), clearWaypoints: vi.fn(async () => {}), abandonCalibration: vi.fn(async () => {}), - captureCalibrationPoint: vi.fn(), previewCalibrationPoints: vi.fn(async () => ({ points: [], reads: 25 })), pushCalibration: vi.fn(), redoCalibrationPoint: vi.fn(), @@ -127,7 +126,7 @@ describe("the Calibrate tab", () => { fireEvent.click(screen.getByText("Calibrate")); expect(screen.getByLabelText("Rectangle")).toBeInTheDocument(); expect(screen.getByText("Start")).toBeInTheDocument(); - expect(screen.queryByText("Capture")).not.toBeInTheDocument(); + expect(screen.queryByText("Redo")).not.toBeInTheDocument(); }); it("opens on the rail's Calibrate action rather than starting a session", () => { @@ -147,9 +146,9 @@ describe("the Calibrate tab", () => { }); expect(tabNames()).toEqual(["Robot", "Layers", "Calibrate"]); - // The step card takes the tab over from the setup card, so the capture + // The step card takes the tab over from the setup card, so its redo // button is on screen the moment the session opens. - expect(screen.getByText("Capture")).toBeInTheDocument(); + expect(screen.getByText("Redo")).toBeInTheDocument(); }); it("leaves Robot and Layers reachable during a session", () => { @@ -162,7 +161,7 @@ describe("the Calibrate tab", () => { fireEvent.click(screen.getByText("Layers")); expect(screen.getByText("Areas")).toBeInTheDocument(); // The card left with the tab; it is not stacked above the others. - expect(screen.queryByText("Capture")).not.toBeInTheDocument(); + expect(screen.queryByText("Redo")).not.toBeInTheDocument(); }); it("hands the pane back on Done, to the tab that was open", () => { @@ -174,14 +173,14 @@ describe("the Calibrate tab", () => { act(() => { rerender(); }); - expect(screen.getByText("Capture")).toBeInTheDocument(); + expect(screen.getByText("Redo")).toBeInTheDocument(); session = null; act(() => { rerender(); }); - expect(screen.queryByText("Capture")).not.toBeInTheDocument(); + expect(screen.queryByText("Redo")).not.toBeInTheDocument(); // Robot is selected again: its empty-state line is what the tab shows. expect(screen.getByText("Select a bot to inspect it.")).toBeInTheDocument(); }); @@ -213,40 +212,19 @@ const afterCaptures = ( ...over, }); -const capturing = () => - screen.getByPlaceholderText(/click a robot on the map/) as HTMLInputElement; - describe("the step card following the robot's button", () => { - it("advances the step and adopts the robot that pressed", () => { + it("advances the step and names the robot that pressed", () => { const { rerender } = render(); session = SESSION; act(() => rerender()); expect(screen.getByText("Step 1 of 4")).toBeInTheDocument(); - expect(capturing().value).toBe(""); + expect(screen.queryByText(/last capture from/)).not.toBeInTheDocument(); session = afterCaptures(1, "ABCD"); act(() => rerender()); expect(screen.getByText("Step 2 of 4")).toBeInTheDocument(); - expect(capturing().value).toBe("ABCD"); - }); - - it("replaces a robot typed in the card with the one that pressed", () => { - const { rerender } = render(); - session = SESSION; - act(() => rerender()); - fireEvent.change(capturing(), { target: { value: "1234" } }); - expect(capturing().value).toBe("1234"); - - session = afterCaptures(1, "ABCD"); - act(() => rerender()); - expect(capturing().value).toBe("ABCD"); - - // A pick made after that capture stands until the next one. - fireEvent.change(capturing(), { target: { value: "5678" } }); - session = afterCaptures(1, "ABCD"); - act(() => rerender()); - expect(capturing().value).toBe("5678"); + expect(screen.getByText("last capture from ABCD")).toBeInTheDocument(); }); it("moves to save and push after the last point, never a NaN step", () => { @@ -267,7 +245,7 @@ describe("the step card following the robot's button", () => { expect(screen.getByText("After the last point")).toBeInTheDocument(); expect(screen.getByText("Save")).toBeInTheDocument(); expect(screen.getByText("Push")).toBeInTheDocument(); - expect(screen.queryByText("Capture")).not.toBeInTheDocument(); + expect(screen.queryByText(/press the calibrate app's button/i)).not.toBeInTheDocument(); }); }); diff --git a/dotbot/console-web/src/cameraLayer.test.tsx b/dotbot/console-web/src/cameraLayer.test.tsx index 0d7792cc..afe61389 100644 --- a/dotbot/console-web/src/cameraLayer.test.tsx +++ b/dotbot/console-web/src/cameraLayer.test.tsx @@ -100,7 +100,6 @@ const calibration = { error: "", pushed: "", start: vi.fn(), - capture: vi.fn(), redo: vi.fn(), save: vi.fn(), push: vi.fn(), @@ -231,8 +230,6 @@ const Harness: React.FC<{ } session={null} calibration={calibration} - device="" - onDeviceChange={() => {}} onCalibrationDone={() => {}} />
diff --git a/dotbot/console-web/src/goKey.test.tsx b/dotbot/console-web/src/goKey.test.tsx index 62d953ce..f8364e0d 100644 --- a/dotbot/console-web/src/goKey.test.tsx +++ b/dotbot/console-web/src/goKey.test.tsx @@ -81,7 +81,6 @@ vi.mock("./api", () => ({ putWaypointBatches: vi.fn(async () => {}), clearWaypoints: vi.fn(async () => {}), abandonCalibration: vi.fn(async () => {}), - captureCalibrationPoint: vi.fn(), previewCalibrationPoints: vi.fn(async () => ({ points: [], reads: 25 })), pushCalibration: vi.fn(), redoCalibrationPoint: vi.fn(), diff --git a/dotbot/console-web/src/heldWaypointsList.test.tsx b/dotbot/console-web/src/heldWaypointsList.test.tsx index 646cf0e3..ff3e0e03 100644 --- a/dotbot/console-web/src/heldWaypointsList.test.tsx +++ b/dotbot/console-web/src/heldWaypointsList.test.tsx @@ -87,7 +87,6 @@ vi.mock("./api", () => ({ putWaypointBatches: vi.fn(async () => {}), clearWaypoints: vi.fn(async () => {}), abandonCalibration: vi.fn(async () => {}), - captureCalibrationPoint: vi.fn(), previewCalibrationPoints: vi.fn(async () => ({ points: [], reads: 25 })), pushCalibration: vi.fn(), redoCalibrationPoint: vi.fn(), diff --git a/dotbot/console-web/src/mrtaVisibility.test.tsx b/dotbot/console-web/src/mrtaVisibility.test.tsx index 038b5905..15caef02 100644 --- a/dotbot/console-web/src/mrtaVisibility.test.tsx +++ b/dotbot/console-web/src/mrtaVisibility.test.tsx @@ -85,7 +85,6 @@ vi.mock("./api", () => ({ putWaypointBatches: vi.fn(async () => {}), clearWaypoints: vi.fn(async () => {}), abandonCalibration: vi.fn(async () => {}), - captureCalibrationPoint: vi.fn(), previewCalibrationPoints: vi.fn(async () => ({ points: [], reads: 25 })), pushCalibration: vi.fn(), redoCalibrationPoint: vi.fn(), diff --git a/dotbot/console-web/src/panelKeys.test.tsx b/dotbot/console-web/src/panelKeys.test.tsx index 7067cad8..e58a06a9 100644 --- a/dotbot/console-web/src/panelKeys.test.tsx +++ b/dotbot/console-web/src/panelKeys.test.tsx @@ -71,7 +71,6 @@ vi.mock("./api", () => ({ putWaypointBatches: vi.fn(async () => {}), clearWaypoints: vi.fn(async () => {}), abandonCalibration: vi.fn(async () => {}), - captureCalibrationPoint: vi.fn(), previewCalibrationPoints: vi.fn(async () => ({ points: [], reads: 25 })), pushCalibration: vi.fn(), redoCalibrationPoint: vi.fn(), diff --git a/dotbot/console-web/src/poseQueue.test.tsx b/dotbot/console-web/src/poseQueue.test.tsx index e1ccae86..13dbf2f2 100644 --- a/dotbot/console-web/src/poseQueue.test.tsx +++ b/dotbot/console-web/src/poseQueue.test.tsx @@ -90,7 +90,6 @@ vi.mock("./api", () => ({ putWaypointBatches: vi.fn(async () => {}), clearWaypoints: vi.fn(async () => {}), abandonCalibration: vi.fn(async () => {}), - captureCalibrationPoint: vi.fn(), previewCalibrationPoints: vi.fn(async () => ({ points: [], reads: 25 })), pushCalibration: vi.fn(), redoCalibrationPoint: vi.fn(), diff --git a/dotbot/console-web/src/redoMission.test.tsx b/dotbot/console-web/src/redoMission.test.tsx index d230d75c..87c3c436 100644 --- a/dotbot/console-web/src/redoMission.test.tsx +++ b/dotbot/console-web/src/redoMission.test.tsx @@ -91,7 +91,6 @@ vi.mock("./api", () => ({ putWaypointBatches: vi.fn(async () => {}), clearWaypoints: vi.fn(async () => {}), abandonCalibration: vi.fn(async () => {}), - captureCalibrationPoint: vi.fn(), previewCalibrationPoints: vi.fn(async () => ({ points: [], reads: 25 })), pushCalibration: vi.fn(), redoCalibrationPoint: vi.fn(), diff --git a/dotbot/console-web/src/rightPaneCollapsed.test.tsx b/dotbot/console-web/src/rightPaneCollapsed.test.tsx index 9e9364d2..ef6f1076 100644 --- a/dotbot/console-web/src/rightPaneCollapsed.test.tsx +++ b/dotbot/console-web/src/rightPaneCollapsed.test.tsx @@ -18,7 +18,6 @@ const calibration = { error: "", pushed: "", start: vi.fn(), - capture: vi.fn(), redo: vi.fn(), save: vi.fn(), push: vi.fn(), @@ -90,8 +89,6 @@ const Harness: React.FC<{ session?: CalibrationSession | null }> = ({ onLayerToggle={() => {}} session={session} calibration={calibration} - device="" - onDeviceChange={() => {}} onCalibrationDone={() => {}} /> diff --git a/dotbot/console-web/src/robotDrawing.test.tsx b/dotbot/console-web/src/robotDrawing.test.tsx index 13531caa..c10f40c6 100644 --- a/dotbot/console-web/src/robotDrawing.test.tsx +++ b/dotbot/console-web/src/robotDrawing.test.tsx @@ -89,8 +89,6 @@ const Harness: React.FC = () => { onRobotDrawing={setDrawing} session={null} calibration={{} as Calibration} - device="" - onDeviceChange={() => {}} onCalibrationDone={() => {}} /> diff --git a/dotbot/console-web/src/savedView.test.tsx b/dotbot/console-web/src/savedView.test.tsx index a91b6ec9..2b483027 100644 --- a/dotbot/console-web/src/savedView.test.tsx +++ b/dotbot/console-web/src/savedView.test.tsx @@ -79,7 +79,6 @@ vi.mock("./api", () => ({ putWaypointBatches: vi.fn(async () => {}), clearWaypoints: vi.fn(async () => {}), abandonCalibration: vi.fn(async () => {}), - captureCalibrationPoint: vi.fn(), previewCalibrationPoints: vi.fn(async () => ({ points: [], reads: 25 })), pushCalibration: vi.fn(), redoCalibrationPoint: vi.fn(), diff --git a/dotbot/console-web/src/setupCard.test.tsx b/dotbot/console-web/src/setupCard.test.tsx index 3a74f212..0918b177 100644 --- a/dotbot/console-web/src/setupCard.test.tsx +++ b/dotbot/console-web/src/setupCard.test.tsx @@ -54,7 +54,6 @@ const calibration = (): Calibration => ({ error: "", pushed: "", start: vi.fn(async () => {}), - capture: vi.fn(async () => {}), redo: vi.fn(async () => {}), save: vi.fn(async () => {}), push: vi.fn(async () => {}), @@ -70,7 +69,7 @@ beforeEach(() => { describe("the Calibrate tab before a session", () => { it("opens on the site's field and asks the controller for its corners", async () => { - render(); + render(); expect(screen.getByLabelText("Rectangle")).toHaveValue("arena"); await waitFor(() => @@ -79,7 +78,7 @@ describe("the Calibrate tab before a session", () => { }); it("shows the four resolved corners in capture order, in frame mm", async () => { - render(); + render(); await screen.findByText("top-left"); expect( @@ -92,15 +91,15 @@ describe("the Calibrate tab before a session", () => { }); it("seeds reads per point from the controller's own default", async () => { - render(); + render(); await waitFor(() => expect(screen.getByLabelText("Reads per point")).toHaveValue("25"), ); }); - it("starts a session over the chosen area, its reads and the chosen robot", async () => { + it("starts a session over the chosen area and its reads", async () => { const cal = calibration(); - render(); + render(); await waitFor(() => expect(screen.getByLabelText("Reads per point")).toHaveValue("25"), ); @@ -114,12 +113,12 @@ describe("the Calibrate tab before a session", () => { }); fireEvent.click(screen.getByText("Start")); - expect(cal.start).toHaveBeenCalledWith(["annex:corners"], "annex", "ABCD", 40); + expect(cal.start).toHaveBeenCalledWith(["annex:corners"], "annex", "", 40); }); it("starts a session over a typed rectangle, which names no area", async () => { const cal = calibration(); - render(); + render(); await waitFor(() => expect(screen.getByLabelText("Reads per point")).toHaveValue("25"), ); @@ -145,7 +144,7 @@ describe("the Calibrate tab before a session", () => { it("does not start while the typed rectangle is incomplete", async () => { const cal = calibration(); - render(); + render(); await waitFor(() => expect(screen.getByLabelText("Reads per point")).toHaveValue("25"), ); @@ -165,7 +164,7 @@ describe("the Calibrate tab before a session", () => { it("shows the controller's refusal rather than an empty list", async () => { preview.mockRejectedValue(new Error("unknown area 'balcony'")); - render(); + render(); expect(await screen.findByText("unknown area 'balcony'")).toBeInTheDocument(); }); diff --git a/dotbot/console-web/src/shortcutsPanel.test.tsx b/dotbot/console-web/src/shortcutsPanel.test.tsx index 0c8e60db..67d77f6b 100644 --- a/dotbot/console-web/src/shortcutsPanel.test.tsx +++ b/dotbot/console-web/src/shortcutsPanel.test.tsx @@ -70,7 +70,6 @@ vi.mock("./api", () => ({ putWaypointBatches: vi.fn(async () => {}), clearWaypoints: vi.fn(async () => {}), abandonCalibration: vi.fn(async () => {}), - captureCalibrationPoint: vi.fn(), previewCalibrationPoints: vi.fn(async () => ({ points: [], reads: 25 })), pushCalibration: vi.fn(), redoCalibrationPoint: vi.fn(), diff --git a/dotbot/console-web/src/spreadMany.test.tsx b/dotbot/console-web/src/spreadMany.test.tsx index bddeec69..c671e4a2 100644 --- a/dotbot/console-web/src/spreadMany.test.tsx +++ b/dotbot/console-web/src/spreadMany.test.tsx @@ -78,7 +78,6 @@ vi.mock("./api", () => ({ putWaypointBatches: vi.fn(async () => {}), clearWaypoints: vi.fn(async () => {}), abandonCalibration: vi.fn(async () => {}), - captureCalibrationPoint: vi.fn(), previewCalibrationPoints: vi.fn(async () => ({ points: [], reads: 25 })), pushCalibration: vi.fn(), redoCalibrationPoint: vi.fn(), diff --git a/dotbot/console-web/src/spreadMission.test.tsx b/dotbot/console-web/src/spreadMission.test.tsx index adabc5db..1d167734 100644 --- a/dotbot/console-web/src/spreadMission.test.tsx +++ b/dotbot/console-web/src/spreadMission.test.tsx @@ -75,7 +75,6 @@ vi.mock("./api", () => ({ putWaypointBatches: vi.fn(async () => {}), clearWaypoints: vi.fn(async () => {}), abandonCalibration: vi.fn(async () => {}), - captureCalibrationPoint: vi.fn(), previewCalibrationPoints: vi.fn(async () => ({ points: [], reads: 25 })), pushCalibration: vi.fn(), redoCalibrationPoint: vi.fn(), diff --git a/dotbot/console-web/src/testbedKeys.test.tsx b/dotbot/console-web/src/testbedKeys.test.tsx index 8f6e190f..8287742b 100644 --- a/dotbot/console-web/src/testbedKeys.test.tsx +++ b/dotbot/console-web/src/testbedKeys.test.tsx @@ -75,7 +75,6 @@ vi.mock("./api", () => ({ putWaypointBatches: vi.fn(async () => {}), clearWaypoints: vi.fn(async () => {}), abandonCalibration: vi.fn(async () => {}), - captureCalibrationPoint: vi.fn(), previewCalibrationPoints: vi.fn(async () => ({ points: [], reads: 25 })), pushCalibration: vi.fn(), redoCalibrationPoint: vi.fn(), diff --git a/dotbot/console-web/src/unknownRobots.test.tsx b/dotbot/console-web/src/unknownRobots.test.tsx index 3f1a5c63..b592f7a4 100644 --- a/dotbot/console-web/src/unknownRobots.test.tsx +++ b/dotbot/console-web/src/unknownRobots.test.tsx @@ -76,7 +76,6 @@ vi.mock("./api", () => ({ putWaypointBatches: vi.fn(), clearWaypoints: vi.fn(), abandonCalibration: vi.fn(async () => {}), - captureCalibrationPoint: vi.fn(), previewCalibrationPoints: vi.fn(async () => ({ points: [], reads: 25 })), pushCalibration: vi.fn(), redoCalibrationPoint: vi.fn(), diff --git a/dotbot/console-web/src/useCalibration.test.ts b/dotbot/console-web/src/useCalibration.test.ts index 45d0d983..bfc240c7 100644 --- a/dotbot/console-web/src/useCalibration.test.ts +++ b/dotbot/console-web/src/useCalibration.test.ts @@ -3,7 +3,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("./api", () => ({ abandonCalibration: vi.fn(), - captureCalibrationPoint: vi.fn(), pushCalibration: vi.fn(async () => ({ id: "00", bytes: 84, stale: [] })), redoCalibrationPoint: vi.fn(), saveCalibration: vi.fn(), diff --git a/dotbot/console-web/src/useCalibration.ts b/dotbot/console-web/src/useCalibration.ts index a2f70171..de80448c 100644 --- a/dotbot/console-web/src/useCalibration.ts +++ b/dotbot/console-web/src/useCalibration.ts @@ -2,7 +2,6 @@ import { useCallback, useState } from "react"; import { abandonCalibration, - captureCalibrationPoint, pushCalibration, redoCalibrationPoint, saveCalibration, @@ -25,7 +24,6 @@ export interface Calibration { device?: string, reads?: number, ) => Promise; - capture: (device: string) => Promise; redo: () => Promise; save: (tag?: string) => Promise; push: (stale?: string[]) => Promise; @@ -66,7 +64,6 @@ export function useCalibration( setPushed(""); onSession(await startCalibration(points, device, area, reads)); }), - capture: (device) => run(async () => onSession(await captureCalibrationPoint(device))), redo: () => run(async () => onSession(await redoCalibrationPoint())), save: (tag = "") => run(async () => { @@ -98,18 +95,3 @@ export function useCalibration( }), }; } - -/** - * The robot the card captures with: the one clicked or typed since the - * session last changed hands, else the session's own, which each capture - * sets, a button press included. A pick is tied to the capture count and - * device it was made at, so the next capture from any robot replaces it. - */ -export function useCapturer( - session: CalibrationSession | null, -): [string, (device: string) => void] { - const [pick, setPick] = useState({ device: "", at: "" }); - const at = session ? `${session.captured} ${session.device}` : ""; - const device = pick.at === at && pick.device ? pick.device : (session?.device ?? ""); - return [device, (next) => setPick({ device: next, at })]; -} diff --git a/dotbot/console-web/src/zoomAgreement.test.tsx b/dotbot/console-web/src/zoomAgreement.test.tsx index 3eeffd30..731bb662 100644 --- a/dotbot/console-web/src/zoomAgreement.test.tsx +++ b/dotbot/console-web/src/zoomAgreement.test.tsx @@ -67,7 +67,6 @@ vi.mock("./api", () => ({ putWaypointBatches: vi.fn(async () => {}), clearWaypoints: vi.fn(async () => {}), abandonCalibration: vi.fn(async () => {}), - captureCalibrationPoint: vi.fn(), previewCalibrationPoints: vi.fn(async () => ({ points: [], reads: 25 })), pushCalibration: vi.fn(), redoCalibrationPoint: vi.fn(), From 81b0e08cabf380aaef0adea722cc2da14884991c Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Wed, 14 Oct 2026 15:57:56 -0300 Subject: [PATCH 3/5] dotbot/calibration: drop the session's reads per point Breaking: the calibration session, its preview and its start request no longer carry `reads`, and a point's per-station reads no longer carry a `target`. The calibrate app decides how many reads a press holds. AI-assisted: Claude Opus 5.5 --- dotbot/calibration/driver.py | 8 ++------ dotbot/calibration/ota.py | 1 - dotbot/calibration/session.py | 11 +---------- dotbot/models.py | 12 ++---------- dotbot/server.py | 2 +- dotbot/tests/test_calibration_session.py | 19 ++++++++++++------- 6 files changed, 18 insertions(+), 35 deletions(-) diff --git a/dotbot/calibration/driver.py b/dotbot/calibration/driver.py index 02e2e7bb..43ec1d32 100644 --- a/dotbot/calibration/driver.py +++ b/dotbot/calibration/driver.py @@ -15,7 +15,6 @@ import asyncio from typing import Any, Callable, Sequence -from dotbot.calibration.ota import CAPTURE_READS_DEFAULT from dotbot.calibration.points import field_corners, resolve_placement_points from dotbot.calibration.push import PushRefused, gate_push, push_worklist from dotbot.calibration.session import ( @@ -78,8 +77,7 @@ def preview(self, specs: Sequence[str]) -> dict: "points": [ placement_dict(index, placement) for index, placement in enumerate(placements) - ], - "reads": CAPTURE_READS_DEFAULT, + ] } async def start( @@ -87,13 +85,11 @@ async def start( specs: Sequence[str], device: str = "", area: str = "", - reads: int | None = None, ) -> dict: """Resolve the points and open a session with point 0 outstanding.""" async with self._lock: self._close_stream() - extra = {} if reads is None else {"reads": reads} - session = CalibrationSession.resolve(list(specs), site=self.site, **extra) + session = CalibrationSession.resolve(list(specs), site=self.site) session.device = device.upper() session.area = area self.session = session diff --git a/dotbot/calibration/ota.py b/dotbot/calibration/ota.py index 83850c0e..c20ec6df 100644 --- a/dotbot/calibration/ota.py +++ b/dotbot/calibration/ota.py @@ -25,7 +25,6 @@ from dotbot.calibration.lighthouse2 import LH_PERIODS, LH2CalibrationSample, Sample from dotbot.calibration.points import CORNERS # noqa: F401 - the capture order -CAPTURE_READS_DEFAULT = 25 # Each raw sample inside the LOG payload is [lh_index:1][count1:4 LE][count2:4 LE]. _SAMPLE_SIZE = 9 diff --git a/dotbot/calibration/session.py b/dotbot/calibration/session.py index 765df240..4d13ae6e 100644 --- a/dotbot/calibration/session.py +++ b/dotbot/calibration/session.py @@ -30,7 +30,6 @@ read_calibration_file, ) from dotbot.calibration.ota import ( - CAPTURE_READS_DEFAULT, PointCapture, samples_from_reads, ) @@ -63,7 +62,6 @@ class SessionPoint: device: str = "" # Reads received per station while this point's capture is running. reads_per_station: dict[int, int] = field(default_factory=dict) - reads_target: int = 0 @property def captured(self) -> bool: @@ -86,7 +84,6 @@ class CalibrationSession: area: str = "" device: str = "" robot: str = ROBOT_DEFAULT - reads: int = CAPTURE_READS_DEFAULT tag: str = "" stations: list[StationSolution] = field(default_factory=list) unsolved: list[tuple[int, int]] = field(default_factory=list) @@ -201,7 +198,6 @@ def redo(self) -> SessionPoint: point.capture = None point.device = "" point.reads_per_station = {} - point.reads_target = 0 self.stations = [] self.saved_path = None self.saved_id = "" @@ -270,7 +266,6 @@ def as_dict(self) -> dict: "site": self.site.name, "area": self.area, "device": self.device, - "reads": self.reads, "status": self.status, "outstanding": None if self.complete else self.outstanding.index, "captured": self.captured_count, @@ -333,11 +328,7 @@ def _point_dict(point: SessionPoint) -> dict: **placement_dict(point.index, point.placement), "captured": point.captured, "reads": [ - { - "station": station, - "reads": count, - "target": point.reads_target or count, - } + {"station": station, "reads": count} for station, count in sorted(point.reads_per_station.items()) ], "dropped": (point.capture.drop_count if point.capture else 0), diff --git a/dotbot/models.py b/dotbot/models.py index 911ccda5..03a45e33 100644 --- a/dotbot/models.py +++ b/dotbot/models.py @@ -383,7 +383,6 @@ class DotBotCalibrationReadsModel(BaseModel): station: int reads: int - target: int class DotBotCalibrationPointModel(BaseModel): @@ -430,7 +429,6 @@ class DotBotCalibrationSessionModel(BaseModel): # The area the expected error is evaluated over; empty means none chosen. area: str = "" device: str = "" - reads: int = 0 status: str = "collecting" outstanding: Optional[int] = None captured: int = 0 @@ -448,24 +446,18 @@ class DotBotCalibrationStartModel(BaseModel): """Where this session's points are, in `--points` form. Empty `points` means the site's field corners. `area` names the area the - expected error is evaluated over; empty means none chosen. `reads` is - captures averaged per point; None takes the session's own default. + expected error is evaluated over; empty means none chosen. """ points: Union[str, List[str]] = [] device: str = "" area: str = "" - reads: Optional[int] = None class DotBotCalibrationPreviewModel(BaseModel): - """What a session over one `--points` specification would open on. - - `reads` is the captures-per-point a start with no `reads` would use. - """ + """What a session over one `--points` specification would open on.""" points: List[DotBotCalibrationPointModel] = [] - reads: int = 0 class DotBotCalibrationSaveModel(BaseModel): diff --git a/dotbot/server.py b/dotbot/server.py index 15b8f89f..4972fe7e 100644 --- a/dotbot/server.py +++ b/dotbot/server.py @@ -642,7 +642,7 @@ async def calibration_session_start(request: DotBotCalibrationStartModel): specs = ([points] if points else []) if isinstance(points, str) else list(points) return await _calibration( api.controller.calibration_session.start( - specs, request.device, request.area, request.reads + specs, request.device, request.area ) ) diff --git a/dotbot/tests/test_calibration_session.py b/dotbot/tests/test_calibration_session.py index 657d67a4..f4fa56f4 100644 --- a/dotbot/tests/test_calibration_session.py +++ b/dotbot/tests/test_calibration_session.py @@ -236,7 +236,7 @@ async def test_a_capture_stores_the_reads_and_advances_to_the_next_point(): assert state["captured"] == 1 assert state["device"] == "ABCD" assert state["points"][0]["captured"] is True - assert state["points"][0]["reads"] == [{"station": 0, "reads": 25, "target": 25}] + assert state["points"][0]["reads"] == [{"station": 0, "reads": 25}] assert state["points"][1]["captured"] is False @@ -316,7 +316,7 @@ async def test_a_capture_arriving_while_point_k_is_outstanding_is_point_k(): state = driver.state() assert state["outstanding"] == 2 assert state["points"][1]["captured"] is True - assert state["points"][1]["reads"] == [{"station": 0, "reads": 1, "target": 1}] + assert state["points"][1]["reads"] == [{"station": 0, "reads": 1}] @pytest.mark.asyncio @@ -725,7 +725,6 @@ async def test_a_preview_resolves_the_same_points_a_start_would(rest): (p["x"], p["y"]) for p in started["points"] ] assert [p["corner"] for p in preview["points"]] == list(CORNERS) - assert preview["reads"] == started["reads"] @pytest.mark.asyncio @@ -758,15 +757,21 @@ async def test_a_preview_of_points_no_area_answers_to_is_refused(rest): @pytest.mark.asyncio -async def test_a_session_takes_the_reads_per_point_it_is_started_with(rest): +async def test_a_session_and_its_preview_carry_no_reads_per_point(rest): + """The calibrate app sets the reads of a press, so neither names a count.""" http, _, _ = rest + preview = ( + await http.get( + "/controller/calibration/session/preview", + params={"points": "arena:corners"}, + ) + ).json() body = ( await http.post( - "/controller/calibration/session", - json={"points": ["arena:corners"], "reads": 40}, + "/controller/calibration/session", json={"points": ["arena:corners"]} ) ).json() - assert body["reads"] == 40 + assert "reads" not in preview and "reads" not in body # --- rehearsing without a fleet --------------------------------------------- From a63d3aa5fcc0e596eb1a77917838f12ad9f67790 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Wed, 14 Oct 2026 15:57:56 -0300 Subject: [PATCH 4/5] dotbot/console-web: drop the Reads per point setting and the read bars AI-assisted: Claude Opus 5.5 --- dotbot/console-web/src/SetupCard.tsx | 27 ++---------- dotbot/console-web/src/StepCard.tsx | 42 ------------------- dotbot/console-web/src/api.ts | 3 +- dotbot/console-web/src/calibration.test.ts | 14 ------- dotbot/console-web/src/calibration.ts | 5 --- .../console-web/src/calibrationSetup.test.ts | 16 ------- dotbot/console-web/src/calibrationSetup.ts | 9 +--- .../console-web/src/calibrationTab.test.tsx | 3 +- dotbot/console-web/src/goKey.test.tsx | 2 +- .../src/heldWaypointsList.test.tsx | 2 +- dotbot/console-web/src/localization.test.ts | 1 - .../console-web/src/mrtaVisibility.test.tsx | 2 +- dotbot/console-web/src/panelKeys.test.tsx | 2 +- dotbot/console-web/src/poseQueue.test.tsx | 2 +- dotbot/console-web/src/redoMission.test.tsx | 2 +- .../src/rightPaneCollapsed.test.tsx | 1 - dotbot/console-web/src/savedView.test.tsx | 2 +- dotbot/console-web/src/setupCard.test.tsx | 34 +++------------ .../console-web/src/shortcutsPanel.test.tsx | 2 +- dotbot/console-web/src/spreadMany.test.tsx | 2 +- dotbot/console-web/src/spreadMission.test.tsx | 2 +- dotbot/console-web/src/testbedKeys.test.tsx | 2 +- dotbot/console-web/src/types.ts | 6 +-- dotbot/console-web/src/unknownRobots.test.tsx | 2 +- dotbot/console-web/src/useCalibration.ts | 11 ++--- dotbot/console-web/src/zoomAgreement.test.tsx | 2 +- 26 files changed, 29 insertions(+), 169 deletions(-) diff --git a/dotbot/console-web/src/SetupCard.tsx b/dotbot/console-web/src/SetupCard.tsx index 281b82a6..5290048e 100644 --- a/dotbot/console-web/src/SetupCard.tsx +++ b/dotbot/console-web/src/SetupCard.tsx @@ -5,15 +5,13 @@ import { TYPED_RECT, areaChoices, defaultChoice, - parseReads, pointsSpec, } from "./calibrationSetup"; import type { CalibrationPoint, Site } from "./types"; import type { Calibration } from "./useCalibration"; // The Calibrate tab before a session exists: which rectangle the four corner -// marks come from, how many reads each point averages, and the four points -// themselves in capture order. +// marks come from, and the four points themselves in capture order. // // The points are resolved by the controller rather than here, so what the // operator checks against the floor is what the session will then ask for, @@ -56,7 +54,6 @@ export const SetupCard: React.FC = ({ const names = areaChoices(site); const [choice, setChoice] = useState(() => defaultChoice(site)); const [rect, setRect] = useState(""); - const [reads, setReads] = useState(""); const [points, setPoints] = useState([]); const [refused, setRefused] = useState(""); @@ -83,9 +80,6 @@ export const SetupCard: React.FC = ({ if (!live) return; setPoints(preview.points); setRefused(""); - // The controller owns the default, so the field is seeded from it - // rather than from a second copy of the number here. - setReads((current) => current || String(preview.reads)); }) .catch((e: unknown) => { if (!live) return; @@ -97,8 +91,7 @@ export const SetupCard: React.FC = ({ }; }, [spec]); - const readsValue = parseReads(reads); - const ready = spec !== null && readsValue !== null && !calibration.busy; + const ready = spec !== null && !calibration.busy; const message = refused || calibration.error; return ( @@ -147,20 +140,6 @@ export const SetupCard: React.FC = ({ )} -
-
Reads per point
- setReads(e.target.value)} - inputMode="numeric" - style={field} - /> -
- A single read costs about 60 % of the accuracy at every point. -
-
-
Points
{points.length === 0 && ( @@ -198,7 +177,7 @@ export const SetupCard: React.FC = ({ // A typed rectangle names no area, so the expected error it would // be evaluated over is left unchosen rather than named after it. const area = choice === TYPED_RECT ? "" : choice; - calibration.start([spec!], area, "", readsValue!); + calibration.start([spec!], area); }} style={{ display: "flex", diff --git a/dotbot/console-web/src/StepCard.tsx b/dotbot/console-web/src/StepCard.tsx index cfe35fe7..eafcb9c5 100644 --- a/dotbot/console-web/src/StepCard.tsx +++ b/dotbot/console-web/src/StepCard.tsx @@ -6,7 +6,6 @@ import { expectedErrorLine, isComplete, placementInstruction, - readFraction, residualLines, stepLabel, } from "./calibration"; @@ -50,39 +49,6 @@ function button(kind: "accent" | "plain", disabled: boolean): React.CSSPropertie }; } -const ReadBar: React.FC<{ station: number; reads: number; target: number }> = ({ - station, - reads, - target, -}) => ( -
- - station {station} - -
-
-
- - {reads}/{target} - -
-); - interface StepCardProps { session: CalibrationSession; calibration: Calibration; @@ -147,14 +113,6 @@ export const StepCard: React.FC = ({ last capture from {session.device}
)} - {point && point.reads.length > 0 && ( -
-
Reads per station
- {point.reads.map((r) => ( - - ))} -
- )}
diff --git a/dotbot/console-web/src/api.ts b/dotbot/console-web/src/api.ts index 55226a58..d5a1b8d6 100644 --- a/dotbot/console-web/src/api.ts +++ b/dotbot/console-web/src/api.ts @@ -203,11 +203,10 @@ export async function startCalibration( points: string[], device = "", area = "", - reads?: number, ): Promise { return controllerJson(SESSION, { method: "POST", - body: { points, device, area, ...(reads === undefined ? {} : { reads }) }, + body: { points, device, area }, }); } diff --git a/dotbot/console-web/src/calibration.test.ts b/dotbot/console-web/src/calibration.test.ts index 7e1104f0..d70622a5 100644 --- a/dotbot/console-web/src/calibration.test.ts +++ b/dotbot/console-web/src/calibration.test.ts @@ -13,7 +13,6 @@ import { noseRotation, outstandingIndex, placementInstruction, - readFraction, residualLines, sessionRect, stepLabel, @@ -53,7 +52,6 @@ const session = (over: Partial = {}): CalibrationSession => site: "c405-arena", area: "", device: "", - reads: 25, status: "collecting", outstanding: 0, captured: 0, @@ -166,18 +164,6 @@ describe("the step card's wording", () => { }); }); -describe("the read bars", () => { - it("fills in proportion to the target and never past it", () => { - expect(readFraction(0, 25)).toBe(0); - expect(readFraction(18, 25)).toBeCloseTo(0.72); - expect(readFraction(30, 25)).toBe(1); - }); - - it("stays empty when no target is known yet", () => { - expect(readFraction(3, 0)).toBe(0); - }); -}); - describe("the expected-error line", () => { it("is absent while the controller sends no number", () => { // The predictor is a later phase, so the line is not shown rather than diff --git a/dotbot/console-web/src/calibration.ts b/dotbot/console-web/src/calibration.ts index 43d2d7f3..85d34e1a 100644 --- a/dotbot/console-web/src/calibration.ts +++ b/dotbot/console-web/src/calibration.ts @@ -102,11 +102,6 @@ export function currentPoint(session: CalibrationSession): CalibrationPoint | nu return index === null ? null : session.points[index]; } -/** How far one station's reads have got, as a fraction of the target. */ -export function readFraction(reads: number, target: number): number { - if (target <= 0) return 0; - return Math.max(0, Math.min(1, reads / target)); -} /** * The expected-error line, or null. The predictor is a later phase, so the diff --git a/dotbot/console-web/src/calibrationSetup.test.ts b/dotbot/console-web/src/calibrationSetup.test.ts index 11c82370..f2ed482c 100644 --- a/dotbot/console-web/src/calibrationSetup.test.ts +++ b/dotbot/console-web/src/calibrationSetup.test.ts @@ -4,7 +4,6 @@ import { TYPED_RECT, areaChoices, defaultChoice, - parseReads, parseRect, pointsSpec, } from "./calibrationSetup"; @@ -96,18 +95,3 @@ describe("the specification Start sends", () => { expect(pointsSpec(TYPED_RECT, "750,750")).toBeNull(); }); }); - -describe("reads per point", () => { - it("takes a whole count of at least one", () => { - expect(parseReads("25")).toBe(25); - expect(parseReads(" 1 ")).toBe(1); - }); - - it("refuses anything that is not one", () => { - expect(parseReads("")).toBeNull(); - expect(parseReads("0")).toBeNull(); - expect(parseReads("-5")).toBeNull(); - expect(parseReads("2.5")).toBeNull(); - expect(parseReads("lots")).toBeNull(); - }); -}); diff --git a/dotbot/console-web/src/calibrationSetup.ts b/dotbot/console-web/src/calibrationSetup.ts index 5af8c807..1f1de24e 100644 --- a/dotbot/console-web/src/calibrationSetup.ts +++ b/dotbot/console-web/src/calibrationSetup.ts @@ -1,8 +1,7 @@ import type { Area, Site } from "./types"; // What the Calibrate tab asks for before a session exists: which rectangle -// the four corner marks are taken from, and how many reads each point -// averages. +// the four corner marks are taken from. // // The rectangle is named or typed. A name is one of the site's areas; a typed // rectangle is `x,y,w,h` in frame millimetres, which is the same literal the @@ -43,9 +42,3 @@ export function pointsSpec(choice: string, rect: string): string | null { return parsed === null ? null : `${parsed.name}:corners`; } -/** A reads-per-point entry the controller will take, or null while it is not one. */ -export function parseReads(text: string): number | null { - if (!/^\d+$/.test(text.trim())) return null; - const n = Number(text.trim()); - return n >= 1 ? n : null; -} diff --git a/dotbot/console-web/src/calibrationTab.test.tsx b/dotbot/console-web/src/calibrationTab.test.tsx index c99c16a0..ea54dd64 100644 --- a/dotbot/console-web/src/calibrationTab.test.tsx +++ b/dotbot/console-web/src/calibrationTab.test.tsx @@ -54,7 +54,7 @@ vi.mock("./api", () => ({ putWaypointBatches: vi.fn(async () => {}), clearWaypoints: vi.fn(async () => {}), abandonCalibration: vi.fn(async () => {}), - previewCalibrationPoints: vi.fn(async () => ({ points: [], reads: 25 })), + previewCalibrationPoints: vi.fn(async () => ({ points: [] })), pushCalibration: vi.fn(), redoCalibrationPoint: vi.fn(), saveCalibration: vi.fn(), @@ -80,7 +80,6 @@ const SESSION: CalibrationSession = { site: "c405-arena", area: "arena", device: "", - reads: 25, status: "collecting", outstanding: 0, captured: 0, diff --git a/dotbot/console-web/src/goKey.test.tsx b/dotbot/console-web/src/goKey.test.tsx index f8364e0d..f5b3b0b8 100644 --- a/dotbot/console-web/src/goKey.test.tsx +++ b/dotbot/console-web/src/goKey.test.tsx @@ -81,7 +81,7 @@ vi.mock("./api", () => ({ putWaypointBatches: vi.fn(async () => {}), clearWaypoints: vi.fn(async () => {}), abandonCalibration: vi.fn(async () => {}), - previewCalibrationPoints: vi.fn(async () => ({ points: [], reads: 25 })), + previewCalibrationPoints: vi.fn(async () => ({ points: [] })), pushCalibration: vi.fn(), redoCalibrationPoint: vi.fn(), saveCalibration: vi.fn(), diff --git a/dotbot/console-web/src/heldWaypointsList.test.tsx b/dotbot/console-web/src/heldWaypointsList.test.tsx index ff3e0e03..81149b9e 100644 --- a/dotbot/console-web/src/heldWaypointsList.test.tsx +++ b/dotbot/console-web/src/heldWaypointsList.test.tsx @@ -87,7 +87,7 @@ vi.mock("./api", () => ({ putWaypointBatches: vi.fn(async () => {}), clearWaypoints: vi.fn(async () => {}), abandonCalibration: vi.fn(async () => {}), - previewCalibrationPoints: vi.fn(async () => ({ points: [], reads: 25 })), + previewCalibrationPoints: vi.fn(async () => ({ points: [] })), pushCalibration: vi.fn(), redoCalibrationPoint: vi.fn(), saveCalibration: vi.fn(), diff --git a/dotbot/console-web/src/localization.test.ts b/dotbot/console-web/src/localization.test.ts index 8293d15a..89340618 100644 --- a/dotbot/console-web/src/localization.test.ts +++ b/dotbot/console-web/src/localization.test.ts @@ -65,7 +65,6 @@ const session = (over: Partial = {}): CalibrationSession => site: "c405-arena", area: "", device: "", - reads: 25, status: "collecting", outstanding: null, captured: 4, diff --git a/dotbot/console-web/src/mrtaVisibility.test.tsx b/dotbot/console-web/src/mrtaVisibility.test.tsx index 15caef02..ca35f418 100644 --- a/dotbot/console-web/src/mrtaVisibility.test.tsx +++ b/dotbot/console-web/src/mrtaVisibility.test.tsx @@ -85,7 +85,7 @@ vi.mock("./api", () => ({ putWaypointBatches: vi.fn(async () => {}), clearWaypoints: vi.fn(async () => {}), abandonCalibration: vi.fn(async () => {}), - previewCalibrationPoints: vi.fn(async () => ({ points: [], reads: 25 })), + previewCalibrationPoints: vi.fn(async () => ({ points: [] })), pushCalibration: vi.fn(), redoCalibrationPoint: vi.fn(), saveCalibration: vi.fn(), diff --git a/dotbot/console-web/src/panelKeys.test.tsx b/dotbot/console-web/src/panelKeys.test.tsx index e58a06a9..a202b1dd 100644 --- a/dotbot/console-web/src/panelKeys.test.tsx +++ b/dotbot/console-web/src/panelKeys.test.tsx @@ -71,7 +71,7 @@ vi.mock("./api", () => ({ putWaypointBatches: vi.fn(async () => {}), clearWaypoints: vi.fn(async () => {}), abandonCalibration: vi.fn(async () => {}), - previewCalibrationPoints: vi.fn(async () => ({ points: [], reads: 25 })), + previewCalibrationPoints: vi.fn(async () => ({ points: [] })), pushCalibration: vi.fn(), redoCalibrationPoint: vi.fn(), saveCalibration: vi.fn(), diff --git a/dotbot/console-web/src/poseQueue.test.tsx b/dotbot/console-web/src/poseQueue.test.tsx index 13dbf2f2..e3ef85f1 100644 --- a/dotbot/console-web/src/poseQueue.test.tsx +++ b/dotbot/console-web/src/poseQueue.test.tsx @@ -90,7 +90,7 @@ vi.mock("./api", () => ({ putWaypointBatches: vi.fn(async () => {}), clearWaypoints: vi.fn(async () => {}), abandonCalibration: vi.fn(async () => {}), - previewCalibrationPoints: vi.fn(async () => ({ points: [], reads: 25 })), + previewCalibrationPoints: vi.fn(async () => ({ points: [] })), pushCalibration: vi.fn(), redoCalibrationPoint: vi.fn(), saveCalibration: vi.fn(), diff --git a/dotbot/console-web/src/redoMission.test.tsx b/dotbot/console-web/src/redoMission.test.tsx index 87c3c436..a8c9d0b9 100644 --- a/dotbot/console-web/src/redoMission.test.tsx +++ b/dotbot/console-web/src/redoMission.test.tsx @@ -91,7 +91,7 @@ vi.mock("./api", () => ({ putWaypointBatches: vi.fn(async () => {}), clearWaypoints: vi.fn(async () => {}), abandonCalibration: vi.fn(async () => {}), - previewCalibrationPoints: vi.fn(async () => ({ points: [], reads: 25 })), + previewCalibrationPoints: vi.fn(async () => ({ points: [] })), pushCalibration: vi.fn(), redoCalibrationPoint: vi.fn(), saveCalibration: vi.fn(), diff --git a/dotbot/console-web/src/rightPaneCollapsed.test.tsx b/dotbot/console-web/src/rightPaneCollapsed.test.tsx index ef6f1076..95b9e5fb 100644 --- a/dotbot/console-web/src/rightPaneCollapsed.test.tsx +++ b/dotbot/console-web/src/rightPaneCollapsed.test.tsx @@ -29,7 +29,6 @@ const SESSION = { site: "c405-arena", area: "arena", device: "", - reads: 25, status: "collecting", outstanding: 0, captured: 0, diff --git a/dotbot/console-web/src/savedView.test.tsx b/dotbot/console-web/src/savedView.test.tsx index 2b483027..926b709a 100644 --- a/dotbot/console-web/src/savedView.test.tsx +++ b/dotbot/console-web/src/savedView.test.tsx @@ -79,7 +79,7 @@ vi.mock("./api", () => ({ putWaypointBatches: vi.fn(async () => {}), clearWaypoints: vi.fn(async () => {}), abandonCalibration: vi.fn(async () => {}), - previewCalibrationPoints: vi.fn(async () => ({ points: [], reads: 25 })), + previewCalibrationPoints: vi.fn(async () => ({ points: [] })), pushCalibration: vi.fn(), redoCalibrationPoint: vi.fn(), saveCalibration: vi.fn(), diff --git a/dotbot/console-web/src/setupCard.test.tsx b/dotbot/console-web/src/setupCard.test.tsx index 0918b177..530b0949 100644 --- a/dotbot/console-web/src/setupCard.test.tsx +++ b/dotbot/console-web/src/setupCard.test.tsx @@ -40,7 +40,6 @@ const point = (index: number, corner: string, x: number, y: number): Calibration }); const CORNERS: CalibrationPreview = { - reads: 25, points: [ point(0, "top-left", 47, 18.5), point(1, "top-right", 3283, 18.5), @@ -90,38 +89,24 @@ describe("the Calibrate tab before a session", () => { expect(screen.getByText("3283, 1982 mm")).toBeInTheDocument(); }); - it("seeds reads per point from the controller's own default", async () => { - render(); - await waitFor(() => - expect(screen.getByLabelText("Reads per point")).toHaveValue("25"), - ); - }); - - it("starts a session over the chosen area and its reads", async () => { + it("starts a session over the chosen area", async () => { const cal = calibration(); render(); - await waitFor(() => - expect(screen.getByLabelText("Reads per point")).toHaveValue("25"), - ); + await waitFor(() => expect(preview).toHaveBeenCalled()); fireEvent.change(screen.getByLabelText("Rectangle"), { target: { value: "annex" }, }); await waitFor(() => expect(preview).toHaveBeenCalledWith(["annex:corners"])); - fireEvent.change(screen.getByLabelText("Reads per point"), { - target: { value: "40" }, - }); fireEvent.click(screen.getByText("Start")); - expect(cal.start).toHaveBeenCalledWith(["annex:corners"], "annex", "", 40); + expect(cal.start).toHaveBeenCalledWith(["annex:corners"], "annex"); }); it("starts a session over a typed rectangle, which names no area", async () => { const cal = calibration(); render(); - await waitFor(() => - expect(screen.getByLabelText("Reads per point")).toHaveValue("25"), - ); + await waitFor(() => expect(preview).toHaveBeenCalled()); fireEvent.change(screen.getByLabelText("Rectangle"), { target: { value: "typed" }, @@ -134,20 +119,13 @@ describe("the Calibrate tab before a session", () => { ); fireEvent.click(screen.getByText("Start")); - expect(cal.start).toHaveBeenCalledWith( - ["750,750,500,500:corners"], - "", - "", - 25, - ); + expect(cal.start).toHaveBeenCalledWith(["750,750,500,500:corners"], ""); }); it("does not start while the typed rectangle is incomplete", async () => { const cal = calibration(); render(); - await waitFor(() => - expect(screen.getByLabelText("Reads per point")).toHaveValue("25"), - ); + await waitFor(() => expect(preview).toHaveBeenCalled()); fireEvent.change(screen.getByLabelText("Rectangle"), { target: { value: "typed" }, diff --git a/dotbot/console-web/src/shortcutsPanel.test.tsx b/dotbot/console-web/src/shortcutsPanel.test.tsx index 67d77f6b..034d76c1 100644 --- a/dotbot/console-web/src/shortcutsPanel.test.tsx +++ b/dotbot/console-web/src/shortcutsPanel.test.tsx @@ -70,7 +70,7 @@ vi.mock("./api", () => ({ putWaypointBatches: vi.fn(async () => {}), clearWaypoints: vi.fn(async () => {}), abandonCalibration: vi.fn(async () => {}), - previewCalibrationPoints: vi.fn(async () => ({ points: [], reads: 25 })), + previewCalibrationPoints: vi.fn(async () => ({ points: [] })), pushCalibration: vi.fn(), redoCalibrationPoint: vi.fn(), saveCalibration: vi.fn(), diff --git a/dotbot/console-web/src/spreadMany.test.tsx b/dotbot/console-web/src/spreadMany.test.tsx index c671e4a2..4672921a 100644 --- a/dotbot/console-web/src/spreadMany.test.tsx +++ b/dotbot/console-web/src/spreadMany.test.tsx @@ -78,7 +78,7 @@ vi.mock("./api", () => ({ putWaypointBatches: vi.fn(async () => {}), clearWaypoints: vi.fn(async () => {}), abandonCalibration: vi.fn(async () => {}), - previewCalibrationPoints: vi.fn(async () => ({ points: [], reads: 25 })), + previewCalibrationPoints: vi.fn(async () => ({ points: [] })), pushCalibration: vi.fn(), redoCalibrationPoint: vi.fn(), saveCalibration: vi.fn(), diff --git a/dotbot/console-web/src/spreadMission.test.tsx b/dotbot/console-web/src/spreadMission.test.tsx index 1d167734..0783f177 100644 --- a/dotbot/console-web/src/spreadMission.test.tsx +++ b/dotbot/console-web/src/spreadMission.test.tsx @@ -75,7 +75,7 @@ vi.mock("./api", () => ({ putWaypointBatches: vi.fn(async () => {}), clearWaypoints: vi.fn(async () => {}), abandonCalibration: vi.fn(async () => {}), - previewCalibrationPoints: vi.fn(async () => ({ points: [], reads: 25 })), + previewCalibrationPoints: vi.fn(async () => ({ points: [] })), pushCalibration: vi.fn(), redoCalibrationPoint: vi.fn(), saveCalibration: vi.fn(), diff --git a/dotbot/console-web/src/testbedKeys.test.tsx b/dotbot/console-web/src/testbedKeys.test.tsx index 8287742b..b560b11c 100644 --- a/dotbot/console-web/src/testbedKeys.test.tsx +++ b/dotbot/console-web/src/testbedKeys.test.tsx @@ -75,7 +75,7 @@ vi.mock("./api", () => ({ putWaypointBatches: vi.fn(async () => {}), clearWaypoints: vi.fn(async () => {}), abandonCalibration: vi.fn(async () => {}), - previewCalibrationPoints: vi.fn(async () => ({ points: [], reads: 25 })), + previewCalibrationPoints: vi.fn(async () => ({ points: [] })), pushCalibration: vi.fn(), redoCalibrationPoint: vi.fn(), saveCalibration: vi.fn(), diff --git a/dotbot/console-web/src/types.ts b/dotbot/console-web/src/types.ts index a15405f6..03eb27e7 100644 --- a/dotbot/console-web/src/types.ts +++ b/dotbot/console-web/src/types.ts @@ -190,7 +190,6 @@ export interface CameraDetection { export interface CalibrationReads { station: number; reads: number; - target: number; } export interface CalibrationPoint { @@ -219,7 +218,6 @@ export interface CalibrationSession { site: string; area: string; // the area the expected error is for; "" means none chosen device: string; - reads: number; status: string; // collecting | solved | saved outstanding: number | null; // null once every point is captured captured: number; @@ -236,11 +234,9 @@ export interface CalibrationSession { } // What a session over one points specification would open on, resolved by the -// controller without opening one. `reads` is the captures-per-point a start -// that names none would use. +// controller without opening one. export interface CalibrationPreview { points: CalibrationPoint[]; - reads: number; } export interface CalibrationSaved { diff --git a/dotbot/console-web/src/unknownRobots.test.tsx b/dotbot/console-web/src/unknownRobots.test.tsx index b592f7a4..73c6f77c 100644 --- a/dotbot/console-web/src/unknownRobots.test.tsx +++ b/dotbot/console-web/src/unknownRobots.test.tsx @@ -76,7 +76,7 @@ vi.mock("./api", () => ({ putWaypointBatches: vi.fn(), clearWaypoints: vi.fn(), abandonCalibration: vi.fn(async () => {}), - previewCalibrationPoints: vi.fn(async () => ({ points: [], reads: 25 })), + previewCalibrationPoints: vi.fn(async () => ({ points: [] })), pushCalibration: vi.fn(), redoCalibrationPoint: vi.fn(), saveCalibration: vi.fn(), diff --git a/dotbot/console-web/src/useCalibration.ts b/dotbot/console-web/src/useCalibration.ts index de80448c..34831ad9 100644 --- a/dotbot/console-web/src/useCalibration.ts +++ b/dotbot/console-web/src/useCalibration.ts @@ -18,12 +18,7 @@ export interface Calibration { busy: boolean; error: string; pushed: string; - start: ( - points: string[], - area?: string, - device?: string, - reads?: number, - ) => Promise; + start: (points: string[], area?: string, device?: string) => Promise; redo: () => Promise; save: (tag?: string) => Promise; push: (stale?: string[]) => Promise; @@ -59,10 +54,10 @@ export function useCalibration( busy, error, pushed, - start: (points, area = "", device = "", reads) => + start: (points, area = "", device = "") => run(async () => { setPushed(""); - onSession(await startCalibration(points, device, area, reads)); + onSession(await startCalibration(points, device, area)); }), redo: () => run(async () => onSession(await redoCalibrationPoint())), save: (tag = "") => diff --git a/dotbot/console-web/src/zoomAgreement.test.tsx b/dotbot/console-web/src/zoomAgreement.test.tsx index 731bb662..f91f89c5 100644 --- a/dotbot/console-web/src/zoomAgreement.test.tsx +++ b/dotbot/console-web/src/zoomAgreement.test.tsx @@ -67,7 +67,7 @@ vi.mock("./api", () => ({ putWaypointBatches: vi.fn(async () => {}), clearWaypoints: vi.fn(async () => {}), abandonCalibration: vi.fn(async () => {}), - previewCalibrationPoints: vi.fn(async () => ({ points: [], reads: 25 })), + previewCalibrationPoints: vi.fn(async () => ({ points: [] })), pushCalibration: vi.fn(), redoCalibrationPoint: vi.fn(), saveCalibration: vi.fn(), From 130b88e8ee65b66d15b88bc5ab4c47f1dc73433a Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Wed, 14 Oct 2026 17:12:25 -0300 Subject: [PATCH 5/5] dotbot/calibration: refuse a session start that still carries reads AI-assisted: Claude Opus 5.5 --- dotbot/models.py | 11 ++++++++++- dotbot/tests/test_calibration_session.py | 11 +++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/dotbot/models.py b/dotbot/models.py index 03a45e33..c780d25c 100644 --- a/dotbot/models.py +++ b/dotbot/models.py @@ -12,7 +12,7 @@ from enum import IntEnum from typing import Annotated, Any, Dict, List, Literal, Optional, Union -from pydantic import BaseModel, BeforeValidator, Field, field_validator +from pydantic import BaseModel, BeforeValidator, Field, field_validator, model_validator from dotbot.area import Area, Role from dotbot.calibration.points import PointsKind @@ -453,6 +453,15 @@ class DotBotCalibrationStartModel(BaseModel): device: str = "" area: str = "" + @model_validator(mode="before") + @classmethod + def _refuse_reads(cls, data): + if isinstance(data, dict) and "reads" in data: + raise ValueError( + "`reads` was removed: the calibrate app sets its own read count" + ) + return data + class DotBotCalibrationPreviewModel(BaseModel): """What a session over one `--points` specification would open on.""" diff --git a/dotbot/tests/test_calibration_session.py b/dotbot/tests/test_calibration_session.py index f4fa56f4..128636c1 100644 --- a/dotbot/tests/test_calibration_session.py +++ b/dotbot/tests/test_calibration_session.py @@ -660,6 +660,17 @@ async def test_a_start_with_no_points_opens_on_the_sites_field(rest): assert body["points"][0]["where"] == "top-left corner of arena" +@pytest.mark.asyncio +async def test_a_start_that_carries_reads_is_refused(rest): + http, controller, _ = rest + response = await http.post( + "/controller/calibration/session", json={"points": "arena:corners", "reads": 5} + ) + assert response.status_code == 422 + assert "`reads` was removed" in response.text + assert controller.calibration_session.state() is None + + @pytest.mark.asyncio async def test_a_route_that_needs_a_session_refuses_without_one(rest): http, _, _ = rest