Files
yiliao2026/docs/superpowers/plans/2026-07-23-unified-usb-camera-launch.md

11 KiB

Unified USB Camera Launch Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Make both USB camera launch files use one 1280x720 native-MJPEG YAML configuration, with WebSocket as their only behavioral difference.

Architecture: The base launch owns all camera behavior and loads a single ROS parameter YAML. The WebSocket launch includes the base launch unchanged and adds only the WebSocket include. A minimal CMake file installs both launch/ and config/, replacing reliance on stale install-space symlinks.

Tech Stack: ROS 2 Humble launch, ROS 2 parameter YAML, ament_cmake, Python ast/unittest, D-Robotics hobot_usb_cam and websocket packages.


File Structure

  • Create src/car_usb_cam/config/usb_camera.yaml: sole camera runtime configuration.
  • Keep src/car_usb_cam/config/usb_camera_calibration.yaml: calibration data only.
  • Modify src/car_usb_cam/launch/hobot_usb_cam.launch.py: load YAML and start one camera plus shared-memory environment.
  • Modify src/car_usb_cam/launch/hobot_usb_cam_websocket.launch.py: include the base launch and add WebSocket only.
  • Create src/car_usb_cam/CMakeLists.txt: install launch/config directories and register tests.
  • Create src/car_usb_cam/test/test_unified_launch_config.py: structural regression check.
  • Append D:/Programme/agent/projects/yiliao/log.md: timestamped implementation and verification record.

Task 1: Add a failing structural regression test

Files:

  • Create: src/car_usb_cam/test/test_unified_launch_config.py

  • Step 1: Write the failing test

import ast
import pathlib
import unittest

import yaml


PACKAGE_ROOT = pathlib.Path(__file__).resolve().parents[1]
BASE_LAUNCH = PACKAGE_ROOT / "launch" / "hobot_usb_cam.launch.py"
WEB_LAUNCH = PACKAGE_ROOT / "launch" / "hobot_usb_cam_websocket.launch.py"
CAMERA_CONFIG = PACKAGE_ROOT / "config" / "usb_camera.yaml"


def string_constants(path):
    tree = ast.parse(path.read_text(encoding="utf-8"))
    return {
        node.value
        for node in ast.walk(tree)
        if isinstance(node, ast.Constant) and isinstance(node.value, str)
    }


def call_names(path):
    tree = ast.parse(path.read_text(encoding="utf-8"))
    names = []
    for node in ast.walk(tree):
        if not isinstance(node, ast.Call):
            continue
        if isinstance(node.func, ast.Name):
            names.append(node.func.id)
        elif isinstance(node.func, ast.Attribute):
            names.append(node.func.attr)
    return names


class UnifiedLaunchConfigTest(unittest.TestCase):
    def test_yaml_defines_final_native_mjpeg_configuration(self):
        document = yaml.safe_load(CAMERA_CONFIG.read_text(encoding="utf-8"))
        params = document["hobot_usb_cam"]["ros__parameters"]
        self.assertEqual(params["video_device"], "/dev/video0")
        self.assertEqual(params["image_width"], 1280)
        self.assertEqual(params["image_height"], 720)
        self.assertEqual(params["framerate"], 30)
        self.assertEqual(params["pixel_format"], "mjpeg")
        self.assertEqual(params["io_method"], "mmap")
        self.assertFalse(params["zero_copy"])

    def test_base_launch_owns_the_only_camera_node(self):
        constants = string_constants(BASE_LAUNCH)
        self.assertIn("usb_camera.yaml", constants)
        self.assertEqual(call_names(BASE_LAUNCH).count("Node"), 1)
        self.assertIn("hobot_usb_cam", constants)

    def test_web_launch_only_adds_websocket(self):
        constants = string_constants(WEB_LAUNCH)
        self.assertIn("hobot_usb_cam.launch.py", constants)
        self.assertIn("websocket.launch.py", constants)
        self.assertEqual(call_names(WEB_LAUNCH).count("Node"), 0)
        for forbidden in (
            "hobot_codec",
            "/image_mjpeg",
            "yuyv2rgb",
            "usb_image_width",
            "usb_image_height",
            "usb_pixel_format",
        ):
            self.assertNotIn(forbidden, constants)


if __name__ == "__main__":
    unittest.main()
  • Step 2: Run it and verify RED

Run:

cd /home/sunrise/yiliao_ws
python3 -m unittest src/car_usb_cam/test/test_unified_launch_config.py -v

Expected: errors because config/usb_camera.yaml does not exist, plus failure because the current WebSocket launch contains codec and camera override literals.

Task 2: Create the shared runtime YAML and install rules

Files:

  • Create: src/car_usb_cam/config/usb_camera.yaml

  • Create: src/car_usb_cam/CMakeLists.txt

  • Step 1: Add the shared camera parameter YAML

hobot_usb_cam:
  ros__parameters:
    camera_calibration_file_path: /home/sunrise/yiliao_ws/src/car_usb_cam/config/usb_camera_calibration.yaml
    frame_id: default_usb_cam
    framerate: 30
    image_height: 720
    image_width: 1280
    io_method: mmap
    pixel_format: mjpeg
    video_device: /dev/video0
    zero_copy: false
  • Step 2: Add reproducible package installation and test registration
cmake_minimum_required(VERSION 3.8)
project(car_usb_cam)

find_package(ament_cmake REQUIRED)

install(
  DIRECTORY config launch
  DESTINATION share/${PROJECT_NAME}
)

