diff --git a/Dockerfile b/Dockerfile
index 6d0ceb2..5db1546 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -25,6 +25,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ros-${ROS_DISTRO}-geometry-msgs \
ros-${ROS_DISTRO}-std-msgs \
ros-${ROS_DISTRO}-sensor-msgs \
+ ros-${ROS_DISTRO}-launch-ros \
python3-smbus \
&& rm -rf /var/lib/apt/lists/*
@@ -42,4 +43,4 @@ COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
ENTRYPOINT ["/docker-entrypoint.sh"]
-CMD ["ros2", "run", "my_robot", "motor_controller"]
+CMD ["ros2", "launch", "my_robot", "robot.launch.py"]
diff --git a/README.md b/README.md
index f27dd58..eade836 100644
--- a/README.md
+++ b/README.md
@@ -165,7 +165,7 @@ Replace `matt` with the username configured in [ansible/inventory.ini](ansible/i
## Launching
-The container needs access to the I²C bus that the motor controller is wired to. Pass only that device rather than running the container in privileged mode:
+The default `CMD` starts both nodes together via the launch file. The container needs access to the I²C bus — pass only that device rather than running privileged:
```bash
docker run --rm \
@@ -175,11 +175,11 @@ docker run --rm \
my_robot:latest
```
-If your board exposes the motor controller on a different bus (check with `ls /dev/i2c-*` on the host), substitute the correct device node (e.g. `--device /dev/i2c-0`).
+If your board exposes the controller on a different bus (check with `ls /dev/i2c-*` on the host), substitute the correct device node (e.g. `--device /dev/i2c-0`).
-### Camera orientation node
+### Overriding parameters at launch
-Override the default `CMD` to run the camera orientation node instead:
+Launch arguments can be appended after the image name:
```bash
docker run --rm \
@@ -187,63 +187,22 @@ docker run --rm \
--device /dev/i2c-1 \
--env ROS_DOMAIN_ID=0 \
my_robot:latest \
- ros2 run my_robot camera_orientation
+ ros2 launch my_robot robot.launch.py \
+ wheel_base:=0.25 max_speed:=0.8 tilt_center_deg:=45.0
```
-#### Overriding parameters at launch
+Available launch arguments:
-```bash
-docker run --rm \
- --network=host \
- --device /dev/i2c-1 \
- --env ROS_DOMAIN_ID=0 \
- my_robot:latest \
- ros2 run my_robot camera_orientation \
- --ros-args -p pan_center_deg:=90.0 -p tilt_center_deg:=45.0
-```
-
-#### Commanding the camera from the host
-
-Pan to centre (90°) and tilt to 30°:
-
-```bash
-ros2 topic pub --once /joint_command sensor_msgs/msg/JointState \
- "{name: ['pan', 'tilt'], position: [1.5708, 0.5236]}"
-```
-
-You can command a single axis by omitting the other joint name:
-
-```bash
-# Pan only
-ros2 topic pub --once /joint_command sensor_msgs/msg/JointState \
- "{name: ['pan'], position: [0.0]}"
-```
-
-#### Verifying orientation state
-
-```bash
-ros2 topic echo /joint_states
-```
-
----
-
-### Overriding parameters at launch (motor controller)
-
-ROS 2 parameters can be passed through `--ros-args`:
-
-```bash
-docker run --rm \
- --network=host \
- --device /dev/i2c-1 \
- --env ROS_DOMAIN_ID=0 \
- my_robot:latest \
- ros2 run my_robot motor_controller \
- --ros-args -p wheel_base:=0.25 -p max_speed:=0.8
-```
+| Argument | Default | Description |
+|---|---|---|
+| `wheel_base` | `0.3` | Distance between left and right wheels (m) |
+| `max_speed` | `1.0` | Maximum motor speed in library units |
+| `pan_center_deg` | `90.0` | Pan angle at startup and shutdown (degrees) |
+| `tilt_center_deg` | `60.0` | Tilt angle at startup and shutdown (degrees) |
### Sending velocity commands from the host
-With the container running, publish a `cmd_vel` message from another terminal (requires ROS 2 installed on the host or a second container on the same network):
+With the container running, publish from another terminal (requires ROS 2 on the host or a second container on the same network):
```bash
# Drive forward at 0.2 m/s
@@ -259,10 +218,31 @@ ros2 topic pub --once /cmd_vel geometry_msgs/msg/Twist \
"{linear: {x: 0.0}, angular: {z: 0.0}}"
```
+### Commanding the camera from the host
+
+Pan to centre (90°) and tilt to 30°:
+
+```bash
+ros2 topic pub --once /joint_command sensor_msgs/msg/JointState \
+ "{name: ['pan', 'tilt'], position: [1.5708, 0.5236]}"
+```
+
+A single axis can be commanded by omitting the other joint name:
+
+```bash
+# Pan only
+ros2 topic pub --once /joint_command sensor_msgs/msg/JointState \
+ "{name: ['pan'], position: [0.0]}"
+```
+
### Verifying telemetry
```bash
+# Wheel speeds
ros2 topic echo /current_wheel_speeds
+
+# Camera orientation
+ros2 topic echo /joint_states
```
---
@@ -277,6 +257,8 @@ ros2 topic echo /current_wheel_speeds
│ └── my_robot/
│ ├── package.xml # ROS package manifest
│ ├── setup.py # ament_python build definition
+│ ├── launch/
+│ │ └── robot.launch.py # Starts both nodes together
│ └── my_robot/
│ ├── __init__.py
│ ├── motor_controller_node.py # Differential-drive motor control
diff --git a/src/my_robot/launch/robot.launch.py b/src/my_robot/launch/robot.launch.py
new file mode 100644
index 0000000..b137090
--- /dev/null
+++ b/src/my_robot/launch/robot.launch.py
@@ -0,0 +1,45 @@
+from launch import LaunchDescription
+from launch.actions import DeclareLaunchArgument
+from launch.substitutions import LaunchConfiguration
+from launch_ros.actions import Node
+
+
+def generate_launch_description():
+ return LaunchDescription([
+
+ # ── Motor controller arguments ────────────────────────────────────
+ DeclareLaunchArgument('wheel_base', default_value='0.3',
+ description='Distance between left and right wheels (m)'),
+ DeclareLaunchArgument('max_speed', default_value='1.0',
+ description='Maximum motor speed in library units'),
+
+ # ── Camera orientation arguments ──────────────────────────────────
+ DeclareLaunchArgument('pan_center_deg', default_value='90.0',
+ description='Pan angle at startup and shutdown (degrees)'),
+ DeclareLaunchArgument('tilt_center_deg', default_value='60.0',
+ description='Tilt angle at startup and shutdown (degrees)'),
+
+ # ── Nodes ─────────────────────────────────────────────────────────
+ Node(
+ package='my_robot',
+ executable='motor_controller',
+ name='motor_controller',
+ parameters=[{
+ 'wheel_base': LaunchConfiguration('wheel_base'),
+ 'max_speed': LaunchConfiguration('max_speed'),
+ }],
+ output='screen',
+ ),
+
+ Node(
+ package='my_robot',
+ executable='camera_orientation',
+ name='camera_orientation',
+ parameters=[{
+ 'pan_center_deg': LaunchConfiguration('pan_center_deg'),
+ 'tilt_center_deg': LaunchConfiguration('tilt_center_deg'),
+ }],
+ output='screen',
+ ),
+
+ ])
diff --git a/src/my_robot/package.xml b/src/my_robot/package.xml
index 28d89fb..0d238ed 100644
--- a/src/my_robot/package.xml
+++ b/src/my_robot/package.xml
@@ -10,6 +10,8 @@
geometry_msgs
std_msgs
sensor_msgs
+ launch
+ launch_ros
ament_python
diff --git a/src/my_robot/setup.py b/src/my_robot/setup.py
index 32c1aa4..096ae89 100644
--- a/src/my_robot/setup.py
+++ b/src/my_robot/setup.py
@@ -9,6 +9,7 @@ setup(
data_files=[
('share/ament_index/resource_index/packages', ['resource/' + package_name]),
('share/' + package_name, ['package.xml']),
+ ('share/' + package_name + '/launch', ['launch/robot.launch.py']),
],
install_requires=['setuptools'],
zip_safe=True,