Navigating · Chapter 19 · Time: 3 hours on the stand, 1 hour on the floor · Level: Intermediate · Status: Done on this robot
Every way the wheels can be stopped, the contact reflex installed as a service, and the stand and floor stop proofs run by you with the measured times to compare against - plus the list of stops that are still unproven.
A robot that drives itself is only as safe as its slowest stop. This chapter collects every path that stops the
wheels, from the deadman timer in the driver to the power switch, and teaches you to measure each one yourself:
first with the wheels in the air, then on the floor. The measurements are physical (the gyro shaking, the LiDAR and
camera seeing motion), not the software saying "stopped", because on 2026-10-02 the software said "stopped" and the
robot kept rolling.
Safety for this whole chapter
- Stand tests: robot on a stand with all four wheels in the air, charger unplugged, nothing within reach of the
arm. Floor tests: clear floor ahead, charger unplugged, you next to the robot with the gamepad in your hand.- The power switch is the last stop. Know where it is before every test.
- The board itself has no stop watchdog: one motor command, then silence, kept the wheels turning for 5 s
until an explicit zero (measured 2026-09-27,scripts/test_fw_watchdog.py). Every stop in this chapter is a
software stop sent to the board. If nothing sends it, nothing stops.
The rules were agreed with the owner on 2026-09-27, before the driver wrote its first motor command, and are kept in
docs/motion_safety.md:
board_driver is the only process that opens /dev/ttyACM0 (TIOCEXCL).cmd_vel only. More than one publisher on cmd_vel -> stop and refuse.enable call.cmd_vel for 0.3 s -> zero.estop topic (any source trips it, an explicit reset clears it); any gamepad button asboard_driver dies for any reason (including SIGKILL), launchstop_motors at once, then shuts down; systemd's ExecStopPost sends the stop frame again;Restart=always brings the driver back disabled.Rule 5 in the document still says 0.2 m/s. The driver runs max_linear: 0.35 since 2026-10-01
(config/arm_limits.yaml), and Nav2's limits match it.
New idea: fail-safe stops
A stop is fail-safe when the failure itself causes the stop. The deadman is fail-safe: if the program that
sends commands crashes, hangs or loses its network link, commands stop arriving and the wheels stop. An e-stop
message is not fail-safe on its own: it only works if the message gets through. A good design has both
kinds, and you test each by making the thing it guards against actually happen.
New idea: a latched e-stop
"Latched" means the stop stays in force after its cause is gone, until someone deliberately clears it. On this
robot a latched e-stop zeroes the wheels and disables motion; clearing it (~/reset_estop, or holding the
gamepad's MODE button for 2 s) removes the latch but leaves motion disabled. Only a separate~/enablecall
makes the wheels move again.
| Stop path | Triggered by | Acts in | Latched | Needs DDS (ROS messaging) |
|---|---|---|---|---|
| Deadman | no cmd_vel for cmd_timeout 0.3 s |
MotionGate.block_reason |
no | no (absence of messages stops it) |
estop topic |
any std_msgs/Bool true on /estop |
board_driver.on_estop -> trip |
yes | yes |
| Disable | service /rosorin_board_driver/enable false |
board_driver.on_enable |
no (motion off until enabled) | yes |
| Gamepad button | any button except MODE, first frame | board_driver serial thread -> trip |
yes | no (read from the board directly) |
| Second publisher | more than one cmd_vel (or arm/goal) publisher |
board_driver.control -> trip |
yes | discovery |
| Scan gate | no /scan for scan_timeout 0.5 s while software commands arrive |
MotionGate.block_reason |
no (released when scans return) | no (absence stops it) |
| Driver crash | board_driver exits for any reason |
launch OnProcessExit -> stop_motors |
restarts disabled | no |
| Service stop | systemctl stop rosorin-base |
ExecStopPost -> stop_motors |
restarts disabled | no |
| Contact reflex | wheels commanded, LiDAR and camera see no motion | rosorin-contact publishes /estop x3 |
yes | yes |
| Collision monitor | LiDAR points in the approach or slowdown zone | Nav2 collision_monitor scales cmd_vel |
no | yes (it is a ROS node in the chain) |
| Skill exits | explore / practice / task unit stops; explore sees e-stop, plugged in or battery < 10.0 V | ExecStopPost calls enable false |
no | yes |
| Navigation stop | rosorin-nav stops |
nav_stop.sh calls enable false |
no | yes |
| Power switch | your hand | the battery | – | no |
The heart of the first six rows is MotionGate in ros2/rosorin_base/rosorin_base/motion.py (built in chapter 11).
Each control tick (50 Hz) asks for a reason not to move; any reason means zero now, with no ramp:
On the robot:
def block_reason(self, now):
if self.estop is not None:
return 'estop: ' + self.estop
if not self.enabled:
return 'disabled'
if self.cmd_t is None or now - self.cmd_t > self.timeout:
return 'cmd timeout'
if self.sense_timeout > 0 and not self.cmd_manual and (
self.sense_t is None or now - self.sense_t > self.sense_timeout):
return 'no scan'
return None
def step(self, now, dt):
if self.block_reason(now) is not None:
self.cur = (0.0, 0.0, 0.0)
return self.cur
A trip writes the stop frame immediately, without waiting for the next tick, holds the arm, and drops the gamepad to
locked (from board_driver.py):
On the robot:
def trip(self, reason):
first = self.gate.estop is None
self.gate.trip(reason)
self.write(STOP_FRAME) # immediate, not waiting for the timer
self.last_rps = [0.0] * 4
self.arm_hold('e-stop')
self.pad_mode, self.pad_mode_events = 'locked', 0 # an e-stop always drops the gamepad to locked
The gamepad trips on the very first frame of any button except MODE. A two-frame confirmation was tried on
2026-09-29 and reverted: real quick taps last one frame at the pad's 20 Hz.
On the robot:
other = buttons & ~mode_mask
if other and self.gate.estop is None:
self.trip('gamepad button 0x%04x' % other)
Safety: who clears an e-stop
The robot's mind (vision/mind.py, chapter 21) clears an e-stop whose reason isestop topicby itself, 20 s
after it latched, if no wheel skill is running: "its own contact reflex stopped it and the skill has ended".
That reason is the same whether the contact reflex sent it or you did withros2 topic pub /estop. Clearing does
not enable the wheels, but afterwards the mind may start a wheel skill again. A gamepad e-stop is never
cleared by the mind. When you need the robot to stay stopped, press a gamepad button, or stoprosorin-mind
as well.
At about 05:10 UTC on 2026-10-02, during a self-run exploration, the robot touched something. The owner reported it
"still rolling". The command log shows every software stop being used, in order:
| Time (UTC) | Action | What the software said |
|---|---|---|
| 05:10:39 | exploration stopped, enable false |
success=True, "enabled": false "estop": null |
| 05:11:16 | estop published |
"estop": "estop topic", "blocked": "estop: estop topic", "body_vel": [0.0, 0.0, 0.0 |
| 05:11:59 | serial link checked | no USB or ttyACM errors in the kernel log; /dev/ttyACM0 present; driver log E-STOP latched: estop topic |
| 05:12:27-43 | rosorin-base stopped, driver killed |
stop_motors: stop frame sent x3 (after 0.0 s), rosorin-base.service: Deactivated successfully. |
| 05:14:08 | stop frames written to the port by hand | the one-off script failed on an import error |
It stopped only when the owner cut the power and the robot went offline. The stand tests of 2026-09-27 had passed
(stop frame within 0.13 s). What had changed since: wheel speeds raised (driver 0.35 m/s, Nav2 0.35 / 0.2 / 1.0),
nvblox and depth in the costmaps, the stock recovery tree with BackUp enabled, and a body-follow behaviour in the
mind (switched off).
The cause is still unknown. The logs were lost: the journal was volatile and the robot was power-cycled. The
journal has been persistent since that day (scripts/persistent_journal.sh, chapter 6).
The incident created a gate, written into docs/lessons.md the same morning:
GATE: no wheels on the floor until the stop is re-proven ON THE STAND (wheels in the air): spin, then each stop
path (estop topic, enable false, driver kill), time to stop measured; find the cause from the board's frames.
Two things about the proof changed for good:
body_vel 0 the whole time.The gate was cleared the same day in two steps: on the stand (12 of 12 stop paths, morning) and on the floor (14 of
14). The floor proof cleared driving at up to 0.2 m/s; the stalled-wheel case stays unproven, so a contact must still
end in the reflex e-stop.
New idea: contact without bumpers or encoders
This robot has no bumpers and no wheel encoders: the board reports nothing about how fast the wheels really
turn. So "I hit something" is detected as a disagreement: the wheels are commanded to move, and the sensors
that see the room say the robot is not moving. The LiDAR odometry (rf2o, chapter 16) and the camera odometry
(cuVSLAM,/odom_vslam) each measure real motion. When both see too little motion for long enough, the robot
has run into something (or is stuck), and the reflex latches the e-stop.
behavior/contact_monitor.py (171 lines) runs as a service. Its structure:
| Part | What it does |
|---|---|
| constants | LAG, WIN, V_MIN, SETTLE, RATIO, HOLD = 0.2, 0.3, 0.08, 0.5, 0.15, 0.3 |
Monitor.__init__ |
subscribes /wheel/odom (the driver's gated command), /odom_rf2o, /odom_vslam, /scan, /imu/data_raw, /aurora/rgb/image_raw; publisher /estop |
cam_velocity |
body-frame velocity from the camera odometry over the last 0.3 s, or None if not fresh |
on_rf2o |
the detector, run on every LiDAR-odometry tick |
main |
--act (latch the e-stop) or log only; writes ~/bump/<UTC>/monitor.npz, events.json and a camera frame per contact |
The numbers, with their reasons from the file:
On the robot:
# RATIO 0.15: on the rug free driving makes only 25-88 % of the command (median 54 %, run 2); contacts ~0-3 %
LAG, WIN, V_MIN, SETTLE, RATIO, HOLD = 0.2, 0.3, 0.08, 0.5, 0.15, 0.3
LAG 0.2 s: rf2o lags the command by this much; the command is delayed to match.WIN 0.3 s: velocities are averaged over this window.V_MIN 0.08 m/s and SETTLE 0.5 s: only judge a command that is fast enough and has lasted long enough (skipsRATIO 0.15: contact when the measured motion along the commanded direction is below 15 % of the command.HOLD 0.3 s: and it stays that way this long.The decision, from on_rf2o. The measured motion is projected onto the commanded direction; with fresh camera
odometry both sensors must see the stall; without it the LiDAR alone may decide, and only for forward or backward
commands, because rf2o cannot see sideways motion:
On the robot:
cam = self.cam_velocity(t)
along_cam = None if cam is None or cl <= 1e-3 else (cam[0] * cvx + cam[1] * cvy) / cl
if along_cam is not None: # both must see no motion
stall = settled and along < RATIO * cl and along_cam < RATIO * cl
else: # LiDAR alone: it cannot judge sideways motion
stall = settled and along < RATIO * cl and abs(cvx) >= abs(cvy)
self.stall_since = (self.stall_since or t) if stall else None
contact = self.stall_since is not None and t - self.stall_since >= HOLD
On contact, with --act:
On the robot:
if self.act: # crashed into something: stop, now
for _ in range(3):
self.estop_pub.publish(Bool(data=True))
The full file is in the repo at behavior/contact_monitor.py; on the robot it lives in ~/behavior/ and runs under
the vision virtual environment (~/vision/venv, chapter 20) because it uses NumPy and OpenCV.
The unit, systemd/rosorin-contact.service, complete:
On the robot:
[Unit]
Description=ROSOrin contact monitor: wheels commanded but LiDAR sees no motion = crashed -> e-stop (behavior/contact_monitor.py --act)
After=rosorin-base.service
Wants=rosorin-base.service
[Service]
User=burgerbarn
Environment=ROS_DOMAIN_ID=0
ExecStart=/bin/bash -c 'source /opt/ros/humble/setup.bash && source /home/burgerbarn/ros2_ws/install/setup.bash && source /home/burgerbarn/vision/venv/bin/activate && exec python /home/burgerbarn/behavior/contact_monitor.py --act --seconds 1e9'
KillSignal=SIGINT
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
--seconds 1e9 makes the monitor run indefinitely. Wants= (not Requires=): it keeps running when the base
restarts, and picks the topics up again when they return.
Order on a fresh install
The unit runs the monitor inside~/vision/venv, which chapter 20 creates (scripts/install_vision.sh). On this
robot the venv existed (2026-09-29) before the reflex was installed (2026-10-02). If you follow this guide in
order, create the venv first (chapter 20) or come back to this section after it. Running the monitor with the
system Python instead has not been tried.
There is no install script for this unit in the repo. These are the commands that installed it on 2026-10-02:
On your laptop:
cd ~/CCode/rosorin-pro
ssh rosorin-wifi 'mkdir -p ~/behavior'
scp -q behavior/contact_monitor.py rosorin-wifi:~/behavior/
scp -q systemd/rosorin-contact.service rosorin-wifi:~/setup/
On the robot:
sudo install -m 0644 ~/setup/rosorin-contact.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now rosorin-contact
Check
On the robot:systemctl is-enabled rosorin-contact; systemctl is-active rosorin-contact journalctl -u rosorin-contact --since "-1 min" --no-pager -o cat | head -3The install on 2026-10-02 printed:
On the robot:
Created symlink /etc/systemd/system/multi-user.target.wants/rosorin-contact.service → /etc/systemd/system/rosorin-contact.service. Started ROSOrin contact monitor: wheels commanded but LiDAR sees no motion = crashed -> e-stop (behavior/contact_monitor.py --act). contact monitor running (ACT: e-stop on contact)Live on 2026-10-07 both
systemctllines readenabledandactive. The real test of the reflex is the last
case of the stand proof below.
If it fails
- It stops a robot that is driving freely sideways. The first version trusted rf2o alone; rf2o cannot see
sideways motion (floor test: a 15 cm strafe read 1.1 cm in rf2o, 15.4 cm in the camera odometry), and the reflex
e-stopped the robot on every sideways move (2026-10-02, five floor runs lost to this and a config edit). The
rule since: a safety check is proven on every motion the robot can make, not one.- It trips on the stand. With the wheels in the air nothing moves, so every spin looks like a contact. The
stand proof stopsrosorin-contactfor the drive tests and starts it again for its own test.- It trips at the rug edge. On 2026-10-02 runs 2 and 3 ended in the reflex at the hardwood/rug edge
(commanded 0.11 m/s, measured 0.007; commanded 0.35, measured 0.031). It cut the wheels after about 1.5 s both
times and nothing was pushed. That is the reflex working; the rug edge is a navigation problem.- It evaluates only on rf2o ticks. If the LiDAR stops, rf2o stops and the reflex stops judging. That is one
of the reasons the scan gate exists (next section).
Chapter 18 put Nav2's collision monitor last in the velocity chain. On 2026-10-02 the installed Humble build was
read at source level and then measured on an isolated ROS domain (scripts/tests/nav2_isolated_checks.sh cm, fake
scan, nothing connected to the wheels). It does less than its configuration file says:
| Case | Input | Output | Meaning |
|---|---|---|---|
| 1 return in the slow zone | vx 0.20 | 0.20 | nothing |
| 2 returns in the slow zone | vx 0.20 | 0.12 | slowdown to 60 % |
| 1 return 0.45 m ahead | vx 0.35 | 0.35 | the approach look-ahead ignores a single return |
| 2 returns 0.45 m ahead | vx 0.35 | 0.233 | approach scales the speed |
| 2 returns to the left, strafe left / right | vy ±0.20 | 0.133 / -0.20 | sideways sign correct |
| wall at the nose, scan silent | vx 0.20 | 0.20 | stale scan ignored, command passes |
What follows for this robot:
source_timeout is ignored; with no points,action_type: approach, Humble ignores points and usesfootprint_padding: 0.01), not the + 3 cm decided onfootprint_padding: 0.03, which also widens what the controller keeps clear; that is left to the owner.max_points: 1 means: one return inside the footprint stops all axes (including turning away); the slowdownstop_pub_timeout 2 s of zero. TheThe fix for the silent LiDAR is in the driver, not in Nav2, because Humble's collision monitor has no option for it:
software commands move the wheels only while the LiDAR publishes. It is the 'no scan' reason in
block_reason above. The driver subscribes to /scan without decoding it (raw=True), only to note the arrival
time, and has the timeout as a parameter:
On the robot:
sense_timeout=dp('scan_timeout', 0.5).value) # LiDAR ring = ~0.1 s; 0 = gate off
On the robot:
# arrival of scans only (raw=True: no deserialization in the control thread)
self.create_subscription(LaserScan, 'scan', lambda _: self.gate.sensed(time.monotonic()),
qos_profile_sensor_data, raw=True)
It is not latched: when scans return, the wheels follow the commands again by themselves. Gamepad commands are exempt:
a person holding the pad is looking, and the pad must still move a robot whose LiDAR is dead. The LiDAR reader also
respawns now (respawn=True in base.launch.py, chapter 14) and exits if its read thread dies.
Both directions are logged, from two separate call sites:
On the robot:
blocked = self.gate.block_reason(now) == 'no scan'
if blocked != self.scan_blocked: # logged both ways: the stop and the recovery
self.scan_blocked = blocked
age = None if self.gate.sense_t is None else round(now - self.gate.sense_t, 2)
if blocked: # two call sites: rclpy refuses one call site that changes severity (it raised
self.get_logger().warn(f'wheels held: no LiDAR scan for {age} s while commanded') # and killed
else: # the driver on the first release - found on the stand, 2026-10-02)
self.get_logger().info('LiDAR scan back: wheels released')
That comment is a real bug. The first version used one line,
(logger.warn if x else logger.info)(...); rclpy refuses a call site whose severity changes, raised, and the driver
died the moment the scans came back. It failed safe (stop frame, service restart), and only the stand proof found
it: the unit tests cover MotionGate, not the node, and the live check runs with the wheels disabled, where the gate
never engages.
scripts/tests/scan_gate_live.py kills the LiDAR reader with the wheels disabled (nothing moves), watches the
driver's own view of the scan age, and checks that the reader comes back by itself:
On the robot:
"""Live check of the LiDAR-loss path with the wheels DISABLED (nothing moves): kill the LiDAR reader process, watch
the base driver's own view of the scan (state.scan_age) and the reader coming back (launch respawn).
Pass = the driver saw the scan go stale beyond its scan_timeout, and scans returned without anybody's help.
The wheel-stop itself (enabled + commanded + scan lost -> 'no scan' -> zero) is unit-tested in test_motion.py and
is a case in scripts/stop_proof.py for the next run on the stand."""
import json, os, signal, subprocess, sys, time
import rclpy
from std_msgs.msg import String
rclpy.init(); n = rclpy.create_node('scan_gate_live'); st = []
n.create_subscription(String, '/rosorin_board_driver/state', lambda m: st.append((time.time(), json.loads(m.data))), 20)
def spin(s):
end = time.time() + s
while time.time() < end:
rclpy.spin_once(n, timeout_sec=0.05)
spin(2.0)
if not st or st[-1][1].get('enabled'):
print('abort: no driver state, or wheels enabled (this check is for a disabled robot)'); sys.exit(2)
before = st[-1][1].get('scan_age')
pids = subprocess.run(['pgrep', '-f', 'lib/rosorin_base/[l]idar_reader'], capture_output=True, text=True).stdout.split()
if not pids:
print('abort: LiDAR reader process not found'); sys.exit(2)
t0 = time.time()
for p in pids:
os.kill(int(p), signal.SIGKILL)
spin(9.0)
ages = [(round(t - t0, 2), s.get('scan_age')) for t, s in st if t >= t0]
worst = max((a for _, a in ages if a is not None), default=None)
back = next((t for t, a in ages if t > 1.0 and a is not None and a < 0.3), None)
new = subprocess.run(['pgrep', '-f', 'lib/rosorin_base/[l]idar_reader'], capture_output=True, text=True).stdout.split()
print(json.dumps(dict(scan_age_before=before, killed_pids=pids, worst_scan_age_s=worst, scans_back_after_s=back,
reader_pids_now=new, state_blocked=st[-1][1].get('blocked'))))
ok = worst is not None and worst > 0.5 and back is not None and new and new != pids
print('PASS' if ok else 'FAIL')
sys.exit(0 if ok else 1)
Note the [l]idar_reader pattern: the brackets stop pgrep from matching its own command line. Run it with the
wheels disabled (check "enabled": false first):
On your laptop:
cd ~/CCode/rosorin-pro
scp -q scripts/tests/scan_gate_live.py rosorin-wifi:~/setup/tests/
On the robot:
source /opt/ros/humble/setup.bash
python3 ~/setup/tests/scan_gate_live.py
journalctl -u rosorin-base --since "-20 s" --no-pager | grep -iE "lidar|respawn|process has died" | tail -5
Check
The run on 2026-10-02 16:17 UTC:On the robot:
{"scan_age_before": 0.07, "killed_pids": ["7806"], "worst_scan_age_s": 2.97, "scans_back_after_s": 3.03, "reader_pids_now": ["8146"], "state_blocked": "disabled"} PASS Oct 02 16:18:08 rosorin bash[7774]: [ERROR] [lidar_reader-4]: process has died [pid 7806, exit code -9, cmd '/home/burgerbarn/ros2_ws/install/rosorin_base/lib/rosorin_base/lidar_reader --ros Oct 02 16:18:10 rosorin bash[7774]: [INFO] [lidar_reader-4]: process started with pid [8146] Oct 02 16:18:11 rosorin bash[7774]: [lidar_reader-4] [INFO] [1790957891.194646720] [rosorin_lidar_reader]: reading /dev/lidar (read-only)
state_blockedsaysdisabled, notno scan, because disabled comes first inblock_reason. That the wheels
really stop while driving is measured in the stand proof (scanlosscase).
The stand proof drives the wheels in the air and times every stop path from the IMU. You run it once after building
the base (chapter 11), and again after any change to the driver's control loop: "a change in the driver's control
loop is not done until the stand proof has exercised that exact path, both directions" (docs/lessons.md).
With the wheels in the air the robot does not move, but spinning wheels shake the body, and the IMU's gyro (110 Hz)
sees it. scripts/stop_proof.py characterize measured the levels on this robot (gyro x/y standard deviation):
wheels still about 0.0016-0.0025 rad/s, 0.2 m/s about 0.005-0.006, 0.35 m/s about 0.006-0.010. The threshold sits
between still and spinning:
On the robot:
THR = 0.0035 # gyro x,y vibration (rad/s): still ~0.0016, wheels at 0.2 m/s ~0.006, 0.35 m/s ~0.009 (characterize)
WIN = 0.2
def score(w):
return float(np.hypot(w[:, 4].std(), w[:, 5].std()))
The judge slides a 0.2 s window along the recording and reports the time from which the vibration stays below the
threshold. The first version accepted the first quiet window; at 0.2 m/s a window can dip under the threshold while
the wheels still turn, which printed "still within 0.10 s" followed by FAIL. A pass also needs the spinning level
before the stop to be above the threshold, the stop within 2 s, and at least 2 s of quiet afterwards:
On the robot:
loud = [t for t, sc in w if sc >= THR]
settled = w[0][0] if not loud else next((t for t, sc in w if t > loud[-1]), None)
if settled is None:
print(f'{name:34s} spinning {spin_score:.4f} -> NEVER settled in {t_end - t_stop:.1f} s FAIL', flush=True)
return False
after = [sc for t, sc in w if t >= settled]
quiet_s = t_end - settled
ok = spin_score > THR and settled - t_stop <= 2.0 and quiet_s >= need_quiet
settled is the end of the first window after the last loud window. These lines are the middle of judge() in
scripts/stop_proof.py (lines 160-179).
For the two paths that kill the driver (kill, service), there is no driver left to publish the IMU. The script
then opens /dev/ttyACM0 itself and reads the board's IMU frames straight from the port (the board streams them
unasked) until the driver comes back: raw_watch(), lines 24-58.
Each drive path in prove() follows the same pattern: spin up for 3 s, trigger the stop, keep publishing the
drive command for 5 s, judge. For the estop path:
On the robot:
if want('estop') and (sp := spin_up(n, kw)) is not None:
t = time.time(); n.est.publish(Bool(data=True))
n.drive(5.0, **kw) # commands keep coming: must stay stopped
res.append(judge(n, f'estop topic, {mname}', t, time.time(), sp))
n.reset(); n.spin(0.5)
The paths, in order: deadman, estop and disable each at 0.2 m/s, 0.35 m/s and 1.0 rad/s; scanloss (kill the
LiDAR reader at 0.35 m/s, then check that the wheels turn again by themselves when scans return); kill (SIGKILL the
driver); service (systemctl stop rosorin-base); and contact (drive at 0.2 m/s with the wheels in the air and
wait for the reflex to trip on its own). The script is scripts/stop_proof.py (301 lines).
scripts/tests/stand_proof.sh refuses to run while navigation is up (the collision monitor is a cmd_vel publisher,
and the test's own publisher would trip the driver), pauses the mind so it cannot start a wheel skill in the middle,
stops the contact reflex for the drive tests, then starts it again and runs the contact test, and logs everything:
On the robot:
#!/bin/bash
# Stop proof on the STAND (wheels in the air, unplugged), with the robot mind paused so it cannot start a wheel skill
# of its own in the middle. Part 1 with the contact reflex stopped (it would e-stop every spin on the stand), part 2
# = the contact reflex itself. Log: ~/setup/logs/stop_proof_stand_<time>.log. Leaves contact + mind running.
source /opt/ros/humble/setup.bash
source /home/burgerbarn/ext_ws/install/setup.bash
source /home/burgerbarn/ros2_ws/install/setup.bash
systemctl is-active --quiet rosorin-nav && { echo "navigation is running - not now"; exit 2; }
mkdir -p ~/setup/logs; LOG=~/setup/logs/stop_proof_stand_$(date +%Y%m%d_%H%M%S).log
python3 - "$@" 2>&1 <<'PY' | tee $LOG
import subprocess, sys
sys.path.insert(0, '/home/burgerbarn/vision')
from mind_pause import mind_paused
paths = sys.argv[1] if len(sys.argv) > 1 else 'deadman,estop,disable,scanloss,kill,service'
with mind_paused(reason='stand stop proof'):
subprocess.run('sudo systemctl stop rosorin-contact', shell=True)
try:
subprocess.run(['python3', '/home/burgerbarn/setup/stop_proof.py', 'prove', paths])
finally:
subprocess.run('sudo systemctl start rosorin-contact', shell=True)
if len(sys.argv) <= 1:
import time; time.sleep(8)
subprocess.run(['python3', '/home/burgerbarn/setup/stop_proof.py', 'prove', 'contact'])
PY
echo "log: $LOG"
It imports mind_paused from ~/vision/mind_pause.py (chapter 21). If you run this chapter before the mind exists,
call stop_proof.py directly, as was done the morning of 2026-10-02 (the contact reflex stopped for the drive tests,
started again for its own test):
On the robot:
source /opt/ros/humble/setup.bash; source ~/ros2_ws/install/setup.bash
sudo systemctl stop rosorin-contact
python3 ~/setup/stop_proof.py prove deadman,estop,disable,scanloss,kill,service
sudo systemctl start rosorin-contact; sleep 8
python3 ~/setup/stop_proof.py prove contact
Put the robot on the stand, wheels in the air, charger unplugged. Have the gamepad and the power switch in reach.
Copy the scripts and stop navigation:
On your laptop:
cd ~/CCode/rosorin-pro
scp -q scripts/stop_proof.py rosorin-wifi:~/setup/
scp -q scripts/tests/stand_proof.sh rosorin-wifi:~/setup/tests/
On the robot:
sudo systemctl stop rosorin-nav
Run the full proof (about 3 minutes; the wheels spin up and stop about a dozen times, then the contact test):
On the robot:
bash ~/setup/tests/stand_proof.sh
Read back and restore: navigation on, wheels disabled, no e-stop, mind and contact active:
On the robot:
sudo systemctl start rosorin-nav
source /opt/ros/humble/setup.bash
timeout 5 ros2 topic echo --once --field data /rosorin_board_driver/state | grep -o '"enabled": [a-z]*\|"estop": [^,]*'
systemctl is-active rosorin-contact rosorin-mind rosorin-nav
Check
The run on 2026-10-02 17:16 UTC (docs/evidence/stop_proof_stand_20261002_171617.log), after the scan-gate fix:On the robot:
mind paused for stand stop proof driver state at start: {'enabled': False, 'estop': None, 'plugged': False} deadman (silence), fwd 0.2 spinning 0.0053 -> still from 0.55 s on; worst after 0.0023 over 4.5 s PASS estop topic, fwd 0.2 spinning 0.0056 -> still from 0.40 s on; worst after 0.0033 over 4.6 s PASS enable false, fwd 0.2 spinning 0.0047 -> still from 0.35 s on; worst after 0.0033 over 4.7 s PASS deadman (silence), fwd 0.35 spinning 0.0072 -> still from 0.75 s on; worst after 0.0034 over 4.3 s PASS estop topic, fwd 0.35 spinning 0.0066 -> still from 0.50 s on; worst after 0.0032 over 4.5 s PASS enable false, fwd 0.35 spinning 0.0071 -> still from 0.75 s on; worst after 0.0032 over 4.3 s PASS deadman (silence), rot 1.0 spinning 0.0048 -> still from 0.50 s on; worst after 0.0025 over 4.5 s PASS estop topic, rot 1.0 spinning 0.0041 -> still from 0.25 s on; worst after 0.0031 over 4.8 s PASS enable false, rot 1.0 spinning 0.0055 -> still from 0.30 s on; worst after 0.0028 over 4.8 s PASS LiDAR silent (scan gate), fwd 0.35 spinning 0.0058 -> still from 0.95 s on; worst after 0.0026 over 1.4 s PASS LiDAR back: wheels released by themselves vibration 0.0066 (spinning > 0.0035) PASS kill: board IMU read directly from 0.04 to 3.54 s after the command driver kill, fwd 0.35 spinning 0.0067 -> still from 0.55 s on; worst after 0.0030 over 3.0 s PASS no IMU data while spinning up (driver gone?) RESULT: 12/12 passed end state: {'enabled': False, 'estop': None} driver state at start: {'enabled': False, 'estop': None, 'plugged': False} contact reflex: e-stop "estop topic" 1.52 s after the wheels started contact reflex, fwd 0.2 spinning 0.0043 -> still from 0.25 s on; worst after 0.0031 over 5.2 s PASS RESULT: 1/1 passed end state: {'enabled': False, 'estop': None} mind resumedThe
servicepath did not run in that pass (no IMU data while spinning up: the driver was not back yet after
the kill). It was run on its own three minutes later (bash ~/setup/tests/stand_proof.sh service):On the robot:
service: board IMU read directly from 0.10 to 3.59 s after the command driver service, fwd 0.35 spinning 0.0068 -> still from 0.50 s on; worst after 0.0031 over 3.1 s PASS RESULT: 1/1 passedEvery line must say PASS. Your times should be close to the table below; a stop that takes much longer, or a
NEVER settled, is a reason to stop and find out why before the wheels touch the floor.
If it fails
navigation is running - not now: stoprosorin-navfirst (step 2).no IMU data while spinning up (driver gone?)right after thekillpath: the driver had not come back
(restart takes a few seconds, and topics are not visible for a few more). Run the missing path on its own:
bash ~/setup/tests/stand_proof.sh service.LiDAR back: wheels released by themselves vibration nan ... FAILfollowed by a Python traceback: this is what
the first run on 2026-10-02 17:12 printed (docs/evidence/stop_proof_stand_20261002_171209.log). The driver
had died on the scan-gate release (the one-call-site log bug above). Check the base journal for a traceback
fromboard_driver.still within 0.10 s ... FAILon a stop that obviously worked: you are running the old judge. Copy the current
scripts/stop_proof.py.enable refused: the arm could not reach the drive pose (refused: arm is not in the drive pose), or an e-stop
is latched. Read the driver state; fix the arm (chapter 13) before anything else.- The proof needs the IMU at full rate.
ABORT: no driver state received - nothing sentmeans the driver's topics
were not visible; right after a driver start this is normal for a few seconds.
First proof after the incident, the morning of 2026-10-02 (docs/evidence/stop_proof_2026-10-02.log, 12/12; times
from the older "first quiet window" judge; the starred rotation results are weak evidence because rotation shakes the
gyro only slightly above the threshold, 0.0038 against 0.0035):
| Stop path | 0.2 m/s | 0.35 m/s | rotate 1.0 rad/s |
|---|---|---|---|
| deadman (silence, 0.3 s timeout) | 0.55 s | 0.60 s | 0.15 s* |
estop topic |
0.20 s | 0.35 s | 0.20 s* |
~/enable false |
0.20 s | 0.35 s | 0.05 s* |
| SIGKILL of board_driver | – | 0.50 s | – |
systemctl stop rosorin-base |
– | 0.40 s | – |
| contact reflex | trips 1.50 s after start, still 0.10 s later | – | – |
After the scan gate was added, the afternoon of 2026-10-02, with the stricter judge (time until the vibration
stays at the still level; 14/14):
| Stop path | 0.2 m/s | 0.35 m/s | rotate 1.0 rad/s |
|---|---|---|---|
| deadman (silence) | 0.55 s | 0.75 s | 0.50 s |
estop topic |
0.40 s | 0.50 s | 0.25 s |
~/enable false |
0.35 s | 0.75 s | 0.30 s |
| LiDAR silent (scan gate) | – | 0.95 s after the reader was killed (driver: held at 0.52 s without a scan) | – |
| LiDAR back | – | wheels turning again by themselves 4.5 s later, no reset | – |
| SIGKILL of board_driver | – | 0.55 s | – |
systemctl stop rosorin-base |
– | 0.50 s | – |
| contact reflex | e-stop 1.52 s after start, still 0.25 s later | – | – |
The crash path used to be slower. On 2026-09-27, after a SIGKILL of the driver, the stop frame went out only after
1.17 s, because only systemd's ExecStopPost sent it; with launch's OnProcessExit running stop_motors it measured
0.127-0.138 s (four runs) for the stop frame to be sent. The 0.50-0.55 s above is the time until the wheels are
physically still.
The stand cannot show stopping distance, and it cannot reproduce a wheel loaded by the floor.
scripts/stop_proof_floor.py drives short, slow legs on the floor and times each stop from measured motion:
position change in the LiDAR odometry (rf2o) and the camera odometry (/odom_vslam), and the gyro for turns. It also
reports how far the robot travelled after the trigger.
It is the only cmd_vel publisher while it runs, and it guards itself:
On the robot:
HALF_W = 0.30 # corridor half width (robot half width ~0.15 + margin)
CLEAR = 1.2 # m free ahead needed to start a drive
GUARD = 0.45 # m: anything this close in the direction of travel while moving -> stop everything
FAIL_S = 0.8 # s after a stop trigger with the robot still moving -> the stop FAILED, stop everything
MOVING = 0.04 # m/s measured (position change over WIN) that counts as moving
WIN = 0.3
STILL_GZ = 0.08 # rad/s gyro z that counts as turning
A forward leg (1.5 s) starts only into a corridor the LiDAR shows clear for 1.2 m; a backward leg only retraces floor
a forward leg covered seconds before (the rear is blind). If a stop has not stopped the robot 0.8 s after its
trigger, every other stop fires at once. It refuses to drive when plugged in, and without measured odometry.
Clear floor ahead of the robot (the script needs 1.2 m clear and checks it). Charger unplugged. You next to it,
gamepad in hand.
Stop navigation and the mind (on 2026-10-02 the mind was stopped for this). Leave rosorin-contact running: the
floor proof also counts its false trips.
On your laptop:
cd ~/CCode/rosorin-pro
scp -q scripts/stop_proof_floor.py rosorin-wifi:/tmp/
On the robot:
sudo systemctl stop rosorin-nav rosorin-mind
source /opt/ros/humble/setup.bash; source ~/ros2_ws/install/setup.bash
cd /tmp && python3 stop_proof_floor.py look
Read the look output: state, battery, LiDAR coverage per bearing. On 2026-10-02 it began:
On the robot:
state {'enabled': False, 'estop': None, 'plugged': False, 'arm_enabled': True} battery 11.593000411987305 | vslam msgs 3 rf2o msgs 30
LiDAR beams 400, valid 60 %
bearing -180 deg: valid 0/ 0 nearest nan m
bearing -150 deg: valid 0/ 0 nearest nan m
bearing -120 deg: valid 4/ 9 nearest 1.85 m
bearing -90 deg: valid 26/ 41 nearest 0.58 m
bearing -60 deg: valid 36/ 43 nearest 0.58 m
bearing -30 deg: valid 35/ 35 nearest 2.00 m
bearing +0 deg: valid 34/ 34 nearest 2.12 m
The empty bearings at -180 and -150 degrees are the blind rear.
Run the proof in the two halves that were run on this robot, then restore:
On the robot:
cd /tmp
timeout 170 python3 stop_proof_floor.py prove deadman,estop,disable
timeout 200 python3 stop_proof_floor.py prove kill,service
journalctl -u rosorin-contact --since "-10 min" --no-pager -o cat | grep -c CONTACT
sudo systemctl start rosorin-mind rosorin-nav
Check
The first half on 2026-10-02 (docs/evidence/stop_proof_floor_2026-10-02.log):On the robot:
deadman 0.1 fwd before: 0.08 m/s measured; measured still after 0.70 s; travel after the trigger: camera 6.0 cm, LiDAR 4.0 cm PASS estop 0.1 back before: 0.09 m/s measured; measured still after 0.44 s; travel after the trigger: camera 3.1 cm, LiDAR 2.8 cm PASS disable 0.1 fwd before: 0.09 m/s measured; measured still after 0.48 s; travel after the trigger: camera 3.4 cm, LiDAR 2.2 cm PASS deadman 0.1 back before: 0.09 m/s measured; measured still after 0.69 s; travel after the trigger: camera 6.7 cm, LiDAR 4.2 cm PASS estop 0.2 fwd before: 0.22 m/s measured; measured still after 0.38 s; travel after the trigger: camera 5.6 cm, LiDAR 3.1 cm PASS deadman 0.2 back before: 0.18 m/s measured; measured still after 0.71 s; travel after the trigger: camera 10.2 cm, LiDAR 9.4 cm PASS disable 0.2 fwd before: 0.19 m/s measured; measured still after 0.43 s; travel after the trigger: camera 5.9 cm, LiDAR 5.1 cm PASS estop 0.2 back before: 0.18 m/s measured; measured still after 0.38 s; travel after the trigger: camera 6.4 cm, LiDAR 3.9 cm PASS estop turn 0.5 before: turning; measured still after 0.32 s; PASS deadman turn -0.5 before: turning; measured still after 0.60 s; PASS RESULT: 10/10 passed end state: {'enabled': False, 'estop': None} battery 11.564000129699707And the second half:
On the robot:
driver kill 0.2 fwd before: 0.17 m/s measured; measured still after 0.67 s; travel after the trigger: camera 7.4 cm, LiDAR 3.5 cm PASS deadman 0.2 back before: 0.22 m/s measured; measured still after 0.59 s; travel after the trigger: camera 10.4 cm, LiDAR 7.1 cm PASS service stop 0.2 fwd before: 0.19 m/s measured; measured still after 0.46 s; travel after the trigger: camera 6.1 cm, LiDAR 1.8 cm PASS disable 0.2 back before: 0.18 m/s measured; measured still after 0.48 s; travel after the trigger: camera 6.0 cm, LiDAR 4.3 cm PASS RESULT: 4/4 passed end state: {'enabled': False, 'estop': None} battery 11.527999877929688The contact reflex made 0 false trips during these drives.
If it fails
NOT RUN - corridor ahead: nearest ..., valid beams ...: less than 1.2 m clear, or fewer than 60 % valid beams
ahead. Move the robot; do not lowerCLEAR.ABORTED - something within 0.45 m in the direction of travel: the guard fired. That is the harness working.STILL MOVING 0.8 s after the stop - all stops firedorMOVED AGAIN after stopping: a real failure, and the
proof stops itself (STOPPING THE PROOF: a stop failed). Do not drive again until it is explained.- The driver log shows
E-STOP latched: multiple cmd_vel publishersand the legs do not run: navigation (its
collision monitor) or another tool still publishes/cmd_vel. The script creates its own publisher only after
its checks, because this happened during a run on 2026-10-02. Stop the other publisher and start again.ABORT: plugged in - no driving: the driver believes the charger is connected. The plug state is a belief
from one voltage step (190 mV threshold); a full pack can unplug with a smaller step (-176 mV seen on
2026-10-06). Chapter 11 covers~/battery/plugged.json.
Floor, 2026-10-02, on the rug, unplugged (14/14 passed, 0 false trips of the contact reflex while driving freely).
"Still after" includes the 0.3 s measuring window. Measured speed before the stops: 0.08-0.09 m/s at 0.1 commanded,
0.17-0.22 at 0.2.
| Stop path | 0.1 m/s | 0.2 m/s | turn 0.5 rad/s | travel after the trigger |
|---|---|---|---|---|
| deadman | 0.69-0.70 s | 0.59-0.71 s | 0.60 s | 4-7 cm at 0.1, 7-10 cm at 0.2 |
estop topic |
0.44 s | 0.38 s | 0.32 s | 3 cm at 0.1, 3-6 cm at 0.2 |
~/enable false |
0.48 s | 0.43-0.48 s | – | 2-3 cm at 0.1, 4-6 cm at 0.2 |
| SIGKILL of board_driver | – | 0.67 s | – | 4-7 cm |
systemctl stop rosorin-base |
– | 0.46 s | – | 2-6 cm |
The deadman is the slowest stop because it first waits out its own 0.3 s timeout.
On 2026-09-28 the board's USB device was unbound and re-bound in software (1-2.1, 1a86:55d4) on the stand with the
wheels disabled, to see what the driver does when the link drops:
OnProcessExit runs stop_motors, which now waits up to 30 s for /dev/ttyACM0 to return (the firstExecStopPost sends it again; systemd restarts the base after 3 s, and the driver starts disabled and writes aOn the robot:
Sep 28 06:14:12 rosorin bash[31468]: [INFO] [stop_motors-6]: process started with pid [31601]
Sep 28 06:14:14 rosorin bash[31468]: [stop_motors-6] stop_motors: /dev/ttyACM0 unavailable ([Errno 2] No such file or directory: '/dev/ttyACM0'); retrying up to 30 s
Sep 28 06:14:20 rosorin bash[31468]: [stop_motors-6] stop_motors: stop frame sent x3 (after 7.6 s)
Not covered by any measurement
- USB link gone while driving. While the board's USB is gone, nothing can stop the wheels: the board has no
stop watchdog (verified 2026-09-27) and keeps the last speed until USB returns. There is no software fix. The
power switch is the only stop for this case.- Board firmware hang. Never tested. If the STM32 stops reading frames, no stop frame reaches the motors.
- Stalled or loaded wheels, the situation of the 2026-10-02 incident (wheels against something). The stand
cannot reproduce it, and no floor test has stalled the wheels on purpose. The contact reflex is the stop for
this case, and it is exactly what was not understood that day.- The cause of the 2026-10-02 incident is still unknown.
- The
estoptopic depends on DDS. A transient DDS delivery failure was seen once (the driver's topics not
received for minutes, cause unknown, not reproduced). The contact reflex,ros2 topic pub /estop, the enable
service and the collision monitor all ride on DDS. The gamepad (read by the driver from the board) and the
power switch do not. The deadman and the scan gate stop the wheels when messages stop arriving, but not when
only the stop message is lost and commands still arrive.- The gamepad button was tested on 2026-09-27 (stopped, stayed stopped, twice) but never timed; the stop proofs
cannot press a button.- Speeds above 0.2 m/s on the floor. The floor proof covered 0.1 and 0.2 m/s; Nav2 and the driver allow
0.35 m/s forward. Stops at 0.35 m/s were measured only on the stand.- Sideways motion in the stop proofs. Both proofs drive forward, backward and turning; no stop path was
timed during a strafe.- Approach margin. The collision monitor's approach polygon runs at chassis + 1 cm, not the decided + 3 cm.
systemctl is-active rosorin-contact says active, and its journal showscontact monitor running (ACT: e-stop on contact).scan_gate_live.py prints PASS with worst_scan_age_s above 0.5 and a new LiDAR reader PID.stand_proof.sh ends with every line PASS (12/12 + service 1/1 + contact 1/1), with times close to theRESULT: 10/10 passed and RESULT: 4/4 passed, and the contact reflex counted 0 trips.rosorin-nav, rosorin-mind and rosorin-contact are active again and the driver reports"enabled": false and no e-stop.Where this comes from
Repo (branchrebuild, HEAD bfb61d8):ros2/rosorin_base/rosorin_base/motion.py(MotionGate),
rosorin_base/board_driver.py(trip,on_estop,on_enable,on_reset, gamepad trip, scan-gate logging),
rosorin_base/stop_motors.py,launch/base.launch.py,ros2/rosorin_base/systemd/rosorin-base.service,
config/arm_limits.yaml,config/collision_monitor.yaml;behavior/contact_monitor.py,
systemd/rosorin-contact.service;scripts/stop_proof.py,scripts/stop_proof_floor.py,
scripts/tests/stand_proof.sh,scripts/tests/scan_gate_live.py,scripts/test_fw_watchdog.py;
vision/mind.pylines 290-297 (clearingestop topic). Evidence logs:docs/evidence/stop_proof_2026-10-02.log,
stop_proof_stand_20261002_171209.log,_171617.log,_171932.log,stop_proof_floor_2026-10-02.log.
Docs:docs/motion_safety.md(rules 2026-09-27, stand tests, scan gate, stand and floor proofs, USB drop, DDS
risk),docs/lessons.md2026-10-02 (CRITICAL kept rolling, gate, rug edge, five floor runs, three Humble guards,
scan-gate log bug, judge),docs/decisions.md2026-10-01 (approach polygon), 2026-10-02 afternoon (scan gate,
approach 1 cm left for the owner),docs/research/nav2_reference.mdsections 25-26. Command log
(cmdlog_robot.md): incident 2026-10-02 05:10-05:20, contact service install 05:26, floor proof 06:29-06:32,
scan_gate_live 16:17, stand proofs 17:12-17:19, USB drop 2026-09-28 06:14.sources/survey_live_robot.md
(rosorin-contact has no install script; install commands). Live read-only lookup 2026-10-07: rosorin-contact
enabled and active.