if(BUILD_TESTING)
  find_package(ament_cmake_pytest REQUIRED)
  ament_add_pytest_test(
    test_unified_launch_config
    test/test_unified_launch_config.py
  )
endif()

ament_package()
  • Step 3: Run the test and confirm the remaining failure is the old launch structure

Run the same unittest command. Expected: YAML test passes; WebSocket structure test still fails because the old file contains hobot_codec and camera overrides.

Task 3: Refactor the launch files

Files:

  • Modify: src/car_usb_cam/launch/hobot_usb_cam.launch.py

  • Modify: src/car_usb_cam/launch/hobot_usb_cam_websocket.launch.py

  • Step 1: Replace the base launch with the YAML-backed camera definition

import os

from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument, IncludeLaunchDescription
from launch.launch_description_sources import PythonLaunchDescriptionSource
from launch.substitutions import LaunchConfiguration
from launch_ros.actions import Node


def generate_launch_description():
    package_share = get_package_share_directory("car_usb_cam")
    camera_config = os.path.join(package_share, "config", "usb_camera.yaml")

    return LaunchDescription([
        DeclareLaunchArgument(
            "camera_config",
            default_value=camera_config,
            description="ROS parameter file for the USB camera",
        ),
        IncludeLaunchDescription(
            PythonLaunchDescriptionSource(
                os.path.join(
                    get_package_share_directory("hobot_shm"),
                    "launch",
                    "hobot_shm.launch.py",
                )
            )
        ),
        Node(
            package="hobot_usb_cam",
            executable="hobot_usb_cam",
            name="hobot_usb_cam",
            parameters=[LaunchConfiguration("camera_config")],
            arguments=["--ros-args", "--log-level", "warn"],
        ),
    ])
  • Step 2: Replace the WebSocket launch with a base include plus WebSocket include
import os

from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch.actions import IncludeLaunchDescription
from launch.launch_description_sources import PythonLaunchDescriptionSource


def generate_launch_description():
    camera_launch = IncludeLaunchDescription(
        PythonLaunchDescriptionSource(
            os.path.join(
                get_package_share_directory("car_usb_cam"),
                "launch",
                "hobot_usb_cam.launch.py",
            )
        )
    )

    websocket_launch = IncludeLaunchDescription(
        PythonLaunchDescriptionSource(
            os.path.join(
                get_package_share_directory("websocket"),
                "launch",
                "websocket.launch.py",
            )
        ),
        launch_arguments={
            "websocket_image_topic": "/image",
            "websocket_image_type": "mjpeg",
            "websocket_only_show_image": "True",
            "websocket_output_fps": "30",
        }.items(),
    )

    return LaunchDescription([camera_launch, websocket_launch])
  • Step 3: Run the structural test and verify GREEN

Run:

python3 -m unittest src/car_usb_cam/test/test_unified_launch_config.py -v

Expected: all three tests pass.

  • Step 4: Build the package and run package tests

Run:

colcon build --symlink-install --packages-select car_usb_cam
colcon test --packages-select car_usb_cam --event-handlers console_direct+
colcon test-result --verbose

Expected: build succeeds and test_unified_launch_config passes with zero failures.

  • Step 5: Commit implementation
git add src/car_usb_cam/CMakeLists.txt \
  src/car_usb_cam/config/usb_camera.yaml \
  src/car_usb_cam/launch/hobot_usb_cam.launch.py \
  src/car_usb_cam/launch/hobot_usb_cam_websocket.launch.py \
  src/car_usb_cam/test/test_unified_launch_config.py
git commit -m "refactor: unify usb camera launch configuration"

Task 4: Sequential runtime verification

Files:

  • Append: D:/Programme/agent/projects/yiliao/log.md

  • Step 1: Stop current camera, codec, generic USB camera, and WebSocket processes

Use targeted pkill -TERM -f patterns and verify /dev/video0 is not held with fuser -v /dev/video0.

  • Step 2: Start the base launch and verify camera-only behavior

Run ros2 launch car_usb_cam hobot_usb_cam.launch.py, then verify:

  • one /hobot_usb_cam node;

  • /image type is sensor_msgs/msg/CompressedImage;

  • decoded image dimensions are 1280x720;

  • ros2 topic hz /image is approximately 30 FPS;

  • no /websocket node and no listener on port 8080.

  • Step 3: Stop the base launch and start the WebSocket launch

Run ros2 launch car_usb_cam hobot_usb_cam_websocket.launch.py, then verify the same camera node parameters, image type, dimensions, and FPS. Confirm exactly one /websocket node and one listener on port 8080.

  • Step 4: Compare both captured camera parameter sets

Capture ros2 param dump /hobot_usb_cam for both runs, normalize volatile output, and use diff -u. Expected: no differences.

  • Step 5: Leave the WebSocket launch running and verify .164 routing

Confirm the webpage receives a 1280x720 frame and ip route get <client> from 192.168.10.164 returns dev wlx200db0c3fca5 table 164.

  • Step 6: Append the dated implementation and verification results to log.md

Record changed files, test outputs, both launch measurements, parameter diff result, final process state, and the .164 route result.


Self-Review

  • Spec coverage: shared YAML, base include architecture, removal of codec, native MJPEG flow, sequential runtime validation, and single WebSocket difference are all mapped to Tasks 1-4.
  • Scope: all changes are confined to car_usb_cam, its test/install metadata, and the requested local operation log.
  • Type consistency: both launch variants publish /image as sensor_msgs/msg/CompressedImage; WebSocket consumes /image as mjpeg.
  • Operational constraint: validation explicitly handles exclusive ownership of /dev/video0.