Skip to content

Fleet API: GET /v1/robots/<id> with retained presence, pose, capabilities and last mission status - #115

Merged
MJohnson459 merged 3 commits into
mainfrom
fleet-robot-state-route
Sep 11, 2026
Merged

Fleet API: GET /v1/robots/<id> with retained presence, pose, capabilities and last mission status#115
MJohnson459 merged 3 commits into
mainfrom
fleet-robot-state-route

Conversation

@MJohnson459

Copy link
Copy Markdown
Contributor

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.py gains a subscription of its own.
BrokerFeed connects to mote/v2/+/{presence,health,pose,capabilities, mission/status} and fills RobotState, an in-memory map of the last retained
payload per robot per leaf.

  • GET /v1/robots/<id> — the registry row, broker_connected, and
    presence / health / pose / capabilities / mission_status, each the
    publisher's own document or null. Operator token, like GET /v1/audit
    (#623 has not landed, so the handler checks it the way audit does).
  • GET /v1/robots — each row now carries its presence payload, so picking an
    online robot costs one request rather than N.

Five decisions are load-bearing:

  • Forwarded, never rebuilt. No field added, renamed or reinterpreted — the
    rule the agent follows, so there is one definition of this wire.
  • Absent state is null per field. A robot the server has never heard from
    is 200 with every field null; 404 stays 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, and null alone cannot say which.
  • Nothing is persisted. Every topic read is retained, so a restarted server
    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_status is the last status, not a history — one transition is all
    that is retained. watch and dispatch keep the broker for the rest.
  • The token is checked before the robot is looked up, so an anonymous caller
    cannot enumerate ids by reading 401 against 404.

serve() takes publisher and feed as a pair: injecting a publisher is how a
test 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.

fleetctl reads this route rather than the broker: robots gains a
PRESENCE column with three states (online / offline / unknown — a robot
nobody 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.md specifies both routes as contract (auth
table, status codes, the field table, and what the route deliberately is not);
control-plane.md and docs/fleet/README.md §8 point at it; CLAUDE.md gains
a section. The deploy Dockerfile's note on why paho is installed now covers both
halves of the broker hop.

Verification

  • mote_fleet/test279 passed, run against the dev environment's Python
    (pixi run inside a worktree would provision a duplicate multi-GB env).
  • test_fleet_server.py over the real socket: unknown robot 404; enrolled robot
    with 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 real
    mosquitto, the fleet server with its own subscription, the agent and the real
    mote_tasks tree: discover the robot, read its capability set, dispatch, and
    follow the mission to succeeded and then a typed unresolved_zone refusal,
    with no MQTT client in the test.
  • pre-commit run --all-files clean.

fleetctl's two read commands were also driven against a live server; the
transcript in docs/fleet/README.md §8 is that run's actual output.

MJohnson459 and others added 2 commits September 2, 2026 15:49
…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
Comment on lines +1264 to +1267
super().server_close()
if self.feed is not None:
self.feed.close()
self.publisher.close()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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()

Comment thread mote_fleet/server/fleet_server.py Outdated
Comment on lines +396 to +399
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Comment thread mote_fleet/server/fleet_server.py Outdated
Comment on lines +655 to +657
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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@MJohnson459
MJohnson459 merged commit 16b387b into main Sep 11, 2026
6 checks passed
@MJohnson459
MJohnson459 deleted the fleet-robot-state-route branch September 11, 2026 14:35
MJohnson459 added a commit that referenced this pull request Sep 11, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant