Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ Milestone M2 of `docs/design/fleet.md`; the operator flow is `docs/fleet/README.

## Fleet: the operator view + dispatch API (M3)

Milestone M3 of `docs/design/fleet.md`, and the end of v0. The **HTTP** wire is specified as its own versioned contract in **`docs/fleet/fleet-api.md`** (M1's MQTT one is `control-plane.md`); the operator flow is `docs/fleet/README.md` §6–9 and the measurements are `m3-verification.md`. **The two directions of the loop take different paths on purpose.** *Reads* ride MQTT: the browser subscribes to `mote/v1/+/{presence,health,pose,task/status}` over WebSockets, and because all of those are retained it has the whole fleet's state within a second of loading — no polling, no service in the middle. *Writes* ride HTTP: `POST /v1/robots/<id>/dispatch` authorizes an operator token (`fleetctl operator new --name <you>`; the name is what the audit row records), writes the audit row, then publishes to the same `task/command` topic. **The topic tree did not change — only who publishes to it**, and `fleetctl dispatch` moved to the API too, so there is one write path rather than one per client. The command grammar is still parsed only by the robot's task layer: a parser in the server would be a second grammar to keep in step. **The browser cannot publish**: `server/ui/mqtt.mjs` is a hand-rolled subscribe-only MQTT 3.1.1 client that implements no PUBLISH packet, so the split is enforced by omission (M7 makes it structural with a subscribe-only broker credential). The UI is static ES modules — no bundler, no npm, no vendored library — served by the same stdlib `http.server`; `map.mjs` holds the Q5 world→pixel transform (`px = (wx-origin_x)/res`, `py = height - (wy-origin_y)/res`) and a pan/zoom/follow canvas, and only draws robots on the *same* site+floor as the selected one because a pose from another floor is a different map frame. **Basemaps come from site bundles on the fleet box** (`--maps-dir`, default `$MOTE_FLEET_HOME/sites`, the layout `sites.py` writes, seeded by rsync until **M4** makes the registry canonical behind the same two routes). **M1's websockets blocker is settled**: `pixi run fleet-broker` runs `eclipse-mosquitto` under docker with the repo's own `mosquitto.conf`, because conda-forge's build has none; `pixi run -e fleet fleet-broker-local` is the conda binary for a box without docker, and it strips the WS stanza and says so. Two things that run in the same file (`test_ui.py` → `ui_test.mjs`) are the MQTT codec and the transform, tested under node against the very files the browser loads; `browser_check.mjs` drives a real headless Chrome over CDP against a running stack and is an operator's tool, not a CI test. **The phone is the realistic off-LAN client**, so below 760 px the three panes become one at a time behind a bottom tab bar (`server/ui/layout.mjs`), selecting a robot in the roster navigates to the map — what the desktop layout gets for free by showing both — and the canvas gained pinch-to-zoom (`pinchSpan`/`pinchUpdate` in `map.mjs`, pure and tested, because a division by a zero span puts NaN in the view scale and blanks the map for good) plus a fingertip-sized hit target. The breakpoint is a **silent** seam — CSS decides what is displayed, JS decides when a selection navigates, and disagreement yields a tab bar over stacked panes rather than an error — so it lives in `layout.mjs` and `ui_test.mjs` reads the stylesheet and holds it there, as it does for every pane having a tab and for `touch-action: none` on the canvas (without which the browser eats the drag and the pinch before a single pointer event arrives). Dispatch gained a **zone picker that writes `goto <zone>` into the command box rather than sending it**: the grammar stays the robot's, and the keyboard leaves the common case. Three pre-existing bugs fell out, all of which a desk hides: `hidden` does not hide an element whose class sets `display` (the empty promote picker), the canvas backing store was resized on width alone so a height change left the previous frame's scale bar under the new one, and the scale bar was drawn in the dark theme's near-white on a white basemap — a canvas gets no cascade, so it now reads `--dim` off the element. Measurements, including `browser_check.mjs`'s phone pass, are `m3-verification.md` §9; **a real device is still the acceptance** — emulation gets the viewport and the touch points right and the thumb wrong.
Milestone M3 of `docs/design/fleet.md`, and the end of v0. The **HTTP** wire is specified as its own versioned contract in **`docs/fleet/fleet-api.md`** (M1's MQTT one is `control-plane.md`); the operator flow is `docs/fleet/README.md` §6–9 and the measurements are `m3-verification.md`. **The two directions of the loop take different paths on purpose.** *Reads* ride MQTT: the browser subscribes to `mote/v1/+/{presence,health,pose,task/status}` over WebSockets, and because all of those are retained it has the whole fleet's state within a second of loading — no polling, no service in the middle. *Writes* ride HTTP: `POST /v1/robots/<id>/dispatch` authorizes an operator token (`fleetctl operator new --name <you>`; the name is what the audit row records), writes the audit row, then publishes to the same `task/command` topic. **The topic tree did not change — only who publishes to it**, and `fleetctl dispatch` moved to the API too, so there is one write path rather than one per client. The command grammar is still parsed only by the robot's task layer: a parser in the server would be a second grammar to keep in step. **The browser cannot publish**: `server/ui/mqtt.mjs` is a hand-rolled subscribe-only MQTT 3.1.1 client that implements no PUBLISH packet, so the split is enforced by omission (M7 makes it structural with a subscribe-only broker credential). The UI is static ES modules — no bundler, no npm, no vendored library — served by the same stdlib `http.server`; `map.mjs` holds the Q5 world→pixel transform (`px = (wx-origin_x)/res`, `py = height - (wy-origin_y)/res`) and a pan/zoom/follow canvas, and only draws robots on the *same* site+floor as the selected one because a pose from another floor is a different map frame. **Basemaps come from site bundles on the fleet box** (`--maps-dir`, default `$MOTE_FLEET_HOME/sites`, the layout `sites.py` writes, seeded by rsync until **M4** makes the registry canonical behind the same two routes). **M1's websockets blocker is settled**: `pixi run fleet-broker` runs `eclipse-mosquitto` under docker with the repo's own `mosquitto.conf`, because conda-forge's build has none; `pixi run -e fleet fleet-broker-local` is the conda binary for a box without docker, and it strips the WS stanza and says so. Two things that run in the same file (`test_ui.py` → `ui_test.mjs`) are the MQTT codec and the transform, tested under node against the very files the browser loads; `browser_check.mjs` drives a real headless Chrome over CDP against a running stack and is an operator's tool, not a CI test — `pixi run fleet-ui-check` is that stack in one command (broker on ephemeral ports, server, a temp `MOTE_FLEET_HOME`, the sim's `office_world` bundle as the basemap, and `test/fake_robots.py`, which publishes `protocol.py` payloads and answers `task/command` and is *not* a second robot implementation), torn down afterwards; `-- --keep` leaves it up for UI work. It stays out of CI because it needs docker (conda's mosquitto still has no websockets) *and* a chrome, which the arm runner has not — the decision, and what wiring it in would take, are recorded in `m3-verification.md` §2 rather than left looking like coverage. **The phone is the realistic off-LAN client**, so below 760 px the three panes become one at a time behind a bottom tab bar (`server/ui/layout.mjs`), selecting a robot in the roster navigates to the map — what the desktop layout gets for free by showing both — and the canvas gained pinch-to-zoom (`pinchSpan`/`pinchUpdate` in `map.mjs`, pure and tested, because a division by a zero span puts NaN in the view scale and blanks the map for good) plus a fingertip-sized hit target. The breakpoint is a **silent** seam — CSS decides what is displayed, JS decides when a selection navigates, and disagreement yields a tab bar over stacked panes rather than an error — so it lives in `layout.mjs` and `ui_test.mjs` reads the stylesheet and holds it there, as it does for every pane having a tab and for `touch-action: none` on the canvas (without which the browser eats the drag and the pinch before a single pointer event arrives). Dispatch gained a **zone picker that writes `goto <zone>` into the command box rather than sending it**: the grammar stays the robot's, and the keyboard leaves the common case. Three pre-existing bugs fell out, all of which a desk hides: `hidden` does not hide an element whose class sets `display` (the empty promote picker), the canvas backing store was resized on width alone so a height change left the previous frame's scale bar under the new one, and the scale bar was drawn in the dark theme's near-white on a white basemap — a canvas gets no cascade, so it now reads `--dim` off the element. Measurements, including `browser_check.mjs`'s phone pass, are `m3-verification.md` §9; **a real device is still the acceptance** — emulation gets the viewport and the touch points right and the thumb wrong.

## Fleet: the map registry (M4)

Expand Down
9 changes: 9 additions & 0 deletions docs/fleet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,15 @@ a second command language for the fleet server to keep in step.

Between 760 and 1100 px the panes stack and scroll, as before.

**Working on the page itself?** `pixi run fleet-ui-check` builds a throwaway
fleet to point it at — a broker, a fleet server, a basemap and three robots that
exist only on the wire — runs the browser checks against it, and tears it all
down; `pixi run fleet-ui-check -- --keep` leaves it up and prints the URL and an
operator token instead. It uses ports and a state directory of its own, so it
runs beside the fleet you actually operate. Needs a docker and a chrome
([`m3-verification.md`](m3-verification.md) §2). The checks include the phone
layout above, so the emulated pass is one command too.

**What it does not do**, deliberately: no marker clustering, no basemap tiling,
no 3D, no camera, no teleop. The first two are what `fleet.md` Q5 describes for
large sites and would be unmeasured complexity at this fleet size; the last
Expand Down
119 changes: 99 additions & 20 deletions docs/fleet/m3-verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,38 +48,115 @@ instead, and serves robots and `fleetctl` unchanged. (Ms made the container the
default and folded the two tasks into one: that fallback is now
`pixi run -e fleet fleet-broker-local`.)

## 2. The operator view, in a real browser — **9/9 checks, off the ROS graph**
## 2. The operator view, in a real browser — **15/15 checks, off the ROS graph**

The dashboard was driven by headless Chrome over the DevTools protocol against a
live stack: the container broker, `fleet-server` (from the ROS-free `fleet`
environment), two enrolled robots, and an operator token.
The dashboard is driven by headless Chrome over the DevTools protocol against a
live stack: the container broker, `fleet-server`, a site bundle to draw on,
enrolled robots, and an operator token. **One command builds all of that, runs
the checks and takes it down again:**

```console
$ node mote_fleet/test/browser_check.mjs http://127.0.0.1:8088 <token>
$ pixi run fleet-ui-check
broker: eclipse-mosquitto:2.1-alpine on 52619 (mqtt) / 46001 (ws)
server: http://127.0.0.1:54489 (state in /tmp/mote-ui-check-qhaatfpk)
robots: mote-01, mote-02, mote-03 on office_world/ground
mote-03 is offline — the broker published its will

