mote_fleet¶
The fleet control plane: the agent that runs on a robot, and the enrollment,
map-registry and dispatch server that runs off it. One package for both halves, because
the thing that matters most is that they agree about the wire — and the wire is
a single module, protocol.py, that both import.
Same arrangement as mote_perception: a torch-free node on the robot, a server
off-board, and one shared wire module (depth_wire.py) so the two cannot drift.
Here the robot-side half is the ament package and the off-board half is
server/ — plain scripts, because the fleet box runs them without
installing anything.
The design doc calls the robot-side process mote_agent, and that is what it
is called everywhere it is visible: the node is mote_agent, the service is
mote-agent.service, the console script is agent.
| Interface contracts | control-plane.md — the MQTT topic tree and payload schemas · fleet-api.md — the HTTP routes, dispatch and audit |
| Operator runbook | docs/fleet/README.md §6–9, and §11 for maps |
| Deploying the server | server-pipelines.md — the container stack, gated updates, backup/restore |
| What was measured | m1-verification.md · m3-verification.md · m4-verification.md · ms-verification.md |
| Design | docs/design/fleet.md — M1, M3, M4 and Ms, and Q1/Q2/Q3/Q4/Q5 |
On the robot¶
pixi run enroll -- --server http://fleet-box:8080 --token <token>
pixi run agent
pixi run publish-map # after save-map: offer the map to the registry
| Module | |
|---|---|
agent.py |
the node: presence/health/pose/capabilities up, missions down |
dispatch.py |
what the agent remembers about a mission — dedup, retention, the unanswered-mission timeout, and source. ROS-free and MQTT-free, so the awkward cases are plain function calls |
protocol.py |
the transport binding — topics, QoS, retain, and the telemetry payloads. Stdlib only |
enroll.py |
the enroll CLI |
facts.py |
hardware facts and the fingerprint enrollment is idempotent on |
fleet_config.py |
$MOTE_HOME/fleet.yaml — where this robot's fleet lives |
mapsync.py |
the map registry's robot side: pull the canonical revision, publish a candidate. ROS-free |
publish.py |
the publish-map CLI |
The agent is a bridge and a reporter, never in the control loop. Nav2, SLAM
and the behaviour tree run locally and keep running with the fleet server
unplugged; a dropped link means the agent stops reporting, not that the robot
stops. It is also the robot's sole egress — nothing off-box joins the ROS
graph — which is what makes the fleet-scale DDS story a non-problem rather than
a design (fleet.md Q1/Q3).
Off the robot¶
pixi run fleet-broker # mosquitto + WebSockets
pixi run -e fleet fleet-server -- --broker-host fleet-box # API + dashboard
pixi run -e fleet fleetctl -- dispatch mote-01 goto target=kitchen
| Script | |
|---|---|
server/fleet_server.py |
the fleet API: enrollment, roster, one robot's live state, dispatch, audit, basemaps, and the UI — stdlib http.server |
server/registry.py |
the SQLite row store: robots, enrollment tokens, operators, the audit log, transactional id allocation |
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 |
operator CLI: tokens, roster, one robot's state, dispatch, audit, watch |
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), review.mjs (see a candidate before promoting it), zone_editor.mjs (name its zones, saved as a new candidate), theme.mjs (the palette, for the canvases a stylesheet cannot reach) |
server/mosquitto.conf, broker.sh |
the broker, its WebSocket listener, and where its state goes |
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) |
The payloads are a specification's, not Mote's. A mission command, a
mission status and the capability set are mission/v0 and capability/v0
documents, built by mote_bringup/spec/
— which is why protocol.py describes the topic tree and stops there. Mote
vendors no copy of those schemas: the authority is the specification, and
mote_bringup/test/test_spec_conformance.py validates real payloads against it
where a checkout is present. What Mote still defines, and still mirrors in
schema/, is its own telemetry.
The server imports mote_fleet.protocol, mote_bringup.spec and
mote_bringup.bundle from the source tree by path (the depth_server.py
pattern) and nothing else — no ROS, no framework, no ament. protocol is the
wire the robot and the server agree on, and bundle is the bundle format they
agree on, so the server validates
an uploaded map revision with the same code that wrote it rather than a second
implementation that agrees by convention (fleet.md Q4). protocol is
stdlib-only; bundle additionally imports PyYAML and Pillow, which the image
installs beside paho — reading these files with anything other than the
libraries that write them is exactly the second implementation the rule exists
to avoid, and both hand-rolled readers it shipped with proved the point. Server state lives in $MOTE_FLEET_HOME (default
~/.mote-fleet), with the site bundles under sites/.
http.server rather than a web framework stays a floor, not an aspiration: a
dozen routes, no templating, no ORM, and one fewer dependency to solve on
whatever the fleet box turns out to be. M3 was where a framework was expected to
earn its keep and it did not — the UI is static files and there is nothing to
render server-side. The same goes for the browser: no bundler, no npm, and no
vendored MQTT library, because what the read path needs is five packet types of
a published wire format and a minified blob nobody can review is a worse
dependency than 200 lines that are tested.
Dispatch is mediated by the server, and only by the server. Every write to
mission/command — from the dashboard or from fleetctl — is a POST that
authorizes an operator token and writes an audit row first. The read path is
unchanged and goes straight to the broker.
Every /v1 route needs that token, not only the writes, and one gate in
front of routing takes it — so a route added later is authenticated by default
and an anonymous caller gets 401 rather than a 404 that would say which
routes exist. fleet_server.ROUTES is the table it matches against and the table
the tests walk. Four routes are open, each for a stated reason: /healthz, the
static UI, enrollment, and the two robot-facing map routes, which carry no
credential because robots have none to carry yet. The broker is still anonymous.
What is dispatched is a capability and a typed input, and the server
validates neither: the capability that declared the input_schema runs on the
robot, so a copy on the server would be a second contract to keep in step and
would refuse missions a newer robot understands. What it buys instead is a typed
refusal coming back — invalid_input naming the property, busy naming the
mission that holds the lane — which a dispatcher acts on without reading prose.
Uploading a map is not publishing it. A revision a robot uploads is a
candidate that changes nothing; an operator promotes one, which flips the
floor's map symlink and publishes the retained …/current topic agents pull
from. Two robots that map one floor leave two candidates, never a merge.
Tests¶
pixi run test # colcon: everything except the broker tests
pixi run -e dev test-fleet # + the real-broker end-to-end run
Four tiers, so the same files give full coverage wherever they run:
- contract (
test_protocol.py,test_fleet_server.py,test_map_registry.py,test_registry.py) — the code, the JSON Schema files and the doc's field tables checked against each other, and every HTTP route over a real socket with an injected publisher, so a payload or status-code change that nobody described fails here rather than in a dashboard later.api_harness.pyis the live server the last two share. - bridge (
test_dispatch.py,test_agent.py,test_mapsync.py) — the single-in-flight rule and the full agent against an injected fake MQTT client, so CI covers it on both architectures without a broker; and the robot's map staging and symlink flip against a real fleet server, with no ROS at all. - browser (
test_ui.py→ui_test.mjs,test_fake_robots.py) — the MQTT packet codec and the world→pixel transform under node, against the same.mjsfiles the browser loads (skips where there is no node); and the wire-only robots the dashboard is checked against, held toprotocol.pyand to the task layer's grammar, so the fixture can never become a second definition of the wire.browser_check.mjsis the other half — a real headless browser against a running stack, which needs a docker and a chrome, so it is an operator's tool rather than a test (the reasoning, and what wiring it into CI would take, are indocs/fleet/m3-verification.md§2). It needs no stack of your own:pixi run fleet-ui-checkbuilds one — broker, server, basemap, and the wire-only robots offake_robots.py— on ports nobody else is using, runs the checks and tears it all down.-- --keepleaves it up instead, which is the loop for working onserver/ui/. - end to end (
test_e2e_fleet.py,test_e2e_map_registry.py,test_fleet_outage.py) — a real mosquitto, the real fleet server, theenrollCLI, a real paho client, and the actualmote_tasksbehaviour tree driving a mock Nav2; including a dispatch that goes out through the API. The second kills the broker under a live agent: the robot finishes its task anyway and the agent reconnects by itself, which is the claim the fleet server's update pipeline is allowed to have downtime on. The third publishes a map, promotes it withfleetctl, and starts a second robot's agent afterwards — so the only thing that can tell it about the map is a retained message handed over on connect. All skip where there is no broker, and sharefleet_harness.py.