Making it move · Chapter 12 · Time: 1.5 hours · Level: Intermediate · Status: Partly test-built
The robot's shape as numbers - a URDF generated from measured factory geometry, robot_state_publisher turning arm angles into frames, fixed frames for the LiDAR and the IMU - and the tools to check every frame against the real robot.
Every sensor on this robot measures from its own position. The LiDAR sits 4.8 cm ahead of the body centre and is
mounted backwards; the depth camera rides on the arm and moves every time the arm moves. Before navigation can use a
LiDAR point or a camera pixel, it has to know where that sensor was, relative to the body, at the moment of the
measurement. This chapter writes that knowledge down once, as a robot description, and starts the node that keeps it
current from the arm's joint angles. The geometry was not invented or measured with a ruler: it was read out of the
factory's own robot description on 2026-09-27 and kept as numbers only.
New idea: frames and transforms
A frame is a coordinate system attached to something: the robot's body (base_link), the LiDAR (laser),
the camera (camera_link), the room (map). A transform says where one frame sits inside another: a
translation (x, y, z in metres) and a rotation (roll, pitch, yaw in radians, or the same rotation as a
quaternion). ROS 2 carries all transforms on two topics:/tffor those that change and/tf_staticfor those
that never change. The tf2 library chains them for you: ask forbase_linktocamera_linkand it multiplies
base_link -> link1 -> link2 -> ... -> camera_linkat the time you ask for. Every frame has exactly one parent, so
the frames form a tree. On this robot, as everywhere in ROS: x forward, y left, z up, and a positive yaw turns
left (counter-clockwise seen from above).
New idea: URDF, links and joints
URDF (Unified Robot Description Format) is an XML file that lists links (rigid parts) and joints (how
a child link is attached to its parent link). Afixedjoint never moves. Arevolutejoint turns about an
axisby an angle q, within alimit. Each joint has anorigin(xyzin metres,rpyin radians): where the
child frame sits in the parent frame when q = 0. Links can carry visual, collision and inertia data; this robot's
URDF carries only collision boxes (used by MoveIt in chapter 13).
New idea: robot_state_publisher and /joint_states
robot_state_publisheris a stock ROS node. It reads the URDF from itsrobot_descriptionparameter and the
joint angles from the/joint_statestopic (sensor_msgs/JointState: joint names and positions in radians).
For every fixed joint it publishes a transform once on/tf_static; for every revolute joint it publishes a new
transform on/tfeach time a joint state arrives. On this robotboard_driver(chapter 11) publishes
/joint_statesat 10 Hz from the arm servos; chapter 13 explains how servo pulses become radians.
| Edge | Published by | Chapter |
|---|---|---|
map -> odom |
slam_toolbox (AMCL when navigation runs with slam:=false) |
17, 18 |
odom -> base_link |
EKF (robot_localization, ekf.yaml publish_tf: true) |
16 |
base_link -> laser, base_link -> imu_link |
two static_transform_publisher nodes in base.launch.py |
this one |
base_link -> link1 ... depth_camera_link |
robot_state_publisher from rosorin.urdf + /joint_states |
this one |
depth_camera_link -> rgb_camera_link |
the Deptrum camera node, from the camera's stored calibration | 15 |
There is no base_footprint frame on the running robot. robot_model.json contains one (2.5 cm below base_link)
because the arm's collision model uses it for the chassis height map, but the URDF's root is base_link, and every
config file uses base_link (slam.yaml: "rosorin: no base_footprint frame").
rosorin-base runs (chapter 11) and /rosorin_board_driver/state is published.~/CCode/rosorin-pro (branch rebuild).~/.bashrc on the robot does not source ROS. In every robot shell in this chapter run first:On the robot:
source /opt/ros/humble/setup.bash
source ~/ros2_ws/install/setup.bash
All geometry lives in one file, model/robot_model.json (8557 bytes, one line). A byte-identical copy is installed
with the package as ros2/rosorin_base/config/robot_model.json, because the base driver's arm collision check
(chapter 13) loads it at run time. Look at its top-level keys:
On your laptop:
cd ~/CCode/rosorin-pro
python3 -c "import json; m = json.load(open('model/robot_model.json')); print(list(m)); print(m['source'])"
cmp model/robot_model.json ros2/rosorin_base/config/robot_model.json && echo identical
Check
['source', 'pulse_to_rad', 'joints', 'boxes', 'chassis_heightmap'] factory URDF/meshes on APP_old, measured 2026-09-27 (numbers only) identical
What each key holds:
| Key | Contents |
|---|---|
pulse_to_rad |
rad_per_pulse 0.0041887902047863905, zero pulse 500 for joints 1-5, flipped: true: rad = (500 - pulse) x k (chapter 13) |
joints |
11 joints with parent, child, xyz, rpy, axis, limit: joint1-joint5, gripper_joint, end_effector_joint, camera_connect_joint, camera_joint, lidar_joint, base_joint |
boxes |
one bounding box (min/max corner, metres, in the link's own frame) per part: link1-link5, gripper_link, camera_link0, camera_link, lidar_frame |
chassis_heightmap |
the chassis top surface as heights on a 1 cm grid, 30 x 23 cells, frame base_footprint, origin (-0.14266, -0.106), "triangle surface sampling, spacing <= cell/3" |
Two joints as they are stored:
On your laptop:
python3 -c "import json; m = json.load(open('model/robot_model.json')); [print(k, json.dumps(m['joints'][k])) for k in ('joint1', 'lidar_joint')]"
Check
joint1 {"type": "revolute", "parent": "base_link", "child": "link1", "xyz": [0.04662, 7.5437e-05, 0.16044], "rpy": [0.0087266, 0.0, 0.0], "axis": [-5.4513e-05, -0.0087266, -0.99996], "limit": [-2.09, 2.09]} lidar_joint {"type": "fixed", "parent": "base_link", "child": "lidar_frame", "xyz": [0.048156, 7.5552e-05, 0.106748], "rpy": [0.0, 0.0, 3.141592653589793], "axis": [0.0, 0.0, 0.0], "limit": null}
Read joint1 like this: the arm's base turntable sits 4.7 cm ahead of the body centre and 16 cm up; it turns about
an axis that points almost straight down (z component -0.99996, tilted half a degree). The limit of +-2.09 rad
(+-120 degrees, the servo's full travel) is not the limit the robot uses; the real, mapped limits are in
config/arm_limits.yaml (chapter 13).
On 2026-09-27 the factory partition (APP_old, /dev/nvme0n1p1, kept by the in-place install of chapter 5) was
mounted read-only and two repo scripts read the factory URDF and meshes. Only numbers came out; no factory file was
copied. You need this only if you want to re-derive the model. On a blank drive there is no factory partition: use
the repo's robot_model.json.
On the robot:
sudo mkdir -p /mnt/factory
findmnt /mnt/factory >/dev/null || sudo mount -o ro /dev/nvme0n1p1 /mnt/factory
mkdir -p ~/model
python3 ~/model/extract_robot_model.py /mnt/factory/home/ubuntu/ros2_ws/src/simulations/rosorin_description ~/model/robot_model.json
python3 ~/model/heightmap_fix.py /mnt/factory/home/ubuntu/ros2_ws/src/simulations/rosorin_description/meshes/pro/mecanum/base_link.stl ~/model/robot_model.json
(Copy scripts/extract_robot_model.py and scripts/heightmap_fix.py to ~/model/ first.)
Check
Output recorded on 2026-09-27:joints: ['joint1', 'joint2', 'joint3', 'joint4', 'joint5', 'gripper_joint', 'end_effector_joint', 'camera_connect_joint', 'camera_joint', 'lidar_joint', 'base_joint'] boxes: ['link1', 'link2', 'link3', 'link4', 'link5', 'gripper_link', 'camera_link0', 'camera_link', 'lidar_frame'] heightmap (30, 23) z range 0.014..0.208and from
heightmap_fix.py:cells changed: 195 of 690 | max z 0.208. The first height map took each
triangle's bounding-box maximum; the fix samples each triangle's surface instead, which is why it changed 195
cells.
If it fails
- The mount fails: the factory partition is gone (blank drive, or path b of chapter 5). Use the repo file.
- The model is not the robot in two known places (
docs/motion_safety.md, arm collision model): the factory
chassis mesh has a 0.21 m block at the rear that this robot does not have (likely the LCD bracket; the screen
is not mounted), and the boxes are padded, so the model reports contacts slightly early. Chapter 13 shows how
the driver lives with that.
model/make_urdf.py turns robot_model.json into ros2/rosorin_base/urdf/rosorin.urdf. It is 70 lines. Build it
yourself in a scratch folder on the Mac, then compare your output with the repo's file.
On your laptop:
mkdir -p ~/learn/model
cp ~/CCode/rosorin-pro/model/robot_model.json ~/learn/model/
Create ~/learn/model/make_urdf.py. First the header, the model, and the list of joints that go into the URDF:
On your laptop:
"""Generate ros2/rosorin_base/urdf/rosorin.urdf from model/robot_model.json (factory URDF numbers,
measured 2026-09-27). Kinematics + box collision geometry (same boxes as arm_model.py; no meshes). Root = base_link (EKF owns odom->base_link).
LiDAR/IMU frames stay as static TFs in base.launch.py (names laser/imu_link).
camera_link -> depth_camera_link: optical rotation rpy(-pi/2, 0, -pi/2), as factory
(depth_camera.urdf.xacro depth_cam_joint_sim / aurora930.launch.py static TF). The Deptrum node
itself publishes depth_camera_link -> rgb_camera_link from the camera's stored extrinsics."""
import json, math, os
HERE = os.path.dirname(os.path.abspath(__file__))
m = json.load(open(os.path.join(HERE, 'robot_model.json')))
J = m['joints']
KEEP = ['joint1', 'joint2', 'joint3', 'joint4', 'joint5', 'gripper_joint', 'end_effector_joint',
'camera_connect_joint', 'camera_joint']
f = lambda v: ' '.join(f'{x:.6g}' for x in v)
links = {'base_link'}
out = ['<?xml version="1.0"?>', '<!-- GENERATED by model/make_urdf.py from model/robot_model.json - do not edit -->',
'<robot name="rosorin">', ' <link name="base_link"/>']
KEEP leaves out three joints on purpose. base_joint would add base_footprint above base_link, but the EKF
owns the transform into base_link, and a frame can have only one parent. lidar_joint stays out because the LiDAR
frame is published as a static transform named laser in the launch file (Step 5). f formats a list of numbers
with 6 significant digits.
Next, one <joint> element per kept joint, adding each link the first time it is seen:
On your laptop:
for name in KEEP:
j = J[name]
for l in (j['parent'], j['child']):
if l not in links:
links.add(l); out.append(f' <link name="{l}"/>')
out.append(f' <joint name="{name}" type="{j["type"]}">')
out.append(f' <parent link="{j["parent"]}"/><child link="{j["child"]}"/>')
out.append(f' <origin xyz="{f(j["xyz"])}" rpy="{f(j["rpy"])}"/>')
if j['type'] == 'revolute':
out.append(f' <axis xyz="{f(j["axis"])}"/>')
lo, hi = j['limit']
out.append(f' <limit lower="{lo}" upper="{hi}" effort="1.0" velocity="1.0"/>')
out.append(' </joint>')
That is all robot_state_publisher needs. The next part adds collision boxes for MoveIt (chapter 13). The arm and
camera boxes are copied as they are. The LiDAR box is moved into base_link by hand: the LiDAR joint has a yaw of pi,
which turns (x, y) into (-x, -y). The chassis height map becomes a stack of 1 cm boxes, one per run of equal
heights in each row, heights rounded up to the next centimetre so the boxes are never smaller than the chassis:
On your laptop:
# ---- collision geometry (2026-09-29, for MoveIt): the SAME boxes arm_model.py checks with.
# Arm/camera boxes are axis-aligned in their link frames; the chassis height map (base_footprint frame, 1 cm cells)
# becomes run-length-merged boxes per row; the LiDAR box moves into base_link through lidar_joint (yaw pi).
def box_xml(lo, hi):
c = [(a + b) / 2 for a, b in zip(lo, hi)]; sz = [max(b - a, 1e-3) for a, b in zip(lo, hi)]
return f' <collision><origin xyz="{f(c)}" rpy="0 0 0"/><geometry><box size="{f(sz)}"/></geometry></collision>'
coll = {l: [] for l in links}
for l, b in m['boxes'].items():
if l in coll:
coll[l].append(box_xml(b['min'], b['max']))
lj, lb = J['lidar_joint'], m['boxes']['lidar_frame'] # yaw pi: (x, y) -> (-x, -y)
coll['base_link'].append(box_xml([lj['xyz'][0] - lb['max'][0], lj['xyz'][1] - lb['max'][1], lj['xyz'][2] + lb['min'][2]],
[lj['xyz'][0] - lb['min'][0], lj['xyz'][1] - lb['min'][1], lj['xyz'][2] + lb['max'][2]]))
hm = m['chassis_heightmap']; ox, oy = hm['origin_xy']; cell = hm['cell']; dz = J['base_joint']['xyz'][2]
n_chassis = 0
for i, row in enumerate(hm['z']):
q = [math.ceil(z * 100) / 100 if z > 0 else 0 for z in row] # 1 cm steps, rounded UP (conservative)
j = 0
while j < len(q):
k = j
while k + 1 < len(q) and q[k + 1] == q[j]:
k += 1
if q[j] > 0:
coll['base_link'].append(box_xml([ox + i * cell, oy + j * cell, -dz], [ox + (i + 1) * cell, oy + (k + 1) * cell, q[j] - dz]))
n_chassis += 1
j = k + 1
dz is the 2.5 cm between base_footprint (where the height map lives) and base_link: every chassis box is
shifted down by it.
Last, put the collision boxes into the <link> elements, add the camera's optical frame, and write the file:
On your laptop:
for i, line in enumerate(out): # expand '<link name="x"/>' with collisions
for l, c in coll.items():
if c and line == f' <link name="{l}"/>':
out[i] = '\n'.join([f' <link name="{l}">'] + c + [' </link>'])
print('collision boxes:', {l: len(c) for l, c in coll.items() if c}, f'(chassis {n_chassis})')
out += [' <link name="depth_camera_link"/>',
' <joint name="camera_optical_joint" type="fixed">',
' <parent link="camera_link"/><child link="depth_camera_link"/>',
f' <origin xyz="0 0 0" rpy="{-math.pi / 2:.6f} 0 {-math.pi / 2:.6f}"/>',
' </joint>', '</robot>', '']
dst = os.path.join(HERE, '..', 'ros2', 'rosorin_base', 'urdf', 'rosorin.urdf')
os.makedirs(os.path.dirname(dst), exist_ok=True)
open(dst, 'w').write('\n'.join(out))
print('wrote', os.path.normpath(dst), len(links), 'links')
The optical joint exists because cameras use a different axis convention: in an image frame z points out of the
lens, x right, y down. The rotation rpy(-pi/2, 0, -pi/2) turns the body-style camera_link (x forward) into that
convention. The value is the factory's (depth_camera.urdf.xacro). The camera driver's depth images are stamped
with frame depth_camera_link, so this joint is what lets a depth pixel be placed in the room.
The complete file (model/make_urdf.py in the repo):
On your laptop:
"""Generate ros2/rosorin_base/urdf/rosorin.urdf from model/robot_model.json (factory URDF numbers,
measured 2026-09-27). Kinematics + box collision geometry (same boxes as arm_model.py; no meshes). Root = base_link (EKF owns odom->base_link).
LiDAR/IMU frames stay as static TFs in base.launch.py (names laser/imu_link).
camera_link -> depth_camera_link: optical rotation rpy(-pi/2, 0, -pi/2), as factory
(depth_camera.urdf.xacro depth_cam_joint_sim / aurora930.launch.py static TF). The Deptrum node
itself publishes depth_camera_link -> rgb_camera_link from the camera's stored extrinsics."""
import json, math, os
HERE = os.path.dirname(os.path.abspath(__file__))
m = json.load(open(os.path.join(HERE, 'robot_model.json')))
J = m['joints']
KEEP = ['joint1', 'joint2', 'joint3', 'joint4', 'joint5', 'gripper_joint', 'end_effector_joint',
'camera_connect_joint', 'camera_joint']
f = lambda v: ' '.join(f'{x:.6g}' for x in v)
links = {'base_link'}
out = ['<?xml version="1.0"?>', '<!-- GENERATED by model/make_urdf.py from model/robot_model.json - do not edit -->',
'<robot name="rosorin">', ' <link name="base_link"/>']
for name in KEEP:
j = J[name]
for l in (j['parent'], j['child']):
if l not in links:
links.add(l); out.append(f' <link name="{l}"/>')
out.append(f' <joint name="{name}" type="{j["type"]}">')
out.append(f' <parent link="{j["parent"]}"/><child link="{j["child"]}"/>')
out.append(f' <origin xyz="{f(j["xyz"])}" rpy="{f(j["rpy"])}"/>')
if j['type'] == 'revolute':
out.append(f' <axis xyz="{f(j["axis"])}"/>')
lo, hi = j['limit']
out.append(f' <limit lower="{lo}" upper="{hi}" effort="1.0" velocity="1.0"/>')
out.append(' </joint>')
# ---- collision geometry (2026-09-29, for MoveIt): the SAME boxes arm_model.py checks with.
# Arm/camera boxes are axis-aligned in their link frames; the chassis height map (base_footprint frame, 1 cm cells)
# becomes run-length-merged boxes per row; the LiDAR box moves into base_link through lidar_joint (yaw pi).
def box_xml(lo, hi):
c = [(a + b) / 2 for a, b in zip(lo, hi)]; sz = [max(b - a, 1e-3) for a, b in zip(lo, hi)]
return f' <collision><origin xyz="{f(c)}" rpy="0 0 0"/><geometry><box size="{f(sz)}"/></geometry></collision>'
coll = {l: [] for l in links}
for l, b in m['boxes'].items():
if l in coll:
coll[l].append(box_xml(b['min'], b['max']))
lj, lb = J['lidar_joint'], m['boxes']['lidar_frame'] # yaw pi: (x, y) -> (-x, -y)
coll['base_link'].append(box_xml([lj['xyz'][0] - lb['max'][0], lj['xyz'][1] - lb['max'][1], lj['xyz'][2] + lb['min'][2]],
[lj['xyz'][0] - lb['min'][0], lj['xyz'][1] - lb['min'][1], lj['xyz'][2] + lb['max'][2]]))
hm = m['chassis_heightmap']; ox, oy = hm['origin_xy']; cell = hm['cell']; dz = J['base_joint']['xyz'][2]
n_chassis = 0
for i, row in enumerate(hm['z']):
q = [math.ceil(z * 100) / 100 if z > 0 else 0 for z in row] # 1 cm steps, rounded UP (conservative)
j = 0
while j < len(q):
k = j
while k + 1 < len(q) and q[k + 1] == q[j]:
k += 1
if q[j] > 0:
coll['base_link'].append(box_xml([ox + i * cell, oy + j * cell, -dz], [ox + (i + 1) * cell, oy + (k + 1) * cell, q[j] - dz]))
n_chassis += 1
j = k + 1
for i, line in enumerate(out): # expand '<link name="x"/>' with collisions
for l, c in coll.items():
if c and line == f' <link name="{l}"/>':
out[i] = '\n'.join([f' <link name="{l}">'] + c + [' </link>'])
print('collision boxes:', {l: len(c) for l, c in coll.items() if c}, f'(chassis {n_chassis})')
out += [' <link name="depth_camera_link"/>',
' <joint name="camera_optical_joint" type="fixed">',
' <parent link="camera_link"/><child link="depth_camera_link"/>',
f' <origin xyz="0 0 0" rpy="{-math.pi / 2:.6f} 0 {-math.pi / 2:.6f}"/>',
' </joint>', '</robot>', '']
dst = os.path.join(HERE, '..', 'ros2', 'rosorin_base', 'urdf', 'rosorin.urdf')
os.makedirs(os.path.dirname(dst), exist_ok=True)
open(dst, 'w').write('\n'.join(out))
print('wrote', os.path.normpath(dst), len(links), 'links')
Run it and compare with the repo's generated file:
On your laptop:
cd ~/learn
python3 model/make_urdf.py
diff ~/learn/ros2/rosorin_base/urdf/rosorin.urdf ~/CCode/rosorin-pro/ros2/rosorin_base/urdf/rosorin.urdf && echo IDENTICAL
grep -c '<link' ~/learn/ros2/rosorin_base/urdf/rosorin.urdf
Check
Output when this guide was written (2026-10-07, against the repo atbfb61d8):collision boxes: {'link4': 1, 'link3': 1, 'link1': 1, 'gripper_link': 1, 'camera_link0': 1, 'camera_link': 1, 'link2': 1, 'link5': 1, 'base_link': 149} (chassis 148) wrote /Users/matty/learn/ros2/rosorin_base/urdf/rosorin.urdf 10 links IDENTICAL 11The order of the names in the first line changes from run to run (
linksis a Python set); the file does not.
"10 links" is counted beforedepth_camera_linkis appended, so the file has 11.base_linkcarries 149
boxes: 148 chassis boxes and the LiDAR housing.
If it fails
diffprints lines: you typed something differently. The usual suspects are thefformat (.6g) and the
rounding in the height map (math.ceil). Fix your copy until the diff is empty; the robot uses the repo file.- Never edit
rosorin.urdfby hand. Its second line says "GENERATED ... do not edit": the next run of
make_urdf.pyoverwrites it. Changerobot_model.jsonormake_urdf.pyinstead and regenerate.
The URDF and the model are data files of the rosorin_base package. Three lines in the package decide that they get
installed (all three are already in the repo copy):
setup.py: ('share/rosorin_base/urdf', ['urdf/rosorin.urdf']), and 'config/robot_model.json' in theshare/rosorin_base/config list.package.xml: <exec_depend>robot_state_publisher</exec_depend>.This project kept the package in the repo on the Mac and copied it to the robot with rsync, then built it there (log
2026-09-28). The rsync replaces the robot's package source with the repo copy, including any file you typed on the
robot.
On your laptop:
cd ~/CCode/rosorin-pro/ros2
rsync -a --exclude __pycache__ rosorin_base/ rosorin:ros2_ws/src/rosorin_base/
On the robot:
cd ~/ros2_ws
source /opt/ros/humble/setup.bash
colcon build --packages-select rosorin_base 2>&1 | tail -1
ls install/rosorin_base/share/rosorin_base/urdf/ install/rosorin_base/share/rosorin_base/config/robot_model.json
Check
colconends withSummary: 1 package finished [2.40s](time varies), andlslistsrosorin.urdfand the
model file.robot_state_publisheritself came withros-humble-ros-basein chapter 9; the robot has
ros-humble-robot-state-publisher 3.0.3-2jammy.20260908.051617(log 2026-09-28).
If it fails
scripts/ops/deploy.sh driver(chapter 29) copies only*.pyand*.yaml. It does not copy
rosorin.urdforrobot_model.json. After you regenerate the URDF, copy it yourself and rebuild.colcon buildwithout--symlink-installcopies data files intoinstall/. Editing the file insrc/changes
nothing until you build again.
In ros2/rosorin_base/launch/base.launch.py two places belong to this chapter. If you copied the complete launch
file in chapter 11, they are already there. Line 27 reads the URDF text once, at launch:
On the robot:
urdf = open(os.path.join(share, 'urdf', 'rosorin.urdf')).read() # generated: model/make_urdf.py
and lines 36-38 start the node with that text as its robot_description parameter:
On the robot:
# 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}]),
The joint angles come from board_driver, which publishes /joint_states from a 10 Hz timer
(board_driver.py, publish_joints):
On the robot:
def publish_joints(self):
now = time.monotonic()
idle = not self.gate.enabled and not self.arm_enabled and self.arm_traj is None
if idle and now - self.joint_read_t > self.joint_refresh_s:
self.joint_read_t = now
for sid in (1, 2, 3, 4, 5):
self.servo_query(sid, 0x05, timeout=0.1)
js = pulses_to_joints(self.joint_pulses)
if js is None:
return
m = JointStateMsg()
m.header.stamp = self.get_clock().now().to_msg()
m.name, m.position = js
self.joint_pub.publish(m)
joint_pulses holds the commanded pulses while the driver streams a move, and the servos' own read-back otherwise.
The servo read-back refresh runs every 2 s (joint_refresh_s) and only while wheels and arm are idle, because a read
blocks the driver for up to 0.1 s per servo. If any of joints 1-5 is unknown, nothing is published. The claw
(servo 10) is not a joint in the URDF and is not in the message.
Restart the base service so the launch file is read again. If rosorin-mind is installed (chapter 21), stop it
first and start it after: the mind hangs in its service wait when the driver restarts under it
(docs/lessons.md 2026-10-05; scripts/ops/deploy.sh does the same).
On the robot:
sudo systemctl stop rosorin-mind 2>/dev/null
sudo systemctl restart rosorin-base
sleep 15
systemctl is-active rosorin-base
sudo systemctl start rosorin-mind 2>/dev/null
timeout 6 ros2 topic hz /joint_states 2>&1 | grep -m1 average
timeout 5 ros2 topic echo --once /joint_states | grep -A6 position
Check
active, then (2026-09-28, arm tucked):average rate: 10.012 position: - 0.0 - -2.014808088502254 - 1.9058995431778076 - 1.554041165975751 - 0.0041887902047863905 velocity: []Five positions, in radians, for
joint1tojoint5. Your numbers depend on where the arm is.
The LiDAR and the IMU never move relative to the body, so they get static transforms. The helper at the top of
base.launch.py wraps the stock static_transform_publisher:
On the robot:
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])
and two calls use it, with values from the factory URDF (pro/lidar.urdf.xacro and pro/imu.urdf.xacro, read-only
on 2026-09-26):
On the robot:
# 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'),
On the robot:
# 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'),
What the numbers say:
laser: 4.8 cm ahead of the body centre, 10.7 cm up, turned 180 degrees. The LiDAR is mounted backwards: its ownlaser and leaves the mounting to this transformdocs/decisions.md 2026-09-26: "mounting goes in TF").imu_link: 2.7 cm behind the centre, 4.8 cm up, turned 90 degrees (yaw 1.5707) and tilted -0.5 degrees in pitchimu_link (chapter 11), and the EKFThe model file names the LiDAR frame lidar_frame and stores y = 7.5552e-05 m; the launch file uses the name
laser and y = 0.0000756 m (docs/hardware.md). The two differ by 0.0004 mm; the launch value is what runs.
Do not read IMU axes off this yaw
docs/hardware.md(accelerometer calibration, 2026-09-27): a prediction of which robot side gives -X, made from
the factory URDF yaw, was wrong. The transform is what the factory used and what the EKF runs on; do not use it
to guess which physical side an IMU axis points to.
Check both transforms (the base service must be running):
On the robot:
timeout 6 ros2 run tf2_ros tf2_echo base_link laser 2>&1 | grep -m2 -E "Translation|RPY \(degree"
timeout 6 ros2 run tf2_ros tf2_echo base_link imu_link 2>&1 | grep -m2 -E "Translation|RPY \(degree"
Check
Recorded on 2026-09-27 at the first base launch:- Translation: [0.048, 0.000, 0.107] - Rotation: in RPY (degree) [0.000, -0.000, 180.000] - Translation: [-0.027, -0.000, 0.048] - Rotation: in RPY (degree) [-0.000, -0.500, 89.994]
tf2_echo SOURCE TARGET prints, about once a second, where TARGET is inside SOURCE: translation in metres, then
the rotation as a quaternion, as roll/pitch/yaw in radians and in degrees, then the 4x4 matrix. timeout 6 and the
grep above keep only the lines you need; without them it runs until Ctrl-C.
On the robot:
timeout 6 ros2 run tf2_ros tf2_echo base_link camera_link 2>/dev/null | grep -m2 -E "Translation|RPY \(deg"
timeout 6 ros2 run tf2_ros tf2_echo base_link depth_camera_link 2>/dev/null | grep -m3 -E "Translation|RPY \(deg"
Check
Recorded on 2026-09-28 with the arm tucked:- Translation: [0.023, 0.001, 0.289] - Rotation: in RPY (degree) [0.219, -7.197, 0.981] - Translation: [0.023, 0.001, 0.289] - Rotation: in RPY (degree) [-82.803, 0.217, -88.992]The camera sits 2.3 cm ahead of the body centre, 28.9 cm up, pitched 7.2 degrees: it looks forward and about 7
degrees up, which matched the room view the camera captured in that pose (docs/hardware.md). The depth frame
has the same position and the optical rotation on top. Move the arm (chapter 13) and run it again: the numbers
follow.
The arm model in chapter 13 computes the same point independently: camera_link at (0.023, 0.001, 0.314) in
base_footprint for the tucked pose, which is 0.289 in base_link after the 2.5 cm offset. Two independent paths
giving the same answer is the check that the URDF, the pulse conversion and the joint states agree.
If it fails
tf2_echokeeps printing that the frame does not exist (the real form, from the Nav2 log on 2026-09-28:
Invalid frame ID "map" passed to canTransform argument target_frame - frame does not exist). For an arm or
camera frame this means robot_state_publisher has no joint states: checkros2 topic hz /joint_states. The
driver publishes nothing while any of servos 1-5 does not answer (pulses_to_jointsreturns None). A silent
claw (servo 10) does not stop it.- Right after a restart the driver's topics can take a few seconds to appear (
docs/lessons.md). Wait 15 s
before you conclude anything.robot_state_publisheruses a large share of a core for a 10 Hz job and/tfarrives late: the 2026-10-05
DDS host-id split (chapter 9), where processes started before the network address settled talked over UDP
instead of shared memory; robot_state_publisher was at 37 % of a core then. Fix as in chapter 9.
Not test-built
Neither was used on this robot.ros-humble-tf2-tools 0.25.23is installed (it providesview_frames), and
/opt/ros/humble/share/rviz2exists, pulled in by other packages; the robot has no screen.
ros2 run tf2_tools view_frameslistens to/tffor a few seconds and writesframes_<date>.pdfand.gv
into the current directory. Run it in a scratch directory and copy the PDF to the Mac withscp.- RViz on a desktop needs ROS 2 Humble on that desktop, the same
ROS_DOMAIN_ID(0) and a network on which DDS
discovery reaches the robot. None of the three machines in this project has had that set up; bigbuddy runs
Fedora without ROS.
Four failures in the record came from frames, not from the sensors themselves:
map -> odom went 10 s stale, and on its first navigation goal the robot drove about 2.6 m past a 0.6 m goal into~/arm_busy around such moves, and abase_link -> laser once, on its first scan."laser" passed to lookupTransform argument source_frame does not exist.) and the sign of its output flipped between restarts. Fix in chapter 16: rf2o works in the laserdocs/lessons.md, factory section). Lookups at the exact image time failed; for slow-moving transforms use thediff of your ~/learn URDF against the repo's prints nothing.ros2 topic hz /joint_states shows about 10 Hz.tf2_echo base_link laser shows [0.048, 0.000, 0.107] and yaw 180.000.tf2_echo base_link imu_link shows [-0.027, -0.000, 0.048], pitch -0.500, yaw 89.994.tf2_echo base_link camera_link prints a transform, and it changes when the arm moves.Where this comes from
Repo (branchrebuild,bfb61d8):model/robot_model.json,model/make_urdf.py,
ros2/rosorin_base/urdf/rosorin.urdf,ros2/rosorin_base/launch/base.launch.py(lines 14-18, 27, 36-38, 45-47,
52-54),ros2/rosorin_base/rosorin_base/board_driver.py(publish_joints),ros2/rosorin_base/setup.py,
ros2/rosorin_base/package.xml,ros2/rosorin_base/config/slam.yaml,ekf.yaml,rf2o.yaml,
scripts/extract_robot_model.py,scripts/heightmap_fix.py,scripts/ops/deploy.sh. Docs:docs/hardware.md
(LiDAR orientation, accelerometer calibration, depth camera frames, tuck camera pose 2026-09-28),
docs/decisions.md2026-09-26 (mounting in TF),docs/motion_safety.md(arm collision model),
docs/navigation.md2026-09-28 (collision),docs/lessons.md(factory section; 2026-09-30 rf2o; 2026-10-02
camera TF; 2026-10-05 host id, mind after driver restart). Command log: model extraction 2026-09-27 16:02,
heightmap_fix.py2026-09-27 16:04, URDF generated 2026-09-28 15:59, robot_state_publisher added and tested
2026-09-28 16:00,tf2_echolaser/IMU 2026-09-27 06:06, joint states and camera TF 2026-09-28 16:00.
Read-only lookup on the robot 2026-10-07: tf2_tools 0.25.23 installed, rviz2 share present. Themake_urdf.py
run and thediffwere done for this guide on 2026-10-07 on a copy of the repo.