ok the browser connected to the broker over WebSockets — broker connected
ok the roster came from retained MQTT state — mote-01,mote-02
ok health states are rendered — ok,degraded
ok the roster came from retained MQTT state — mote-01,mote-02,mote-03
ok health states are rendered — ok,degraded,offline
ok a basemap was resolved for the selected robot — office_world/ground
ok the map canvas has pixels on it — 467726 painted pixels
ok the health roll-up lists subsystems — 4 rows
ok dispatch went through the fleet API — dispatched f6f07ef1809a4f18
ok the robot answered on task/status — accepted,dispatched,rejected
ok dispatch went through the fleet API — dispatched c751a9e20c304c1e
ok the robot answered on task/status — succeeded,accepted,dispatched
screenshot: fleet-ui.png

ok a coarse pointer is what the page thinks it has
ok one pane at a time, with a tab bar to move between them — {"tabs":"flex","shown":1}
ok no pane scrolls sideways on a phone
ok the canvas backing store follows the pane it is in — 1170x1674 for 1170x1674
ok picking a robot in the roster shows it on the map — map
ok two fingers zoom the map
screenshot: fleet-ui-phone.png
ok no uncaught page errors — 0

9/9 checks passed
15/15 checks passed
```

![The fleet dashboard](../images/fleet-ui.webp)

Nothing was polled: the roster, the health roll-up, both robot positions and the
task-status log are all retained MQTT state that arrived on the WebSocket within
a second of the page loading. Both colour schemes were rendered
(`Emulation.setEmulatedMedia`) and checked by eye.

The robots behind it are the wire, not the hardware — a script publishing the
real `protocol.py` payloads and answering `task/command`. The **real** agent and
behaviour tree are covered by the end-to-end test in §4; what this run is for is
the half only a browser can answer.
That is the original M3 run's 9 assertions and §9's phone pass, in one command
against a stack it built itself — 6.2 s end to end. The painted-pixel count is
identical to the hand-assembled run's because it is the same basemap: the
harness serves
`mote_simulation/sim_home/sites/office_world`, the only real saved map
(`map.yaml` + PNG + zones) committed in the tree, so the world→pixel transform
and the zone overlay are exercised at a real scale rather than against a fixture
that agrees with them.

Nothing is polled *by the page*: the roster, the health roll-up, all three robot
positions and the task-status log are retained MQTT state that arrives on the
WebSocket within a second of loading. Both colour schemes were rendered
(`Emulation.setEmulatedMedia`) and checked by eye during the original run; the
committed checks assert what a screenshot cannot, and leave the two themes to
the two screenshots they write.

**The robots are the wire, not the hardware.** `mote_fleet/test/fake_robots.py`
publishes `protocol.py` payloads and answers `task/command`, and that is the
whole of it — not a second robot implementation but the contract itself, which
is exactly what the UI consumes. What it does model is what the UI renders
differently: an `ok` robot and a `degraded` one, a pose that moves, the task
transitions (`goto dropoff` → dispatched/accepted/succeeded, `wibble` → rejected
`unknown command 'wibble'`, `goto nowhere` → rejected `unknown zone 'nowhere'` —
the same shape as §3's real robot, measured through `fleetctl` against this
fixture), a redelivered command recognised rather than re-run, and one robot
that **drops its socket without a DISCONNECT** so the broker publishes its will.
The harness waits on that will rather than on a sleep: an offline row the
fixture published for itself would not be testing the Last Will path at all. The
**real** agent and behaviour tree are covered by the end-to-end test in §4; what
this run is for is the half only a browser can answer.

Two things it deliberately does not touch. It never uses `~/.mote-fleet` — the
registry and the basemaps go in a temp directory that is deleted afterwards —
and it never uses 1883/9001, because the workstation this was measured on was
**already running a broker and a fleet server for a real robot on exactly those
ports**, and both were still serving when the run finished. Every process starts
in its own session, so teardown reaps this stack and nothing else (verified:
no container, no temp directory and no process left behind). Two runs at once on
different sites — `office_world` and `hospital_world` — both pass 15/15, sharing
no port, container or directory.

The broker image is the compose file's pin, read at startup exactly as
`broker.sh` reads it: a tag of the harness's own would be a third broker able to
drift onto a mosquitto whose websockets support differs, which is the failure
`test_deploy_config.py` exists to prevent — so that test now watches this file
too.

`-- --keep` skips the browser and leaves the stack up with its URL and operator
token printed, which is the loop for actually working on `server/ui/`.

### Can it gate CI? — **no, and this is the decision rather than an oversight**

It needs two things the `build` workflow's runners do not both have:

- **docker**, because the browser's read path is MQTT-over-WebSockets and
conda-forge's mosquitto is still built without them (re-measured at 2.0.20:
`ldd` finds no libwebsockets). Hosted `ubuntu-latest` has docker, so this half
would be free there.
- **a chrome**. `ubuntu-latest` ships one; the matrix's other half,
`ubuntu-24.04-arm`, does not — so wiring it in buys one architecture's
coverage of a page that has no architecture-specific behaviour.

The flakiness objection is at least answered: every assertion now polls to a
deadline instead of sleeping a guessed interval (`settle()` in
`browser_check.mjs`), so a loaded shared runner makes the job slower rather than
red. What is left is a judgement about a ~40 s job — image pull included — that
would gate every PR on a headless browser to protect the six files in
`server/ui/`. So it stays **a command an operator runs when touching `server/ui/`**,
one workflow step away from being a gate if that changes. Same shape as the sim
smoke test's answer (#51), and for one of the same reasons: hosted CI does not
have the machine the check needs.

## 3. Dispatch is mediated — **confirmed, including the refusals**

Expand Down Expand Up @@ -361,7 +438,9 @@ seams that fail silently: the CSS/JS breakpoint, every pane having a tab, and
`browser_check.mjs` grew a **phone pass** — 390x844 at device scale 3, mobile
metrics and touch emulation on, driven with real `Input.dispatchTouchEvent`
gestures rather than synthesised DOM events. Against a live stack (container
broker, fleet server, three scripted robots on the `office_world` bundle):
broker, fleet server, three scripted robots on the `office_world` bundle — since
this was measured, that stack is `pixi run fleet-ui-check` and those robots are
the committed `fake_robots.py`, so this pass is now the tail of §2's run):

```
ok a coarse pointer is what the page thinks it has
Expand Down
Loading