M2 verification ledger¶
What was measured for M2, how, and what is still unverified. M2 is the milestone that lets an operator watch and drive one robot remotely, and the milestone that finally pins the robot's DDS graph to the robot.
1. A browser teleop panel reaches the wheels — confirmed end to end¶
The acceptance criterion's "drives" half, run against the real
foxglove_bridge 3.3.0 with a WebSocket client speaking the protocol the
Teleop panel speaks. Everything is real except the operator and the motors: the
bridge, the wire, the twist_relay node, and a TwistStamped subscriber
standing in for DiffDriveController.
The two facts it pins down (mote_bringup/test/test_foxglove_teleop.py):
- a client advertise turns into a real ROS publisher, and the bytes arrive with the velocities intact;
twist_relayconverts them toTwistStampedwith a non-zero stamp taken on the robot — which is whatcmd_vel_timeoutmeasures, so the operator's clock never enters the safety path.
The type-name trap, checked rather than assumed¶
Foxglove's Teleop panel advertises the ROS 1 spelling geometry_msgs/Twist
(TeleopPanel.tsx hardcodes it), while the
ROS 2 type is geometry_msgs/msg/Twist. Had the bridge not normalised that, the
panel would have looked connected and the robot would simply never have moved —
a silent failure, and the sort that is expensive to diagnose from the far end of
a WAN link. So it was measured, with a separate topic per variant so a publisher
created by one could not make the next look like it worked:
| client advertises | schema sent | ROS messages received |
|---|---|---|
geometry_msgs/msg/Twist |
yes | 20 / 20 |
geometry_msgs/Twist |
yes | 20 / 20 |
geometry_msgs/msg/Twist |
no | 20 / 20 |
geometry_msgs/Twist |
no | 20 / 20 |
All four work — the bridge normalises the name and does not require the schema at all. The e2e test advertises the ROS 1 spelling deliberately, so that if a future bridge stops normalising, the test fails here rather than an operator discovering it on a robot.
The subprotocol changed under this version — worth knowing¶
ros-jazzy-foxglove-bridge 3.3.0 speaks foxglove.sdk.v1, not the
foxglove.websocket.v1 that the older bridge and most third-party examples use.
A client offering only the old subprotocol is refused at the handshake:
['foxglove.websocket.v1'] -> FAIL InvalidStatus: server rejected WebSocket connection: HTTP 400
['foxglove.sdk.v1'] -> OK {"op":"serverInfo", "capabilities":["clientPublish", ...]}
Current Foxglove offers both and negotiates foxglove.sdk.v1, so this does not
affect the shipped path. It matters for anything else pointed at the bridge —
an old Studio build, a scripted client, a third-party tool — which will fail with
a bare HTTP 400 and no explanation.
2. Teleop still works with DDS pinned to the robot — confirmed¶
This is the bet M2 exists to make: that foxglove_bridge is a real replacement
for joining the robot's ROS graph from a workstation, and therefore that
discovery can stop leaving the machine. The same e2e run, with the pin on:
The WebSocket is TCP and unaffected by DDS discovery scope, and the bridge shares a host with the graph it serves, so the pin costs the operator nothing.
Where the pin is applied: the systemd units only — every mote-*.service
now carries ROS_AUTOMATIC_DISCOVERY_RANGE=LOCALHOST, alongside the
CYCLONEDDS_URI they already had. An interactive pixi run keeps stock
discovery, exactly as cyclonedds.xml already worked, so bench workflows that
depend on a LAN graph — RViz, camera calibration, pixi run teleop from a
workstation — are untouched. Consistency is the reason it went on all the
units rather than the new one: a localhost-range participant can discover a
same-host default-range one but not the reverse, so a mixed set would be
asymmetric and confusing rather than half-safe.
3. DDS participant cost — 2 slots, and the budget still holds¶
M0 asked that dds-check be re-run whenever a milestone adds processes, having
measured 17/33 for the sim nav mission and projected ~24 with M1 and M2 added.
$ pixi run dds-check # nothing running
0/33 participant slots used, 33 free (MaxAutoParticipantIndex=32)
$ pixi run dds-check # with `pixi run foxglove` up
index 0 port 30160 pid 3701921 foxglove_bridge __node:=foxglove_bridge
index 1 port 30162 pid 3701923 python3.12 twist_relay
2/33 participant slots used, 31 free (MaxAutoParticipantIndex=32)
Two, not one: the projection counted foxglove_bridge and not the teleop relay,
which did not exist as a concept until the panel's message type forced it. The
robot's nav mission plus perception plus M1's agent plus these two lands around
25 of 33 — still headroom, and still the number to re-check at M3.
4. The launch brings up what it claims — confirmed¶
$ pixi run foxglove
[foxglove_bridge-1] Starting foxglove_bridge (jazzy, 3.3.0@7c27d75d-dirty)
[foxglove_bridge-1] Server listening on port 8765
[foxglove_bridge-1] Advertising new channel 4 for topic "/diff_drive_controller/cmd_vel"
[foxglove_bridge-1] Advertising new channel 5 for topic "/cmd_vel_teleop"
$ pixi run -- ros2 topic info /diff_drive_controller/cmd_vel
Type: geometry_msgs/msg/TwistStamped
Publisher count: 1
5. Not verified here — needs Foxglove, or the hardware¶
- The layout has never been opened in Foxglove. No Foxglove client is
installable in this environment, so
mote_bringup/foxglove/mote.jsonis verified only as far astest_foxglove_layout.pygoes: valid JSON, every placed panel configured, the teleop rate above the controller's deadman, the teleop velocities inside the controller's limits, the camera panel on the compressed stream. Those are the things that drift silently. What they do not prove is that Foxglove renders it. The panel configs are the least certain part — they were written against the panel schemas rather than exported from the app, and the URDF layer in the 3D panel is the single most likely thing to need a click to fix, since its key names could not be checked against a running client. A layout key Foxglove does not recognise is ignored rather than fatal, so the expected worst case is a panel that opens with a default setting, not a layout that fails to import. - The acceptance criterion, off-LAN, on the robot. Everything above ran on
one workstation. What makes it remote is the M0 tailnet rather than this code —
the same WebSocket over a WireGuard interface is the same WebSocket — but that
hop is unrun, as it is for
M1. To close it:
systemctl enable --now mote-foxgloveon the Pi, connect Foxglove tows://<robot-id>:8765from a tethered laptop, and drive. - Teleop against a moving robot. The relay was exercised against a
subscriber, not against
DiffDriveControlleron real wheels, socmd_vel_timeouthalting the robot when the link drops is reasoned from the controller's documented behaviour ("Timeout in seconds, after which input command on cmd_vel topic is considered staled") and not observed. It is the one safety claim in M2 that has not been watched happening. mote-foxglove.serviceunder systemd. The unit is installed bypixi run setupand modelled onmote-health.service, but has only been run viapixi run foxglove.- The dashboard's deep link, clicked. M3 ships an open in Foxglove button
per robot and its own ledger records the far end as unobserved, because
"neither exists until M2"
(
m3-verification.md). The two agree on paper — the server's template isws://<robot_id>:8765, which is this bridge's default port on the MagicDNS name M0 assigns — but nobody has clicked it and watched Foxglove connect. M3's open item and this milestone's are the same test: one session with the desktop app, a Pi, and the layout closes both. The mixed-content reasoning about the hosted web app (§10 of the runbook) is part of what that session should confirm rather than trust. - Bandwidth over a real link. The camera is streamed compressed and only while a panel is subscribed, but nothing here measured what a DERP-relayed connection does to it.
6. Teleop pre-empting Nav2 — closed, after M2¶
M2 shipped with this as a known limitation: both the relay and Nav2's controller
published TwistStamped to /diff_drive_controller/cmd_vel, so driving by hand
during an active goal meant two writers competing, and the documented remedy was
to cancel the task first. That was deliberate rather than overlooked —
arbitrating properly is a change to how the robot drives, affecting every
mission including fully autonomous ones, inside a milestone whose subject is how
the robot is watched — so it was filed as follow-up work.
It has since been done, on its own: twist_mux now sits in the drive path with
teleop above navigation, so the first arrow an operator presses takes the wheels.
The design, the numbers and what they were measured against are in
mote_bringup/README.md "Drive path".
Three consequences that matter to this ledger:
- the relay's output is
/cmd_vel_teleop_stampednow, not the controller's topic. The panel's own topic is unchanged, so §1's measurements and the shipped layout still hold; - §3's participant count goes up by one, to ~26 of 33;
- the deadman claim in §5 is unchanged and still the thing to watch on hardware.
twist_muxpublishes only from an input callback and stores no last command, whichtest_twist_mux_arbitration.pyasserts by watching the drive topic stay silent after every source stops — but "cmd_vel_timeouthalts real wheels" remains reasoned from the controller's documentation, exactly as it was.