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 @@ -108,7 +108,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.
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.

## Fleet: the map registry (M4)

Expand Down
31 changes: 29 additions & 2 deletions docs/fleet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -625,8 +625,9 @@ broker credential that can publish**, and its MQTT client implements no PUBLISH
packet at all.

**The map.** A floor's PNG basemap with live robot markers on it: pan by
dragging, zoom with the wheel, click a robot to select it, `follow` to keep the
selected one centred, `fit` to see the whole floor. The scale bar is metres.
dragging, zoom with the wheel or by pinching, click a robot to select it,
`follow` to keep the selected one centred, `fit` to see the whole floor. The
scale bar is metres.
Only robots on the *same* site and floor as the selected one are drawn — a pose
from another floor is a different map frame, and drawing it here would place a
robot somewhere it is not.
Expand All @@ -646,6 +647,32 @@ Beside the map's floor label is the **canonical revision** it is showing, and,
when a robot has published one, a picker to promote a candidate onto the floor
(§11). Both need the operator token; without one the pane is read-only.

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

**On a phone.** The realistic off-LAN client is a phone — it is what an operator
has in a corridor, and "where is the robot and what is it doing" is exactly the
question you ask from one. Below 760 px the three panes become **one at a time**
behind a tab bar at the bottom of the screen, within thumb reach, so the map
gets the whole display instead of a couple of hundred pixels between the roster
and the detail pane. Two things follow from losing the side-by-side view:

- **Picking a robot in the roster takes you to the map.** On a desk that
happens for free, both panes being visible; on a phone it has to be done.
The third tab is labelled with the selected robot's id, so the selection is
legible without switching to it.
- **Pinch to zoom**, since there is no wheel. One finger pans, two zoom about
the point between them, and a two-finger drag carries the map along. Robot
markers get a larger hit target when the pointer is a fingertip rather than
a cursor.

**Dispatch has a zone picker** beside the command box, listing the taught zones
of the floor on screen. It *writes* `goto <zone>` into the box rather than
sending it — the grammar is still the robot's, parsed only by its task layer —
which on a touchscreen removes the keyboard from the common case without adding
a second command language for the fleet server to keep in step.

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

**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
75 changes: 74 additions & 1 deletion docs/fleet/m3-verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ why the bug survived M1.

- **A phone-sized layout.** The panes stack below 1100 px, which is not the same
as being usable one-handed on a 390 px screen — and the map canvas is the part
that suffers. Observed, not designed for.
that suffers. Observed, not designed for. **Addressed later — §9 below.**
- **The Foxglove deep link.** The button is rendered from the configured
template and opens `foxglove://…`, which needs both the Foxglove desktop app
on the operator's machine and a `foxglove_bridge` on the robot. Neither exists
Expand All @@ -333,3 +333,76 @@ why the bug survived M1.
- **A robot dropping off while the dashboard watches.** The Last Will is tested
against a real broker (`m1-verification.md` §2) and the UI renders `offline`
from the same retained payload, but the two have not been observed together.

---

## 9. The phone layout (2026-07-31)

Closing §6's "the one thing the run did not enjoy is the small screen" and the
first bullet of §8. The network path was already proven from a phone on
cellular; what was missing was a layout for the screen it arrived on.

### What was changed

Below 760 px the three panes become one at a time behind a bottom tab bar;
selecting a robot in the roster navigates to the map; the map canvas gained
pinch-to-zoom and a fingertip-sized hit target; and the dispatch box gained a
zone picker that writes `goto <zone>` rather than sending it. The operator flow
is [`README.md` §9](README.md); the breakpoint lives in `server/ui/layout.mjs`
and the stylesheet is held to it by `ui_test.mjs`, because the two failing to
agree produces a tab bar over stacked panes rather than an error.

### How it was measured

`ui_test.mjs` grew the pinch arithmetic (`pinchSpan`, `pinchUpdate`) and the
seams that fail silently: the CSS/JS breakpoint, every pane having a tab, and
`touch-action: none` on the canvas. 24/24 under node.

