Making it move · Chapter 11 · Time: 4-6 hours, plus an hour of stand tests · Level: Intermediate · Status: Partly test-built
The ROS 2 package rosorin_base and its board driver, the only program that talks to the controller board - IMU, battery, gamepad, and wheel motion behind a gate (enable, deadman, limits, ramps, latched e-stop, scan gate) - started by a launch file and a systemd service that send the stop frame on any crash, with every stop path timed on the stand.
The board driver is the robot's safety layer. It is the only program allowed to open /dev/ttyACM0, it turns
velocity commands into motor frames, and it decides every 20 ms whether the wheels may turn at all. Because the board
keeps the last wheel speed forever (no stop watchdog, chapter 10), every way this software can fail must end in a
stop frame. This chapter builds the ROS 2 package around the rrc_protocol.py you wrote, the pure motion logic
(motion.py), the stop tool (stop_motors.py), walks through board_driver.py, and wraps it in a launch file and a
systemd service. It ends on the stand, wheels in the air, timing each way of stopping.
The steps are written for a fresh build, where none of this exists yet. On the robot as it runs today the package and
the service are already installed and rosorin-base owns the port; to follow along there, stop the services as in
chapter 10 ("If the finished software already runs on the robot") and start them again at the end.
What was done on this robot: every file, test and measurement quoted below, between 2026-09-26 and 2026-10-05. New
in this guide: the order (copy the later chapters' files first, type this chapter's), and the reduced launch file and
service unit you use until chapters 12 and 16 add their parts; see the box at the end.
On 2026-09-27, before any code wrote to the board, the owner approved these rules (docs/motion_safety.md lines
3-17). Everything in this chapter implements one of them.
board_driver is the only process that opens /dev/ttyACM0 (TIOCEXCL).cmd_vel only. More than one publisher on cmd_vel: stop andenable call.cmd_vel for 0.3 s: speed zero.estop topic, any gamepad button, the power switch. Only an explicit reset clears it.board_driver dies for any reason, a stop frame follows at once, a second one from systemd,New idea: deadman and latched e-stop
A deadman stops the machine when the controlling signal goes quiet: the commands must keep coming (here at
least every 0.3 s), and silence means stop. It covers a crashed planner, a lost network link, a hung script. An
e-stop (emergency stop) is a stop someone or something asks for. Latched means it stays in force after
the request ends: a singletrueon theestoptopic stops the wheels until someone deliberately resets it,
and even then the wheels stay disabled until they are enabled again.
The driver's data flow:
New idea: packages, workspaces and colcon
ROS 2 code is organised in packages: a folder with apackage.xml(name, version, dependencies) and build
instructions. A workspace is a folder with asrc/subfolder full of packages; here~/ros2_ws. colcon
builds every package insrc/intoinstall/, andsource install/setup.bashmakes the built packages visible
to theros2command, on top of the system ROS in/opt/ros/humble(an "overlay"). An ament_python package
is a Python package with asetup.py; building it copies the modules, creates one small executable per entry
point, and installs data files (launch files, configuration) where ROS tools can find them by package name.
Chapter 10 left ~/ros2_ws/src/rosorin_base/rosorin_base/ with __init__.py and rrc_protocol.py, and
test/test_rrc_protocol.py. The finished package (repository folder ros2/rosorin_base) looks like this:
Anywhere:
rosorin_base/
package.xml you type it (this chapter)
setup.py, setup.cfg you type them (this chapter)
resource/rosorin_base empty marker file (this chapter)
rosorin_base/ the Python modules
rrc_protocol.py chapter 10
motion.py this chapter, typed
gyro_bias.py this chapter, typed
stop_motors.py this chapter, typed
board_driver.py this chapter, copied (1138 lines) and walked through
arm.py, arm_model.py copied; chapter 13
coin_d6.py, lidar_reader.py copied; chapter 14
the rest copied; chapters 16-18 (rf2o_fix, vslam_odom, depth_gate, nav_supervisor, ...)
launch/base.launch.py this chapter (other launch files: chapters 17-18)
config/, bt/, urdf/ copied; used by later chapters (three files used now)
systemd/rosorin-base.service this chapter
test/ unit tests
Create the folders and the package marker on the robot:
On the robot:
mkdir -p ~/ros2_ws/src/rosorin_base/{resource,launch,systemd}
touch ~/ros2_ws/src/rosorin_base/resource/rosorin_base
The empty file resource/rosorin_base is how ROS finds the package by name after installation (the "ament index").
setup.py lists every launch file, configuration file and program of the finished package, and board_driver.py
imports the arm modules. For the build to succeed now, those files must exist. Copy them from your clone of the
repository on your laptop, ~/CCode/rosorin-pro (github.com/burgerbarn/rosorin-pro, branch rebuild):
On your laptop:
cd ~/CCode/rosorin-pro/ros2/rosorin_base
scp -r config bt urdf rosorin-wifi:ros2_ws/src/rosorin_base/
scp launch/nav.launch.py launch/nav_slam.launch.py launch/slam.launch.py rosorin-wifi:ros2_ws/src/rosorin_base/launch/
scp rosorin_base/board_driver.py rosorin_base/arm.py rosorin_base/arm_model.py rosorin_base/coin_d6.py \
rosorin_base/lidar_reader.py rosorin_base/board_reader.py rosorin_base/gamepad_teleop.py \
rosorin_base/rf2o_fix.py rosorin_base/vslam_odom.py rosorin_base/depth_gate.py \
rosorin_base/nav_supervisor.py rosorin_base/learned_limits.py rosorin-wifi:ros2_ws/src/rosorin_base/rosorin_base/
scp test/test_motion.py test/test_gyro_bias.py test/test_arm.py test/test_coin_d6.py test/coin_d6_sample.bin \
test/test_gamepad_teleop.py rosorin-wifi:ros2_ws/src/rosorin_base/test/
Three of the copied configuration files matter in this chapter: config/imu_calibration.yaml (accelerometer
calibration), config/arm_limits.yaml (arm limits and poses, and the wheel speed limit max_linear: 0.35), and
config/robot_model.json, the arm geometry the driver loads at start for its collision check (chapters 12-13).
config/fastdds_shm.xml comes along but is not installed and not used (docs/status.md: tested, not deployed).
If it fails
scp: dest open ... No such file or directory: the target folder on the robot is missing. Run themkdir
above first.- Quote remote paths if you write your own variants: an unquoted
~in a command typed on the Mac expands to
the Mac's home folder (docs/lessons.md2026-09-26).
Create ~/ros2_ws/src/rosorin_base/package.xml:
On the robot:
<?xml version="1.0"?>
<package format="3">
<name>rosorin_base</name>
<version>0.1.0</version>
<description>ROSOrin base drivers written from the documented controller-board protocol. No vendor code.</description>
<maintainer email="<your-email>">burgerbarn</maintainer>
<license>MIT</license>
<exec_depend>rclpy</exec_depend>
<exec_depend>control_msgs</exec_depend>
<exec_depend>python3-numpy</exec_depend>
<exec_depend>sensor_msgs</exec_depend>
<exec_depend>geometry_msgs</exec_depend>
<exec_depend>nav_msgs</exec_depend>
<exec_depend>robot_localization</exec_depend>
<exec_depend>slam_toolbox</exec_depend>
<exec_depend>navigation2</exec_depend>
<exec_depend>std_msgs</exec_depend>
<exec_depend>std_srvs</exec_depend>
<exec_depend>tf2_ros</exec_depend>
<exec_depend>robot_state_publisher</exec_depend>
<exec_depend>launch_ros</exec_depend>
<test_depend>python3-pytest</test_depend>
<export><build_type>ament_python</build_type></export>
</package>
exec_depend lists what must be installed to run the package; test_depend what the tests need. Several of these
(robot_localization, slam_toolbox, navigation2) are for later chapters; chapter 9 installed them with apt.
colcon build does not check them. <build_type>ament_python</build_type> tells colcon to build it with setup.py.
Create ~/ros2_ws/src/rosorin_base/setup.cfg:
On the robot:
[develop]
script_dir=$base/lib/rosorin_base
[install]
install_scripts=$base/lib/rosorin_base
It puts the generated executables in install/rosorin_base/lib/rosorin_base/, the folder where ros2 run and
ros2 launch look for a package's programs.
Create ~/ros2_ws/src/rosorin_base/setup.py:
On the robot:
from setuptools import setup
setup(
name='rosorin_base',
version='0.1.0',
packages=['rosorin_base'],
data_files=[
('share/ament_index/resource_index/packages', ['resource/rosorin_base']),
('share/rosorin_base', ['package.xml']),
('share/rosorin_base/launch', ['launch/base.launch.py', 'launch/slam.launch.py', 'launch/nav.launch.py', 'launch/nav_slam.launch.py']),
('share/rosorin_base/config', ['config/imu_calibration.yaml', 'config/arm_limits.yaml', 'config/ekf.yaml', 'config/slam.yaml', 'config/nav2.yaml', 'config/collision_monitor.yaml', 'config/robot_model.json', 'config/rf2o.yaml', 'config/nvblox.yaml', 'config/explore.yaml']),
('share/rosorin_base/bt', ['bt/navigate_no_backup.xml']),
('share/rosorin_base/urdf', ['urdf/rosorin.urdf']),
],
install_requires=['setuptools'],
entry_points={'console_scripts': ['board_reader = rosorin_base.board_reader:main',
'lidar_reader = rosorin_base.lidar_reader:main',
'board_driver = rosorin_base.board_driver:main',
'stop_motors = rosorin_base.stop_motors:main',
'gamepad_teleop = rosorin_base.gamepad_teleop:main',
'rf2o_fix = rosorin_base.rf2o_fix:main',
'vslam_odom = rosorin_base.vslam_odom:main',
'depth_gate = rosorin_base.depth_gate:main',
'nav_supervisor = rosorin_base.nav_supervisor:main',
'nav_wait = rosorin_base.nav_supervisor:wait_main']},
)
packages=['rosorin_base']: the folder of Python modules.data_files: (target folder under install/rosorin_base/, list of source files). The resource marker goes intopackage.xml and the launch, config, behaviour-tree and URDF files go undershare/rosorin_base/. Code finds them there with get_package_share_directory('rosorin_base'). Files not listedconfig/arm_poses.yaml and config/fastdds_shm.xml are not.entry_points: each line name = module:function becomes an executable name that calls function(). Thisboard_driver, stop_motors and lidar_reader.motion.py holds all of the motion logic as plain Python: no ROS, no serial port. That makes it testable on any
computer, and the driver only feeds it inputs and the clock. Build it up in
~/ros2_ws/src/rosorin_base/rosorin_base/motion.py.
The header, the motor function number, and two helpers:
On the robot:
"""Pure motion logic (no ROS, no I/O) - see docs/motion_safety.md."""
import math
import struct
from rosorin_base.rrc_protocol import crc8_maxim
FUNC_MOTOR = 3
def clamp(v, lim):
return max(-lim, min(lim, v))
def ramp(cur, tgt, max_step):
return cur + clamp(tgt - cur, max_step)
clamp limits a value to plus or minus lim. ramp moves cur toward tgt by at most max_step: called every
tick, it turns a jump in the command into a slope.
The gamepad mapping, used by the optional gamepad_teleop node (the driver's own gamepad modes do their own
mapping):
On the robot:
def joy_to_twist(axes, max_lin, max_ang, deadzone=0.1):
"""Gamepad axes, raw order (lx, ly, rx, ry, ...) each raw/127 -> (vx, vy, wz). Factory default layout."""
lx, ly, rx = (0.0 if abs(v) < deadzone else max(-1.0, min(1.0, v)) for v in axes[:3])
return ly * max_lin, -lx * max_lin, -rx * max_ang
New idea: body velocity
ROS describes how a robot should move with three numbers in ageometry_msgs/Twistmessage:linear.x
forward speed (m/s),linear.ysideways speed, positive to the left (m/s), andangular.zturn rate, positive
counter-clockwise seen from above (rad/s). The frame is the robot's own body (base_link: x forward, y left,
z up). The topic for it iscmd_vel.
Advancing a pose by a body velocity, used for the wheel odometry later in this chapter:
On the robot:
def integrate_pose(x, y, th, vx, vy, wz, dt):
"""Advance a planar pose by body velocity over dt (midpoint heading)."""
h = th + wz * dt / 2.0
c, s = math.cos(h), math.sin(h)
x += (vx * c - vy * s) * dt
y += (vx * s + vy * c) * dt
th = math.atan2(math.sin(th + wz * dt), math.cos(th + wz * dt))
return x, y, th
It turns the body velocity into a world-frame step using the heading halfway through the time step, then keeps the
heading between -pi and pi.
New idea: mecanum kinematics
A mecanum wheel has small rollers around its rim at 45 degrees. Each wheel pushes the robot partly forward and
partly sideways, so by choosing the four wheel speeds the robot can drive forward, slide sideways, turn on the
spot, or any mix. Kinematics is the formula from the body velocity (vx, vy, wz) to the four wheel speeds.
For this chassis (factory chassis code, read as a reference,docs/motion_safety.md): with
k = (wheelbase + track) / 2 = (0.17706 + 0.17165) / 2 m, motor1 = vx - vy - wz k, motor2 = vx + vy - wz k,
motor3 = -(vx + vy + wz k), motor4 = -(vx - vy + wz k), each in m/s at the wheel rim. Dividing by the wheel
circumference (pi x 0.08 m) gives revolutions per second, the unit the board takes.
On the robot:
def mecanum_rps(vx, vy, wz, wheel_d=0.08, wheelbase=0.17706, track=0.17165):
"""Body velocity (m/s, m/s, rad/s) -> wheel rps for motors 1..4 (factory layout/signs)."""
k = (wheelbase + track) / 2.0
m1 = vx - vy - wz * k
m2 = vx + vy - wz * k
m3 = vx + vy + wz * k
m4 = vx - vy + wz * k
c = math.pi * wheel_d
return [m1 / c, m2 / c, -m3 / c, -m4 / c]
Worked values (computed from this function): forward 0.2 m/s gives [0.796, 0.796, -0.796, -0.796] rps; turning
at 1.0 rad/s gives -0.694 on all four (the motors on one side are mounted mirrored, so the same sign on the wire
turns the sides opposite ways); sliding left at 0.2 m/s gives [-0.796, 0.796, -0.796, 0.796]. On the stand on
2026-09-27 a turn of +0.5 rad/s drove the left wheels backward and the right wheels forward, the right sense for
counter-clockwise (docs/motion_safety.md, test C).
The motor frame and the stop frame, the same bytes you built by hand in chapter 10:
On the robot:
def motor_frame(rps):
"""rps for motors 1..4 -> board frame (func 3, sub 0x01, wire ids 0..3)."""
payload = bytes([0x01, len(rps)]) + b''.join(struct.pack('<Bf', i, float(r)) for i, r in enumerate(rps))
body = bytes([FUNC_MOTOR, len(payload)]) + payload
return b'\xaa\x55' + body + bytes([crc8_maxim(body)])
STOP_FRAME = motor_frame([0.0, 0.0, 0.0, 0.0])
MotionGate decides, every tick, what body velocity the wheels get. Its rule: speeding up is gradual, stopping is
immediate.
On the robot:
class MotionGate:
"""Decides the commanded body velocity each tick. Safety stops are immediate (no ramp)."""
def __init__(self, max_lin=0.2, max_ang=1.0, acc_lin=0.5, acc_ang=2.0, timeout=0.3, sense_timeout=0.0):
self.max_lin, self.max_ang = max_lin, max_ang
self.acc_lin, self.acc_ang = acc_lin, acc_ang
self.timeout = timeout
# Sensing gate (2026-10-02): commands from software (cmd_vel) move the wheels only while the obstacle sensor
# is alive. On Humble the Nav2 collision monitor IGNORES a stale scan and passes commands through unchanged
# (measured, scripts/tests/nav2_isolated_checks.sh), and the contact reflex runs off LiDAR odometry, so a
# silent LiDAR removed every guard at once. 0 = off. Not latched: motion resumes when the sensor is back.
# Gamepad commands (manual=True) are exempt: a person holding the pad is looking.
self.sense_timeout = sense_timeout
self.sense_t = None
self.enabled = False # disabled at start (rule 3)
self.estop = None # None or reason string (latched, rule 6)
self.cmd = (0.0, 0.0, 0.0)
self.cmd_t = None
self.cmd_manual = False
self.cur = (0.0, 0.0, 0.0)
The limits default to 0.2 m/s and 1.0 rad/s, with 0.5 m/s^2 and 2.0 rad/s^2 of acceleration and a 0.3 s deadman.
sense_timeout is the scan gate, explained below. The gate starts with enabled = False (rule 3) and no e-stop.
The inputs:
On the robot:
def set_cmd(self, vx, vy, wz, now, manual=False):
self.cmd = (vx, vy, wz)
self.cmd_t = now
self.cmd_manual = manual
def sensed(self, now):
"""The obstacle sensor delivered data (the LiDAR published a scan)."""
self.sense_t = now
def trip(self, reason):
if self.estop is None:
self.estop = reason
self.enabled = False
self.cur = (0.0, 0.0, 0.0)
def reset(self):
self.estop = None # motion stays disabled until enabled again
def enable(self, on):
if on and self.estop is not None:
return False
self.enabled = on
if not on:
self.cur = (0.0, 0.0, 0.0)
return True
set_cmd stores the latest command and its time. manual=True marks a gamepad command.sensed records when the LiDAR last delivered a scan.trip latches an e-stop: it keeps the first reason, disables motion and zeroes the current speed at once.reset clears the latch and nothing else: motion stays disabled.enable(True) is refused while an e-stop is latched; enable(False) zeroes the speed.Why the wheels are blocked, if they are:
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
Why the scan gate
On 2026-10-02 the stock Nav2 collision monitor on this robot (Humble 1.1.20) was tested with a silent LiDAR: it
ignores a scan older than its timeout and passes the command through unchanged (0.2 m/s in, 0.2 m/s out with a
wall at the nose). The contact reflex (chapter 19) runs on LiDAR odometry and stops evaluating too. A silent
LiDAR removed every guard at once. Since then software commands move the wheels only while scans arrive: no scan
for 0.5 s blocks them (no scan), not latched, and the wheels follow commands again when scans return. Gamepad
commands are exempt, because a person holding the pad is watching the robot and must be able to move it even
with a dead LiDAR (docs/lessons.md2026-10-02 afternoon,docs/motion_safety.md).
One tick:
On the robot:
def step(self, now, dt):
if self.block_reason(now) is not None:
self.cur = (0.0, 0.0, 0.0)
return self.cur
vx, vy, wz = self.cmd
tgt = (clamp(vx, self.max_lin), clamp(vy, self.max_lin), clamp(wz, self.max_ang))
self.cur = (ramp(self.cur[0], tgt[0], self.acc_lin * dt),
ramp(self.cur[1], tgt[1], self.acc_lin * dt),
ramp(self.cur[2], tgt[2], self.acc_ang * dt))
return self.cur
Any block reason gives zero at once, with no ramp. Otherwise the command is clamped to the limits and the current
speed moves toward it by at most acceleration x time step: at 50 Hz and 0.5 m/s^2 that is 0.01 m/s per tick.
On the robot:
"""Pure motion logic (no ROS, no I/O) - see docs/motion_safety.md."""
import math
import struct
from rosorin_base.rrc_protocol import crc8_maxim
FUNC_MOTOR = 3
def clamp(v, lim):
return max(-lim, min(lim, v))
def ramp(cur, tgt, max_step):
return cur + clamp(tgt - cur, max_step)
def joy_to_twist(axes, max_lin, max_ang, deadzone=0.1):
"""Gamepad axes, raw order (lx, ly, rx, ry, ...) each raw/127 -> (vx, vy, wz). Factory default layout."""
lx, ly, rx = (0.0 if abs(v) < deadzone else max(-1.0, min(1.0, v)) for v in axes[:3])
return ly * max_lin, -lx * max_lin, -rx * max_ang
def integrate_pose(x, y, th, vx, vy, wz, dt):
"""Advance a planar pose by body velocity over dt (midpoint heading)."""
h = th + wz * dt / 2.0
c, s = math.cos(h), math.sin(h)
x += (vx * c - vy * s) * dt
y += (vx * s + vy * c) * dt
th = math.atan2(math.sin(th + wz * dt), math.cos(th + wz * dt))
return x, y, th
def mecanum_rps(vx, vy, wz, wheel_d=0.08, wheelbase=0.17706, track=0.17165):
"""Body velocity (m/s, m/s, rad/s) -> wheel rps for motors 1..4 (factory layout/signs)."""
k = (wheelbase + track) / 2.0
m1 = vx - vy - wz * k
m2 = vx + vy - wz * k
m3 = vx + vy + wz * k
m4 = vx - vy + wz * k
c = math.pi * wheel_d
return [m1 / c, m2 / c, -m3 / c, -m4 / c]
def motor_frame(rps):
"""rps for motors 1..4 -> board frame (func 3, sub 0x01, wire ids 0..3)."""
payload = bytes([0x01, len(rps)]) + b''.join(struct.pack('<Bf', i, float(r)) for i, r in enumerate(rps))
body = bytes([FUNC_MOTOR, len(payload)]) + payload
return b'\xaa\x55' + body + bytes([crc8_maxim(body)])
STOP_FRAME = motor_frame([0.0, 0.0, 0.0, 0.0])
class MotionGate:
"""Decides the commanded body velocity each tick. Safety stops are immediate (no ramp)."""
def __init__(self, max_lin=0.2, max_ang=1.0, acc_lin=0.5, acc_ang=2.0, timeout=0.3, sense_timeout=0.0):
self.max_lin, self.max_ang = max_lin, max_ang
self.acc_lin, self.acc_ang = acc_lin, acc_ang
self.timeout = timeout
# Sensing gate (2026-10-02): commands from software (cmd_vel) move the wheels only while the obstacle sensor
# is alive. On Humble the Nav2 collision monitor IGNORES a stale scan and passes commands through unchanged
# (measured, scripts/tests/nav2_isolated_checks.sh), and the contact reflex runs off LiDAR odometry, so a
# silent LiDAR removed every guard at once. 0 = off. Not latched: motion resumes when the sensor is back.
# Gamepad commands (manual=True) are exempt: a person holding the pad is looking.
self.sense_timeout = sense_timeout
self.sense_t = None
self.enabled = False # disabled at start (rule 3)
self.estop = None # None or reason string (latched, rule 6)
self.cmd = (0.0, 0.0, 0.0)
self.cmd_t = None
self.cmd_manual = False
self.cur = (0.0, 0.0, 0.0)
def set_cmd(self, vx, vy, wz, now, manual=False):
self.cmd = (vx, vy, wz)
self.cmd_t = now
self.cmd_manual = manual
def sensed(self, now):
"""The obstacle sensor delivered data (the LiDAR published a scan)."""
self.sense_t = now
def trip(self, reason):
if self.estop is None:
self.estop = reason
self.enabled = False
self.cur = (0.0, 0.0, 0.0)
def reset(self):
self.estop = None # motion stays disabled until enabled again
def enable(self, on):
if on and self.estop is not None:
return False
self.enabled = on
if not on:
self.cur = (0.0, 0.0, 0.0)
return True
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
vx, vy, wz = self.cmd
tgt = (clamp(vx, self.max_lin), clamp(vy, self.max_lin), clamp(wz, self.max_ang))
self.cur = (ramp(self.cur[0], tgt[0], self.acc_lin * dt),
ramp(self.cur[1], tgt[1], self.acc_lin * dt),
ramp(self.cur[2], tgt[2], self.acc_ang * dt))
return self.cur
ros2/rosorin_base/test/test_motion.py (copied above) checks it. Read it: each test is a few lines.
test_stop_frame_matches_test1_bytes: STOP_FRAME is the exact byte string that stopped the wheels on 2026-09-27.test_frame_roundtrip: a motor frame passes your own parser.test_kinematics_signs: forward and rotation signs.test_disabled_at_start_and_timeout: no motion before enable, zero 0.31 s after the last commandcmd timeout).test_limits_and_ramp: absurd commands are clamped to (0.2, -0.2, 1.0); the first tick moves 0.01 m/s.test_estop_latches_and_blocks_enable: e-stop zeroes, refuses enable, and reset leaves it disabled.test_integrate_pose: straight line, heading, sideways, full circle.On the robot:
cd ~/ros2_ws/src/rosorin_base
python3 -m pytest -q test/test_motion.py
Check
Recorded on this robot on 2026-10-02:......... [100%] 9 passed in 0.10s
Chapter 10 showed the gyro reading about -1.9 and -2.2 deg/s while standing still. The driver measures that bias for
3 s at start (shown later in board_driver.py). That is not enough on its own: the leftover bias wanders by about
0.01 deg/s over tens of seconds, and the heading estimate drifted 11.4 degrees in about 8 minutes on 2026-09-27
(docs/odometry.md). GyroBiasTracker keeps following the bias whenever the robot is commanded to stand still.
Create ~/ros2_ws/src/rosorin_base/rosorin_base/gyro_bias.py, first the class and its "standing still" bookkeeping:
On the robot:
"""Gyro bias tracking while the robot is commanded still (pure logic, no ROS).
Startup calibration gives the initial bias; the residual wanders (measured 2026-09-27: +-0.01 deg/s
over tens of seconds -> EKF heading drift equal to the gyro integral). While the commanded velocity
has been zero for `settle` s AND every axis reads within `max_dev` of the current bias (rejects
the robot being pushed/turned by hand), the bias follows the reading with time constant `tau`.
"""
class GyroBiasTracker:
def __init__(self, bias=(0.0, 0.0, 0.0), tau=20.0, settle=1.0, max_dev=0.5):
self.bias = list(bias)
self.tau, self.settle, self.max_dev = tau, settle, max_dev
self.still_since = None
self.last_t = None
def set_commanded_still(self, still, now):
if not still:
self.still_since = None
elif self.still_since is None:
self.still_since = now
def tracking(self, now):
return self.still_since is not None and now - self.still_since >= self.settle
Then the update, called with every gyro sample:
On the robot:
def update(self, g, now):
"""g: raw gyro (deg/s, 3 axes). Returns bias-corrected gyro."""
dt = 0.0 if self.last_t is None else now - self.last_t
self.last_t = now
if self.tracking(now) and dt > 0 and all(abs(x - b) <= self.max_dev for x, b in zip(g, self.bias)):
a = min(dt / self.tau, 1.0)
self.bias = [b + a * (x - b) for x, b in zip(g, self.bias)]
return tuple(x - b for x, b in zip(g, self.bias))
While the commanded velocity has been zero for settle (1 s) and every axis reads within max_dev (0.5 deg/s) of
the current bias, the bias moves toward the reading by dt / tau of the difference (tau 20 s). A push or a turn by
hand reads far more than 0.5 deg/s and is ignored. While driving, the bias is frozen. The complete file is the two
blocks above with one blank line between them, 33 lines, the same as ros2/rosorin_base/rosorin_base/gyro_bias.py.
On the robot:
cd ~/ros2_ws/src/rosorin_base
python3 -m pytest -q test/test_gyro_bias.py
Check
2 passed. The two tests: the bias moves only when commanded still and settled, and a 10 deg/s turn by hand or
driving leaves it alone. With the tracker in place the heading at rest stayed within +-0.11 degrees over 5
minutes (docs/odometry.md, 2026-09-27).
stop_motors writes the stop frame to the board directly, without ROS. It runs whenever the driver is gone: from the
launch file the moment the driver exits, and from systemd after the service stops. Create
~/ros2_ws/src/rosorin_base/rosorin_base/stop_motors.py, starting with the header and the wait time:
On the robot:
"""Write the stop frame directly to the board (systemd ExecStopPost; also usable by hand)."""
import os
import sys
import termios
import time
from rosorin_base.motion import STOP_FRAME
WAIT_S = 30.0 # board USB drop (tested 2026-09-28: gone ~3 s, re-enumerates as ttyACM0); board has no
# stop watchdog, so keep trying until it is back. After this, systemd restarts the
# service (RestartSec=3) and this runs again; board_driver also sends STOP at start.
Opening the port, retrying:
On the robot:
def main():
dev = sys.argv[1] if len(sys.argv) > 1 else '/dev/ttyACM0'
wait = float(sys.argv[2]) if len(sys.argv) > 2 else WAIT_S
t0, warned = time.monotonic(), False
while True: # port may be closing, or the device re-enumerating
try:
fd = os.open(dev, os.O_RDWR | os.O_NOCTTY)
break
except OSError as e:
if time.monotonic() - t0 > wait:
print(f'stop_motors: cannot open {dev} for {wait:.0f} s: {e}', file=sys.stderr)
sys.exit(1)
if not warned and time.monotonic() - t0 > 1.0:
print(f'stop_motors: {dev} unavailable ({e}); retrying up to {wait:.0f} s', file=sys.stderr)
warned = True
time.sleep(0.2)
The port can be busy for a moment (the dying driver still closing it) or gone (the board's USB re-enumerating). It
retries every 0.2 s for up to 30 s, warns once after 1 s, and gives up after the wait.
Raw mode, three stop frames, done:
On the robot:
a = termios.tcgetattr(fd)
a[0] = a[1] = a[3] = 0
a[2] = termios.CS8 | termios.CREAD | termios.CLOCAL
a[4] = a[5] = termios.B1000000
termios.tcsetattr(fd, termios.TCSANOW, a)
for _ in range(3):
os.write(fd, STOP_FRAME)
os.close(fd)
print(f'stop_motors: stop frame sent x3 (after {time.monotonic() - t0:.1f} s)')
There is no TIOCEXCL here on purpose: stop_motors must not lock out the driver that systemd starts 3 s later.
The reverse does hold: while the driver owns the port, stop_motors cannot open it. It is a tool for when the
driver is gone, not a second way in.
The complete file:
On the robot:
"""Write the stop frame directly to the board (systemd ExecStopPost; also usable by hand)."""
import os
import sys
import termios
import time
from rosorin_base.motion import STOP_FRAME
WAIT_S = 30.0 # board USB drop (tested 2026-09-28: gone ~3 s, re-enumerates as ttyACM0); board has no
# stop watchdog, so keep trying until it is back. After this, systemd restarts the
# service (RestartSec=3) and this runs again; board_driver also sends STOP at start.
def main():
dev = sys.argv[1] if len(sys.argv) > 1 else '/dev/ttyACM0'
wait = float(sys.argv[2]) if len(sys.argv) > 2 else WAIT_S
t0, warned = time.monotonic(), False
while True: # port may be closing, or the device re-enumerating
try:
fd = os.open(dev, os.O_RDWR | os.O_NOCTTY)
break
except OSError as e:
if time.monotonic() - t0 > wait:
print(f'stop_motors: cannot open {dev} for {wait:.0f} s: {e}', file=sys.stderr)
sys.exit(1)
if not warned and time.monotonic() - t0 > 1.0:
print(f'stop_motors: {dev} unavailable ({e}); retrying up to {wait:.0f} s', file=sys.stderr)
warned = True
time.sleep(0.2)
a = termios.tcgetattr(fd)
a[0] = a[1] = a[3] = 0
a[2] = termios.CS8 | termios.CREAD | termios.CLOCAL
a[4] = a[5] = termios.B1000000
termios.tcsetattr(fd, termios.TCSANOW, a)
for _ in range(3):
os.write(fd, STOP_FRAME)
os.close(fd)
print(f'stop_motors: stop frame sent x3 (after {time.monotonic() - t0:.1f} s)')
Why 30 s
On 2026-09-28 the board's USB was dropped on purpose (sysfs unbind and bind of USB 1-2.1, robot on the stand,
wheels disabled). The driver's next write failed withEIOand it exited. With a 1 s waitstop_motorsgave up
while the device was still gone. With the 30 s wait: a 3 s drop recovered with topics back after about 8 s; an
8 s drop got its stop frame 7.6 s after the crash, the moment the device returned, and the service was back at
+14 s. The risk that stays: while the board's USB is gone, nothing can stop the wheels; they keep their last
speed until it returns (docs/motion_safety.md, "Board USB drop").
board_driver.py is 1138 lines; you copied it above. About half of it is the arm (chapter 13). This section walks
through the parts that own the port, the wheels, the IMU and the battery. Read the file next to it:
ros2/rosorin_base/rosorin_base/board_driver.py.
| Lines | What |
|---|---|
| 1-69 | imports, function numbers, buzzer tones, arm ids, PLUG_STEP_MV |
| 72-106 | node rosorin_board_driver, parameters, the MotionGate, gyro tracker, odometry settings |
| 108-126 | topics, services, last charger state |
| 127-210 | arm parameters, arm services and inputs (chapter 13) |
| 212-233 | open the port, send STOP, start the read thread and the timers |
| 235-303 | write, the 50 Hz control loop, trip |
| 305-390 | buzzer tones and gamepad modes |
| 392-430 | cmd_vel, estop, ~/enable, ~/reset_estop |
| 432-950 | arm: servo queries, poses, tuck, trajectories, jog, auto-tuck, joint states (chapters 12-13) |
| 951-964 | the ~/state JSON |
| 966-1023 | the read thread: frames from the board |
| 1025-1058 | gyro calibration at start, IMU messages |
| 1060-1075 | wheel odometry |
| 1077-1115 | battery, charger detection, battery log |
| 1117-1138 | shutdown and main |
New idea: nodes, topics and messages
A ROS 2 program that takes part in the system is a node; it has a name, hererosorin_board_driver. Nodes
exchange messages on named topics: a publisher sends, any number of subscribers receive, and neither
knows the other. Each topic carries one message type (sensor_msgs/Imu,geometry_msgs/Twist, ...). A name
starting with~/is private to the node:~/statebecomes/rosorin_board_driver/state.
On the robot:
class BoardDriver(Node):
def __init__(self):
super().__init__('rosorin_board_driver')
dp = self.declare_parameter
self.dev = dp('device', '/dev/ttyACM0').value
self.frame_id = dp('imu_frame_id', 'imu_link').value
self.calib_s = dp('gyro_calib_seconds', 3.0).value
self.calib_max_std = dp('gyro_calib_max_std_dps', 0.3).value
self.estop_key = dp('estop_key', 1).value
self.rate = dp('control_rate_hz', 50.0).value
self.gate = MotionGate(max_lin=dp('max_linear', 0.2).value, max_ang=dp('max_angular', 1.0).value,
acc_lin=dp('max_accel_linear', 0.5).value,
acc_ang=dp('max_accel_angular', 2.0).value,
timeout=dp('cmd_timeout', 0.3).value,
sense_timeout=dp('scan_timeout', 0.5).value) # LiDAR ring = ~0.1 s; 0 = gate off
self.scan_blocked = False
self.acc_off = list(dp('accel_offset_g', [0.0, 0.0, 0.0]).value)
self.acc_scale = list(dp('accel_scale', [1.0, 1.0, 1.0]).value)
self.gyro = GyroBiasTracker(tau=dp('gyro_bias_tau_s', 20.0).value,
settle=dp('gyro_bias_settle_s', 1.0).value,
max_dev=dp('gyro_bias_max_dev_dps', 0.5).value)
# measured 2026-09-27 at rest: gyro std 0.0007-0.0011 rad/s
self.gyro_var = dp('gyro_noise_rad_s', 0.0012).value ** 2
# no wheel encoders (board protocol has no feedback): wheel/odom = gated COMMANDED velocity
self.odom_frame = dp('odom_frame_id', 'odom').value
self.base_frame = dp('base_frame_id', 'base_link').value
self.odom_lin_var = dp('odom_linear_std', 0.05).value ** 2
self.odom_ang_var = dp('odom_angular_std', 0.2).value ** 2
# commanded -> actual body velocity (floor, LiDAR scan match, docs/odometry.md):
# strafe 0.79/0.78 left, 0.73/0.76 right -> mean 0.765; forward ~1.02 (1 run, not applied)
self.odom_scale_x = dp('odom_scale_x', 1.0).value
self.odom_scale_y = dp('odom_scale_y', 0.765).value
self.odom_pose = (0.0, 0.0, 0.0)
self.odom_t = None
self.calib, self.calib_t0 = [], None
New idea: parameters
A parameter is a named setting of a node, declared in code with a default (declare_parameter('cmd_timeout', 0.3)) and overridable at start from a launch file or a YAML file, without changing code.ros2 param get <node> <name>reads the value a running node uses. Parameters files name the node they belong to; both
configuration files used here start withrosorin_board_driver:andros__parameters:.
The ones that matter for driving:
| Parameter | Default in code | Running value | Meaning |
|---|---|---|---|
device |
/dev/ttyACM0 |
same (launch file) | the board's port |
control_rate_hz |
50.0 | same | control loop rate |
max_linear |
0.2 | 0.35 (config/arm_limits.yaml, owner 2026-10-01) |
m/s limit, both x and y |
max_angular |
1.0 | same | rad/s limit |
max_accel_linear, max_accel_angular |
0.5, 2.0 | same | m/s^2, rad/s^2 |
cmd_timeout |
0.3 | same | deadman, s |
scan_timeout |
0.5 | same | scan gate, s; 0 turns it off |
gyro_calib_seconds, gyro_calib_max_std_dps |
3.0, 0.3 | same | start-up bias window and stillness test |
gyro_bias_tau_s, _settle_s, _max_dev_dps |
20, 1, 0.5 | same | GyroBiasTracker |
accel_offset_g, accel_scale |
0s, 1s | [0.0460, -0.0635, -0.0501], [1.0058, 0.9964, 1.0076] (config/imu_calibration.yaml) |
accelerometer calibration |
odom_scale_x, odom_scale_y |
1.0, 0.765 | same | wheel odometry correction |
pad_sounds |
false | same | buzzer tones; off since the 2026-09-29 incident |
drive_pan |
-1 | same (navigation sets 500) | base joint when the wheels are enabled |
docs/motion_safety.md rule 5 still says 0.2 m/s; the running value is 0.35 from config/arm_limits.yaml.
Two places for one setting
A launch file'sparameters=[{...}]silently overrides thedeclare_parameterdefault, and a YAML file
overrides both if it comes later in the list (docs/lessons.md, factory stack). Keep each tunable in one place.
Here the launch file sets onlydeviceandimu_frame_id, to the same values as the code defaults.
On the robot:
self.imu_pub = self.create_publisher(Imu, 'imu/data_raw', 10)
self.bat_pub = self.create_publisher(BatteryState, 'battery', 10)
self.state_pub = self.create_publisher(String, '~/state', 10)
self.odom_pub = self.create_publisher(Odometry, 'wheel/odom', 10)
self.joy_pub = self.create_publisher(Joy, 'gamepad', 10)
self.create_subscription(Twist, 'cmd_vel', self.on_cmd, 10)
# 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)
self.create_subscription(Bool, 'estop', self.on_estop, 10)
self.create_service(SetBool, '~/enable', self.on_enable)
self.create_service(Trigger, '~/reset_estop', self.on_reset)
self.create_service(Trigger, '~/arm/read_state', self.on_arm_read)
self.servo_q = queue.Queue()
self.plugged = None # charger: True/False after a detected step, None = unknown
try: # a driver restart doesn't move the cable: last detected state survives it
self.plugged = json.load(open(os.path.expanduser('~/battery/plugged.json')))['plugged']
except (OSError, ValueError, KeyError):
pass
New idea: services
A service is a request with a reply, for actions that happen once:~/enabletakes astd_srvs/SetBool
(data: trueorfalse) and answerssuccessand amessage;~/reset_estoptakes astd_srvs/Trigger(no
arguments). Topics are for streams, services for "do this now and tell me if it worked".
| Topic or service | Type | Direction | Notes |
|---|---|---|---|
imu/data_raw |
sensor_msgs/Imu |
out, ~110 Hz | frame imu_link, SI units, bias-corrected gyro |
battery |
sensor_msgs/BatteryState |
out, ~1 Hz | voltage only, percentage NaN |
wheel/odom |
nav_msgs/Odometry |
out, 50 Hz | from the gated commanded velocity |
gamepad |
sensor_msgs/Joy |
out, ~20 Hz | raw axes / 127, hat value, 16 button bits |
~/state |
std_msgs/String (JSON) |
out, 10 Hz | everything the driver knows about itself |
joint_states, ~/arm_busy |
out | arm (chapters 12-13) | |
cmd_vel |
geometry_msgs/Twist |
in | the only wheel input |
scan |
sensor_msgs/LaserScan |
in | only the arrival time is used (raw=True: no decoding) |
estop |
std_msgs/Bool |
in | true latches the e-stop |
~/enable |
std_srvs/SetBool |
service | wheels on (moves the arm to the drive pose first) or off |
~/reset_estop |
std_srvs/Trigger |
service | clears the latch; motion stays disabled |
~/arm/... |
services | chapter 13 |
The plugged.json lines read the last charger state from disk: a driver restart does not unplug the charger.
On the robot:
self.lock = threading.Lock()
self.parser = FrameParser()
self.fd = os.open(self.dev, os.O_RDWR | os.O_NOCTTY)
fcntl.ioctl(self.fd, termios.TIOCEXCL)
a = termios.tcgetattr(self.fd)
a[0] = a[1] = a[3] = 0
a[2] = termios.CS8 | termios.CREAD | termios.CLOCAL
a[4] = a[5] = termios.B1000000
a[6][termios.VMIN] = 1
a[6][termios.VTIME] = 0
termios.tcsetattr(self.fd, termios.TCSANOW, a)
self.write(STOP_FRAME) # known state at start
self.last_rps, self.last_write = None, 0.0
self.last_state = None
self.running = True
self.thread = threading.Thread(target=self.read_loop, daemon=True)
self.thread.start()
self.timer = self.create_timer(1.0 / self.rate, self.control)
self.create_timer(0.1, self.publish_state) # 10 Hz (was 2): clients see arm arrival quickly
self.create_timer(0.1, self.publish_joints)
self.get_logger().info(f'owning {self.dev}; motion DISABLED until ~/enable')
The same raw settings as chapter 10, read-write and exclusive. The first frame written is STOP_FRAME: whatever the
wheels were doing before this start, they stop now. Then the read thread starts, the 50 Hz control timer, and two
10 Hz timers for the state and the joint states. The log line owning /dev/ttyACM0; motion DISABLED until ~/enable
is the one to look for.
On the robot:
# ---------- output
def write(self, frame):
with self.lock:
os.write(self.fd, frame)
def control(self):
now = time.monotonic()
if self.count_publishers('cmd_vel') > 1:
self.trip('multiple cmd_vel publishers')
if self.count_publishers('arm/goal') > 1:
self.trip('multiple arm/goal publishers')
self.arm_tick += 1
self.pad_step(now)
if self.arm_traj is not None and self.arm_tick % 2 == 0: # 25 Hz streaming
targets = self.arm_traj.at(now)
self.write(arm_move_frame(targets, 40))
self.arm_active_t = now
self.arm_pose.update(targets)
self.joint_pulses.update(targets)
if self.arm_traj.done(now):
if self.fjt and self.fjt[2] is self.arm_traj:
self.fjt_finish('ok')
self.arm_traj = None
elif self.follow is not None and self.arm_tick % 2 == 0:
if now - self.follow['t'] > FOLLOW_TIMEOUT or not self.arm_enabled:
self.joint_pulses.update({k: self.arm_pose[k] for k in self.follow['target']})
self.follow = None # stale: servos hold the last streamed position
else:
# FRACTIONAL pulses internally + reported: Servo integrates from the reported state, and rounding it
# stalled the loop (2026-09-29: 0.3 rad/s commanded -> ~0.04 rad/s). Only the servo frame is rounded.
step = self.arm_speed * 2.0 / self.rate # pulses per 25 Hz tick at the speed cap
pos = self.follow.setdefault('pos', {k: float(self.arm_pose[k]) for k in self.follow['target']})
for k, v in self.follow['target'].items():
pos[k] += max(-step, min(step, v - pos[k]))
out = {k: int(round(v)) for k, v in pos.items()}
if out != {k: self.arm_pose[k] for k in out}:
self.write(arm_move_frame(out, 40))
self.arm_pose.update(out)
self.arm_activity(holding=True)
elif self.jog is not None and self.arm_tick % 2 == 0:
self.jog_step(now)
if self.fjt and self.fjt[0].is_cancel_requested:
self.arm_hold('trajectory canceled')
vx, vy, wz = self.gate.step(now, 1.0 / self.rate)
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')
self.publish_wheel_odom(now, vx, vy, wz)
self.gyro.set_commanded_still(vx == 0.0 and vy == 0.0 and wz == 0.0, now)
rps = mecanum_rps(vx, vy, wz)
if rps != self.last_rps or now - self.last_write > 0.2: # on change + 5 Hz refresh
self.write(motor_frame(rps))
self.last_rps, self.last_write = rps, now
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
if first:
self.tone(*TONE_ESTOP)
self.get_logger().warn(f'E-STOP latched: {reason}')
Every 20 ms:
cmd_vel (or arm/goal) trips the e-stop (rule 2). count_publishers asks ROS howgate.step gives the allowed body velocity.no scan is logged, once each way. The two log calls are separate lines on purpose:docs/lessons.md 2026-10-02).trip latches the gate, writes STOP_FRAME at once instead of waiting for the next tick, holds the arm, drops the
gamepad to locked, and logs E-STOP latched: <reason> once.
The buzzer tone used by the gamepad modes, with the 100 ms off time from chapter 10, silent unless pad_sounds is
true:
On the robot:
def tone(self, freq, ms):
"""Board buzzer (func 2, <HHHH freq, on ms, off ms, repeat> - factory SDK reference): one tone.
2026-09-29: off-time 0 left the buzzer sounding continuously (stopped only by a raw 'buzzer off' frame), so
tones now use a 100 ms off-time like the SDK examples, and sounds stay OFF unless `pad_sounds` is true."""
if not self.pad_sounds:
return
self.write(encode_frame(FUNC_BUZZER, struct.pack('<HHHH', int(freq), int(ms), 100, 1)))
On the robot:
def on_cmd(self, m):
self.gate.set_cmd(m.linear.x, m.linear.y, m.angular.z, time.monotonic())
def on_estop(self, m):
if m.data:
self.trip('estop topic')
def on_enable(self, req, res):
# driving happens in the DRIVE pose (camera down on the floor ahead), never tucked (owner 2026-09-29):
# enabling the wheels first moves the arm there (single slow move; arm must be disabled, no e-stop)
# 2026-10-05: the wheels are NEVER enabled with the arm anywhere else. Before, an arm still held by the mind
# (tracking the owner) made this step skip silently: a whole exploration run drove with the arm stretched to
# one side, camera on the owner instead of the floor ahead (owner: "awkward ... could get snagged"). Now the
# arm is taken over, moved, and checked; if it is not in the drive pose afterwards the enable is refused.
if req.data and not self.gate.enabled and self.gate.estop is None:
if self.arm_enabled or self.arm_traj is not None or self.follow is not None or self.jog is not None:
self.arm_hold('wheels enabled')
if not self.in_drive_pose():
self.on_arm_drive(Trigger.Request(), Trigger.Response())
if not self.in_drive_pose(tol=30):
res.success, res.message = False, 'refused: arm is not in the drive pose'
self.get_logger().warn(f'enable(True) -> {res.message}')
return res
res.success = self.gate.enable(req.data)
if not req.data:
self.write(STOP_FRAME)
self.wheels_off_t = time.monotonic() # plug detection ignores the motor-stop transient
res.message = 'enabled' if (req.data and res.success) else (
'refused: e-stop latched (' + str(self.gate.estop) + ')' if req.data else 'disabled')
self.get_logger().info(f'enable({req.data}) -> {res.message}')
return res
def on_reset(self, req, res):
was = self.gate.estop
self.gate.reset()
res.success, res.message = True, f'e-stop cleared (was: {was}); motion still disabled'
self.get_logger().info(res.message)
return res
on_enable does more than flip the gate. Since 2026-10-05 the wheels are never enabled with the arm anywhere but the
drive pose (camera tilted down at the floor ahead): it takes the arm from whatever held it, moves it there in one
slow move, reads it back, and refuses with refused: arm is not in the drive pose if joints 2-5 (and the base
joint, when drive_pan is set) are more than 30 pulses off. Before that change an exploration run drove 4 m with
the arm stretched to one side, because the mind still held it (docs/lessons.md 2026-10-05). Disabling writes
STOP_FRAME and notes the time, which the charger detection uses.
Enabling the wheels moves the arm
~/enable truecan move the arm for a few seconds before it answers (4.8 s measured on 2026-09-29). Keep hands
and objects clear of the arm whenever you enable the wheels.
On the robot:
# ---------- telemetry
def read_loop(self):
while self.running:
ready, _, _ = select.select([self.fd], [], [], 0.2)
if not ready:
continue
data = os.read(self.fd, 512)
try:
for func, payload in self.parser.feed(data):
if func == FUNC_IMU and len(payload) == 24:
self.publish_imu(payload)
elif func == FUNC_SYS:
mv = decode_battery_mv(payload)
if mv is not None:
self.publish_battery(mv)
elif func == FUNC_BUS_SERVO:
self.servo_q.put(payload)
elif func == FUNC_GAMEPAD and len(payload) == 7:
buttons, hat, *axes = struct.unpack('<HB4b', payload)
j = Joy()
j.header.stamp = self.get_clock().now().to_msg()
j.axes = [a / 127.0 for a in axes] + [float(hat)] # raw order; hat raw value
j.buttons = [(buttons >> i) & 1 for i in range(16)]
self.joy_pub.publish(j)
mode_mask = 1 << self.pad_mode_bit
self.pad_hist.append((round(time.monotonic(), 2), payload.hex())) # raw frames, for diagnosis
# any button except MODE trips on its FIRST frame. A 2-frame confirmation was tried 2026-09-29
# and REVERTED: real quick taps last one frame at the pad's 20 Hz (8 owner taps -> 0 trips).
other = buttons & ~mode_mask
if other and self.gate.estop is None:
self.trip('gamepad button 0x%04x' % other)
self.get_logger().warn('gamepad trip raw frames (t, hex <HB4b>): %s' % list(self.pad_hist)[-6:])
now = time.monotonic()
pressed = bool(buttons & mode_mask)
if pressed and not self.pad_prev_mode and now - self.pad_mode_down_t > 0.6:
self.pad_mode_down_t = now # rising edge, 0.6 s lockout (pad pulses)
self.pad_mode_events += 1
self.pad_prev_mode = pressed
if pressed:
self.pad_mode_off = 0
if self.pad_mode_hold_t is None:
self.pad_mode_hold_t = now
elif now - self.pad_mode_hold_t >= 2.0 and self.gate.estop is not None:
self.pad_reset_req = True
self.pad_mode_hold_t = now + 999 # one reset per hold
else:
self.pad_mode_off += 1
if self.pad_mode_off >= 3:
self.pad_mode_hold_t = None
self.pad = (buttons, hat, axes, now) # acted on by the control loop
elif func == FUNC_KEY:
self.get_logger().info(f'key frame: {payload.hex()}')
if len(payload) >= 2 and payload[0] == self.estop_key and payload[1] in KEY_TRIP_EVENTS:
self.trip(f'key{self.estop_key}')
except Exception:
if not rclpy.ok():
return
raise
It waits up to 0.2 s for bytes (so it notices self.running going false), feeds the parser, and sends each frame
where it belongs: IMU and battery to their publishers, servo replies to a queue the arm code waits on, gamepad
frames to gamepad and the e-stop logic, key frames to the e-stop logic.
The gamepad is the physical e-stop (decision 2026-09-27; the board's key1 is not reachable on the assembled robot).
Any button except MODE trips on its first frame. A two-frame confirmation was tried on 2026-09-29 and reverted:
the pad reports at 20 Hz, and real quick taps last one frame (8 taps by the owner, 0 trips). Holding MODE for 2 s
while stopped clears the e-stop; the pad comes back locked and motion stays disabled (docs/motion_safety.md).
Why the try/except around the loop
rclpy's Ctrl-C handler shuts the ROS context down beforemain's cleanup runs. A thread that publishes after
that raises an exception. The loop treats "context gone" (not rclpy.ok()) as a normal stop and returns; any
other exception is real and is raised (docs/lessons.md2026-09-26). The read-only reader that introduced this
pattern was stopped five times in a row by SIGINT and SIGTERM on 2026-09-26, with no traceback.
On the robot:
def calibrate(self, g):
now = time.monotonic()
if self.calib_t0 is None:
self.calib_t0 = now
self.calib.append(g)
if now - self.calib_t0 < self.calib_s:
return False
stds = [statistics.pstdev(c[i] for c in self.calib) for i in range(3)]
if max(stds) <= self.calib_max_std:
self.gyro.bias = [statistics.mean(c[i] for c in self.calib) for i in range(3)]
self.get_logger().info('gyro bias (deg/s) %s from %d samples, std %s' % (
[round(b, 3) for b in self.gyro.bias], len(self.calib), [round(x, 3) for x in stds]))
else:
self.get_logger().warn('robot not stationary during gyro calibration (std %s deg/s); '
'publishing uncorrected gyro' % [round(x, 3) for x in stds])
self.calib = None
return True
def publish_imu(self, payload):
(ax, ay, az), (gx, gy, gz) = decode_imu(payload)
if self.calib is not None and not self.calibrate((gx, gy, gz)):
return
gx, gy, gz = self.gyro.update((gx, gy, gz), time.monotonic())
ax, ay, az = ((v - o) * k for v, o, k in zip((ax, ay, az), self.acc_off, self.acc_scale))
m = Imu()
m.header.stamp = self.get_clock().now().to_msg()
m.header.frame_id = self.frame_id
m.orientation_covariance[0] = -1.0
m.angular_velocity_covariance[0] = m.angular_velocity_covariance[4] = \
m.angular_velocity_covariance[8] = self.gyro_var
m.linear_acceleration.x, m.linear_acceleration.y, m.linear_acceleration.z = ax * G, ay * G, az * G
m.angular_velocity.x, m.angular_velocity.y, m.angular_velocity.z = (
math.radians(gx), math.radians(gy), math.radians(gz))
self.imu_pub.publish(m)
For the first 3 s the samples are collected and not published. If the robot was still (standard deviation at most
0.3 deg/s on every axis) their mean becomes the starting bias; otherwise a warning says so and the gyro is published
uncorrected until the tracker catches up. After that, each sample goes through GyroBiasTracker, the accelerometer
through (raw - offset) x scale, and out as an Imu message in m/s^2 and rad/s. orientation_covariance[0] = -1
is the ROS convention for "this message has no orientation estimate". The gyro variance 0.0012^2 sits just above
the noise measured at rest on 2026-09-27 (standard deviation 0.0007 to 0.0011 rad/s).
New idea: odometry
Odometry is the robot's own estimate of how far it has moved, added up from small steps. This board has no
wheel encoders: the motor frame is write-only and nothing reports how far a wheel actually turned
(docs/odometry.md). Sowheel/odomadds up the commanded velocity after the gate: what the wheels were
told, not what they did. That is dead reckoning, and it is wrong whenever a wheel slips or stalls, and on the
stand. Chapter 16 fuses it with the gyro in an EKF and adds LiDAR odometry.
On the robot:
def publish_wheel_odom(self, now, vx, vy, wz):
vx, vy = vx * self.odom_scale_x, vy * self.odom_scale_y # odometry only; motors get the command
dt = 0.0 if self.odom_t is None else min(now - self.odom_t, 0.1)
self.odom_t = now
x, y, th = self.odom_pose = integrate_pose(*self.odom_pose, vx, vy, wz, dt)
m = Odometry()
m.header.stamp = self.get_clock().now().to_msg()
m.header.frame_id, m.child_frame_id = self.odom_frame, self.base_frame
m.pose.pose.position.x, m.pose.pose.position.y = x, y
m.pose.pose.orientation.z, m.pose.pose.orientation.w = math.sin(th / 2), math.cos(th / 2)
m.twist.twist.linear.x, m.twist.twist.linear.y, m.twist.twist.angular.z = vx, vy, wz
for i, v in ((0, self.odom_lin_var), (7, self.odom_lin_var), (14, 1e-6), (21, 1e-6), (28, 1e-6),
(35, self.odom_ang_var)):
m.twist.covariance[i] = v
m.pose.covariance[i] = 1e3 if i in (0, 7, 35) else 1e-6 # pose is dead reckoning: not trusted
self.odom_pub.publish(m)
Sideways motion is scaled by odom_scale_y = 0.765: on the floor a commanded slide moved the robot 73 to 79 % as far
as commanded, measured against the LiDAR on 2026-09-28 (docs/odometry.md). Only the odometry is scaled; the motors
get the command unchanged. The pose covariance of 1e3 tells the EKF not to trust the summed-up position; it uses the
velocity part.
On the robot:
def publish_battery(self, mv):
m = BatteryState()
m.header.stamp = self.get_clock().now().to_msg()
m.voltage = mv / 1000.0
m.present = True
m.percentage = float('nan')
self.bat_pub.publish(m)
# plugged-in detection (2026-10-01 battery log: plug +249 mV, unplug -226 mV in one step with the wheels OFF;
# motor load gives +-170 mV with the wheels ON). None = unknown since start.
now_m = time.monotonic()
hist = getattr(self, 'bat_hist', [])
hist = [(t, v) for t, v in hist if now_m - t < 8.0]
if hist and not self.gate.enabled and now_m - getattr(self, 'wheels_off_t', 0.0) > 3.0:
base = sorted(v for t, v in hist if now_m - t > 2.0) or [hist[0][1]]
step = mv - base[len(base) // 2]
if step >= PLUG_STEP_MV and self.plugged is not True:
self.plugged = True; self.get_logger().info(f'battery: PLUGGED IN (+{step} mV)'); hist = []
try:
json.dump({'plugged': self.plugged, 't': time.time()}, open(os.path.expanduser('~/battery/plugged.json'), 'w'))
except OSError:
pass
elif step <= -PLUG_STEP_MV and self.plugged is not False:
self.plugged = False; self.get_logger().info(f'battery: UNPLUGGED ({step} mV)'); hist = []
try:
json.dump({'plugged': self.plugged, 't': time.time()}, open(os.path.expanduser('~/battery/plugged.json'), 'w'))
except OSError:
pass
self.bat_hist = hist + [(now_m, mv)]
# battery log (owner 2026-10-01: learn full charge + the plug/unplug voltage step): one line per 10 s,
# ~/battery/YYYYMMDD.csv = unix time, mV, wheels enabled, arm enabled (load context for the sag)
now = time.time()
if now - getattr(self, 'bat_log_t', 0.0) >= 10.0:
self.bat_log_t = now
try:
d = os.path.expanduser('~/battery'); os.makedirs(d, exist_ok=True)
with open(os.path.join(d, time.strftime('%Y%m%d') + '.csv'), 'a') as f:
f.write(f'{now:.0f},{mv},{int(self.gate.enabled)},{int(self.arm_enabled)}\n')
except OSError:
pass
The board reports voltage only, and the voltage level alone does not show whether the charger is in: 12.30 V plugged
in and 12.40 V on battery were both seen on 2026-09-29 (docs/hardware.md). The plug and unplug are visible as a
jump: +249 mV and -226 mV in one step with the wheels off, while motor load moves it by about 170 mV. So the driver
looks for a step of at least PLUG_STEP_MV = 190 mV against the median of the last few seconds, only with the
wheels disabled and more than 3 s after they were disabled. The result goes to ~/state (plugged) and to
~/battery/plugged.json. Every 10 s one line goes to ~/battery/YYYYMMDD.csv: time, millivolts, wheels enabled,
arm enabled.
On the robot:
def publish_state(self):
now = time.monotonic()
s = json.dumps({'enabled': self.gate.enabled, 'estop': self.gate.estop, 'plugged': self.plugged,
'arm_enabled': self.arm_enabled, 'arm_moving': self.arm_traj is not None or self.follow is not None or self.jog is not None,
'arm_pose': self.arm_pose,
'servo_silent': self.servo_silent,
'blocked': self.gate.block_reason(now), 'body_vel': [round(v, 3) for v in self.gate.cur],
'scan_age': None if self.gate.sense_t is None else round(now - self.gate.sense_t, 2),
'gyro_bias_dps': [round(b, 4) for b in self.gyro.bias],
'gyro_bias_tracking': self.gyro.tracking(now),
'arm_holding': self.arm_holding, 'pad_mode': self.pad_mode, 'pad_joint': self.pad_joint,
'auto_tuck_in_s': (round(max(0.0, self.auto_tuck_s - (now - self.arm_active_t)), 1)
if self.arm_holding and self.auto_tuck_s > 0 else None)})
self.state_pub.publish(String(data=s))
On the robot:
def stop(self):
self.running = False
try:
self.write(STOP_FRAME)
self.write(STOP_FRAME)
finally:
self.thread.join(timeout=1.0)
os.close(self.fd)
def main():
rclpy.init()
node = BoardDriver()
try:
rclpy.spin(node)
except (KeyboardInterrupt, ExternalShutdownException):
pass
finally:
node.stop()
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
On a clean exit the driver writes the stop frame twice, stops the read thread, and closes the port. main runs that
cleanup whatever ended the spin.
Your typed files and the copied ones are now in place, except launch/base.launch.py, which the next section
writes. setup.py installs it, so create it empty for now; an empty launch file is never run:
On the robot:
touch ~/ros2_ws/src/rosorin_base/launch/base.launch.py
Build:
On the robot:
source /opt/ros/humble/setup.bash
cd ~/ros2_ws
colcon build --packages-select rosorin_base
ls install/rosorin_base/lib/rosorin_base/
Check
colcon ends with a line like the one recorded on 2026-09-27:Summary: 1 package finished [2.28s]and the folder holds the ten programs named in
setup.py:board_driver board_reader depth_gate gamepad_teleop lidar_reader nav_supervisor nav_wait rf2o_fix stop_motors vslam_odom
If it fails
- An error that a file listed in
setup.pydoes not exist (for examplelaunch/base.launch.py): it was not
copied or created. Check thescpcommands and thetouchabove.- Edits in
src/change nothing until you build again:colcon buildwithout--symlink-installcopies the
files intoinstall/.
Compare the files you typed with the repository, from your laptop:
On your laptop:
cd ~/CCode/rosorin-pro/ros2/rosorin_base
for f in package.xml setup.cfg setup.py rosorin_base/rrc_protocol.py rosorin_base/motion.py rosorin_base/gyro_bias.py rosorin_base/stop_motors.py; do
ssh rosorin-wifi cat ros2_ws/src/rosorin_base/$f | diff -q - $f >/dev/null && echo "same $f" || echo "DIFFERS $f"
done
Check
Seven lines starting withsame.
Run the whole test folder:
On the robot:
cd ~/ros2_ws/src/rosorin_base
python3 -m pytest -q test/
Check
33 passed. That is the number of tests in the repository today (3 protocol, 9 motion, 2 gyro, 14 arm,
3 LiDAR, 2 gamepad), all passing on a copy of the repository on 2026-10-07. The last full run recorded on the
robot was 27 tests on 2026-09-30, before the scan gate tests and the optional-servo arm tests were added.
If it fails
colcon testreports 0 tests for this package: the tests are not registered with colcon (docs/status.md,
known gap). Usepython3 -m pytestas above.
New idea: launch files
A launch file starts a set of nodes with their parameters in one command,ros2 launch <package> <file>.
In ROS 2 it is a Python file with agenerate_launch_description()function. It can also react to events: start
something when a process exits, or shut the whole launch down.
The finished base.launch.py also starts the robot model (chapter 12), the EKF and LiDAR odometry (chapter 16),
which are not built yet. Write the file without those lines now; each of those chapters adds its part, and the end
of this chapter shows the complete file. The LiDAR reader is in from the start: the scan gate blocks every software
command until /scan arrives, so the wheels need it. Chapter 14 explains the reader; chapter 7 made /dev/lidar.
Create ~/ros2_ws/src/rosorin_base/launch/base.launch.py:
On the robot:
"""ROSOrin base: controller board (IMU, battery, gated motion) + COIN-D6 LiDAR + static TF.
If board_driver exits for any reason: stop_motors runs immediately (stop frame), then the whole
launch shuts down; systemd ExecStopPost writes the stop frame again as a backup (motion_safety.md).
Mount geometry from the factory URDF (reference data, docs/hardware.md)."""
import os
from ament_index_python.packages import get_package_prefix, get_package_share_directory
from launch import LaunchDescription
from launch.actions import ExecuteProcess, RegisterEventHandler, Shutdown
from launch.event_handlers import OnProcessExit
from launch_ros.actions import Node
def static_tf(name, xyz, rpy, parent, child):
return Node(package='tf2_ros', executable='static_transform_publisher', name=name,
arguments=['--x', xyz[0], '--y', xyz[1], '--z', xyz[2],
'--roll', rpy[0], '--pitch', rpy[1], '--yaw', rpy[2],
'--frame-id', parent, '--child-frame-id', child])
def generate_launch_description():
share = get_package_share_directory('rosorin_base')
calib = os.path.join(share, 'config', 'imu_calibration.yaml')
arm_limits = os.path.join(share, 'config', 'arm_limits.yaml')
board = Node(package='rosorin_base', executable='board_driver', name='rosorin_board_driver',
parameters=[{'device': '/dev/ttyACM0', 'imu_frame_id': 'imu_link'}, calib, arm_limits])
stop_motors = os.path.join(get_package_prefix('rosorin_base'), 'lib', 'rosorin_base', 'stop_motors')
on_board_exit = RegisterEventHandler(OnProcessExit(
target_action=board,
on_exit=[ExecuteProcess(cmd=[stop_motors], output='screen', name='stop_motors',
on_exit=Shutdown(reason='board_driver exited'))]))
return LaunchDescription([
board,
on_board_exit,
Node(package='rosorin_base', executable='lidar_reader', name='rosorin_lidar_reader',
respawn=True, respawn_delay=2.0, # a crashed reader comes back by itself (the scan gate holds the wheels)
parameters=[{'device': '/dev/lidar', 'frame_id': 'laser',
'clockwise': True}]), # native angles run CW: floor test 2026-09-28, docs/hardware.md
# factory pro/lidar.urdf.xacro lidar_joint; native angles published as-is, mount yaw pi
static_tf('tf_base_laser', ['0.048156', '0.0000756', '0.106748'], ['0', '0', '3.14159265'],
'base_link', 'laser'),
# factory pro/imu.urdf.xacro imu_joint
static_tf('tf_base_imu', ['-0.027258', '-0.0000564', '0.048046'], ['0', '-0.0087266', '1.5707'],
'base_link', 'imu_link'),
])
board: the driver node. Its parameters are the dict (port, IMU frame name) and the two YAML files fromshare/rosorin_base/config/.on_board_exit: when the board process exits, for any reason including SIGKILL, run stop_motors, and when thatimu_link, so this one is needed now.Why OnProcessExit
The first crash test on 2026-09-27 (SIGKILL of the driver while the wheels turned) stopped the wheels only
through systemd'sExecStopPost, 1.17 s after the kill. A node killed insideros2 launchdoes not end the
launch, so systemd does not notice the crash at once (docs/lessons.md). WithOnProcessExitrunning
stop_motorsdirectly, the stop frame followed the kill after 0.13 s (0.127 to 0.138 s in 4 runs,
docs/motion_safety.mdtest G).
Build again and start it in the foreground, in one ssh session:
On the robot:
source /opt/ros/humble/setup.bash
cd ~/ros2_ws && colcon build --packages-select rosorin_base
source ~/ros2_ws/install/setup.bash
ros2 launch rosorin_base base.launch.py
Keep the robot still for the first 3 s (gyro calibration). In a second ssh session:
On the robot:
source /opt/ros/humble/setup.bash
source ~/ros2_ws/install/setup.bash
ros2 node list
for t in scan imu/data_raw; do timeout 6 ros2 topic hz $t 2>&1 | grep -m1 "average rate" | sed "s|^|$t |"; done
timeout 5 ros2 topic echo --once battery 2>&1 | grep voltage
timeout 6 ros2 run tf2_ros tf2_echo base_link imu_link 2>&1 | grep -m2 -E "Translation|RPY \(degree"
timeout 5 ros2 topic echo --once /rosorin_board_driver/state --field data
Check
In the launch session, two lines from the driver (recorded 2026-09-27):[board_driver-1] [INFO] [...] [rosorin_board_driver]: owning /dev/ttyACM0; motion DISABLED until ~/enable ... gyro bias (deg/s) [-1.681, -1.553, 0.048] from 341 samples, std [0.052, 0.07, 0.039]In the second session, from the first base launch on 2026-09-27 (then with the read-only
board_readerin place
of the driver; the names and rates are the same with the driver):/rosorin_board_driver /rosorin_lidar_reader /tf_base_imu /tf_base_laser scan average rate: 10.018 imu/data_raw average rate: 110.553 voltage: 12.684000015258789 - Translation: [-0.027, -0.000, 0.048] - Rotation: in RPY (degree) [-0.000, -0.500, 89.994]The state shows the wheels off, for example (2026-10-07 shape, shortened):
{"enabled": false, "estop": null, "plugged": ..., "blocked": "disabled", "body_vel": [0.0, 0.0, 0.0], "scan_age": 0.08, "gyro_bias_dps": [...], ...}.scan_agebelow 0.5 means the scan gate is open.
If it fails
robot not stationary during gyro calibration: the robot moved in the first 3 s. Restart it standing still.OSError: [Errno 16] Device or resource busyfrom the driver: a~/learnscript or a second launch holds
the port (sudo fuser -v /dev/ttyACM0).ros2 node listempty right after the start: another process may not see a new node's topics for a few
seconds (docs/lessons.md). Wait and ask again before diagnosing anything.- Topics from the base vanish after your last ssh session closes, while the base keeps running: logind deleted
the DDS shared-memory files (RemoveIPC). Chapter 6 setsRemoveIPC=no(docs/lessons.md2026-09-28)."blocked": "no scan"and no/scanrate: the LiDAR reader is not publishing. Check/dev/lidar(chapter
7).
Stop the launch with Ctrl-C in its own session, then check nothing is left:
On the robot:
pgrep -af "lib/rosorin_base|static_transform_publisher" || echo "all stopped"
If it fails
- Leftover processes: a launch started in the background from an ssh command (
ros2 launch ... &) ignores
SIGINT, sokill -INTdoes nothing (docs/lessons.md2026-09-26). Run the launch in the foreground, or under
systemd as below. Kill leftovers by PID, not withpkill -finside an ssh command: that pattern can match the
ssh shell itself and close your session.
New idea: a systemd service for a ROS launch
systemd starts the base at boot, restarts it when it dies, and runs a command after it stops. Three settings
decide how it stops.KillSignal=SIGINT: stop with the signal ROS treats as Ctrl-C.KillMode=mixed: send that
signal to the main process only (ros2 launch), which passes it on to its nodes; the default
(control-group) sends it to every process at once, so each node gets it twice.ExecStopPost=: a command run
after the service stopped, for any reason, including a crash.
Create ~/ros2_ws/src/rosorin_base/systemd/rosorin-base.service. This is the unit as it was on 2026-09-27
(commit bf5cac1): it sources only the system ROS and your workspace. Chapter 16 adds the ~/ext_ws workspace
(LiDAR odometry) to both command lines.
On the robot:
[Unit]
Description=ROSOrin base (board driver with gated motion, LiDAR, static TF)
After=network-online.target
Wants=network-online.target
[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 && exec ros2 launch rosorin_base base.launch.py'
KillSignal=SIGINT
KillMode=mixed
TimeoutStopSec=10
# stop frame after ANY stop/crash of the unit (board has no watchdog)
ExecStopPost=/bin/bash -c 'source /opt/ros/humble/setup.bash && source /home/burgerbarn/ros2_ws/install/setup.bash && exec /home/burgerbarn/ros2_ws/install/rosorin_base/lib/rosorin_base/stop_motors'
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
After=/Wants=network-online.target: start after the network is up.User=burgerbarn: runs as you, not root, so the files it writes (~/battery) are yours.ROS_DOMAIN_ID=0: the DDS domain every program on this robot uses (chapter 9).ExecStart: exec replaces bash with ros2 launch, so systemd's signal reaches launch directly. Do not useros2 run here: it does not forward SIGINT, the node keeps running and is killed only at TimeoutStopSecdocs/lessons.md).ExecStopPost: stop_motors after every stop or crash, the backup to the launch file's OnProcessExit.Restart=always, RestartSec=3: a dead driver comes back after 3 s, disabled, and writes the stop frame at start.The install scripts live in ~/setup on the robot. Copy the ones this chapter uses from your laptop:
On your laptop:
cd ~/CCode/rosorin-pro
ssh rosorin-wifi mkdir -p setup
rsync -a scripts/install_base_service.sh scripts/rollback_base_service.sh scripts/install_dds_hostid_fix.sh \
scripts/wait_addresses.sh scripts/motion_test.py scripts/stop_proof.py rosorin-wifi:setup/
scripts/install_base_service.sh copies the unit from your package to /etc/systemd/system/, enables it for boot,
starts it, and shows its status after 8 s:
On the robot:
sudo bash ~/setup/install_base_service.sh
Check
Recorded on 2026-09-27 (then runningboard_reader; now the second process isboard_driver):Active: active (running) since Sun 2026-09-27 06:08:07 UTC; 8s ago Main PID: 5635 (ros2) Tasks: 65 (limit: 18452) Memory: 101.1M CPU: 3.490s CGroup: /system.slice/rosorin-base.service ├─5635 /usr/bin/python3 /opt/ros/humble/bin/ros2 launch rosorin_base base.launch.pyTo undo it:
sudo bash ~/setup/rollback_base_service.sh(scripts/rollback_base_service.sh).
Add the drop-in that makes the service wait until the robot's IPv4 addresses stop changing. Chapter 9 explains the
Fast DDS host id behind it; if chapter 9 already installed the drop-in, running the script again rewrites the same
file.
On the robot:
sudo bash ~/setup/install_dds_hostid_fix.sh | tail -3
bash ~/setup/wait_addresses.sh
sudo systemctl restart rosorin-base
Check
Recorded on 2026-10-05:[Service] ExecStartPre=/bin/bash /home/burgerbarn/setup/wait_addresses.sh TimeoutStartSec=120 addresses stable after 7s: docker0=172.17.0.1/16,wlP1p1s0=192.168.1.108/24Your list shows the interfaces you have (
docker0appears only once Docker is installed). The first boot with
the drop-in loggedaddresses stable after 11s(docs/status.md).
Why wait for addresses
Fast DDS 2.6 gives each process a host id made from the machine's IPv4 addresses at the moment the process
starts. On 2026-10-05 the base started 4 s before Wi-Fi had its address, so its processes and the later ones had
different host ids, could not use shared memory with each other, and fell back to UDP: 65,000 packets per
second, 5,900 kernel receive-buffer overflows per second, load average 30 (scripts/wait_addresses.sh). The
wait is at most 45 s, so the robot still starts without Wi-Fi.
Three clean stops in a row, counting Python tracebacks and leftover processes:
On the robot:
for i in 1 2 3; do
sudo systemctl stop rosorin-base; sleep 1
e=$(journalctl -u rosorin-base --since "-4 s" --no-pager | grep -cE "Traceback|KeyboardInterrupt|SIGKILL")
l=$(pgrep -f "lib/rosorin_base|static_transform_publisher" | wc -l)
echo "stop $i: python tracebacks=$e leftovers=$l"
sudo systemctl start rosorin-base; sleep 8
done
systemctl is-active rosorin-base
Check
Recorded on 2026-09-27 afterKillMode=mixedwas added (before it, one stop logged 6 error lines):stop 1: python tracebacks=0 leftovers=0 stop 2: python tracebacks=0 leftovers=0 stop 3: python tracebacks=0 leftovers=0 active
Now kill the driver the hard way, with the wheels disabled, and read what followed:
On the robot:
P=$(pgrep -f "^/usr/bin/python3 /home/burgerbarn/ros2_ws/install/rosorin_base/lib/rosorin_base/board_driver"); echo "board_driver pid $P"
T=$(date "+%H:%M:%S"); sudo kill -KILL $P; sleep 12
journalctl -u rosorin-base --since "$T" --no-pager | grep -E "process has died|Shutdown|stop_motors|Stopped|Started|Scheduled restart|owning" | sed "s/^.*rosorin //" | cut -c1-120
systemctl is-active rosorin-base
Check
Recorded on 2026-09-27, minutes before the launch file gotOnProcessExit, so the stop frame here comes from
ExecStopPostalone (bash[6113]):board_driver pid 6009 bash[5992]: [ERROR] [board_driver-1]: process has died [pid 6009, exit code -9, cmd '/home/burgerbarn/ros2_ws/install/ro bash[6113]: stop_motors: stop frame sent x3 systemd[1]: rosorin-base.service: Scheduled restart job, restart counter is at 1. systemd[1]: Stopped ROSOrin base (board driver with gated motion, LiDAR, static TF). systemd[1]: Started ROSOrin base (board driver with gated motion, LiDAR, static TF). bash[6130]: [board_driver-1] [INFO] [1790495499.717640035] [rosorin_board_driver]: owning /dev/ttyACM0; motion DISABLED activeWith your launch file the launch's own
stop_motorsruns first and logs under the launch's process, as in this
journal from the USB drop test on 2026-09-28 (there it had to wait for the device to come back):bash[31468]: [ERROR] [board_driver-1]: process has died [pid 31485, exit code 1, cmd '/home/burgerbarn/ros2_ws/install/rosorin_base/lib/rosorin_base/board_driver --ros-args -r bash[31468]: [INFO] [stop_motors-6]: process started with pid [31601] bash[31468]: [stop_motors-6] stop_motors: /dev/ttyACM0 unavailable ([Errno 2] No such file or directory: '/dev/ttyACM0'); retrying up to 30 s bash[31468]: [stop_motors-6] stop_motors: stop frame sent x3 (after 7.6 s)After a plain SIGKILL the port is there at once, so expect
(after 0.0 s)or close to it, and the number after
stop_motors-is the count of processes your launch file starts plus one.
The whole chain, as it runs:
Reboot once and check that the base comes up by itself:
On the robot:
sudo systemctl reboot
After a minute, from a new ssh session:
On the robot:
systemctl is-active rosorin-base; echo "failed units: $(systemctl --failed --no-legend | wc -l)"
source /opt/ros/humble/setup.bash
for t in scan imu/data_raw; do timeout 6 ros2 topic hz $t 2>&1 | grep -m1 "average rate" | sed "s|^|$t |"; done
journalctl -u rosorin-base -b --no-pager | grep -E "gyro bias|not stationary|owning" | sed "s/.*\]: //"
Check
Recorded after the reboot on 2026-09-27 (withboard_reader; the driver logsowning ...as well):active failed units: 0 scan average rate: 10.023 imu/data_raw average rate: 110.595 gyro bias (deg/s) [-1.692, -1.772, 0.047] from 333 samples, std [0.051, 0.059, 0.038]
This is where the wheels turn for the first time under the driver's control.
Safety
- Robot on its stand, all four wheels free in the air, charger unplugged (as in the 2026-10-02 runs).
- Nothing within reach of the arm: every wheel enable moves it to the drive pose first.
- Your hand near the power switch for the whole section. The power switch stops everything, including a hung
board.- Nothing else may publish on
cmd_vel. On a fresh build nothing does; on the finished robot stop navigation and
the mind first (sudo systemctl stop rosorin-mind rosorin-nav) and stoprosorin-contactfor the
deadman/e-stop tests, asscripts/tests/stand_proof.shdoes.- About 2 minutes after the last arm activity, with the wheels disabled, the arm tucks itself (auto-tuck,
chapter 13). Expect it.
On the robot:
source /opt/ros/humble/setup.bash; source ~/ros2_ws/install/setup.bash
timeout 30 ros2 service call /rosorin_board_driver/enable std_srvs/srv/SetBool "{data: true}" 2>&1 | grep -o "message='[^']*'"
journalctl -u rosorin-base --since "-1min" -o cat | grep "to DRIVE" | tail -1 | sed 's/.*arm to DRIVE/arm to DRIVE/'
timeout 20 ros2 service call /rosorin_board_driver/enable std_srvs/srv/SetBool "{data: false}" 2>&1 | grep -o "message='[^']*'"
Check
Recorded on 2026-09-29, arm starting from the tuck:message='enabled' arm to DRIVE: {1: 500, 2: 963, 3: 46, 4: 131, 5: 498, 10: 629} -> {1: 500, 2: 793, 3: 51, 4: 160, 5: 499, 10: 499} in 2.8 s (single command, fast) message='disabled'The wheels do not turn: enabling allows motion, it does not command any.
If it fails
message='refused: arm is not in the drive pose': the arm could not reach the pose (blocked, or a joint servo
not answering). Look at the driver log (journalctl -u rosorin-base -n 30) and chapter 13.message='refused: e-stop latched (...)': something tripped it before. Read the reason, remove its cause,
thenros2 service call /rosorin_board_driver/reset_estop std_srvs/srv/Trigger.
scripts/motion_test.py is the harness of the first stand tests on 2026-09-27. Each step reads the driver's state,
sends commands, prints the state, and always ends with the wheels disabled. Watch the wheels during each step.
On the robot:
source /opt/ros/humble/setup.bash; source ~/ros2_ws/install/setup.bash
python3 ~/setup/motion_test.py disabled
python3 ~/setup/motion_test.py forward
python3 ~/setup/motion_test.py rotate
python3 ~/setup/motion_test.py estop
python3 ~/setup/motion_test.py gamepad
The gamepad step drives forward for 15 s: press any gamepad button except MODE during it.
Check
Recorded on 2026-09-27, trimmed to the lines that matter. Today's state line carries more fields (arm, gyro,
pad); readenabled,estop,blockedandbody_vel.07:37:10 start: {'enabled': False, 'estop': None, 'blocked': 'disabled', 'body_vel': [0.0, 0.0, 0.0]} >> 3 s of forward 0.1 m/s while DISABLED (wheels must stay still) 07:37:13 after: {'enabled': False, 'estop': None, 'blocked': 'disabled', 'body_vel': [0.0, 0.0, 0.0]} enable(True) -> True enabled >> FORWARD 0.1 m/s for 3 s, then silence (deadman) >> silence 07:42:02 after silence: {'enabled': True, 'estop': None, 'blocked': 'cmd timeout', 'body_vel': [0.0, 0.0, 0.0]} enable(False) -> True disabled >> ROTATE +0.5 rad/s (counter-clockwise from above) for 3 s 07:42:43 after: {'enabled': True, 'estop': None, 'blocked': 'cmd timeout', 'body_vel': [0.0, 0.0, 0.0]} >> ESTOP published 07:43:05 latched: {'enabled': False, 'estop': 'estop topic', 'blocked': 'estop: estop topic', 'body_vel': [0.0, 0.0, 0.0]} enable(True) -> False refused: e-stop latched (estop topic) reset: e-stop cleared (was: estop topic); motion still disabled 07:43:06 after reset: {'enabled': False, 'estop': None, 'blocked': 'disabled', 'body_vel': [0.0, 0.0, 0.0]} >> forward 0.1 m/s for 15 s -- PRESS ANY GAMEPAD BUTTON 07:49:33 after: {'enabled': False, 'estop': 'gamepad button 0x0100', 'blocked': 'estop: gamepad button 0x0100', 'body_vel': [0.0, 0.0, 0.0]} reset: e-stop cleared (was: gamepad button 0x0100); motion still disabledWhat you see: nothing moves in
disabled; all four wheels turn forward and stop by themselves after the commands
end inforward; inrotatethe left wheels turn backward and the right ones forward;estopstops the
wheels while commands keep coming and refuses to re-enable until reset; the button stops them ingamepad
(the hex number is whichever button you pressed).
If it fails
ABORT: no driver state received (DDS problem) - not sending anything: the harness could not see the driver.
Right after a (re)start this happens for a few seconds; wait and run it again. If it persists, see the DDS
items in the launch section.AttributeError: 'NoneType' object has no attribute 'success'(seen 2026-09-27): theenablecall got no
answer within the harness's 5 s. Either the driver was not visible yet, or the arm had to travel to the drive
pose first. Do the first enable by hand (above), then rerun.blocked: 'no scan'with the wheels still: the LiDAR is not publishing; the scan gate is doing its job.estop: 'multiple cmd_vel publishers': something else publishes oncmd_vel(a forgotten
ros2 topic pub, a teleop node, navigation).ros2 topic info /cmd_velshows the count. On 2026-09-30 a
killed run's leftover publisher tripped it this way.
scripts/stop_proof.py measures stops physically. It records the board's IMU while the wheels spin and through each
stop: spinning wheels shake the robot, and the gyro's x and y axes show it. "Stopped" is when the vibration falls to
the still level and stays there, not what the software state says. For the two paths that kill the driver, it reads
the IMU straight from the port while the driver is gone. First see what spinning and still look like:
On the robot:
source /opt/ros/humble/setup.bash; source ~/ros2_ws/install/setup.bash
timeout 120 python3 ~/setup/stop_proof.py characterize
Check
One line per phase: idle, enabled still, forward 0.1, 0.2 and 0.35 m/s, rotate 1.0 rad/s, with still phases
between. The levels measured on this robot (gyro x, y standard deviation): still about 0.0016 to 0.0025 rad/s,
0.2 m/s about 0.005 to 0.006, 0.35 m/s about 0.006 to 0.010 (docs/motion_safety.md). The judge counts below
0.0035 as still. The full printout of acharacterizerun is not in the record.
Then the stop paths: deadman, estop topic and enable false at three speeds, the scan gate (it kills the LiDAR
reader while commands keep coming, then checks the wheels resume by themselves when the reader respawns), and a
SIGKILL of the driver:
On the robot:
timeout 240 python3 ~/setup/stop_proof.py prove deadman,estop,disable,scanloss,kill
Check
Recorded on the stand on 2026-10-02 17:16 (docs/evidence/stop_proof_stand_20261002_171617.log). That run asked
for theservicepath as well; it was skipped (theno IMU dataline) because the driver was not back yet after
the kill, and was run on its own three minutes later, as below. Withoutserviceyour run ends after the kill
path.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}Your times will differ by a few tenths; every line must say PASS.
Last, systemctl stop rosorin-base while the wheels turn. The script starts the service again afterwards:
On the robot:
timeout 200 python3 ~/setup/stop_proof.py prove service
Check
Recorded on 2026-10-02 17:19 (docs/evidence/stop_proof_stand_20261002_171932.log):driver state at start: {'enabled': False, 'estop': None, 'plugged': False} 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 passed end state: {'enabled': False, 'estop': None}
If it fails
- The run at 17:12 the same day showed
FAILon two lines withstill within 0.10 sand a highworst after:
that day's first judge accepted the first quiet window, which can dip below the threshold at 0.2 m/s while the
wheels still turn. The repository'sscripts/stop_proof.pywaits until the vibration stays low; use it.LiDAR back ... vibration nan ... FAILfollowed by a traceback: this is what the first run on 2026-10-02
showed. The driver had died the moment the scans came back (the two-severity log line in the control loop),
failed safe, and restarted. The repository'sboard_driver.pyhas the fix; check you copied the current file.no IMU data while spinning up (driver gone?)after the kill path: the driver was not back yet when the next
path started. Run that path on its own, as above forservice.enable refused:and the state: see "First enable by hand".- Any wheel keeps turning after a stop: power switch, then do not drive on the floor until the cause is found.
The stop times measured with this method on 2026-10-02, after the scan gate was added (docs/motion_safety.md):
| 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 | - |
| LiDAR back | - | wheels turning again by themselves 4.5 s later | - |
| SIGKILL of board_driver | - | 0.55 s | - |
systemctl stop rosorin-base |
- | 0.50 s | - |
These times run from the trigger until the wheels are physically still, so they include the deadman's 0.3 s wait,
the wheels running down, and the judge's 0.05 s steps. The 0.13 s for the crash path earlier is a different thing:
the time until the stop frame was sent. The same paths on the floor, chapter 19, took 0.32 to 0.71 s with 2 to 10 cm
of travel.
Not covered by any test
- A gamepad button press timed by
stop_proof.py(it needs a hand;motion_test.py gamepadshows it latches).- Wheels stalled against something, and a hung board firmware.
- While the board's USB is gone, nothing can stop the wheels.
- The
estoptopic needs DDS; a delivery failure of the driver's topics for minutes was seen once and not
reproduced. The gamepad and the power switch do not depend on DDS.- On 2026-10-02 the robot kept rolling after every software stop during an exploration run, until the power was
cut. The stand tests had passed before and passed again after; the cause is still unknown
(docs/lessons.md2026-10-02 CRITICAL). That is why the wheels stay on the stand until chapter 19.
The complete launch file in the repository, ros2/rosorin_base/launch/base.launch.py:
On the robot:
"""ROSOrin base: controller board (IMU, battery, gated motion) + COIN-D6 LiDAR + static TF.
If board_driver exits for any reason: stop_motors runs immediately (stop frame), then the whole
launch shuts down; systemd ExecStopPost writes the stop frame again as a backup (motion_safety.md).
Mount geometry from the factory URDF (reference data, docs/hardware.md)."""
import os
from ament_index_python.packages import get_package_prefix, get_package_share_directory
from launch import LaunchDescription
from launch.actions import ExecuteProcess, RegisterEventHandler, Shutdown
from launch.event_handlers import OnProcessExit
from launch_ros.actions import Node
def static_tf(name, xyz, rpy, parent, child):
return Node(package='tf2_ros', executable='static_transform_publisher', name=name,
arguments=['--x', xyz[0], '--y', xyz[1], '--z', xyz[2],
'--roll', rpy[0], '--pitch', rpy[1], '--yaw', rpy[2],
'--frame-id', parent, '--child-frame-id', child])
def generate_launch_description():
share = get_package_share_directory('rosorin_base')
calib = os.path.join(share, 'config', 'imu_calibration.yaml')
arm_limits = os.path.join(share, 'config', 'arm_limits.yaml')
board = Node(package='rosorin_base', executable='board_driver', name='rosorin_board_driver',
parameters=[{'device': '/dev/ttyACM0', 'imu_frame_id': 'imu_link'}, calib, arm_limits])
urdf = open(os.path.join(share, 'urdf', 'rosorin.urdf')).read() # generated: model/make_urdf.py
stop_motors = os.path.join(get_package_prefix('rosorin_base'), 'lib', 'rosorin_base', 'stop_motors')
on_board_exit = RegisterEventHandler(OnProcessExit(
target_action=board,
on_exit=[ExecuteProcess(cmd=[stop_motors], output='screen', name='stop_motors',
on_exit=Shutdown(reason='board_driver exited'))]))
return LaunchDescription([
board,
on_board_exit,
# arm + camera frames from /joint_states (board_driver); root base_link
Node(package='robot_state_publisher', executable='robot_state_publisher', name='robot_state_publisher',
parameters=[{'robot_description': urdf}]),
Node(package='robot_localization', executable='ekf_node', name='ekf_filter_node',
parameters=[os.path.join(share, 'config', 'ekf.yaml')]),
Node(package='rosorin_base', executable='lidar_reader', name='rosorin_lidar_reader',
respawn=True, respawn_delay=2.0, # a crashed reader comes back by itself (the scan gate holds the wheels)
parameters=[{'device': '/dev/lidar', 'frame_id': 'laser',
'clockwise': True}]), # native angles run CW: floor test 2026-09-28, docs/hardware.md
# factory pro/lidar.urdf.xacro lidar_joint; native angles published as-is, mount yaw pi
static_tf('tf_base_laser', ['0.048156', '0.0000756', '0.106748'], ['0', '0', '3.14159265'],
'base_link', 'laser'),
# LiDAR odometry (2026-09-30): rf2o -> rf2o_fix (sign-corrected) -> odom_lidar = the EKF's motion input
Node(package='rf2o_laser_odometry', executable='rf2o_laser_odometry_node', name='rf2o_laser_odometry',
parameters=[os.path.join(share, 'config', 'rf2o.yaml')], arguments=['--ros-args', '--log-level', 'warn']),
Node(package='rosorin_base', executable='rf2o_fix', name='rf2o_fix'),
# factory pro/imu.urdf.xacro imu_joint
static_tf('tf_base_imu', ['-0.027258', '-0.0000564', '0.048046'], ['0', '-0.0087266', '1.5707'],
'base_link', 'imu_link'),
])
robot_state_publisher (chapter 12).rf2o_fix (chapter 16). The comment onodom_lidar the EKF's motion input; config/ekf.yaml, which the EKF actually reads, still fuseswheel/odom and says the switch to LiDAR odometry was reverted on 2026-09-30. Chapter 16 sorts this out.The final unit, ros2/rosorin_base/systemd/rosorin-base.service, identical to the one installed on the robot today:
On the robot:
[Unit]
Description=ROSOrin base (board driver with gated motion, LiDAR, static TF)
After=network-online.target
Wants=network-online.target
[Service]
User=burgerbarn
Environment=ROS_DOMAIN_ID=0
ExecStart=/bin/bash -c 'source /opt/ros/humble/setup.bash && source /home/burgerbarn/ext_ws/install/setup.bash && source /home/burgerbarn/ros2_ws/install/setup.bash && exec ros2 launch rosorin_base base.launch.py'
KillSignal=SIGINT
KillMode=mixed
TimeoutStopSec=10
# stop frame after ANY stop/crash of the unit (board has no watchdog)
ExecStopPost=/bin/bash -c 'source /opt/ros/humble/setup.bash && source /home/burgerbarn/ext_ws/install/setup.bash && source /home/burgerbarn/ros2_ws/install/setup.bash && exec /home/burgerbarn/ros2_ws/install/rosorin_base/lib/rosorin_base/stop_motors'
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
The only change from your unit is source /home/burgerbarn/ext_ws/install/setup.bash in ExecStart and
ExecStopPost (chapter 16). Whenever you change the unit file in the package, install it again with
sudo bash ~/setup/install_base_service.sh.
Not test-built
The reduced launch file of this chapter is the repository file without the lines of chapters 12 and 16. The
same set of nodes (driver, LiDAR reader, two static transforms) ran as the robot's base on 2026-09-27 (commit
bf5cac1, older versions of the driver and the reader); this exact file with today's driver has not been run.
The same holds for the unit frombf5cac1with today's package. The copy-then-type order of this chapter is also
new: on the robot the package grew file by file between 2026-09-26 and 2026-10-05.
python3 -m pytest -q test/ in ~/ros2_ws/src/rosorin_base passes every test, and your typed files are samesystemctl is-active rosorin-base says active after a reboot, and the journal shows owning /dev/ttyACM0; motion DISABLED until ~/enable and a gyro bias line./imu/data_raw runs at about 110 Hz and /scan at about 10 Hz; ~/state shows "blocked": "disabled".stop_motors: stop frame sent x3 and the service comes back by itself.stop_proof.py prove deadman,estop,disable,scanloss,kill printed RESULT: 12/12 passed and prove service1/1, with the robot on the stand.Where this comes from
Repository:ros2/rosorin_base/package.xml,setup.py,setup.cfg,ros2/rosorin_base/rosorin_base/motion.py,
gyro_bias.py,stop_motors.py,board_driver.py,ros2/rosorin_base/launch/base.launch.py,
ros2/rosorin_base/systemd/rosorin-base.service(and its version at commitbf5cac1),
ros2/rosorin_base/config/arm_limits.yaml,ros2/rosorin_base/config/imu_calibration.yaml,
ros2/rosorin_base/config/ekf.yaml,ros2/rosorin_base/test/,scripts/install_base_service.sh,
scripts/rollback_base_service.sh,scripts/install_dds_hostid_fix.sh,scripts/wait_addresses.sh,
scripts/motion_test.py,scripts/stop_proof.py,scripts/tests/stand_proof.sh,scripts/ops/deploy.sh.
Docs:docs/motion_safety.md(rules, tests A-G, scan gate, stand tables, USB drop, buzzer, gamepad modes),
docs/lessons.md(2026-09-26 serial and shutdown, 2026-09-28 RemoveIPC, 2026-09-30, 2026-10-02 rolling incident,
driver crashes, scan gate; 2026-10-05 drive pose),docs/odometry.md,docs/hardware.md(power, charger
detection),docs/status.md(base bringup, known test gap),docs/decisions.md2026-09-27,
docs/evidence/stop_proof_stand_20261002_171617.log,docs/evidence/stop_proof_stand_20261002_171932.log.
Command log: package build and tests 2026-09-26 23:33; base launch, service install, KillMode test, reboot
2026-09-27 06:06-06:10; driver build and stand tests 2026-09-27 07:36-07:52; enable timing 2026-09-29 19:56;
unit tests on the robot 2026-10-02 16:13; address wait 2026-10-05. The kinematics values and the program list
were computed from the repository code; the 33-test run was on a copy of the repository on 2026-10-07.
← Talk to the controller board · Contents · Describe the robot →