Before you start · Chapter 02 · Time: 1-2 hours to read · Level: Beginner · Status: Reference
Every robotics idea this guide uses, explained once with this robot's real node names, topics, frames and numbers, plus read-only commands that show each one on the running robot.
This chapter is a map of the ideas, not a build step. Each idea is explained with what actually runs on this robot:
the real node names, topic names, frames and measured rates. Later chapters point back here, and repeat the short
version in an !!! idea box when you first need it. Read it in one go, or keep it open as a reference.
The numbers marked "live" were read from the running robot on 2026-10-07 for this guide, with read-only commands.
The last section lists those commands so you can run them yourself once the robot is built.
New idea: computer and microcontroller
A robot usually has two kinds of computer. A computer (here the Jetson Orin NX: 8 CPU cores, a GPU, 16 GB
of memory shared by both, Ubuntu 22.04) runs Linux and all the heavy software: ROS 2, maps, navigation, vision.
Linux is not real-time: a busy Jetson can be late by tens of milliseconds. A microcontroller (here an
STM32F407 on the controller board) runs one small program, its firmware, with exact timing. It switches the
motor currents, talks to the arm servos, and reads the IMU, the battery voltage and the gamepad receiver.
The two talk over one USB cable. The board appears on the Jetson as /dev/ttyACM0 (USB ID 1a86:55d4) and speaks a
binary protocol at 1,000,000 baud. Every message is a frame:
Board bytes:
AA 55 | function | length | payload ... | CRC-8/MAXIM
The board sends IMU frames (function 7) at about 110 Hz, battery frames (function 0) at about 1 Hz and gamepad frames
(function 8) at about 20 Hz without being asked. The Jetson sends motor frames (function 3), servo frames (function
5) and buzzer frames (function 2). This is the frame that stops all four wheels, copied from the unit test
ros2/rosorin_base/test/test_motion.py:
Board bytes:
aa5503160104000000000001000000000200000000030000000007
Nothing in this build changes the board's firmware. Chapter 10 decodes the protocol byte by byte.
The board has no stop watchdog
On 2026-09-27 the board was sent one motor command (0.3 wheel revolutions per second) and then nothing. The
wheels kept turning for the full 5 s of the test, until an explicit zero arrived. The board does not stop the
wheels when the Jetson goes quiet. Stopping is the Jetson software's job, and the power switch is the last line.
Chapters 11 and 19 build and test every stop path.
New idea: sensors and actuators
A sensor measures the world (distance, rotation, light). An actuator changes it (a motor, a servo, a
speaker). Robot software is a loop: read sensors, decide, command actuators, repeat.
| Part | Kind | What it gives or takes | Name in software | Rate |
|---|---|---|---|---|
| COIN-D6 LiDAR | sensor | 400 distances around the robot per turn, 0.12-8.0 m | /scan |
10 Hz (live 10.048) |
| MPU6050 IMU (on the board) | sensor | acceleration and rotation rate | /imu/data_raw |
about 110 Hz (live 112.6) |
| Aurora 930 depth camera (on the arm) | sensor | colour image, depth image, infrared image, point cloud | /aurora/rgb/image_raw, /aurora/depth/image_raw, /aurora/points2 |
RGB 9.3 Hz, depth 14.0 Hz, points 12.9 Hz |
| Battery (read by the board) | sensor | voltage only, no current, no percentage | /battery |
about 1 Hz |
| Arm servos (read back) | sensor | joint positions | /joint_states |
10 Hz (live 9.98) |
| Gamepad (receiver read by the board) | sensor | buttons and sticks | /gamepad |
about 20 Hz |
| reSpeaker XVF3800 mic array | sensor | sound | robot API GET /audio (not ROS) |
16 kHz |
| 4 mecanum wheel motors | actuator | body velocity | /cmd_vel |
refreshed at 5 Hz plus on every change |
| 5 arm joints + claw (bus servos 1-5, 10) | actuator | joint positions | arm/goal, arm_controller/joint_jog, arm_controller/follow_joint_trajectory |
25 Hz while moving |
| Speaker on the mic array | actuator | sound | robot API POST /play (not ROS) |
- |
| Buzzer (on the board) | actuator | beeps | switched off in the driver | - |
New idea: open loop
The wheel motors have no encoders: nothing measures how far the wheels actually turned. The robot only knows
what it asked for. That is called open loop. It is why this robot needs the LiDAR, the IMU and the camera to work
out where it is (see "Odometry" below).
ROS 2 (version Humble on this robot) is not an operating system. It is a set of libraries and tools that let many
small programs share data in a standard way. The pieces below are all you need for this guide.
New idea: nodes
A node is one participant in the ROS system, usually one program. Each has a name. Nodes do not call each
other's functions; they exchange messages.
On the running robot there are about 35 nodes. The ones you will build or configure yourself:
| Node | Program | What it does | Chapter |
|---|---|---|---|
/rosorin_board_driver |
rosorin_base/board_driver.py |
the only program that opens the board; IMU, battery, wheels, arm | 11, 13 |
/rosorin_lidar_reader |
rosorin_base/lidar_reader.py |
reads /dev/lidar, publishes /scan |
14 |
/robot_state_publisher |
stock ROS | arm and camera frames from /joint_states |
12 |
/ekf_filter_node |
stock robot_localization |
fuses motion into one odometry estimate | 16 |
/rf2o_laser_odometry, /rf2o_fix |
rf2o (source build) + rf2o_fix.py |
motion estimate from the LiDAR | 16 |
/aurora/aurora |
Deptrum vendor driver | the depth camera | 15 |
/vslam_odom |
rosorin_base/vslam_odom.py |
motion estimate from the camera (cuVSLAM) | 16 |
/slam_toolbox |
stock | the map and where the robot is on it | 17 |
/controller_server, /planner_server, /bt_navigator, /velocity_smoother, /collision_monitor, ... |
stock Nav2 | driving to a goal | 18 |
/nvblox_node, /depth_gate |
Isaac ROS + depth_gate.py |
3D obstacles from the depth camera | 18 |
/nav_supervisor |
rosorin_base/nav_supervisor.py |
places the robot on the map, starts and heals Nav2 | 18 |
/contact_monitor |
behavior/contact_monitor.py |
stops the robot when it pushes against something | 19 |
/robot_api |
buddy_link/robot_api.py |
HTTP API for Buddy on bigbuddy | 22 |
New idea: topics and messages
A topic is a named stream of messages, like/scan. A node publishes to a topic; any number of nodes
subscribe to it. The publisher does not know who listens. Every topic has one message type that fixes
its fields, for examplesensor_msgs/msg/LaserScan.
/scan is the clearest example. rosorin_lidar_reader publishes it about ten times a second. Many nodes subscribe:
the board driver (to block the wheels when scans stop), slam_toolbox, both costmaps, the collision monitor, rf2o,
the navigation supervisor and the contact monitor. One LaserScan message, read live:
On the robot:
angle_min: 0.0
angle_max: 6.267477512359619
angle_increment: 0.015707964077591896
scan_time: 0.08019399642944336
range_min: 0.11999999731779099
range_max: 8.0
angle_increment is 0.0157 rad, which is 0.9°: 400 readings per turn. range_min 0.12 m hides the robot's own
body, which the LiDAR sees at 50-106 mm.
The message types you will meet most:
| Type | Used on this robot for |
|---|---|
sensor_msgs/msg/LaserScan |
/scan |
sensor_msgs/msg/Imu |
/imu/data_raw |
sensor_msgs/msg/BatteryState |
/battery (voltage only; percentage is .nan) |
sensor_msgs/msg/JointState |
/joint_states, arm/goal |
sensor_msgs/msg/Image |
/aurora/rgb/image_raw, /aurora/depth/image_raw |
geometry_msgs/msg/Twist |
/cmd_vel and the whole velocity chain: a body velocity (x, y, rotation) |
nav_msgs/msg/Odometry |
/wheel/odom, /odometry/filtered, /odom_lidar, /odom_vslam |
nav_msgs/msg/OccupancyGrid |
/map, the costmaps |
std_msgs/msg/Bool |
/estop (any true latches the emergency stop) |
std_msgs/msg/String |
/rosorin_board_driver/state, /nav/status (JSON text) |
New idea: QoS
Each publisher and subscriber also has a quality of service (QoS) profile. Two settings matter here.
Reliability: "reliable" re-sends lost messages, "best effort" does not. The board driver and the supervisor
read/scanbest effort, since a lost scan is replaced by the next one 0.1 s later; a best-effort subscriber
can listen to a reliable publisher, not the other way round. Durability: a "transient local" (latched) topic
keeps its last message for late subscribers;
/nav/statusand/rosorin_board_driver/arm_busyare latched. If the profiles do not match, the subscriber
silently receives nothing. That happened on 2026-10-06: slam_toolbox'sposetopic is volatile, and a latched
subscriber to it got no messages and no error in its own log.
New idea: services
A service is a request and a reply, like a function call between nodes. Use it for things that happen once:
"enable the wheels", "tuck the arm".
The board driver offers these (live list):
On the robot:
/rosorin_board_driver/arm/drive
/rosorin_board_driver/arm/enable
/rosorin_board_driver/arm/pretuck
/rosorin_board_driver/arm/read_state
/rosorin_board_driver/arm/recover
/rosorin_board_driver/arm/relax
/rosorin_board_driver/arm/stiffen
/rosorin_board_driver/arm/tuck
/rosorin_board_driver/enable
/rosorin_board_driver/reset_estop
enable and arm/enable take a std_srvs/srv/SetBool (data: true or false); the others take a
std_srvs/srv/Trigger (no arguments). The wheels are disabled at every start of the driver and move only after
enable is called with true.
Do not call these services yet
Most of them move or release a motor:enablearms the wheels,arm/tuckmoves the arm,arm/relaxlets it
drop. Onlyarm/read_stateis a pure read, and the driver refuses it while the wheels are enabled. Chapters 11
and 13 call them with the robot on a stand.
New idea: actions
An action is a long request with progress reports and a final result, which can be cancelled. Use it for
things that take time: "drive to this point", "move the arm along this path".
Live list:
On the robot:
/arm_controller/follow_joint_trajectory
/assisted_teleop
/backup
/compute_path_through_poses
/compute_path_to_pose
/drive_on_heading
/follow_path
/navigate_through_poses
/navigate_to_pose
/smooth_path
/spin
/wait
/navigate_to_pose is the one the robot's own skills send ("go to this pose on the map"). /compute_path_to_pose
and /follow_path are the two steps inside it. /arm_controller/follow_joint_trajectory is the board driver's arm
input for MoveIt. /spin and /backup are loaded, but this robot's navigation never uses them (see "Behavior tree").
New idea: parameters
A parameter is a named setting of one node, read at start-up, for example a speed limit. Parameters come
from defaults in the code, YAML files and launch files.
The board driver's top wheel speed shows how the layers stack. The code default is 0.2 m/s
(dp('max_linear', 0.2) in board_driver.py). config/arm_limits.yaml sets it to 0.35:
On the robot:
max_linear: 0.35 # wheels (board_driver motion gate; default 0.2): owner 2026-10-01 faster, accel unchanged 0.5 m/s^2
Live, ros2 param get /rosorin_board_driver max_linear prints Double value is: 0.35, and cmd_timeout (the
deadman, see "Driving to a goal") prints Double value is: 0.3.
Two places for one setting
A value given in a launch file'sparameters=[{...}]silently overrides the code's default. On this robot that
caused confusion more than once (docs/lessons.md). Keep each tunable in one place: a YAML file inconfig/.
New idea: launch files
A launch file is a Python script that starts several nodes with their parameters, and can react when one of
them exits.ros2 launch <package> <file>runs it.
ros2/rosorin_base/launch/base.launch.py starts the robot's base. systemd runs it at boot through
rosorin-base.service. It starts:
| Started | Executable | Notes |
|---|---|---|
rosorin_board_driver |
board_driver |
device: /dev/ttyACM0, imu_frame_id: imu_link, plus imu_calibration.yaml and arm_limits.yaml |
| on board driver exit | stop_motors |
writes the stop frame, then shuts the whole launch down |
robot_state_publisher |
stock | the URDF |
ekf_filter_node |
ekf_node |
ekf.yaml |
rosorin_lidar_reader |
lidar_reader |
device: /dev/lidar, frame_id: laser, clockwise: True; restarted 2 s after a crash |
tf_base_laser |
static_transform_publisher |
where the LiDAR sits |
rf2o_laser_odometry + rf2o_fix |
rf2o, rf2o_fix |
LiDAR odometry |
tf_base_imu |
static_transform_publisher |
where the IMU sits |
The "on exit" line is a safety feature, verbatim from the launch file:
On the robot:
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'))]))
If the board driver dies for any reason, stop_motors sends the stop frame at once. Chapter 11 builds it.
launch/nav.launch.py starts the navigation stack the same way, through rosorin-nav.service.
New idea: packages and workspaces
A package is one folder of ROS code with apackage.xml(name, dependencies). A workspace is a folder
withsrc/full of packages;colcon buildbuilds them intoinstall/. You source a workspace's
install/setup.bashto make its packages visible. Workspaces stack: the one sourced last wins
("overlay" on top of an "underlay").
This robot has one underlay and three overlays:
| Workspace | Holds | Built by |
|---|---|---|
/opt/ros/humble |
ROS 2 itself and every apt package (Nav2, slam_toolbox, robot_localization, Isaac ROS) | apt |
~/ext_ws |
rf2o_laser_odometry (MAPIRlab, branch ros2 @ b38c68e) |
colcon, chapter 16 |
~/ros2_ws |
rosorin_base, rosorin_moveit, the patched nav2_behaviors, m-explore-ros2 |
colcon, chapters 11-24 |
~/vendor_ws |
the Aurora 930 vendor driver, kept apart from everything else | colcon, chapter 15 |
The source order is fixed in each unit. From ros2/rosorin_base/systemd/rosorin-base.service:
On the robot:
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'
rosorin_base is a Python package (ament_python). Its setup.py turns each module into a command, for example
'lidar_reader = rosorin_base.lidar_reader:main', which is what executable='lidar_reader' in the launch file
refers to. After changing its code you rebuild only it:
On the robot:
source /opt/ros/humble/setup.bash && cd ~/ros2_ws && colcon build --packages-select rosorin_base
That is the command scripts/ops/deploy.sh uses. Do not run it now; chapter 11 creates the workspace.
New idea: DDS
Nodes find each other and move messages through a middleware called DDS (here Fast DDS, the Humble
default). There is no central server: nodes announce themselves on the network. Every node with the same
domain id (ROS_DOMAIN_ID, a number) can see every other. Nodes on the same machine use shared memory in
/dev/shminstead of the network.
Every robot unit sets Environment=ROS_DOMAIN_ID=0. A login shell on the robot has no ROS_DOMAIN_ID set, which
also means 0, so commands you type see the running robot. Tests that must not touch the running robot use another
domain (chapter 18 uses 77).
Only the robot speaks ROS. bigbuddy and the server never join the ROS graph; they talk to the robot over HTTP
(the robot API on port 8296).
Two DDS traps hit this robot. Chapter 6 and chapter 9 fix both before anything else runs:
systemd-logind deletes a user's /dev/shm files when thatburgerbarn, so every time the last ssh sessionRemoveIPC=no.scripts/wait_addresses.sh).New idea: frames and transforms
A frame is a coordinate system attached to something: the robot's body, the LiDAR, the room. A
transform says where one frame is relative to another (a translation in metres and a rotation). ROS keeps
all transforms in a tree called TF, published on/tf(changing) and/tf_static(fixed). Any node can
then ask "where is this LiDAR point in room coordinates?" and TF chains the transforms.
ROS conventions, which this robot follows: lengths in metres, angles in radians; in a body frame x points forward,
y to the left, z up; a positive rotation about z is counter-clockwise seen from above. (Confirmed on this robot on
2026-09-28: a counter-clockwise turn on the floor gave a positive gyro reading, +82.5°.)
The frames on this robot:
| Frame | Attached to | Published by |
|---|---|---|
map |
the room; fixed | - (the root) |
odom |
where the robot started counting motion; drifts slowly against map |
map → odom by slam_toolbox |
base_link |
the robot's body (there is no base_footprint frame) |
odom → base_link by ekf_filter_node, 50 Hz |
laser |
the LiDAR | tf_base_laser, static |
imu_link |
the IMU on the board | tf_base_imu, static |
link1 ... link5 |
the arm's links | robot_state_publisher from /joint_states |
gripper_link, end_effector_link |
the claw | robot_state_publisher, fixed to link5 |
camera_link0, camera_link, depth_camera_link |
the depth camera, on link4 |
robot_state_publisher, fixed |
rgb_camera_link |
the colour sensor | the Aurora driver, from the camera's stored calibration |
The arm frames (link1 to link5, the camera) come from robot_state_publisher, which reads the joint angles on
/joint_states and the robot's shape from the URDF (see "The arm"). The camera rides on the arm ("eye in hand"),
so its position changes whenever the arm moves.
The LiDAR's transform shows two facts about how it is mounted: 4.8 cm in front of and 10.7 cm above base_link,
and turned around by 180° (the yaw of π in base.launch.py). Read live:
On the robot:
- Translation: [0.048, 0.000, 0.107]
- Rotation: in Quaternion (xyzw) [0.000, 0.000, 1.000, 0.000]
- Rotation: in RPY (degree) [0.000, -0.000, 180.000]
Frames that lie
- The LiDAR reports its angles clockwise, against the ROS convention. The factory configuration read them the
other way, so left and right in the scan were swapped. A floor test on 2026-09-28 found it by comparing the
gyro with the scan during turns.lidar_readernow takesclockwise: True.- While the arm moves, the board driver blocks for short servo commands and
/joint_statesstops, so the
camera frame is stale and a depth image lands in the wrong place.depth_gateholds depth images back while
the arm moves (chapter 18).
New idea: odometry
Odometry is the robot's own estimate of how it moved, from its own sensors, without looking at a map. It is
smooth and fast but drifts: small errors add up. Its frame isodom.
This robot has four motion sources. Only two feed the EKF, the filter that makes the odom frame:
| Source | Topic | How | In the EKF? |
|---|---|---|---|
| Wheels | /wheel/odom |
the velocity the driver commanded, after its limits (no encoders); sideways scaled by 0.765 because strafing on this floor only moves the robot about 76 % as far | yes: forward and sideways speed |
| IMU gyro | /imu/data_raw |
measured rotation rate | yes: turning speed |
| LiDAR (rf2o) | /odom_rf2o → rf2o_fix → /odom_lidar |
compares consecutive scans | no: switched off as an input on 2026-09-30 until proven |
| Camera (cuVSLAM) | /odom_vslam |
tracks the colour and depth images | no; the contact monitor and the practice and explore skills read it |
The EKF (ekf_filter_node, from robot_localization) combines the inputs 50 times a second into the odom →
base_link transform and /odometry/filtered (live 50.16 Hz). Which fields it takes from each input is a
true/false table in config/ekf.yaml:
On the robot:
odom0: wheel/odom
# x y z roll pitch yaw
# vx vy vz vroll vpitch vyaw
# ax ay az
odom0_config: [false, false, false, false, false, false,
true, true, false, false, false, false,
false, false, false]
odom0_differential: false
imu0: imu/data_raw
imu0_config: [false, false, false, false, false, false,
false, false, false, false, false, true,
false, false, false]
From the wheels it takes vx and vy (second row); from the IMU only vyaw, the turning rate.
On a stand, odometry is wrong
With the wheels in the air the wheels spin, the commanded velocity says "moving", and odometry drives away
while the robot stands still. On a rug the robot moves only 25-88 % of the commanded distance, so odometry
over-counts there too. On 2026-10-06, 10 s of spinning on the stand while the map was being updated put phantom
places into the saved map. The rule since: never map while plugged in.
New idea: occupancy grid map
A 2D map here is an image of the floor plan: each cell is free, occupied or unknown. A small YAML file next
to the image says how big a cell is and where the image sits in themapframe.
The robot's saved room map has 5 cm cells. Its description file, maps/room_20260928.yaml in the repo:
On the robot:
image: room_20260928.pgm
mode: trinary
resolution: 0.050
origin: [-1.358, -3.109, 0]
negate: 0
occupied_thresh: 0.65
free_thresh: 0.25
origin is where the image's bottom-left corner sits in the map frame (x and y in metres, then yaw).
New idea: localization
Localization is finding where the robot is on a known map. It works by matching the live LiDAR scan against
the map. The answer is published as themap→odomtransform: the correction that moves the driftingodom
frame back onto the room.
New idea: SLAM
SLAM (simultaneous localization and mapping) builds the map and localizes in it at the same time. Here it
isslam_toolbox, which keeps the map as a pose graph: a chain of places the robot has stood, each with the
scan it saw there.
On this robot slam_toolbox runs all the time on a kept pose graph (~/maps/room.posegraph). The supervisor first
switches it to localization only, with an explicit service call: slam_toolbox 2.6.10 starts in mapping mode
whatever its configuration says. nav_supervisor then places the robot: it searches the whole map on a 10 cm × 6° grid and
accepts a pose only if the scan fits with a score of at least 0.65 and beats the next-best place by 0.10. Once the
pose is confirmed, and the robot is not plugged in, the supervisor switches slam_toolbox to mapping, so the map
updates as the robot drives, and saves a copy after each drive or every 600 s. With slam:=false the older
map_server + AMCL pair runs instead (AMCL is a particle filter: 500 to 2000 guesses of the pose, each weighted by
how well it explains the scan).
The supervisor reports on /nav/status. Read live:
On the robot:
{"state": "ready", "ready": true, "heals": 0, "t": 1791405019.5, "pose": [-0.187, 0.242, 8.0], "fit": 0.85, "probe_fails": 0, "mapping": false}
fit 0.85 is the scan-to-map match; mapping: false because the robot was on its charger.
Nav2 is the stock ROS 2 navigation stack. It turns "go to this pose" into wheel velocities, avoiding obstacles. It
is made of separate servers, each a node.
New idea: costmaps
A costmap is a grid like the map, but each cell holds a cost: free, near an obstacle, or deadly. Layers add
to it: the saved map, live LiDAR obstacles, 3D obstacles from the depth camera, and inflation, a band of
rising cost around every obstacle so the robot keeps its distance. The robot is treated as its footprint, a
rectangle:[[0.155, 0.12], [0.155, -0.115], [-0.15, -0.115], [-0.15, 0.12]](metres frombase_link).
| Costmap | Frame | Size | Layers on this robot |
|---|---|---|---|
| local | odom |
3 × 3 m, moves with the robot, 5 cm cells | LiDAR obstacles, nvblox (depth camera), inflation 0.55 m |
| global | map |
the whole map | saved map, LiDAR obstacles, nvblox, inflation 0.55 m, keepout zones outside the room |
New idea: planner and controller
The planner finds a path through the global costmap from here to the goal (here NavFn, plugin
GridBased). The controller follows that path, many times a second, by trying out short velocity
commands against the local costmap and picking the best one (here DWB at 20 Hz: forward 0 to 0.35 m/s,
sideways ±0.08 m/s, turning up to 1.0 rad/s, never backwards).
New idea: behavior tree
A behavior tree is the script that says in which order to plan, follow and recover. This robot's tree,
ros2/rosorin_base/bt/navigate_no_backup.xml, replans once a second while following the path. If that fails
it clears the costmaps or waits 5 s, up to 6 times. The stock tree's Spin and BackUp recoveries were removed:
the LiDAR is blind over the rear ~65°, and on 2026-10-02 BackUp reversed the robot into a screen door.
New idea: lifecycle nodes
Nav2 servers are lifecycle nodes: they start unconfigured, and a lifecycle manager moves them to active.
On this robotlifecycle_manager_navigationhas autostart off;nav_supervisorstarts the servers only once
the robot is placed on the map, and heals them with RESET then STARTUP if they stop answering.
The velocity commands pass through a chain of stages. Each stage can only reduce what the stage before asked for:
velocity_smoother: caps at [0.35, 0.08, 1.0] (x m/s, y m/s, turn rad/s), never below 0 forward, and limitscollision_monitor: the last Nav2 stage and the only node allowed to publish /cmd_vel. It cuts any motion thatrosorin_board_driver's MotionGate (chapter 11): wheels disabled at every start until enable; a/cmd_vel arrives for 0.3 s; the 0.35 m/s and 1.0 rad/s limits; a/estop or any gamepad button; a scan gate that blocks software commands when no/scan has arrived for 0.5 s; and a trip if more than one node publishes /cmd_vel.Live, /cmd_vel has exactly one publisher and one subscriber:
On the robot:
Node name: collision_monitor
Node namespace: /
Endpoint type: PUBLISHER
Node name: rosorin_board_driver
Node namespace: /
Endpoint type: SUBSCRIPTION
How the sensor data flows between the main nodes:
New idea: URDF and joints
A URDF file describes a robot as links (rigid parts) connected by joints. A revolute joint turns
about an axis between limits; a fixed joint does not move.robot_state_publisherreads the URDF and the
joint angles on/joint_statesand publishes a frame for every link.
This robot's URDF, ros2/rosorin_base/urdf/rosorin.urdf, is generated: model/make_urdf.py builds it from the
measured numbers in model/robot_model.json (taken from the factory's URDF, numbers only). It has five revolute
joints and fixed joints for the claw and the camera. The first joint, verbatim:
On the robot:
<joint name="joint1" type="revolute">
<parent link="base_link"/><child link="link1"/>
<origin xyz="0.04662 7.5437e-05 0.16044" rpy="0.0087266 0 0"/>
<axis xyz="-5.4513e-05 -0.0087266 -0.99996"/>
<limit lower="-2.09" upper="2.09" effort="1.0" velocity="1.0"/>
</joint>
| Joint | Bus servo id | Called in the repo | Moves |
|---|---|---|---|
joint1 |
1 | base joint | turns the whole arm about the vertical |
joint2 |
2 | shoulder | tilts about the y axis |
joint3 |
3 | elbow | tilts about the y axis |
joint4 |
4 | wrist | tilts the camera up and down |
joint5 |
5 | wrist roll | turns the claw about its own axis |
(none; gripper_link is fixed) |
10 | claw | opens and closes; not modelled as a joint |
The camera is fixed to link4. With the arm in its tuck pose, camera_link sat 0.289 m above base_link
(measured 2026-09-28).
| Quantity | Unit in ROS | On this robot |
|---|---|---|
| distance | metre (m) | /scan ranges 0.12-8.0 m; map cells 0.05 m |
| angle | radian (rad) | 0.9° LiDAR step = 0.0157 rad; logs and docs often say degrees |
| linear velocity | m/s | wheels capped at 0.35 m/s |
| angular velocity | rad/s | turning capped at 1.0 rad/s |
| acceleration | m/s² | the board sends g; the driver multiplies by G = 9.80665 |
| gyro rate | rad/s | the board sends deg/s; the driver converts with math.radians |
| battery | volt (V) | the board sends millivolts (12670 = 12.67 V) |
| wheel speed | revolutions per second (rps), board only | rps = m/s ÷ (π × 0.08), wheel diameter 0.08 m: 0.35 m/s is 1.39 rps |
| servo position | pulse 0-1000, board only | 500 = joint at 0 rad; 1 pulse = 0.0041888 rad = 0.24°; the full range is 240° |
| servo speed | pulses per second | arm moves at up to 250 p/s (60°/s); head tracking up to 300 p/s (72°/s) |
| time | second; message stamps in seconds + nanoseconds | deadman 0.3 s, scan gate 0.5 s |
The conversion between servo pulses and joint radians is one constant and one function in
ros2/rosorin_base/rosorin_base/arm.py, verbatim:
On the robot:
RAD_PER_PULSE = 0.0041887902047863905 # model/robot_model.json pulse_to_rad (factory, flipped)
JOINT_IDS = (1, 2, 3, 4, 5)
def pulses_to_joints(pulses: dict):
"""{servo id: pulse} -> (['joint1'..'joint5'], [rad]) for URDF joints; None if any joint unknown."""
if any(pulses.get(sid) is None for sid in JOINT_IDS):
return None
return [f'joint{sid}' for sid in JOINT_IDS], [(500 - pulses[sid]) * RAD_PER_PULSE for sid in JOINT_IDS]
"Flipped" means the sign: a larger pulse is a smaller angle. The arm's joint limits in pulses (for example servo 1:
34 to 966) live in config/arm_limits.yaml; chapter 13 explains them.
Once the robot is built (chapter 18 and later), these commands show every idea in this chapter. None of them moves
anything. The robot's ~/.bashrc does not load ROS, so start every session with the first line.
On the robot:
source /opt/ros/humble/setup.bash
ros2 node list
ros2 topic list -t
timeout 8 ros2 topic hz /scan
timeout 6 ros2 topic echo --once /battery
timeout 6 ros2 topic echo --once /rosorin_board_driver/state --field data
ros2 topic info -v /cmd_vel
ros2 service list | grep rosorin_board_driver
ros2 action list
ros2 param get /rosorin_board_driver max_linear
timeout 6 ros2 run tf2_ros tf2_echo base_link laser
timeout 6 ros2 run tf2_ros tf2_echo map odom
Check
Live values from 2026-10-07, for comparison:
ros2 topic hz /scan:average rate: 10.048ros2 topic echo --once /battery:voltage: 12.668999671936035andpercentage: .nan/rosorin_board_driver/state(shortened):{"enabled": false, "estop": null, "plugged": true, ... "blocked": "disabled", ... "scan_age": 0.03, ... "pad_mode": "locked", ...}. The wheels are disabled
until something callsenable;scan_ageis how old the newest scan is, in seconds.ros2 param get /rosorin_board_driver max_linear:Double value is: 0.35tf2_echo base_link laser:- Translation: [0.048, 0.000, 0.107]ros2 node listprinted about 35 names, severaltransform_listener_impl_...helper nodes, and a warning
that two nodes share the name/rf2o_laser_odometry; the cause of that warning was not investigated.
If it fails
ros2: command not found: the first line (source ...) was skipped.- Lists come back short or empty: the first CLI call starts the
ros2daemon, which needs a few seconds to
discover the graph. Run the command again.--no-daemonlistens only briefly and undercounts.tf2_echoprintsInvalid frame ID "odom": the node that publishes that frame is not running (here: the
EKF inrosorin-base), or you are in anotherROS_DOMAIN_ID.tf2_echoandtopic hzrun until Ctrl-C; thetimeoutin front ends them.
Commands that are not read-only
ros2 topic pubto/cmd_velor/estop,ros2 service callto anything under/rosorin_board_driver/,
ros2 param set, andros2 action send_goalall act on the robot. They appear in later chapters, each with the
robot on a stand and the safety box that goes with it.
map → odom → base_link → laser and say which node publishes each link./wheel/odom is wrong when the wheels are in the air.controller_server to the controller board.Where this comes from
Repo (rebuild@bfb61d8):ros2/rosorin_base/launch/base.launch.py,launch/nav.launch.py(docstring:
velocity chain),config/ekf.yaml,config/arm_limits.yaml,config/nav2.yaml,
config/collision_monitor.yaml,config/slam.yaml,config/rf2o.yaml,bt/navigate_no_backup.xml,
urdf/rosorin.urdf,rosorin_base/board_driver.py(interfaces,G = 9.80665),rosorin_base/arm.py,rosorin_base/motion.py,test/test_motion.py(stop frame),
ros2/rosorin_base/systemd/rosorin-base.service,setup.py,maps/room_20260928.yaml,model/robot_model.json,
scripts/ops/deploy.sh. Docs:docs/hardware.md(board protocol, LiDAR orientation correction 2026-09-28),
docs/motion_safety.md(rules, Test 1 2026-09-27, wheel geometry),docs/lessons.md(launch parameters,
logindRemoveIPC2026-09-28, Fast DDS host id 2026-10-05, slam_toolbox traps 2026-10-06,--no-daemon),
docs/capabilities.md(rug 25-88 %),docs/decisions.md2026-09-30 (rf2o not fused). Live values: read-only
ros2commands on the robot overrosorin-wifi, 2026-10-07; IMU 110.6 Hz and/scan9.944 Hz in the command
log 2026-09-29.