`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):

```
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
```

Separately, in a Playwright context with `hasTouch` (so `pointer: coarse`
actually matches, which CDP touch events alone do not cause): a tap landing
**20 px** off a robot marker selected it — inside the touch target, outside the
14 px one a mouse gets — and after a pinch centred on that marker, a tap at the
same screen point still selected it, which is what shows the zoom anchors where
the fingers are rather than drifting. A `goto dropoff` chosen from the zone
picker dispatched through the API and wrote the expected audit row.

### Three bugs this found, all pre-existing

- **`hidden` did not hide.** `.revisions` and `.dispatch` set `display: flex`,
which outranks the attribute's UA-stylesheet `display: none` — so the promote
picker rendered empty on every floor with no candidate. Invisible on a desk;
a wasted row of a phone. Fixed with a `[hidden] { display: none !important }`
rule, which also covers anything added later.
- **The canvas backing store was resized on width alone.** A height change
without a width change — a tab switch, the toolbar rewrapping, a mobile URL
bar sliding away — left `clearRect` unable to reach the bottom of the store,
and the previous frame's scale bar stayed on screen under the new one.
- **The scale bar was drawn in the dark theme's near-white**, on a basemap whose
free space is white. A canvas gets no cascade, so the stylesheet's light
theme could not reach it; it now reads `--dim` off the canvas element and is
legible in both.

### Not verified here

- **A real device.** Everything above is emulation: it gets the viewport, the
device pixel ratio and the touch points right, and the thumb wrong. Reach,
one-handed grip, the on-screen keyboard covering the dispatch box, and iOS
Safari's own chrome are what a phone in a corridor tests and this does not.
The `100dvh` height, the `env(safe-area-inset-bottom)` padding under the tab
bar and the 16 px input font (which is what stops Safari zooming the page on
focus) are all written for that device and confirmed only in Chrome.
- **Landscape**, and tablets between 760 and 1100 px, which still stack.
Binary file added docs/images/fleet-ui-phone.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion mote_fleet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ pixi run -e fleet fleetctl -- dispatch mote-01 goto kitchen
| [`server/registry.py`](server/registry.py) | the SQLite row store: robots, enrollment tokens, operators, the audit log, transactional id allocation |
| [`server/bundle_store.py`](server/bundle_store.py) | the map registry's byte store: candidate revisions, validation on the way in, the atomic flip that publishes one |
| [`server/fleetctl.py`](server/fleetctl.py) | operator CLI: tokens, roster, dispatch, audit, watch |
| [`server/ui/`](server/ui/) | the dashboard: `index.html`, `app.mjs`, `map.mjs` (basemap + the Q5 transform), `mqtt.mjs` (a subscribe-only MQTT client) |
| [`server/ui/`](server/ui/) | the dashboard: `index.html`, `app.mjs`, `map.mjs` (basemap + the Q5 transform, pan/pinch), `mqtt.mjs` (a subscribe-only MQTT client), `layout.mjs` (one pane at a time on a phone) |
| [`server/mosquitto.conf`](server/mosquitto.conf), [`broker.sh`](server/broker.sh) | the broker, its WebSocket listener, and where its state goes |
| [`deploy/`](deploy/) | the deployed shape: an image for the API+UI, a compose file that runs it beside the broker, and `fleet-deploy.sh` (gated update, rollback, backup, restore) |

Expand Down
48 changes: 46 additions & 2 deletions mote_fleet/server/ui/app.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import { BrokerReader, parseTopic } from './mqtt.mjs';
import { MapView } from './map.mjs';
import { setupPanes } from './layout.mjs';

const TOKEN_KEY = 'mote.operator.token';

Expand All @@ -34,10 +35,12 @@ const state = {
operator: null,
mapKey: null,
floor: null, // the registry's view of the floor on screen: revisions, candidates
zones: [], // the floor's taught places, for the map and the dispatch picker
};

const dom = {};
let mapView = null;
let panes = null;
let pending = false;

// -- small helpers -------------------------------------------------------
Expand Down Expand Up @@ -175,6 +178,7 @@ async function ensureMap(record) {
if (key === state.mapKey) return;
state.mapKey = key;
state.floor = null;
setZones([]);
renderRevisions();
if (!key) {
mapView.clearMap();
Expand All @@ -197,11 +201,19 @@ async function ensureMap(record) {
// Taught places, in the same frame as the basemap. A floor may have none,
// which is a 404 and not an error worth showing.
api(`/v1/maps/${site}/${floor}/zones.json`)
.then((body) => state.mapKey === key && mapView.setZones(body.zones))
.catch(() => state.mapKey === key && mapView.setZones([]));
.then((body) => state.mapKey === key && setZones(body.zones))
.catch(() => state.mapKey === key && setZones([]));
loadFloor(site, floor, key);
}

// Taught places go two ways: onto the basemap, and into the dispatch picker.
// Both are the floor's, so they arrive and are cleared together.
function setZones(zones) {
state.zones = zones || [];
mapView.setZones(state.zones);
renderZones();
}

// -- the map registry ----------------------------------------------------

async function loadFloor(site, floor, key) {
Expand Down Expand Up @@ -240,6 +252,26 @@ function renderRevisions() {
);
}

// The zones of the floor on screen, as a `goto` the operator does not have to
// type. It writes the command rather than sending it: the send button stays the
// one place a task leaves this page.
function renderZones() {
const names = state.zones.map((zone) => zone.name).sort((a, b) => a.localeCompare(b));
dom.zone.hidden = names.length === 0;
dom.zone.replaceChildren(
el('option', { value: '', text: 'go to a taught zone…' }),
...names.map((name) => el('option', { value: name, text: name })),
);
}

function onZone() {
const name = dom.zone.value;
if (!name) return;
dom.command.value = `goto ${name}`;
dom.dispatchNote.textContent = '';
dom.dispatchNote.className = 'note';
}

async function onPromote(event) {
event.preventDefault();
const revision = dom.revision.value;
Expand Down Expand Up @@ -322,6 +354,9 @@ function renderRoster(records) {
onclick: () => {
state.selected = record.id;
state.mapKey = null; // re-resolve: the new robot may be on another floor
// Where the desktop layout has three panes at once, a phone has to
// be taken there: picking a robot means asking where it is.
panes.show('map');
scheduleRender();
},
},
Expand Down Expand Up @@ -358,6 +393,9 @@ function renderRoster(records) {
}

function renderDetail(record) {
// On a phone the detail pane is behind a tab, so the tab is where the
// selection is visible at all.
dom.tabDetail.textContent = record ? record.id : 'robot';
if (!record) {
dom.detailName.textContent = 'no robot selected';
dom.detailMeta.replaceChildren();
Expand Down Expand Up @@ -515,6 +553,8 @@ function bind() {
statusLog: 'status-log',
dispatch: 'dispatch',
command: 'command',
zone: 'zone',
tabDetail: 'tab-detail',
dispatchNote: 'dispatch-note',
foxglove: 'foxglove',
brokerState: 'broker-state',
Expand Down Expand Up @@ -547,6 +587,10 @@ export async function boot() {
scheduleRender();
},
});
// The canvas has no size until its pane is on screen, so the map is told when
// it becomes visible rather than fitting into a hidden 0x0 box.
panes = setupPanes({ onShow: (name) => name === 'map' && mapView.shown() });
dom.zone.addEventListener('change', onZone);
dom.fit.addEventListener('click', () => {
mapView.follow(null);
dom.follow.checked = false;
Expand Down
Loading
Loading