Fleet API: GET /v1/robots/<id> with retained presence, pose, capabilities and last mission status - #115
Conversation
…oker
M3's split — reads over MQTT, writes over HTTP — is right for the dashboard,
which wants a live stream, and wrong for a client that asks once and acts: an
MCP front door or a script then has to track the topic tree, retention
semantics and a broker credential, three contracts where one would do.
The fleet server now holds its own subscription to
mote/v2/+/{presence,health,pose,capabilities,mission/status} (BrokerFeed into
RobotState) and answers GET /v1/robots/<id> with the registry row plus what it
last saw. GET /v1/robots carries the presence payload per row, so picking an
online robot costs one request rather than N.
Payloads are forwarded, never rebuilt — no field added, renamed or
reinterpreted, the rule the agent follows. Absent state is null per field and a
robot never heard from is 200 with every field null, 404 being reserved for one
that is not enrolled; broker_connected sits beside them because with the feed
down every field is null however healthy the fleet is. Nothing is persisted:
every topic read is retained, so a restart is repopulated by the broker within
a second, and a cleared retained topic clears the field. mission_status is the
last status, not a history — watch and dispatch keep the broker for the rest.
The route takes an operator token where the roster does not, checked before the
robot is looked up so an anonymous caller cannot enumerate ids by reading 401
against 404. fleetctl robots gains a presence column and `fleetctl robots <id>`
prints the detail, both over HTTP rather than MQTT.
Verified: 279 mote_fleet tests pass, including the new
test_a_mission_can_be_followed_over_http_alone, which dispatches and follows a
real mission to a terminal state against a real mosquitto with no MQTT client
in the test; pre-commit passes across the tree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKPz7azUramGmdoySHZh57
pymdownx's slugifier strips anything angle-bracketed as an HTML tag, so `GET /v1/robots/<robot_id>` slugged to `get-v1robots_1` and every link written against GitHub's `#get-v1robotsrobot_id` failed the strict build. Drop the brackets before slugging; the toc extension has already turned real tags into text by then. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TeNdP1SdpcegFhMaFiuHi4
| super().server_close() | ||
| if self.feed is not None: | ||
| self.feed.close() | ||
| self.publisher.close() |
There was a problem hiding this comment.
server_close() reads self.feed/self.publisher, but FleetServer.__init__ assigns them (L1254-1255) only after calling super().__init__(address, FleetHandler) (L1239). socketserver.TCPServer.__init__ calls self.server_close() itself, in an except block, if server_bind()/server_activate() fails (e.g. the operator's classic "it's already running" OSError: Address already in use) — at that point self.feed doesn't exist yet, so this override raises AttributeError instead of letting the real bind error propagate, and self.publisher (a live BrokerLink with its own paho thread) never gets closed on that path.
| super().server_close() | |
| if self.feed is not None: | |
| self.feed.close() | |
| self.publisher.close() | |
| super().server_close() | |
| if getattr(self, "feed", None) is not None: | |
| self.feed.close() | |
| publisher = getattr(self, "publisher", None) | |
| if publisher is not None: | |
| publisher.close() |
| def _on_connect(self, client, _userdata, *_args): | ||
| for leaf in STATE_LEAVES: | ||
| client.subscribe(protocol.any_robot(leaf), qos=protocol.QOS) | ||
| self.state.connected = True |
There was a problem hiding this comment.
This ignores paho's CONNACK reason code — on_connect fires on a refused connection too (bad client id, not-authorized, server unavailable), and this handler unconditionally subscribes and sets state.connected = True regardless. That reproduces exactly the ambiguity broker_connected exists to remove (per RobotState's own docstring: "a client cannot tell a robot that has said nothing from a server that cannot hear") — a refused connection would report broker_connected: true with every field null. The test harness's own fake_robots.py already guards this case via reason_code.is_failure; this new production code doesn't.
| def _on_connect(self, client, _userdata, *_args): | |
| for leaf in STATE_LEAVES: | |
| client.subscribe(protocol.any_robot(leaf), qos=protocol.QOS) | |
| self.state.connected = True | |
| def _on_connect(self, client, _userdata, _flags, reason_code, *_args): | |
| if getattr(reason_code, "is_failure", False): | |
| return | |
| for leaf in STATE_LEAVES: | |
| client.subscribe(protocol.any_robot(leaf), qos=protocol.QOS) | |
| self.state.connected = True |
| the mission status says what it was told to do there. The token is | ||
| checked before the robot is looked up, so an unauthenticated caller | ||
| cannot enumerate ids by reading 401 against 404. |
There was a problem hiding this comment.
This docstring's anti-enumeration claim ("an unauthenticated caller cannot enumerate ids by reading 401 against 404") doesn't hold at the fleet level: _roster() (GET /v1/robots, L623-642) has no auth check at all and already returns robot_id/name/site/fingerprint/enrolled_at for every enrolled robot — plus, as of this PR, each robot's presence payload. An anonymous caller enumerates the whole fleet (and now who's online) with one request to /v1/robots, so the careful 401-before-404 ordering here doesn't actually buy id secrecy. The same sentence is repeated in docs/fleet/fleet-api.md and CLAUDE.md. Worth either scoping the claim to what it actually buys (protecting the state data, not the id itself) or noting the roster still enumerates ids until a credential is added.
… the token claim Three review points on #115. A failed bind (port in use) raised AttributeError from server_close(): TCPServer calls it from inside super().__init__, before feed and publisher were assigned. They are now assigned before the socket is bound, so the OSError reaches the operator. The feed treated a refused CONNACK as connected, because paho calls on_connect for both. It now reads the reason code (paho 2's ReasonCode, paho 1's int) and logs the refusal. A real mosquitto with anonymous access off reproduced the defect against the previous code and passes now. The operator token on GET /v1/robots/<id> was documented as stopping id enumeration. It does not: the roster lists every id anonymously, and the broker carries the same payloads until M7. The docstring, fleet-api.md and CLAUDE.md now say what the token and the 401-before-404 order actually buy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Main brought #115 (GET /v1/robots/<id> with retained state), #117 (the zone split collapsed) and #94 (arm teleop). #115 is the one that met the gate. - fleet_server.py: main still dispatched through the if/elif chain and gave `_robot` its own operator check and path parsing. The route table keeps dispatching; main's `_roster` and `_robot` replace this branch's thin ones, with `_robot` taking `robot_id` from the table and its auth from the gate. - fleetctl.py: main's `robots [<id>]` kept, the roster read now sends the token. - fleet-api.md, CLAUDE.md, README.md: main's "the roster is anonymous until M7" prose rewritten, since the roster is behind the gate here. - Tests: main's two 401 tests and two e2e roster reads pass `token=""` / the operator token, since the harness now sends one by default. Verified: mote_fleet/test 338 passed with a real mosquitto on PATH, none skipped; pre-commit clean; ui_check.py 49/49. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UcGpBYdT7QUHoy8hRH5EzZ
The fleet API can now answer is this robot online and what is it doing over
HTTP alone. M3's split — reads over MQTT, writes over HTTP — is right for the
dashboard, which wants a live stream, and wrong for a client that asks once and
acts: an MCP front door or a script otherwise has to track the topic tree,
retention semantics and (after #624) a broker credential, three contracts where
one would do.
What changed
mote_fleet/server/fleet_server.pygains a subscription of its own.BrokerFeedconnects tomote/v2/+/{presence,health,pose,capabilities, mission/status}and fillsRobotState, an in-memory map of the last retainedpayload per robot per leaf.
GET /v1/robots/<id>— the registry row,broker_connected, andpresence/health/pose/capabilities/mission_status, each thepublisher's own document or
null. Operator token, likeGET /v1/audit(#623 has not landed, so the handler checks it the way audit does).
GET /v1/robots— each row now carries itspresencepayload, so picking anonline robot costs one request rather than N.
Five decisions are load-bearing:
rule the agent follows, so there is one definition of this wire.
nullper field. A robot the server has never heard fromis
200with every field null;404stays reserved for one that is notenrolled.
broker_connectedsits beside them because with the feed down everyfield is null however healthy the fleet is, and null alone cannot say which.
is repopulated by the broker within a second; a stored copy could only be the
staler answer. A cleared retained topic (zero-length payload) clears the
field, or the server would assert state the broker has stopped serving.
mission_statusis the last status, not a history — one transition is allthat is retained.
watchanddispatchkeep the broker for the rest.cannot enumerate ids by reading 401 against 404.
serve()takespublisherandfeedas a pair: injecting a publisher is how atest stands the broker down, and a live subscription beside a stubbed publisher
would have the server dialling a broker the test does not have.
server_close()now closes both, which also fixes a paho client leaked per server in the tests.
fleetctlreads this route rather than the broker:robotsgains aPRESENCEcolumn with three states (online/offline/unknown— a robotnobody has heard from is not offline), and
fleetctl robots <id>prints health,pose, capabilities and the last mission. Both say so when the fleet server is
not connected to the broker.
Docs:
docs/fleet/fleet-api.mdspecifies both routes as contract (authtable, status codes, the field table, and what the route deliberately is not);
control-plane.mdanddocs/fleet/README.md§8 point at it;CLAUDE.mdgainsa section. The deploy Dockerfile's note on why paho is installed now covers both
halves of the broker hop.
Verification
mote_fleet/test— 279 passed, run against the dev environment's Python(
pixi runinside a worktree would provision a duplicate multi-GB env).test_fleet_server.pyover the real socket: unknown robot 404; enrolled robotwith no traffic → all-null; retained presence/pose/status published through
the test broker come back verbatim; the newest payload on a topic wins; a
cleared retention clears the field; 401 without a token, and 401 rather than
404 for an unknown id; the roster's presence column; what the state refuses to
hold (a registry announcement, a command, a non-object payload).
test_e2e_fleet.py::test_a_mission_can_be_followed_over_http_alone— a realmosquitto, the fleet server with its own subscription, the agent and the real
mote_taskstree: discover the robot, read its capability set, dispatch, andfollow the mission to
succeededand then a typedunresolved_zonerefusal,with no MQTT client in the test.
pre-commit run --all-filesclean.fleetctl's two read commands were also driven against a live server; thetranscript in
docs/fleet/README.md§8 is that run's actual output.