From ddea309b548dd4743fff7d827e3f8decec0b3254 Mon Sep 17 00:00:00 2001 From: Mirko Ferrati Date: Tue, 7 Jul 2026 17:54:06 +0200 Subject: [PATCH 01/25] Add snap packaging --- CMakeLists.txt | 1 + README.md | 14 ++ snap/local/fastrtps_no_shm.xml | 17 ++ snap/local/pj-bridge-ros2-launcher | 10 ++ snap/snapcraft.yaml | 78 +++++++++ tests/integration/pub_hello.py | 41 +++++ tests/integration/requirements.txt | 4 + tests/integration/smoke_tests.sh | 177 +++++++++++++++++++++ tests/integration/ws_reader.py | 243 +++++++++++++++++++++++++++++ 9 files changed, 585 insertions(+) create mode 100644 snap/local/fastrtps_no_shm.xml create mode 100755 snap/local/pj-bridge-ros2-launcher create mode 100644 snap/snapcraft.yaml create mode 100755 tests/integration/pub_hello.py create mode 100644 tests/integration/requirements.txt create mode 100755 tests/integration/smoke_tests.sh create mode 100755 tests/integration/ws_reader.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 41c34a7..ef22881 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -62,6 +62,7 @@ if(NOT ixwebsocket_FOUND) ixwebsocket URL https://github.com/machinezone/IXWebSocket/archive/refs/tags/v11.4.6.tar.gz URL_HASH SHA256=c024334f8e45980836c67008979a884d6dcc5ef067dd2eb1fa7241f4c17ddc32 + DOWNLOAD_EXTRACT_TIMESTAMP TRUE ) set(USE_TLS ${PJ_BRIDGE_TLS} CACHE BOOL "" FORCE) if(PJ_BRIDGE_TLS) diff --git a/README.md b/README.md index 0913a82..d7243b4 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,20 @@ pixi global install pj-bridge-ros2-humble \ pj_bridge_ros2 --ros-args -p port:=9090 ``` +### Snap + +Pre-built snaps are available from the [Snap Store](https://snapcraft.io/pj-bridge). + +```bash +sudo snap install pj-bridge + +# Run (add arguments if different from default) +snap run pj-bridge.pj-bridge-ros2 --ros-args -p port:=9090 +``` + +> **Note**: The snap bundles ROS2 Jazzy and uses UDP-only DDS transport automatically — +> no extra ROS2 installation is required on the machine running the bridge. + ### AppImage Pre-built AppImages are available from [GitHub Releases](https://github.com/PlotJuggler/plotjuggler_bridge/releases). diff --git a/snap/local/fastrtps_no_shm.xml b/snap/local/fastrtps_no_shm.xml new file mode 100644 index 0000000..50acb94 --- /dev/null +++ b/snap/local/fastrtps_no_shm.xml @@ -0,0 +1,17 @@ + + + + + UDPv4Transport + UDPv4 + + + + + + UDPv4Transport + + false + + + diff --git a/snap/local/pj-bridge-ros2-launcher b/snap/local/pj-bridge-ros2-launcher new file mode 100755 index 0000000..4fd6ff0 --- /dev/null +++ b/snap/local/pj-bridge-ros2-launcher @@ -0,0 +1,10 @@ +#!/bin/bash +# pj-bridge-ros2 launcher +# Strict-confinement snaps do not add /opt/... dirs to LD_LIBRARY_PATH +# automatically, so we prepend them here before exec-ing the binary. +export LD_LIBRARY_PATH="$SNAP/opt/ros/jazzy/lib:$SNAP/opt/ros/jazzy/lib/x86_64-linux-gnu:$SNAP/opt/ros/snap/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" +export AMENT_PREFIX_PATH="$SNAP/opt/ros/snap:$SNAP/opt/ros/jazzy" +export COLCON_PREFIX_PATH="$SNAP/opt/ros/snap" +export ROS_DISTRO=jazzy +export ROS_VERSION=2 +exec "$SNAP/opt/ros/snap/lib/pj_bridge/pj_bridge_ros2" "$@" diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml new file mode 100644 index 0000000..8c13959 --- /dev/null +++ b/snap/snapcraft.yaml @@ -0,0 +1,78 @@ +name: pj-bridge +version: 0.5.0 +title: pj-bridge +summary: ROS2 to WebSocket bridge server — forwards topic content at 50 Hz +license: AGPL-3.0 +contact: https://github.com/PlotJuggler/plotjuggler_bridge/issues +issues: https://github.com/PlotJuggler/plotjuggler_bridge/issues +source-code: https://github.com/PlotJuggler/plotjuggler_bridge +website: https://github.com/PlotJuggler/plotjuggler_bridge +description: > + ROS2 bridge server that forwards middleware topic content over WebSocket to + PlotJuggler clients and any other WebSocket consumer. + Clients subscribe to topics and receive ZSTD-compressed aggregated messages at + 50 Hz without needing a full middleware installation. +confinement: strict +grade: stable +base: core24 + +# ROS2 typesupport and middleware libraries are loaded via dlopen at runtime +# by the ROS2 plugin system; the library linter cannot detect this. +lint: + ignore: + - library + +package-repositories: + - type: apt + url: http://packages.ros.org/ros2/ubuntu + suites: [noble] + components: [main] + key-id: C1CF6E31E6BADE8868B172B4F42ED6FBAB17C654 + key-server: keyserver.ubuntu.com + +parts: + pj_bridge: + plugin: colcon + source: . + build-environment: + - ROS_DISTRO: jazzy + - ROS_VERSION: "2" + build-packages: + - cmake + - git + - libspdlog-dev + - libssl-dev + - libzstd-dev + - nlohmann-json3-dev + - python3-colcon-common-extensions + - python3-rosdep + - ros-jazzy-ament-index-cpp + - ros-jazzy-nav-msgs + - ros-jazzy-rclcpp + - ros-jazzy-sensor-msgs + - zlib1g-dev + stage-packages: + - ros-jazzy-ament-index-cpp + - ros-jazzy-geometry-msgs + - ros-jazzy-launch-ros + - ros-jazzy-nav-msgs + - ros-jazzy-rclcpp + - ros-jazzy-ros2launch + - ros-jazzy-sensor-msgs + + snap-local: + plugin: dump + source: snap/local + organize: + pj-bridge-ros2-launcher: usr/bin/pj-bridge-ros2-launcher + fastrtps_no_shm.xml: etc/pj-bridge/fastrtps_no_shm.xml + +apps: + pj-bridge-ros2: + command: usr/bin/pj-bridge-ros2-launcher + plugs: + - network + - network-bind + - shared-memory + environment: + FASTRTPS_DEFAULT_PROFILES_FILE: $SNAP/etc/pj-bridge/fastrtps_no_shm.xml diff --git a/tests/integration/pub_hello.py b/tests/integration/pub_hello.py new file mode 100755 index 0000000..14dde66 --- /dev/null +++ b/tests/integration/pub_hello.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +""" +Publish std_msgs/String "hello world" on /hello_world at 1 Hz. + +Requirements (host): + sudo apt install ros-jazzy-rclpy ros-jazzy-std-msgs + source /opt/ros/jazzy/setup.bash +""" +import rclpy +from rclpy.node import Node +from std_msgs.msg import String + + +class HelloPublisher(Node): + def __init__(self): + super().__init__("hello_publisher") + self.pub = self.create_publisher(String, "/hello_world", 10) + self.timer = self.create_timer(1.0, self._publish) + self.get_logger().info("Hello publisher started — publishing 'hello world' on /hello_world") + + def _publish(self): + msg = String() + msg.data = "hello world" + self.pub.publish(msg) + self.get_logger().info("Published: hello world") + + +def main(): + rclpy.init() + node = HelloPublisher() + try: + rclpy.spin(node) + except KeyboardInterrupt: + pass + finally: + node.destroy_node() + rclpy.shutdown() + + +if __name__ == "__main__": + main() diff --git a/tests/integration/requirements.txt b/tests/integration/requirements.txt new file mode 100644 index 0000000..c12b577 --- /dev/null +++ b/tests/integration/requirements.txt @@ -0,0 +1,4 @@ +# Python dependencies for the pj_bridge end-to-end smoke test +# Install with: pip3 install -r test/requirements.txt +websockets>=12.0 +zstandard>=0.22.0 diff --git a/tests/integration/smoke_tests.sh b/tests/integration/smoke_tests.sh new file mode 100755 index 0000000..1d232fb --- /dev/null +++ b/tests/integration/smoke_tests.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +# ============================================================================= +# Smoke tests for the pj-bridge snap — full end-to-end bridge verification +# +# Pipeline: +# [host rclpy] pub_hello.py → DDS (UDP) → [snap] pj-bridge-ros2 +# ↓ WebSocket :9090 +# [host Python] ws_reader.py +# ↓ +# assert "hello world" decoded +# +# Requirements on the host: +# sudo apt install ros-jazzy-rclpy ros-jazzy-std-msgs +# pip3 install websockets zstandard is NOT needed — the script creates a venv automatically +# +# Usage: +# bash tests/integration/smoke_tests.sh +# ============================================================================= +set -e + +PROJECT_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel)" +TEST_DIR="$PROJECT_ROOT/tests/integration" +SNAP_FILE="$(ls "$PROJECT_ROOT"/pj-bridge_*.snap 2>/dev/null | head -1 || true)" +WS_PORT=9090 +FASTRTPS_PROFILE="$HOME/.config/pj-bridge/fastrtps_no_shm.xml" + +# --------------------------------------------------------------------------- +# Pre-flight checks +# --------------------------------------------------------------------------- +if [ -z "$SNAP_FILE" ]; then + echo "ERROR: no pj-bridge_*.snap found in $PROJECT_ROOT" + echo " Build it first with: snapcraft --destructive-mode" + exit 1 +fi +echo "Snap file: $SNAP_FILE" + +# --------------------------------------------------------------------------- +# FastRTPS UDP-only profile +# Disables shared-memory transport so host-side DDS can cross the snap +# AppArmor/mount namespace boundary — same technique used by termviz2 tests. +# --------------------------------------------------------------------------- +mkdir -p "$(dirname "$FASTRTPS_PROFILE")" +cat > "$FASTRTPS_PROFILE" << 'EOF' + + + + + UDPv4Transport + UDPv4 + + + + + + UDPv4Transport + + false + + + +EOF +echo "FastRTPS UDP-only profile written to $FASTRTPS_PROFILE" + +# --------------------------------------------------------------------------- +# Install snap +# --------------------------------------------------------------------------- +echo "" +echo "=== Installing snap ===" +sudo snap install --dangerous "$SNAP_FILE" +sleep 3 # let snapd activate AppArmor/mount namespaces + +echo "" +echo "=== snap list ===" +snap list pj-bridge + +echo "" +echo "=== snap connections ===" +snap connections pj-bridge + +# --------------------------------------------------------------------------- +# Python venv for test dependencies +# Created BEFORE sourcing ROS 2 so that set -e is still active and any +# pip failure is caught immediately rather than silently swallowed. +# pub_hello.py still uses the system Python so it can access rclpy from the +# sourced ROS 2 environment; ws_reader.py uses the venv. +# --------------------------------------------------------------------------- +VENV_DIR="/tmp/pj_bridge_test_venv" +echo "" +echo "=== Setting up Python venv ($VENV_DIR) ===" +python3 -m venv --clear "$VENV_DIR" +"$VENV_DIR/bin/pip" install -q -r "$TEST_DIR/requirements.txt" +echo " Dependencies installed: $("$VENV_DIR/bin/pip" freeze | tr '\n' ' ')" + +# --------------------------------------------------------------------------- +# Source ROS 2 Jazzy on host (same requirement as termviz2 smoke tests) +# --------------------------------------------------------------------------- +if [ ! -f /opt/ros/jazzy/setup.bash ]; then + echo "ERROR: /opt/ros/jazzy/setup.bash not found." + echo "Install with: sudo apt install ros-jazzy-rclpy ros-jazzy-std-msgs" + exit 1 +fi +# shellcheck disable=SC1091 +source /opt/ros/jazzy/setup.bash +export FASTRTPS_DEFAULT_PROFILES_FILE="$FASTRTPS_PROFILE" + +# --------------------------------------------------------------------------- +# Start the bridge snap +# --------------------------------------------------------------------------- +echo "" +echo "=== Starting pj-bridge snap ===" +snap run pj-bridge.pj-bridge-ros2 --ros-args -p port:="$WS_PORT" \ + > /tmp/pj_bridge.log 2>&1 & +BRIDGE_PID=$! +echo " Bridge PID: $BRIDGE_PID" +sleep 5 # let ROS 2 init and WebSocket server bind + +# Check it's still alive +if ! kill -0 "$BRIDGE_PID" 2>/dev/null; then + echo "ERROR: pj-bridge-ros2 exited immediately — see /tmp/pj_bridge.log" + cat /tmp/pj_bridge.log + exit 1 +fi +echo " Bridge is running." + +# --------------------------------------------------------------------------- +# Start the hello-world publisher (host side) +# --------------------------------------------------------------------------- +echo "" +echo "=== Starting hello world publisher ===" +python3 "$TEST_DIR/pub_hello.py" > /tmp/pub_hello.log 2>&1 & +PUB_PID=$! +echo " Publisher PID: $PUB_PID" +sleep 3 # let DDS participant discovery complete + +# --------------------------------------------------------------------------- +# Run the WebSocket reader +# Connects to the bridge, subscribes to /hello_world, decodes the CDR- +# serialized std_msgs/String and asserts it contains "hello". +# --------------------------------------------------------------------------- +echo "" +echo "=== Running WebSocket reader (timeout 20 s) ===" +set +e +"$VENV_DIR/bin/python3" "$TEST_DIR/ws_reader.py" \ + --url "ws://localhost:$WS_PORT" \ + --topic /hello_world \ + --timeout 20 +READER_EXIT=$? +set -e + +# --------------------------------------------------------------------------- +# Cleanup +# --------------------------------------------------------------------------- +echo "" +echo "=== Cleaning up ===" +kill "$BRIDGE_PID" "$PUB_PID" 2>/dev/null || true +sleep 1 +kill -9 "$BRIDGE_PID" "$PUB_PID" 2>/dev/null || true +wait "$BRIDGE_PID" "$PUB_PID" 2>/dev/null || true + +echo "" +echo "--- Bridge log (/tmp/pj_bridge.log) ---" +cat /tmp/pj_bridge.log + +echo "" +echo "--- Publisher log (/tmp/pub_hello.log) ---" +cat /tmp/pub_hello.log + +# --------------------------------------------------------------------------- +# Result +# --------------------------------------------------------------------------- +echo "" +if [ "$READER_EXIT" -eq 0 ]; then + echo "=== All smoke tests PASSED ===" +else + echo "FAIL: ws_reader.py exited $READER_EXIT — 'hello world' was not received through the bridge" + exit 1 +fi diff --git a/tests/integration/ws_reader.py b/tests/integration/ws_reader.py new file mode 100755 index 0000000..c54e306 --- /dev/null +++ b/tests/integration/ws_reader.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +""" +WebSocket reader for the pj_bridge smoke test. + +Connects to a running pj-bridge snap on ws://localhost:9090, subscribes to +/hello_world, receives binary frames from the bridge, ZSTD-decompresses them, +parses the CDR-serialized std_msgs/String payload, and asserts the data +contains "hello". + +Protocol reference: plotjuggler_bridge/docs/API.md + +Binary frame layout (16-byte header, little-endian): + [0:4] magic = 0x42524A50 ("PJRB") + [4:8] message_count + [8:12] uncompressed_size + [12:16] flags (reserved, 0) +Followed by a ZSTD-compressed payload. + +Payload — for each message: + uint16 topic_name_len + bytes topic_name (UTF-8) + uint64 timestamp (nanoseconds) + uint32 msg_data_len + bytes msg_data (CDR-serialized) + +std_msgs/String CDR layout (little-endian, eProsima ROS2 convention): + [0:2] 0x00 0x01 (CDR representation ID — little-endian) + [2:4] 0x00 0x00 (padding) + [4:8] uint32 string_len (includes null terminator) + [8:] string bytes + null terminator + +Usage: + pip install websockets zstandard + python3 ws_reader.py [--url ws://localhost:9090] [--topic /hello_world] [--timeout 20] +""" + +import argparse +import asyncio +import json +import struct +import sys +import time + +try: + import websockets +except ImportError: + sys.exit("ERROR: 'websockets' not installed — run: pip install websockets") + +try: + import zstandard as zstd +except ImportError: + sys.exit("ERROR: 'zstandard' not installed — run: pip install zstandard") + + +# Binary frame magic ("PJRB" in little-endian uint32) +_MAGIC = 0x42524A50 + + +def _parse_header(data: bytes) -> tuple[int, int, int] | None: + """Return (message_count, uncompressed_size, flags) or None if invalid.""" + if len(data) < 16: + return None + magic, msg_count, uncompressed_size, flags = struct.unpack_from(" bytes: + dctx = zstd.ZstdDecompressor() + # max_output hint is optional; zstandard infers size from the ZSTD frame header + return dctx.decompress(data, max(max_output, 1)) + + +def _parse_payload(payload: bytes, msg_count: int, target_topic: str) -> str | None: + """ + Walk the payload and return the decoded string for target_topic, or None. + """ + offset = 0 + for _ in range(msg_count): + if offset + 2 > len(payload): + break + name_len = struct.unpack_from(" len(payload): + break + topic_name = payload[offset : offset + name_len].decode("utf-8", errors="replace") + offset += name_len + + if offset + 8 > len(payload): + break + # timestamp (uint64, nanoseconds) — skip + offset += 8 + + if offset + 4 > len(payload): + break + msg_len = struct.unpack_from(" len(payload): + break + msg_data = payload[offset : offset + msg_len] + offset += msg_len + + if topic_name == target_topic: + return _decode_ros2_string(msg_data) + + return None + + +def _decode_ros2_string(data: bytes) -> str | None: + """ + Decode a CDR-serialized std_msgs/String. + + Layout (little-endian ROS 2 CDR): + [0:4] CDR header (0x00 0x01 0x00 0x00 for little-endian) + [4:8] uint32 length (includes null terminator) + [8..] utf-8 bytes + null terminator + """ + if len(data) < 8: + return None + # Bytes 0-3: CDR encapsulation header — skip + str_len = struct.unpack_from(" bool: + """ + Connect to ws_url, subscribe to topic, wait up to timeout seconds for a + message containing "hello". Returns True on success. + """ + print(f"Connecting to {ws_url} …") + async with websockets.connect(ws_url, open_timeout=10) as ws: + print("Connected.") + + # Background heartbeat — required every 1 s or server times out in 10 s. + # Started AFTER the initial handshake to avoid heartbeat ACKs racing + # with get_topics / subscribe responses on ws.recv(). + async def _heartbeat(): + seq = 0 + while True: + await ws.send(json.dumps({"command": "heartbeat", "id": f"hb{seq}"})) + seq += 1 + await asyncio.sleep(1.0) + + hb_task: asyncio.Task | None = None + + try: + # 1. Discover available topics + await ws.send(json.dumps({"command": "get_topics", "id": "gt1"})) + raw = await asyncio.wait_for(ws.recv(), timeout=10.0) + resp = json.loads(raw) + available = [t["name"] for t in resp.get("topics", [])] + print(f"Available topics ({len(available)}): {available}") + + if topic not in available: + print(f" WARN: {topic!r} not yet in topic list — will subscribe anyway " + f"(bridge may forward it once the publisher appears)") + + # 2. Subscribe + await ws.send(json.dumps({"command": "subscribe", "topics": [topic], "id": "s1"})) + raw = await asyncio.wait_for(ws.recv(), timeout=10.0) + resp = json.loads(raw) + status = resp.get("status", "") + if status not in ("success", "partial_success"): + # If topic not yet visible, that is ok — keep waiting for binary + print(f" Subscription response: {resp}") + else: + schemas = resp.get("schemas", {}) + print(f" Subscribed. Schema keys: {list(schemas.keys())}") + + # Start heartbeat only after handshake is complete + hb_task = asyncio.create_task(_heartbeat()) + + # 3. Wait for a binary frame containing our topic + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + remaining = deadline - time.monotonic() + try: + frame = await asyncio.wait_for(ws.recv(), timeout=min(2.0, remaining)) + except asyncio.TimeoutError: + continue + + if isinstance(frame, str): + # Heartbeat ack or other JSON — ignore + continue + + # Binary frame + header = _parse_header(frame) + if header is None: + print(f" WARN: received binary frame with invalid header " + f"({len(frame)} bytes), skipping") + continue + + msg_count, uncompressed_size, _ = header + try: + payload = _decompress(frame[16:], uncompressed_size) + except Exception as exc: + print(f" WARN: ZSTD decompress failed: {exc}") + continue + + value = _parse_payload(payload, msg_count, topic) + if value is not None: + print(f" Decoded message on {topic!r}: {value!r}") + if "hello" in value.lower(): + print("PASS: 'hello world' received and decoded through the bridge!") + return True + else: + print(f" WARN: message received but does not contain 'hello': {value!r}") + + print(f"FAIL: timed out after {timeout:.0f}s waiting for a message on {topic!r}") + return False + + finally: + if hb_task is not None: + hb_task.cancel() + await asyncio.gather(hb_task, return_exceptions=True) + + +def main(): + parser = argparse.ArgumentParser(description="pj_bridge smoke-test WebSocket reader") + parser.add_argument("--url", default="ws://localhost:9090", + help="WebSocket URL of the running pj_bridge (default: ws://localhost:9090)") + parser.add_argument("--topic", default="/hello_world", + help="ROS 2 topic to verify (default: /hello_world)") + parser.add_argument("--timeout", type=float, default=20.0, + help="Seconds to wait for a message before failing (default: 20)") + args = parser.parse_args() + + ok = asyncio.run(run(args.url, args.topic, args.timeout)) + sys.exit(0 if ok else 1) + + +if __name__ == "__main__": + main() From 86c716fd41ef6ad97bd9c0ad9273a7e4236d19b0 Mon Sep 17 00:00:00 2001 From: Mirko Ferrati Date: Tue, 7 Jul 2026 19:34:27 +0200 Subject: [PATCH 02/25] ci: add Snap Store publish job to snap workflow --- .github/workflows/snap.yaml | 151 ++++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 .github/workflows/snap.yaml diff --git a/.github/workflows/snap.yaml b/.github/workflows/snap.yaml new file mode 100644 index 0000000..b6d7ea2 --- /dev/null +++ b/.github/workflows/snap.yaml @@ -0,0 +1,151 @@ +# ============================================================================= +# Snap Package — Build, Test, Release, and Publish to Snap Store +# +# SETUP: Snap Store credentials +# ------------------------------ +# 1. Register the snap name on https://snapcraft.io (one-time, needs Ubuntu SSO) +# 2. Export credentials: +# snapcraft export-login \ +# --snaps pj-bridge \ +# --acls package_access,package_push,package_update,package_release \ +# credentials.txt +# cat credentials.txt # copy the entire output +# 3. In GitHub → Settings → Secrets → Actions, create a secret named +# SNAPCRAFT_STORE_CREDENTIALS and paste the credentials.txt content. +# +# CHANNELS +# --------- +# tag push → stable (e.g. v0.5.0 → snapcraft.io/pj-bridge) +# main push → edge (rolling "try-before-you-buy" channel) +# ============================================================================= + +name: Snap Package + +on: + push: + tags: ['*'] + branches: [main] # edge channel publishes on every main commit + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + snap: + runs-on: ubuntu-24.04 # core24 base requires Noble host for destructive-mode + + steps: + - uses: actions/checkout@v4 + + - name: Install snapcraft + run: sudo snap install snapcraft --classic + + - name: Build snap + run: snapcraft --destructive-mode + + - name: Install snap and run smoke tests + run: | + sudo snap install --dangerous pj-bridge_*.snap + sleep 3 # let snapd activate AppArmor/mount namespaces + + # Install host-side ROS 2 deps for the publisher + sudo apt-get install -y ros-jazzy-rclpy ros-jazzy-std-msgs + + # Set up FastRTPS UDP-only profile for host-side DDS processes + mkdir -p "$HOME/.config/pj-bridge" + cp snap/local/fastrtps_no_shm.xml "$HOME/.config/pj-bridge/fastrtps_no_shm.xml" + + # Python venv for ws_reader.py + python3 -m venv /tmp/pj_test_venv + /tmp/pj_test_venv/bin/pip install -q -r tests/integration/requirements.txt + + # Source ROS 2 and set UDP-only profile for host processes + source /opt/ros/jazzy/setup.bash + export FASTRTPS_DEFAULT_PROFILES_FILE="$HOME/.config/pj-bridge/fastrtps_no_shm.xml" + + # Start the bridge snap + snap run pj-bridge.pj-bridge-ros2 --ros-args -p port:=9090 \ + > /tmp/pj_bridge.log 2>&1 & + BRIDGE_PID=$! + sleep 5 + + if ! kill -0 "$BRIDGE_PID" 2>/dev/null; then + echo "ERROR: bridge exited immediately" + cat /tmp/pj_bridge.log + exit 1 + fi + + # Start hello-world publisher + python3 tests/integration/pub_hello.py > /tmp/pub_hello.log 2>&1 & + PUB_PID=$! + sleep 3 + + # Run WebSocket reader — asserts 'hello world' received end-to-end + /tmp/pj_test_venv/bin/python3 tests/integration/ws_reader.py \ + --url ws://localhost:9090 \ + --topic /hello_world \ + --timeout 25 + READER_EXIT=$? + + kill "$BRIDGE_PID" "$PUB_PID" 2>/dev/null || true + + echo "--- Bridge log ---" + cat /tmp/pj_bridge.log + + exit "$READER_EXIT" + + - uses: actions/upload-artifact@v4 + with: + name: snap-amd64 + path: "*.snap" + + release: + needs: snap + if: startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + name: snap-amd64 + + - name: Upload snap to GitHub Release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + tag="${{ github.ref_name }}" + gh release view "$tag" >/dev/null 2>&1 || \ + gh release create "$tag" --title "$tag" --generate-notes || true + gh release upload "$tag" *.snap --clobber + + publish: + needs: snap + if: startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + name: snap-amd64 + + - name: Determine channel + id: channel + run: | + if [[ "${{ github.ref }}" == refs/tags/* ]]; then + echo "name=stable" >> "$GITHUB_OUTPUT" + else + echo "name=edge" >> "$GITHUB_OUTPUT" + fi + + - name: Publish to Snap Store + uses: canonical/action-publish@v1 + env: + SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} + with: + snap: pj-bridge_*.snap + release: ${{ steps.channel.outputs.name }} From 09d2b1f7fd460ec43b67857a9f11dbbf17fccce1 Mon Sep 17 00:00:00 2001 From: Mirko Ferrati Date: Tue, 7 Jul 2026 17:41:34 +0000 Subject: [PATCH 03/25] fix: resolve snap filename before passing to action-publish (no glob support) --- .github/workflows/snap.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/snap.yaml b/.github/workflows/snap.yaml index b6d7ea2..2264878 100644 --- a/.github/workflows/snap.yaml +++ b/.github/workflows/snap.yaml @@ -133,6 +133,10 @@ jobs: with: name: snap-amd64 + - name: Find snap file + id: snap-file + run: echo "path=$(ls pj-bridge_*.snap)" >> "$GITHUB_OUTPUT" + - name: Determine channel id: channel run: | @@ -147,5 +151,5 @@ jobs: env: SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} with: - snap: pj-bridge_*.snap + snap: ${{ steps.snap-file.outputs.path }} release: ${{ steps.channel.outputs.name }} From ba268a9646bfe264cf52390dfa28a2d8b2b7917b Mon Sep 17 00:00:00 2001 From: Mirko Ferrati Date: Tue, 7 Jul 2026 17:45:48 +0000 Subject: [PATCH 04/25] ci: build snap on every PR, publish only on tag push --- .github/workflows/snap.yaml | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/.github/workflows/snap.yaml b/.github/workflows/snap.yaml index 2264878..abbf367 100644 --- a/.github/workflows/snap.yaml +++ b/.github/workflows/snap.yaml @@ -16,7 +16,6 @@ # CHANNELS # --------- # tag push → stable (e.g. v0.5.0 → snapcraft.io/pj-bridge) -# main push → edge (rolling "try-before-you-buy" channel) # ============================================================================= name: Snap Package @@ -24,7 +23,7 @@ name: Snap Package on: push: tags: ['*'] - branches: [main] # edge channel publishes on every main commit + pull_request: workflow_dispatch: permissions: @@ -126,7 +125,7 @@ jobs: publish: needs: snap - if: startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/main' + if: startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest steps: - uses: actions/download-artifact@v4 @@ -137,19 +136,10 @@ jobs: id: snap-file run: echo "path=$(ls pj-bridge_*.snap)" >> "$GITHUB_OUTPUT" - - name: Determine channel - id: channel - run: | - if [[ "${{ github.ref }}" == refs/tags/* ]]; then - echo "name=stable" >> "$GITHUB_OUTPUT" - else - echo "name=edge" >> "$GITHUB_OUTPUT" - fi - - name: Publish to Snap Store uses: canonical/action-publish@v1 env: SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} with: snap: ${{ steps.snap-file.outputs.path }} - release: ${{ steps.channel.outputs.name }} + release: stable From 3f9a491c591fbbec7ad372770bf9e612d3619015 Mon Sep 17 00:00:00 2001 From: Mirko Ferrati Date: Tue, 7 Jul 2026 17:50:10 +0000 Subject: [PATCH 05/25] ci: align snap workflow with PlotJuggler (action-build, fetch-depth 0, minimal smoke test) --- .github/workflows/snap.yaml | 72 ++++++------------------------------- 1 file changed, 11 insertions(+), 61 deletions(-) diff --git a/.github/workflows/snap.yaml b/.github/workflows/snap.yaml index abbf367..741ab6f 100644 --- a/.github/workflows/snap.yaml +++ b/.github/workflows/snap.yaml @@ -35,72 +35,26 @@ concurrency: jobs: snap: - runs-on: ubuntu-24.04 # core24 base requires Noble host for destructive-mode + runs-on: ubuntu-latest + outputs: + snap-file: ${{ steps.build-snap.outputs.snap }} steps: - uses: actions/checkout@v4 - - - name: Install snapcraft - run: sudo snap install snapcraft --classic + with: + fetch-depth: 0 - name: Build snap - run: snapcraft --destructive-mode - - - name: Install snap and run smoke tests - run: | - sudo snap install --dangerous pj-bridge_*.snap - sleep 3 # let snapd activate AppArmor/mount namespaces - - # Install host-side ROS 2 deps for the publisher - sudo apt-get install -y ros-jazzy-rclpy ros-jazzy-std-msgs - - # Set up FastRTPS UDP-only profile for host-side DDS processes - mkdir -p "$HOME/.config/pj-bridge" - cp snap/local/fastrtps_no_shm.xml "$HOME/.config/pj-bridge/fastrtps_no_shm.xml" - - # Python venv for ws_reader.py - python3 -m venv /tmp/pj_test_venv - /tmp/pj_test_venv/bin/pip install -q -r tests/integration/requirements.txt + uses: snapcore/action-build@v1 + id: build-snap - # Source ROS 2 and set UDP-only profile for host processes - source /opt/ros/jazzy/setup.bash - export FASTRTPS_DEFAULT_PROFILES_FILE="$HOME/.config/pj-bridge/fastrtps_no_shm.xml" - - # Start the bridge snap - snap run pj-bridge.pj-bridge-ros2 --ros-args -p port:=9090 \ - > /tmp/pj_bridge.log 2>&1 & - BRIDGE_PID=$! - sleep 5 - - if ! kill -0 "$BRIDGE_PID" 2>/dev/null; then - echo "ERROR: bridge exited immediately" - cat /tmp/pj_bridge.log - exit 1 - fi - - # Start hello-world publisher - python3 tests/integration/pub_hello.py > /tmp/pub_hello.log 2>&1 & - PUB_PID=$! - sleep 3 - - # Run WebSocket reader — asserts 'hello world' received end-to-end - /tmp/pj_test_venv/bin/python3 tests/integration/ws_reader.py \ - --url ws://localhost:9090 \ - --topic /hello_world \ - --timeout 25 - READER_EXIT=$? - - kill "$BRIDGE_PID" "$PUB_PID" 2>/dev/null || true - - echo "--- Bridge log ---" - cat /tmp/pj_bridge.log - - exit "$READER_EXIT" + - name: Install snap + run: sudo snap install --dangerous ${{ steps.build-snap.outputs.snap }} - uses: actions/upload-artifact@v4 with: name: snap-amd64 - path: "*.snap" + path: ${{ steps.build-snap.outputs.snap }} release: needs: snap @@ -132,14 +86,10 @@ jobs: with: name: snap-amd64 - - name: Find snap file - id: snap-file - run: echo "path=$(ls pj-bridge_*.snap)" >> "$GITHUB_OUTPUT" - - name: Publish to Snap Store uses: canonical/action-publish@v1 env: SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} with: - snap: ${{ steps.snap-file.outputs.path }} + snap: ${{ needs.snap.outputs.snap-file }} release: stable From 0edce30ff01f5c5be5922db98ab22206323c609c Mon Sep 17 00:00:00 2001 From: Mirko Ferrati Date: Tue, 7 Jul 2026 18:05:16 +0000 Subject: [PATCH 06/25] build(snap): derive version and grade from git tags via adopt-info Replace the hardcoded version: 0.5.0 with version: git + adopt-info so a pushed tag (e.g. v0.5.0) drives the published snap version. The pj_bridge part computes the version from git describe and sets grade=stable on a clean tag / grade=devel when ahead of a tag. --- snap/snapcraft.yaml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 8c13959..8f514fe 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -1,5 +1,6 @@ name: pj-bridge -version: 0.5.0 +adopt-info: pj_bridge # version and grade derived from git tags (see pj_bridge part) +version: git title: pj-bridge summary: ROS2 to WebSocket bridge server — forwards topic content at 50 Hz license: AGPL-3.0 @@ -13,7 +14,6 @@ description: > Clients subscribe to topics and receive ZSTD-compressed aggregated messages at 50 Hz without needing a full middleware installation. confinement: strict -grade: stable base: core24 # ROS2 typesupport and middleware libraries are loaded via dlopen at runtime @@ -34,6 +34,16 @@ parts: pj_bridge: plugin: colcon source: . + override-pull: | + craftctl default + # Derive the snap version and grade from the most recent git tag so that + # pushing a tag (e.g. v0.5.0) drives the published snap version. + # A clean tag -> stable grade; anything ahead of a tag -> devel grade. + git config --global --add safe.directory '*' + version="$(git describe --always --tags | sed -e 's/^v//;s/-/+git/;y/-/./')" + if echo "$version" | grep -q '+git'; then grade=devel; else grade=stable; fi + craftctl set version="$version" + craftctl set grade="$grade" build-environment: - ROS_DISTRO: jazzy - ROS_VERSION: "2" From e9ca527ceed9bd14ccf315618eccbacd15b0572e Mon Sep 17 00:00:00 2001 From: Mirko Ferrati Date: Tue, 7 Jul 2026 18:05:16 +0000 Subject: [PATCH 07/25] chore: gitignore built *.snap artifacts --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index ce8fd53..2f4a7b9 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ install_tsan/ build_asan/ install_asan/ log/ + +# Built snap artifacts +*.snap From f4da3ac0c367c756be4420777978b3153aa76b03 Mon Sep 17 00:00:00 2001 From: Mirko Ferrati Date: Tue, 7 Jul 2026 18:07:29 +0000 Subject: [PATCH 08/25] ci: run full integration test against built snap; scope release permissions - Add integration-test job that installs the snap and runs the end-to-end suite (host ROS 2 publisher -> bridge -> ws_reader), restoring the coverage that was inline before the PlotJuggler alignment. - Gate release and publish behind integration-test (needs: [snap, integration-test]) so a failing test blocks tag-driven releases. - Remove the redundant workflow-level 'permissions: contents: write'; keep it scoped to the release job that actually needs it. - Update the header comment to describe the current pipeline. --- .github/workflows/snap.yaml | 85 ++++++++++++++++++++++++++++++++++--- 1 file changed, 79 insertions(+), 6 deletions(-) diff --git a/.github/workflows/snap.yaml b/.github/workflows/snap.yaml index 741ab6f..40e650c 100644 --- a/.github/workflows/snap.yaml +++ b/.github/workflows/snap.yaml @@ -1,5 +1,13 @@ # ============================================================================= -# Snap Package — Build, Test, Release, and Publish to Snap Store +# Snap Package — Build → Integration Test → Release → Publish +# +# Pipeline (every push / PR / manual run): +# snap build the snap (snapcore/action-build) + quick install check +# integration-test install the snap and run the full end-to-end suite +# (host ROS 2 publisher -> bridge -> WebSocket reader) +# Tag pushes additionally run (only if integration-test passes): +# release attach the .snap to a GitHub Release +# publish push the .snap to the Snap Store stable channel # # SETUP: Snap Store credentials # ------------------------------ @@ -26,9 +34,6 @@ on: pull_request: workflow_dispatch: -permissions: - contents: write - concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -56,8 +61,76 @@ jobs: name: snap-amd64 path: ${{ steps.build-snap.outputs.snap }} - release: + integration-test: needs: snap + runs-on: ubuntu-24.04 # ROS 2 Jazzy targets Noble + steps: + - uses: actions/checkout@v4 + + - name: Download snap artifact + uses: actions/download-artifact@v4 + with: + name: snap-amd64 + + - name: Install snap + run: | + sudo snap install --dangerous ./pj-bridge_*.snap + sleep 3 # let snapd activate AppArmor/mount namespaces + + - name: Set up ROS 2 Jazzy (host publisher deps) + uses: ros-tooling/setup-ros@v0.7 + with: + required-ros-distributions: jazzy + + - name: Run end-to-end integration test + run: | + # FastRTPS UDP-only profile so host DDS crosses the snap namespace boundary + mkdir -p "$HOME/.config/pj-bridge" + cp snap/local/fastrtps_no_shm.xml "$HOME/.config/pj-bridge/fastrtps_no_shm.xml" + export FASTRTPS_DEFAULT_PROFILES_FILE="$HOME/.config/pj-bridge/fastrtps_no_shm.xml" + + # Python venv for the WebSocket reader + python3 -m venv /tmp/pj_test_venv + /tmp/pj_test_venv/bin/pip install -q -r tests/integration/requirements.txt + + source /opt/ros/jazzy/setup.bash + + # Start the bridge snap + snap run pj-bridge.pj-bridge-ros2 --ros-args -p port:=9090 \ + > /tmp/pj_bridge.log 2>&1 & + BRIDGE_PID=$! + sleep 5 + if ! kill -0 "$BRIDGE_PID" 2>/dev/null; then + echo "ERROR: bridge exited immediately" + cat /tmp/pj_bridge.log + exit 1 + fi + + # Start the hello-world publisher + python3 tests/integration/pub_hello.py > /tmp/pub_hello.log 2>&1 & + PUB_PID=$! + sleep 3 + + # Reader asserts 'hello world' received end-to-end over the WebSocket + set +e + /tmp/pj_test_venv/bin/python3 tests/integration/ws_reader.py \ + --url ws://localhost:9090 \ + --topic /hello_world \ + --timeout 25 + READER_EXIT=$? + set -e + + kill "$BRIDGE_PID" "$PUB_PID" 2>/dev/null || true + + echo "--- Bridge log ---" + cat /tmp/pj_bridge.log + echo "--- Publisher log ---" + cat /tmp/pub_hello.log + + exit "$READER_EXIT" + + release: + needs: [snap, integration-test] if: startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest permissions: @@ -78,7 +151,7 @@ jobs: gh release upload "$tag" *.snap --clobber publish: - needs: snap + needs: [snap, integration-test] if: startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest steps: From ec5318e7be19034e97f5fe9c77ad6aacfb01f32d Mon Sep 17 00:00:00 2001 From: Mirko Ferrati Date: Tue, 7 Jul 2026 18:43:51 +0000 Subject: [PATCH 09/25] fix: drop DOWNLOAD_EXTRACT_TIMESTAMP from IXWebSocket FetchContent DOWNLOAD_EXTRACT_TIMESTAMP requires CMake >= 3.24, but Ubuntu 22.04 ships CMake 3.22, where the unknown option fails configuration. --- CMakeLists.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index ef22881..41c34a7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -62,7 +62,6 @@ if(NOT ixwebsocket_FOUND) ixwebsocket URL https://github.com/machinezone/IXWebSocket/archive/refs/tags/v11.4.6.tar.gz URL_HASH SHA256=c024334f8e45980836c67008979a884d6dcc5ef067dd2eb1fa7241f4c17ddc32 - DOWNLOAD_EXTRACT_TIMESTAMP TRUE ) set(USE_TLS ${PJ_BRIDGE_TLS} CACHE BOOL "" FORCE) if(PJ_BRIDGE_TLS) From 7519ededa98e046248b5205797da8d8daeecd1b5 Mon Sep 17 00:00:00 2001 From: Mirko Ferrati Date: Tue, 7 Jul 2026 18:52:21 +0000 Subject: [PATCH 10/25] ci: install minimal ROS 2 deps for integration test instead of setup-ros The integration test only needs a host-side ROS 2 publisher (rclpy + std_msgs). Replace ros-tooling/setup-ros@v0.7 -- which installs the full ros-base metapackage plus colcon/rosdep/vcstool dev tooling -- with a direct apt install of ros-jazzy-rclpy, ros-jazzy-std-msgs and ros-jazzy-rmw-fastrtps-cpp (--no-install-recommends). --- .github/workflows/snap.yaml | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/.github/workflows/snap.yaml b/.github/workflows/snap.yaml index 40e650c..bd24f01 100644 --- a/.github/workflows/snap.yaml +++ b/.github/workflows/snap.yaml @@ -77,10 +77,22 @@ jobs: sudo snap install --dangerous ./pj-bridge_*.snap sleep 3 # let snapd activate AppArmor/mount namespaces - - name: Set up ROS 2 Jazzy (host publisher deps) - uses: ros-tooling/setup-ros@v0.7 - with: - required-ros-distributions: jazzy + - name: Install ROS 2 Jazzy publisher deps (rclpy + std_msgs only) + run: | + # The test only needs a host-side ROS 2 publisher (pub_hello.py imports + # rclpy + std_msgs). Install just those instead of the full ros-base + # metapackage + dev tooling that ros-tooling/setup-ros would pull in. + sudo apt-get update + sudo apt-get install -y curl + sudo curl -sSL https://raw.githubusercontent.com/ros/rosdistro/master/ros.key \ + -o /usr/share/keyrings/ros-archive-keyring.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/ros-archive-keyring.gpg] http://packages.ros.org/ros2/ubuntu $(. /etc/os-release && echo "$UBUNTU_CODENAME") main" \ + | sudo tee /etc/apt/sources.list.d/ros2.list > /dev/null + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + ros-jazzy-rclpy \ + ros-jazzy-std-msgs \ + ros-jazzy-rmw-fastrtps-cpp - name: Run end-to-end integration test run: | From ee5fb286392176b82e84fcbf9532dae9b1dce68a Mon Sep 17 00:00:00 2001 From: Mirko Ferrati Date: Tue, 7 Jul 2026 18:56:24 +0000 Subject: [PATCH 11/25] build(snap): drop redundant shared-memory plug The app forces UDP-only DDS via FASTRTPS_DEFAULT_PROFILES_FILE (fastrtps_no_shm.xml), so the Fast DDS shared-memory transport is never used and the shared-memory plug is unnecessary. --- snap/snapcraft.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 8f514fe..ae1c973 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -83,6 +83,5 @@ apps: plugs: - network - network-bind - - shared-memory environment: FASTRTPS_DEFAULT_PROFILES_FILE: $SNAP/etc/pj-bridge/fastrtps_no_shm.xml From bd353ae6019bb639b47a3b556e8403b1b3f8449d Mon Sep 17 00:00:00 2001 From: Mirko Ferrati Date: Fri, 17 Jul 2026 16:33:36 +0200 Subject: [PATCH 12/25] use ros2-jazzy extension --- snap/local/pj-bridge-ros2-launcher | 10 ---------- snap/snapcraft.yaml | 22 +++++++++------------- 2 files changed, 9 insertions(+), 23 deletions(-) delete mode 100755 snap/local/pj-bridge-ros2-launcher diff --git a/snap/local/pj-bridge-ros2-launcher b/snap/local/pj-bridge-ros2-launcher deleted file mode 100755 index 4fd6ff0..0000000 --- a/snap/local/pj-bridge-ros2-launcher +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash -# pj-bridge-ros2 launcher -# Strict-confinement snaps do not add /opt/... dirs to LD_LIBRARY_PATH -# automatically, so we prepend them here before exec-ing the binary. -export LD_LIBRARY_PATH="$SNAP/opt/ros/jazzy/lib:$SNAP/opt/ros/jazzy/lib/x86_64-linux-gnu:$SNAP/opt/ros/snap/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" -export AMENT_PREFIX_PATH="$SNAP/opt/ros/snap:$SNAP/opt/ros/jazzy" -export COLCON_PREFIX_PATH="$SNAP/opt/ros/snap" -export ROS_DISTRO=jazzy -export ROS_VERSION=2 -exec "$SNAP/opt/ros/snap/lib/pj_bridge/pj_bridge_ros2" "$@" diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index ae1c973..73f2932 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -22,13 +22,10 @@ lint: ignore: - library -package-repositories: - - type: apt - url: http://packages.ros.org/ros2/ubuntu - suites: [noble] - components: [main] - key-id: C1CF6E31E6BADE8868B172B4F42ED6FBAB17C654 - key-server: keyserver.ubuntu.com +# The ros2-jazzy extension (enabled on the pj-bridge-ros2 app below) supplies the +# ROS 2 apt package-repository, the colcon build/runtime environment +# (ROS_DISTRO/ROS_VERSION), and the runtime command-chain that sources +# /opt/ros/jazzy and the staged colcon workspace at /opt/ros/snap. parts: pj_bridge: @@ -44,9 +41,6 @@ parts: if echo "$version" | grep -q '+git'; then grade=devel; else grade=stable; fi craftctl set version="$version" craftctl set grade="$grade" - build-environment: - - ROS_DISTRO: jazzy - - ROS_VERSION: "2" build-packages: - cmake - git @@ -70,16 +64,18 @@ parts: - ros-jazzy-ros2launch - ros-jazzy-sensor-msgs - snap-local: + fastrtps-config: plugin: dump source: snap/local organize: - pj-bridge-ros2-launcher: usr/bin/pj-bridge-ros2-launcher fastrtps_no_shm.xml: etc/pj-bridge/fastrtps_no_shm.xml + prime: + - etc/pj-bridge/fastrtps_no_shm.xml apps: pj-bridge-ros2: - command: usr/bin/pj-bridge-ros2-launcher + command: opt/ros/snap/lib/pj_bridge/pj_bridge_ros2 + extensions: [ros2-jazzy] plugs: - network - network-bind From 6b9ef2c391dcf9df9be7374c32d393d61a793cb6 Mon Sep 17 00:00:00 2001 From: Mirko Ferrati Date: Fri, 17 Jul 2026 14:39:48 +0000 Subject: [PATCH 13/25] build(snap): drop packages now resolved by ros2-jazzy extension + rosdep The ros2-jazzy extension plus the colcon plugin's rosdep resolution of package.xml now provide the ROS packages. Remove them from the manual build-packages/stage-packages lists. Keep only git (override-pull git describe) and libssl-dev/zlib1g-dev (IXWebSocket TLS/zlib, pulled via FetchContent, not declared in package.xml). --- snap/snapcraft.yaml | 25 +++++++------------------ 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 73f2932..12c86ca 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -41,28 +41,17 @@ parts: if echo "$version" | grep -q '+git'; then grade=devel; else grade=stable; fi craftctl set version="$version" craftctl set grade="$grade" + # ROS build/runtime dependencies (rclcpp, sensor_msgs, nav_msgs, + # ament_index_cpp, geometry_msgs, launch_ros, ros2launch, spdlog, zstd, + # nlohmann-json, ...) are resolved automatically by the colcon plugin via + # rosdep from package.xml, and cmake/colcon/rosdep come from the ros2-jazzy + # extension. Only list what is NOT declared in package.xml: git (for the + # override-pull git describe) and the IXWebSocket TLS/zlib build deps that + # are pulled in via FetchContent. build-packages: - - cmake - git - - libspdlog-dev - libssl-dev - - libzstd-dev - - nlohmann-json3-dev - - python3-colcon-common-extensions - - python3-rosdep - - ros-jazzy-ament-index-cpp - - ros-jazzy-nav-msgs - - ros-jazzy-rclcpp - - ros-jazzy-sensor-msgs - zlib1g-dev - stage-packages: - - ros-jazzy-ament-index-cpp - - ros-jazzy-geometry-msgs - - ros-jazzy-launch-ros - - ros-jazzy-nav-msgs - - ros-jazzy-rclcpp - - ros-jazzy-ros2launch - - ros-jazzy-sensor-msgs fastrtps-config: plugin: dump From abaf730db6a7f156bce89344825aeeb846d46bde Mon Sep 17 00:00:00 2001 From: Mirko Ferrati Date: Fri, 17 Jul 2026 18:30:39 +0200 Subject: [PATCH 14/25] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Jeremie Deray --- .github/workflows/snap.yaml | 2 +- snap/snapcraft.yaml | 2 +- tests/integration/requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/snap.yaml b/.github/workflows/snap.yaml index bd24f01..e4a2bd0 100644 --- a/.github/workflows/snap.yaml +++ b/.github/workflows/snap.yaml @@ -40,7 +40,7 @@ concurrency: jobs: snap: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 outputs: snap-file: ${{ steps.build-snap.outputs.snap }} diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 12c86ca..50a8d43 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -1,7 +1,7 @@ name: pj-bridge adopt-info: pj_bridge # version and grade derived from git tags (see pj_bridge part) version: git -title: pj-bridge +title: PlotJuggler Bridge summary: ROS2 to WebSocket bridge server — forwards topic content at 50 Hz license: AGPL-3.0 contact: https://github.com/PlotJuggler/plotjuggler_bridge/issues diff --git a/tests/integration/requirements.txt b/tests/integration/requirements.txt index c12b577..f01b7bb 100644 --- a/tests/integration/requirements.txt +++ b/tests/integration/requirements.txt @@ -1,4 +1,4 @@ # Python dependencies for the pj_bridge end-to-end smoke test -# Install with: pip3 install -r test/requirements.txt +# Install with: pip3 install -r tests/integration/requirements.txt websockets>=12.0 zstandard>=0.22.0 From 71f5a39797a44002337cbfe9a372af59fefaf6c1 Mon Sep 17 00:00:00 2001 From: Mirko Ferrati Date: Fri, 17 Jul 2026 16:43:59 +0000 Subject: [PATCH 15/25] ci(snap): publish tags to jazzy/candidate; add manual promote to jazzy/stable Replace the GitHub Release job with a Snap Store publish to jazzy/candidate on tag pushes, and add a manual workflow_dispatch promote job that moves the current candidate revision to jazzy/stable. Scope top-level permissions to contents: read since store auth uses SNAPCRAFT_STORE_CREDENTIALS. --- .github/workflows/snap.yaml | 72 +++++++++++++++++++++---------------- 1 file changed, 42 insertions(+), 30 deletions(-) diff --git a/.github/workflows/snap.yaml b/.github/workflows/snap.yaml index e4a2bd0..324a998 100644 --- a/.github/workflows/snap.yaml +++ b/.github/workflows/snap.yaml @@ -1,18 +1,27 @@ # ============================================================================= -# Snap Package — Build → Integration Test → Release → Publish +# Snap Package — Build → Integration Test → Publish → (manual) Promote # # Pipeline (every push / PR / manual run): # snap build the snap (snapcore/action-build) + quick install check # integration-test install the snap and run the full end-to-end suite # (host ROS 2 publisher -> bridge -> WebSocket reader) # Tag pushes additionally run (only if integration-test passes): -# release attach the .snap to a GitHub Release -# publish push the .snap to the Snap Store stable channel +# publish push the .snap to the Snap Store jazzy/candidate channel +# Manual promotion (workflow_dispatch with "promote_to_stable" = true): +# promote move the current jazzy/candidate revision to jazzy/stable +# +# CHANNELS +# --------- +# tag push -> jazzy/candidate (soak / manual verification) +# workflow_dispatch (promote box) -> jazzy/stable (promote candidate -> stable) +# +# The "jazzy" track lets users pick the ROS 2 distribution, e.g.: +# snap install pj-bridge --channel jazzy/stable # # SETUP: Snap Store credentials # ------------------------------ # 1. Register the snap name on https://snapcraft.io (one-time, needs Ubuntu SSO) -# 2. Export credentials: +# 2. Export credentials (ACLs must include package_release for the promote step): # snapcraft export-login \ # --snaps pj-bridge \ # --acls package_access,package_push,package_update,package_release \ @@ -20,10 +29,6 @@ # cat credentials.txt # copy the entire output # 3. In GitHub → Settings → Secrets → Actions, create a secret named # SNAPCRAFT_STORE_CREDENTIALS and paste the credentials.txt content. -# -# CHANNELS -# --------- -# tag push → stable (e.g. v0.5.0 → snapcraft.io/pj-bridge) # ============================================================================= name: Snap Package @@ -33,6 +38,16 @@ on: tags: ['*'] pull_request: workflow_dispatch: + inputs: + promote_to_stable: + description: 'Promote the current jazzy/candidate revision to jazzy/stable' + type: boolean + default: false + +# Only the checkout needs repository access; publishing/promoting authenticate +# to the Snap Store via SNAPCRAFT_STORE_CREDENTIALS, not the GITHUB_TOKEN. +permissions: + contents: read concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -141,40 +156,37 @@ jobs: exit "$READER_EXIT" - release: + publish: needs: [snap, integration-test] if: startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest - permissions: - contents: write steps: - uses: actions/download-artifact@v4 with: name: snap-amd64 - - name: Upload snap to GitHub Release + - name: Publish to Snap Store (jazzy/candidate) + uses: canonical/action-publish@v1 env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - run: | - tag="${{ github.ref_name }}" - gh release view "$tag" >/dev/null 2>&1 || \ - gh release create "$tag" --title "$tag" --generate-notes || true - gh release upload "$tag" *.snap --clobber + SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} + with: + snap: ${{ needs.snap.outputs.snap-file }} + release: jazzy/candidate - publish: - needs: [snap, integration-test] - if: startsWith(github.ref, 'refs/tags/') + promote: + # Manual gate: promote the current candidate revision to stable only when a + # human triggers the workflow with the promote_to_stable box checked. + if: github.event_name == 'workflow_dispatch' && inputs.promote_to_stable runs-on: ubuntu-latest steps: - - uses: actions/download-artifact@v4 - with: - name: snap-amd64 + - name: Install snapcraft + run: sudo snap install snapcraft --classic - - name: Publish to Snap Store - uses: canonical/action-publish@v1 + - name: Promote jazzy/candidate -> jazzy/stable env: SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} - with: - snap: ${{ needs.snap.outputs.snap-file }} - release: stable + run: | + snapcraft promote pj-bridge \ + --from-channel jazzy/candidate \ + --to-channel jazzy/stable \ + --yes From 3ec971bdf45f36c2977def924bca2b554575ae85 Mon Sep 17 00:00:00 2001 From: Mirko Ferrati Date: Fri, 17 Jul 2026 16:48:27 +0000 Subject: [PATCH 16/25] ci(snap): use canonical/action-build@v1.3.0 instead of snapcore/action-build Aligns the build action with the canonical/action-publish action already used downstream. Same 'snap' output, so the artifact path wiring is unchanged. --- .github/workflows/snap.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/snap.yaml b/.github/workflows/snap.yaml index 324a998..dbb52e8 100644 --- a/.github/workflows/snap.yaml +++ b/.github/workflows/snap.yaml @@ -2,7 +2,7 @@ # Snap Package — Build → Integration Test → Publish → (manual) Promote # # Pipeline (every push / PR / manual run): -# snap build the snap (snapcore/action-build) + quick install check +# snap build the snap (canonical/action-build) + quick install check # integration-test install the snap and run the full end-to-end suite # (host ROS 2 publisher -> bridge -> WebSocket reader) # Tag pushes additionally run (only if integration-test passes): @@ -65,7 +65,7 @@ jobs: fetch-depth: 0 - name: Build snap - uses: snapcore/action-build@v1 + uses: canonical/action-build@v1.3.0 id: build-snap - name: Install snap From 77096587cdb1361930bee704ed1f29846c0e8c62 Mon Sep 17 00:00:00 2001 From: Mirko Ferrati Date: Thu, 8 Oct 2026 18:26:57 +0200 Subject: [PATCH 17/25] address PR review: reusable snap workflows, ros2cli test, trim comments --- .github/workflows/snap-promote.yaml | 24 +++ .github/workflows/snap.yaml | 227 +++++++--------------------- README.md | 13 ++ snap/snapcraft.yaml | 14 +- tests/integration/pub_hello.py | 41 ----- tests/integration/smoke_tests.sh | 33 ++-- 6 files changed, 111 insertions(+), 241 deletions(-) create mode 100644 .github/workflows/snap-promote.yaml delete mode 100755 tests/integration/pub_hello.py diff --git a/.github/workflows/snap-promote.yaml b/.github/workflows/snap-promote.yaml new file mode 100644 index 0000000..006c352 --- /dev/null +++ b/.github/workflows/snap-promote.yaml @@ -0,0 +1,24 @@ +# ============================================================================= +# Snap Promote — manual gate: jazzy/candidate -> jazzy/stable +# +# Run via the Actions tab once a candidate revision has soaked. +# Uses the canonical reusable promote workflow. +# ============================================================================= + +name: Snap Promote + +on: + workflow_dispatch: + +permissions: {} + +jobs: + promote: + permissions: + contents: read + uses: canonical/robotics-actions-workflows/.github/workflows/promote.yaml@main + secrets: + snapstore-login: ${{ secrets.SNAPSTORE_LOGIN }} + with: + from-channel: jazzy/candidate + to-channel: jazzy/stable diff --git a/.github/workflows/snap.yaml b/.github/workflows/snap.yaml index dbb52e8..9307770 100644 --- a/.github/workflows/snap.yaml +++ b/.github/workflows/snap.yaml @@ -1,53 +1,24 @@ # ============================================================================= -# Snap Package — Build → Integration Test → Publish → (manual) Promote +# Snap Package — build, test and publish via the canonical reusable workflow. # -# Pipeline (every push / PR / manual run): -# snap build the snap (canonical/action-build) + quick install check -# integration-test install the snap and run the full end-to-end suite -# (host ROS 2 publisher -> bridge -> WebSocket reader) -# Tag pushes additionally run (only if integration-test passes): -# publish push the .snap to the Snap Store jazzy/candidate channel -# Manual promotion (workflow_dispatch with "promote_to_stable" = true): -# promote move the current jazzy/candidate revision to jazzy/stable +# Branch push -> jazzy/edge, tag push -> jazzy/candidate. PRs build+test only. +# Promotion jazzy/candidate -> jazzy/stable lives in snap-promote.yaml. # -# CHANNELS -# --------- -# tag push -> jazzy/candidate (soak / manual verification) -# workflow_dispatch (promote box) -> jazzy/stable (promote candidate -> stable) -# -# The "jazzy" track lets users pick the ROS 2 distribution, e.g.: -# snap install pj-bridge --channel jazzy/stable -# -# SETUP: Snap Store credentials -# ------------------------------ -# 1. Register the snap name on https://snapcraft.io (one-time, needs Ubuntu SSO) -# 2. Export credentials (ACLs must include package_release for the promote step): -# snapcraft export-login \ -# --snaps pj-bridge \ -# --acls package_access,package_push,package_update,package_release \ -# credentials.txt -# cat credentials.txt # copy the entire output -# 3. In GitHub → Settings → Secrets → Actions, create a secret named -# SNAPCRAFT_STORE_CREDENTIALS and paste the credentials.txt content. +# SETUP: store the credentials from +# snapcraft export-login --snaps pj-bridge --channels edge,candidate - +# as the SNAPSTORE_LOGIN repository secret. # ============================================================================= name: Snap Package on: push: + branches: [main] tags: ['*'] pull_request: workflow_dispatch: - inputs: - promote_to_stable: - description: 'Promote the current jazzy/candidate revision to jazzy/stable' - type: boolean - default: false -# Only the checkout needs repository access; publishing/promoting authenticate -# to the Snap Store via SNAPCRAFT_STORE_CREDENTIALS, not the GITHUB_TOKEN. -permissions: - contents: read +permissions: {} concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -55,138 +26,50 @@ concurrency: jobs: snap: - runs-on: ubuntu-24.04 - outputs: - snap-file: ${{ steps.build-snap.outputs.snap }} - - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Build snap - uses: canonical/action-build@v1.3.0 - id: build-snap - - - name: Install snap - run: sudo snap install --dangerous ${{ steps.build-snap.outputs.snap }} - - - uses: actions/upload-artifact@v4 - with: - name: snap-amd64 - path: ${{ steps.build-snap.outputs.snap }} - - integration-test: - needs: snap - runs-on: ubuntu-24.04 # ROS 2 Jazzy targets Noble - steps: - - uses: actions/checkout@v4 - - - name: Download snap artifact - uses: actions/download-artifact@v4 - with: - name: snap-amd64 - - - name: Install snap - run: | - sudo snap install --dangerous ./pj-bridge_*.snap - sleep 3 # let snapd activate AppArmor/mount namespaces - - - name: Install ROS 2 Jazzy publisher deps (rclpy + std_msgs only) - run: | - # The test only needs a host-side ROS 2 publisher (pub_hello.py imports - # rclpy + std_msgs). Install just those instead of the full ros-base - # metapackage + dev tooling that ros-tooling/setup-ros would pull in. - sudo apt-get update - sudo apt-get install -y curl - sudo curl -sSL https://raw.githubusercontent.com/ros/rosdistro/master/ros.key \ - -o /usr/share/keyrings/ros-archive-keyring.gpg - echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/ros-archive-keyring.gpg] http://packages.ros.org/ros2/ubuntu $(. /etc/os-release && echo "$UBUNTU_CODENAME") main" \ - | sudo tee /etc/apt/sources.list.d/ros2.list > /dev/null - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - ros-jazzy-rclpy \ - ros-jazzy-std-msgs \ - ros-jazzy-rmw-fastrtps-cpp - - - name: Run end-to-end integration test - run: | - # FastRTPS UDP-only profile so host DDS crosses the snap namespace boundary - mkdir -p "$HOME/.config/pj-bridge" - cp snap/local/fastrtps_no_shm.xml "$HOME/.config/pj-bridge/fastrtps_no_shm.xml" - export FASTRTPS_DEFAULT_PROFILES_FILE="$HOME/.config/pj-bridge/fastrtps_no_shm.xml" - - # Python venv for the WebSocket reader - python3 -m venv /tmp/pj_test_venv - /tmp/pj_test_venv/bin/pip install -q -r tests/integration/requirements.txt - - source /opt/ros/jazzy/setup.bash - - # Start the bridge snap - snap run pj-bridge.pj-bridge-ros2 --ros-args -p port:=9090 \ - > /tmp/pj_bridge.log 2>&1 & - BRIDGE_PID=$! - sleep 5 - if ! kill -0 "$BRIDGE_PID" 2>/dev/null; then - echo "ERROR: bridge exited immediately" - cat /tmp/pj_bridge.log - exit 1 - fi - - # Start the hello-world publisher - python3 tests/integration/pub_hello.py > /tmp/pub_hello.log 2>&1 & - PUB_PID=$! - sleep 3 - - # Reader asserts 'hello world' received end-to-end over the WebSocket - set +e - /tmp/pj_test_venv/bin/python3 tests/integration/ws_reader.py \ - --url ws://localhost:9090 \ - --topic /hello_world \ - --timeout 25 - READER_EXIT=$? - set -e - - kill "$BRIDGE_PID" "$PUB_PID" 2>/dev/null || true - - echo "--- Bridge log ---" - cat /tmp/pj_bridge.log - echo "--- Publisher log ---" - cat /tmp/pub_hello.log - - exit "$READER_EXIT" - - publish: - needs: [snap, integration-test] - if: startsWith(github.ref, 'refs/tags/') - runs-on: ubuntu-latest - steps: - - uses: actions/download-artifact@v4 - with: - name: snap-amd64 - - - name: Publish to Snap Store (jazzy/candidate) - uses: canonical/action-publish@v1 - env: - SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} - with: - snap: ${{ needs.snap.outputs.snap-file }} - release: jazzy/candidate - - promote: - # Manual gate: promote the current candidate revision to stable only when a - # human triggers the workflow with the promote_to_stable box checked. - if: github.event_name == 'workflow_dispatch' && inputs.promote_to_stable - runs-on: ubuntu-latest - steps: - - name: Install snapcraft - run: sudo snap install snapcraft --classic - - - name: Promote jazzy/candidate -> jazzy/stable - env: - SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} - run: | - snapcraft promote pj-bridge \ - --from-channel jazzy/candidate \ - --to-channel jazzy/stable \ - --yes + permissions: + contents: read + uses: canonical/robotics-actions-workflows/.github/workflows/snap.yaml@main + secrets: + snapstore-login: ${{ secrets.SNAPSTORE_LOGIN }} + with: + snap-track: jazzy + # End-to-end check: ros2cli snap publisher -> bridge snap -> WebSocket reader. + snap-test-script: | + #!/usr/bin/env bash + set -euxo pipefail + + # Host-side ROS 2 publisher via the ros2cli snap (no apt / ROS install). + sudo snap install ros2cli + + # UDP-only FastRTPS profile so the publisher snap's DDS crosses the + # bridge snap namespace boundary. Must be a non-hidden path so the + # ros2cli snap can read it through the 'home' interface. + cp snap/local/fastrtps_no_shm.xml "$HOME/fastrtps_no_shm.xml" + export FASTRTPS_DEFAULT_PROFILES_FILE="$HOME/fastrtps_no_shm.xml" + + python3 -m venv /tmp/pj_test_venv + /tmp/pj_test_venv/bin/pip install -q -r tests/integration/requirements.txt + + snap run pj-bridge.pj-bridge-ros2 --ros-args -p port:=9090 \ + > /tmp/pj_bridge.log 2>&1 & + BRIDGE_PID=$! + sleep 5 + if ! kill -0 "$BRIDGE_PID" 2>/dev/null; then + echo "ERROR: bridge exited immediately"; cat /tmp/pj_bridge.log; exit 1 + fi + + ros2cli.ros2 topic pub -r 1 /hello_world std_msgs/msg/String \ + "data: 'hello world'" > /tmp/pub_hello.log 2>&1 & + PUB_PID=$! + sleep 3 + + set +e + /tmp/pj_test_venv/bin/python3 tests/integration/ws_reader.py \ + --url ws://localhost:9090 --topic /hello_world --timeout 25 + READER_EXIT=$? + set -e + + kill "$BRIDGE_PID" "$PUB_PID" 2>/dev/null || true + echo "--- Bridge log ---"; cat /tmp/pj_bridge.log + echo "--- Publisher log ---"; cat /tmp/pub_hello.log + exit "$READER_EXIT" diff --git a/README.md b/README.md index d7243b4..c8f4d48 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,19 @@ snap run pj-bridge.pj-bridge-ros2 --ros-args -p port:=9090 > **Note**: The snap bundles ROS2 Jazzy and uses UDP-only DDS transport automatically — > no extra ROS2 installation is required on the machine running the bridge. +The snap is built, tested and published by CI via the +[canonical/robotics-actions-workflows](https://github.com/canonical/robotics-actions-workflows) +reusable workflows (`.github/workflows/snap.yaml`): branch pushes release to +`jazzy/edge`, tags to `jazzy/candidate`, and `snap-promote.yaml` is a manual +gate to `jazzy/stable`. Publishing needs a `SNAPSTORE_LOGIN` repository secret: + +```bash +snapcraft export-login --snaps pj-bridge --channels edge,candidate - +``` + +The end-to-end test publishes with the `ros2cli` snap (no host ROS install); see +`tests/integration/smoke_tests.sh` to run it locally. + ### AppImage Pre-built AppImages are available from [GitHub Releases](https://github.com/PlotJuggler/plotjuggler_bridge/releases). diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 50a8d43..7e428e5 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -33,21 +33,15 @@ parts: source: . override-pull: | craftctl default - # Derive the snap version and grade from the most recent git tag so that - # pushing a tag (e.g. v0.5.0) drives the published snap version. - # A clean tag -> stable grade; anything ahead of a tag -> devel grade. + # Version/grade from git tag: clean tag -> stable, ahead of tag -> devel. git config --global --add safe.directory '*' version="$(git describe --always --tags | sed -e 's/^v//;s/-/+git/;y/-/./')" if echo "$version" | grep -q '+git'; then grade=devel; else grade=stable; fi craftctl set version="$version" craftctl set grade="$grade" - # ROS build/runtime dependencies (rclcpp, sensor_msgs, nav_msgs, - # ament_index_cpp, geometry_msgs, launch_ros, ros2launch, spdlog, zstd, - # nlohmann-json, ...) are resolved automatically by the colcon plugin via - # rosdep from package.xml, and cmake/colcon/rosdep come from the ros2-jazzy - # extension. Only list what is NOT declared in package.xml: git (for the - # override-pull git describe) and the IXWebSocket TLS/zlib build deps that - # are pulled in via FetchContent. + # ROS deps come from package.xml via rosdep (colcon plugin + ros2-jazzy + # extension). Only list what package.xml does not: git (override-pull) and + # the IXWebSocket TLS/zlib build deps pulled in via FetchContent. build-packages: - git - libssl-dev diff --git a/tests/integration/pub_hello.py b/tests/integration/pub_hello.py deleted file mode 100755 index 14dde66..0000000 --- a/tests/integration/pub_hello.py +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env python3 -""" -Publish std_msgs/String "hello world" on /hello_world at 1 Hz. - -Requirements (host): - sudo apt install ros-jazzy-rclpy ros-jazzy-std-msgs - source /opt/ros/jazzy/setup.bash -""" -import rclpy -from rclpy.node import Node -from std_msgs.msg import String - - -class HelloPublisher(Node): - def __init__(self): - super().__init__("hello_publisher") - self.pub = self.create_publisher(String, "/hello_world", 10) - self.timer = self.create_timer(1.0, self._publish) - self.get_logger().info("Hello publisher started — publishing 'hello world' on /hello_world") - - def _publish(self): - msg = String() - msg.data = "hello world" - self.pub.publish(msg) - self.get_logger().info("Published: hello world") - - -def main(): - rclpy.init() - node = HelloPublisher() - try: - rclpy.spin(node) - except KeyboardInterrupt: - pass - finally: - node.destroy_node() - rclpy.shutdown() - - -if __name__ == "__main__": - main() diff --git a/tests/integration/smoke_tests.sh b/tests/integration/smoke_tests.sh index 1d232fb..7d7b9a2 100755 --- a/tests/integration/smoke_tests.sh +++ b/tests/integration/smoke_tests.sh @@ -3,14 +3,14 @@ # Smoke tests for the pj-bridge snap — full end-to-end bridge verification # # Pipeline: -# [host rclpy] pub_hello.py → DDS (UDP) → [snap] pj-bridge-ros2 +# [ros2cli snap] ros2 topic pub → DDS (UDP) → [snap] pj-bridge-ros2 # ↓ WebSocket :9090 # [host Python] ws_reader.py # ↓ # assert "hello world" decoded # # Requirements on the host: -# sudo apt install ros-jazzy-rclpy ros-jazzy-std-msgs +# sudo snap install ros2cli (host-side ROS 2 publisher) # pip3 install websockets zstandard is NOT needed — the script creates a venv automatically # # Usage: @@ -29,7 +29,7 @@ FASTRTPS_PROFILE="$HOME/.config/pj-bridge/fastrtps_no_shm.xml" # --------------------------------------------------------------------------- if [ -z "$SNAP_FILE" ]; then echo "ERROR: no pj-bridge_*.snap found in $PROJECT_ROOT" - echo " Build it first with: snapcraft --destructive-mode" + echo " Build it first with: snapcraft pack" exit 1 fi echo "Snap file: $SNAP_FILE" @@ -78,11 +78,7 @@ echo "=== snap connections ===" snap connections pj-bridge # --------------------------------------------------------------------------- -# Python venv for test dependencies -# Created BEFORE sourcing ROS 2 so that set -e is still active and any -# pip failure is caught immediately rather than silently swallowed. -# pub_hello.py still uses the system Python so it can access rclpy from the -# sourced ROS 2 environment; ws_reader.py uses the venv. +# Python venv for the ws_reader dependencies (websockets + zstandard). # --------------------------------------------------------------------------- VENV_DIR="/tmp/pj_bridge_test_venv" echo "" @@ -92,16 +88,16 @@ python3 -m venv --clear "$VENV_DIR" echo " Dependencies installed: $("$VENV_DIR/bin/pip" freeze | tr '\n' ' ')" # --------------------------------------------------------------------------- -# Source ROS 2 Jazzy on host (same requirement as termviz2 smoke tests) +# Install the ros2cli snap (host-side ROS 2 publisher) and point its DDS at +# the UDP-only profile so it crosses the snap namespace boundary. +# The profile must be a non-hidden path for the snap's 'home' interface. # --------------------------------------------------------------------------- -if [ ! -f /opt/ros/jazzy/setup.bash ]; then - echo "ERROR: /opt/ros/jazzy/setup.bash not found." - echo "Install with: sudo apt install ros-jazzy-rclpy ros-jazzy-std-msgs" - exit 1 +if ! snap list ros2cli >/dev/null 2>&1; then + echo "Installing ros2cli snap..." + sudo snap install ros2cli fi -# shellcheck disable=SC1091 -source /opt/ros/jazzy/setup.bash -export FASTRTPS_DEFAULT_PROFILES_FILE="$FASTRTPS_PROFILE" +cp "$FASTRTPS_PROFILE" "$HOME/fastrtps_no_shm.xml" +export FASTRTPS_DEFAULT_PROFILES_FILE="$HOME/fastrtps_no_shm.xml" # --------------------------------------------------------------------------- # Start the bridge snap @@ -126,8 +122,9 @@ echo " Bridge is running." # Start the hello-world publisher (host side) # --------------------------------------------------------------------------- echo "" -echo "=== Starting hello world publisher ===" -python3 "$TEST_DIR/pub_hello.py" > /tmp/pub_hello.log 2>&1 & +echo "=== Starting hello world publisher (ros2cli snap) ===" +ros2cli.ros2 topic pub -r 1 /hello_world std_msgs/msg/String \ + "data: 'hello world'" > /tmp/pub_hello.log 2>&1 & PUB_PID=$! echo " Publisher PID: $PUB_PID" sleep 3 # let DDS participant discovery complete From 9bc2f9b6ba0856d410b8cb0730c9c84475a4a7a5 Mon Sep 17 00:00:00 2001 From: Mirko Ferrati Date: Mon, 12 Oct 2026 17:38:56 +0200 Subject: [PATCH 18/25] Update permissions in snap.yaml workflow --- .github/workflows/snap.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/snap.yaml b/.github/workflows/snap.yaml index 9307770..22d5a87 100644 --- a/.github/workflows/snap.yaml +++ b/.github/workflows/snap.yaml @@ -28,6 +28,7 @@ jobs: snap: permissions: contents: read + actions: write uses: canonical/robotics-actions-workflows/.github/workflows/snap.yaml@main secrets: snapstore-login: ${{ secrets.SNAPSTORE_LOGIN }} From 617f99aca41e360a71cbb0fd0739ccb3e4683b17 Mon Sep 17 00:00:00 2001 From: Mirko Ferrati Date: Wed, 14 Oct 2026 19:45:06 +0200 Subject: [PATCH 19/25] reduce the test to a simple check for the websocket, remove leftover files --- .github/workflows/snap.yaml | 32 +- README.md | 6 +- snap/snapcraft.yaml | 5 - tests/integration/requirements.txt | 4 - tests/integration/smoke_tests.sh | 174 ---------- tests/integration/test_client.py | 490 ----------------------------- tests/integration/ws_reader.py | 243 -------------- 7 files changed, 21 insertions(+), 933 deletions(-) delete mode 100644 tests/integration/requirements.txt delete mode 100755 tests/integration/smoke_tests.sh delete mode 100755 tests/integration/test_client.py delete mode 100755 tests/integration/ws_reader.py diff --git a/.github/workflows/snap.yaml b/.github/workflows/snap.yaml index 22d5a87..e942d90 100644 --- a/.github/workflows/snap.yaml +++ b/.github/workflows/snap.yaml @@ -34,24 +34,25 @@ jobs: snapstore-login: ${{ secrets.SNAPSTORE_LOGIN }} with: snap-track: jazzy - # End-to-end check: ros2cli snap publisher -> bridge snap -> WebSocket reader. + # Packaging check: start the bridge snap, have a ros2-cli snap publish a + # topic, drive one WebSocket subscribe, and confirm the bridge served it. snap-test-script: | #!/usr/bin/env bash set -euxo pipefail - # Host-side ROS 2 publisher via the ros2cli snap (no apt / ROS install). - sudo snap install ros2cli + sudo snap install ros2-cli --channel jazzy + # websocat has no snap; grab the pinned static musl binary. + sudo curl -fsSL -o /usr/local/bin/websocat \ + https://github.com/vi/websocat/releases/download/v1.13.0/websocat.x86_64-unknown-linux-musl + sudo chmod +x /usr/local/bin/websocat # UDP-only FastRTPS profile so the publisher snap's DDS crosses the # bridge snap namespace boundary. Must be a non-hidden path so the - # ros2cli snap can read it through the 'home' interface. + # ros2-cli snap can read it through the 'home' interface. cp snap/local/fastrtps_no_shm.xml "$HOME/fastrtps_no_shm.xml" export FASTRTPS_DEFAULT_PROFILES_FILE="$HOME/fastrtps_no_shm.xml" - python3 -m venv /tmp/pj_test_venv - /tmp/pj_test_venv/bin/pip install -q -r tests/integration/requirements.txt - - snap run pj-bridge.pj-bridge-ros2 --ros-args -p port:=9090 \ + pj-bridge.pj-bridge-ros2 --ros-args -p port:=9090 \ > /tmp/pj_bridge.log 2>&1 & BRIDGE_PID=$! sleep 5 @@ -59,18 +60,21 @@ jobs: echo "ERROR: bridge exited immediately"; cat /tmp/pj_bridge.log; exit 1 fi - ros2cli.ros2 topic pub -r 1 /hello_world std_msgs/msg/String \ + ros2-cli.ros2 topic pub -r 1 /hello_world std_msgs/msg/String \ "data: 'hello world'" > /tmp/pub_hello.log 2>&1 & PUB_PID=$! - sleep 3 + sleep 4 + + # Drive a single subscribe over the WebSocket (no payload parsing). + echo '{"command":"subscribe","topics":["/hello_world"],"id":"s1"}' \ + | timeout 5 websocat ws://localhost:9090 || true set +e - /tmp/pj_test_venv/bin/python3 tests/integration/ws_reader.py \ - --url ws://localhost:9090 --topic /hello_world --timeout 25 - READER_EXIT=$? + grep -q "subscribed to topic '/hello_world'" /tmp/pj_bridge.log + TEST_EXIT=$? set -e kill "$BRIDGE_PID" "$PUB_PID" 2>/dev/null || true echo "--- Bridge log ---"; cat /tmp/pj_bridge.log echo "--- Publisher log ---"; cat /tmp/pub_hello.log - exit "$READER_EXIT" + exit "$TEST_EXIT" diff --git a/README.md b/README.md index c8f4d48..7391ad3 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ Pre-built snaps are available from the [Snap Store](https://snapcraft.io/pj-brid sudo snap install pj-bridge # Run (add arguments if different from default) -snap run pj-bridge.pj-bridge-ros2 --ros-args -p port:=9090 +pj-bridge.pj-bridge-ros2 --ros-args -p port:=9090 ``` > **Note**: The snap bundles ROS2 Jazzy and uses UDP-only DDS transport automatically — @@ -112,8 +112,8 @@ gate to `jazzy/stable`. Publishing needs a `SNAPSTORE_LOGIN` repository secret: snapcraft export-login --snaps pj-bridge --channels edge,candidate - ``` -The end-to-end test publishes with the `ros2cli` snap (no host ROS install); see -`tests/integration/smoke_tests.sh` to run it locally. +The end-to-end test publishes with the `ros2cli` snap (no host ROS install) and +runs as the `snap-test-script` in `.github/workflows/snap.yaml`. ### AppImage diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 7e428e5..46434df 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -22,11 +22,6 @@ lint: ignore: - library -# The ros2-jazzy extension (enabled on the pj-bridge-ros2 app below) supplies the -# ROS 2 apt package-repository, the colcon build/runtime environment -# (ROS_DISTRO/ROS_VERSION), and the runtime command-chain that sources -# /opt/ros/jazzy and the staged colcon workspace at /opt/ros/snap. - parts: pj_bridge: plugin: colcon diff --git a/tests/integration/requirements.txt b/tests/integration/requirements.txt deleted file mode 100644 index f01b7bb..0000000 --- a/tests/integration/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -# Python dependencies for the pj_bridge end-to-end smoke test -# Install with: pip3 install -r tests/integration/requirements.txt -websockets>=12.0 -zstandard>=0.22.0 diff --git a/tests/integration/smoke_tests.sh b/tests/integration/smoke_tests.sh deleted file mode 100755 index 7d7b9a2..0000000 --- a/tests/integration/smoke_tests.sh +++ /dev/null @@ -1,174 +0,0 @@ -#!/usr/bin/env bash -# ============================================================================= -# Smoke tests for the pj-bridge snap — full end-to-end bridge verification -# -# Pipeline: -# [ros2cli snap] ros2 topic pub → DDS (UDP) → [snap] pj-bridge-ros2 -# ↓ WebSocket :9090 -# [host Python] ws_reader.py -# ↓ -# assert "hello world" decoded -# -# Requirements on the host: -# sudo snap install ros2cli (host-side ROS 2 publisher) -# pip3 install websockets zstandard is NOT needed — the script creates a venv automatically -# -# Usage: -# bash tests/integration/smoke_tests.sh -# ============================================================================= -set -e - -PROJECT_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel)" -TEST_DIR="$PROJECT_ROOT/tests/integration" -SNAP_FILE="$(ls "$PROJECT_ROOT"/pj-bridge_*.snap 2>/dev/null | head -1 || true)" -WS_PORT=9090 -FASTRTPS_PROFILE="$HOME/.config/pj-bridge/fastrtps_no_shm.xml" - -# --------------------------------------------------------------------------- -# Pre-flight checks -# --------------------------------------------------------------------------- -if [ -z "$SNAP_FILE" ]; then - echo "ERROR: no pj-bridge_*.snap found in $PROJECT_ROOT" - echo " Build it first with: snapcraft pack" - exit 1 -fi -echo "Snap file: $SNAP_FILE" - -# --------------------------------------------------------------------------- -# FastRTPS UDP-only profile -# Disables shared-memory transport so host-side DDS can cross the snap -# AppArmor/mount namespace boundary — same technique used by termviz2 tests. -# --------------------------------------------------------------------------- -mkdir -p "$(dirname "$FASTRTPS_PROFILE")" -cat > "$FASTRTPS_PROFILE" << 'EOF' - - - - - UDPv4Transport - UDPv4 - - - - - - UDPv4Transport - - false - - - -EOF -echo "FastRTPS UDP-only profile written to $FASTRTPS_PROFILE" - -# --------------------------------------------------------------------------- -# Install snap -# --------------------------------------------------------------------------- -echo "" -echo "=== Installing snap ===" -sudo snap install --dangerous "$SNAP_FILE" -sleep 3 # let snapd activate AppArmor/mount namespaces - -echo "" -echo "=== snap list ===" -snap list pj-bridge - -echo "" -echo "=== snap connections ===" -snap connections pj-bridge - -# --------------------------------------------------------------------------- -# Python venv for the ws_reader dependencies (websockets + zstandard). -# --------------------------------------------------------------------------- -VENV_DIR="/tmp/pj_bridge_test_venv" -echo "" -echo "=== Setting up Python venv ($VENV_DIR) ===" -python3 -m venv --clear "$VENV_DIR" -"$VENV_DIR/bin/pip" install -q -r "$TEST_DIR/requirements.txt" -echo " Dependencies installed: $("$VENV_DIR/bin/pip" freeze | tr '\n' ' ')" - -# --------------------------------------------------------------------------- -# Install the ros2cli snap (host-side ROS 2 publisher) and point its DDS at -# the UDP-only profile so it crosses the snap namespace boundary. -# The profile must be a non-hidden path for the snap's 'home' interface. -# --------------------------------------------------------------------------- -if ! snap list ros2cli >/dev/null 2>&1; then - echo "Installing ros2cli snap..." - sudo snap install ros2cli -fi -cp "$FASTRTPS_PROFILE" "$HOME/fastrtps_no_shm.xml" -export FASTRTPS_DEFAULT_PROFILES_FILE="$HOME/fastrtps_no_shm.xml" - -# --------------------------------------------------------------------------- -# Start the bridge snap -# --------------------------------------------------------------------------- -echo "" -echo "=== Starting pj-bridge snap ===" -snap run pj-bridge.pj-bridge-ros2 --ros-args -p port:="$WS_PORT" \ - > /tmp/pj_bridge.log 2>&1 & -BRIDGE_PID=$! -echo " Bridge PID: $BRIDGE_PID" -sleep 5 # let ROS 2 init and WebSocket server bind - -# Check it's still alive -if ! kill -0 "$BRIDGE_PID" 2>/dev/null; then - echo "ERROR: pj-bridge-ros2 exited immediately — see /tmp/pj_bridge.log" - cat /tmp/pj_bridge.log - exit 1 -fi -echo " Bridge is running." - -# --------------------------------------------------------------------------- -# Start the hello-world publisher (host side) -# --------------------------------------------------------------------------- -echo "" -echo "=== Starting hello world publisher (ros2cli snap) ===" -ros2cli.ros2 topic pub -r 1 /hello_world std_msgs/msg/String \ - "data: 'hello world'" > /tmp/pub_hello.log 2>&1 & -PUB_PID=$! -echo " Publisher PID: $PUB_PID" -sleep 3 # let DDS participant discovery complete - -# --------------------------------------------------------------------------- -# Run the WebSocket reader -# Connects to the bridge, subscribes to /hello_world, decodes the CDR- -# serialized std_msgs/String and asserts it contains "hello". -# --------------------------------------------------------------------------- -echo "" -echo "=== Running WebSocket reader (timeout 20 s) ===" -set +e -"$VENV_DIR/bin/python3" "$TEST_DIR/ws_reader.py" \ - --url "ws://localhost:$WS_PORT" \ - --topic /hello_world \ - --timeout 20 -READER_EXIT=$? -set -e - -# --------------------------------------------------------------------------- -# Cleanup -# --------------------------------------------------------------------------- -echo "" -echo "=== Cleaning up ===" -kill "$BRIDGE_PID" "$PUB_PID" 2>/dev/null || true -sleep 1 -kill -9 "$BRIDGE_PID" "$PUB_PID" 2>/dev/null || true -wait "$BRIDGE_PID" "$PUB_PID" 2>/dev/null || true - -echo "" -echo "--- Bridge log (/tmp/pj_bridge.log) ---" -cat /tmp/pj_bridge.log - -echo "" -echo "--- Publisher log (/tmp/pub_hello.log) ---" -cat /tmp/pub_hello.log - -# --------------------------------------------------------------------------- -# Result -# --------------------------------------------------------------------------- -echo "" -if [ "$READER_EXIT" -eq 0 ]; then - echo "=== All smoke tests PASSED ===" -else - echo "FAIL: ws_reader.py exited $READER_EXIT — 'hello world' was not received through the bridge" - exit 1 -fi diff --git a/tests/integration/test_client.py b/tests/integration/test_client.py deleted file mode 100755 index 1f91564..0000000 --- a/tests/integration/test_client.py +++ /dev/null @@ -1,490 +0,0 @@ -#!/usr/bin/env python3 -""" -ROS2 Bridge Test Client (WebSocket) - -This script connects to the pj_ros_bridge server over WebSocket and allows -testing of all API operations including topic discovery, subscription, and -receiving aggregated messages. - -Dependencies: - pip install websocket-client zstandard -""" - -import argparse -import json -import struct -import sys -import threading -import time -from collections import defaultdict, deque -from datetime import datetime - -import websocket -import zstandard as zstd - - -class BridgeClient: - """Client for pj_ros_bridge server over WebSocket""" - - # Binary frame header constants - HEADER_SIZE = 16 - MAGIC_PJRB = 0x42524A50 # "PJRB" in ASCII (little-endian) - - def __init__(self, host="localhost", port=9090): - """ - Initialize the bridge client - - Args: - host: Server hostname - port: Server WebSocket port - """ - self.url = f"ws://{host}:{port}" - self.ws = None - - # Buffer for binary frames received while waiting for a text response - self._pending_binary = deque() - - # Lock for send operations (recv is single-threaded from main loop) - self._send_lock = threading.Lock() - - # Heartbeat thread - self.heartbeat_thread = None - self.heartbeat_running = False - - # Statistics - self.stats = defaultdict(lambda: {"count": 0, "bytes": 0}) - self.start_time = None - - # ZSTD decompressor - self.decompressor = zstd.ZstdDecompressor() - - def connect(self): - """Connect to the bridge server""" - self.ws = websocket.create_connection(self.url) - print(f"Connected to WebSocket server: {self.url}") - self.start_time = time.time() - - def disconnect(self): - """Disconnect from the server""" - self.stop_heartbeat() - - if self.ws: - self.ws.close() - - print("Disconnected from server") - - def send_request(self, command_data): - """ - Send a JSON request and wait for the JSON response. - - Any binary frames received while waiting are buffered for later - retrieval via receive_messages(). - - Args: - command_data: Dictionary containing the request - - Returns: - Dictionary containing the response - """ - with self._send_lock: - self.ws.send(json.dumps(command_data)) - - # Read frames until we get a text response - while True: - opcode, data = self.ws.recv_data() - if opcode == websocket.ABNF.OPCODE_TEXT: - return json.loads(data.decode("utf-8")) - elif opcode == websocket.ABNF.OPCODE_BINARY: - # Buffer binary data for later - self._pending_binary.append(data) - - def get_topics(self): - """ - Get list of available topics from the server - - Returns: - List of topic dictionaries with 'name' and 'type' keys - """ - request = {"command": "get_topics"} - response = self.send_request(request) - - if response.get("status") == "success": - return response.get("topics", []) - else: - error_msg = response.get("message", "Unknown error") - raise Exception(f"Failed to get topics: {error_msg}") - - def subscribe(self, topics): - """ - Subscribe to topics - - Args: - topics: List of topic names to subscribe to - - Returns: - Dictionary mapping topic names to schema objects. - Each schema object has: - - "encoding": Schema encoding (e.g., "ros2msg") - - "definition": The actual schema definition string - """ - request = {"command": "subscribe", "topics": topics} - response = self.send_request(request) - - if response.get("status") in ("success", "partial_success"): - return response.get("schemas", {}) - else: - error_msg = response.get("message", "Unknown error") - raise Exception(f"Failed to subscribe: {error_msg}") - - def send_heartbeat(self): - """Send a heartbeat to the server (thread-safe for send, but not recv)""" - with self._send_lock: - self.ws.send(json.dumps({"command": "heartbeat"})) - # Note: response will be picked up by the main recv loop - - def start_heartbeat(self, interval=1.0): - """ - Start heartbeat thread. - - The heartbeat thread only sends; responses are consumed by the - main receive loop and silently discarded. - - Args: - interval: Heartbeat interval in seconds (default: 1.0) - """ - self.heartbeat_running = True - - def heartbeat_loop(): - while self.heartbeat_running: - try: - self.send_heartbeat() - except Exception as e: - print(f"Heartbeat error: {e}") - time.sleep(interval) - - self.heartbeat_thread = threading.Thread(target=heartbeat_loop, daemon=True) - self.heartbeat_thread.start() - print(f"Heartbeat thread started (interval: {interval}s)") - - def stop_heartbeat(self): - """Stop heartbeat thread""" - if self.heartbeat_running: - self.heartbeat_running = False - if self.heartbeat_thread: - self.heartbeat_thread.join(timeout=2.0) - print("Heartbeat thread stopped") - - def deserialize_aggregated_messages(self, data): - """ - Deserialize aggregated messages from binary format (streaming, no header). - - Format per message: - - Topic name length (uint16_t) - - Topic name (N bytes UTF-8) - - Timestamp (uint64_t nanoseconds) - - Message data length (uint32_t) - - Message data (N bytes) - - Args: - data: Decompressed binary data - - Returns: - List of message dictionaries - """ - messages = [] - offset = 0 - - while offset < len(data): - if offset + 2 > len(data): - break - - # Topic name length - topic_len = struct.unpack("10} {'Bytes':>12} {'Rate (Hz)':>12}") - print(f"{'-' * 80}") - - total_count = 0 - total_bytes = 0 - - for topic in sorted(self.stats.keys()): - count = self.stats[topic]["count"] - bytes_val = self.stats[topic]["bytes"] - rate = count / elapsed if elapsed > 0 else 0 - - print(f"{topic:<40} {count:>10} {bytes_val:>12} {rate:>12.2f}") - - total_count += count - total_bytes += bytes_val - - print(f"{'-' * 80}") - total_rate = total_count / elapsed if elapsed > 0 else 0 - print(f"{'TOTAL':<40} {total_count:>10} {total_bytes:>12} {total_rate:>12.2f}") - print(f"{'=' * 80}\n") - - -def main(): - """Main entry point""" - parser = argparse.ArgumentParser(description="pj_ros_bridge test client (WebSocket)") - - parser.add_argument( - "--server", - default="localhost", - help="Server hostname (default: localhost)", - ) - parser.add_argument( - "--port", - type=int, - default=9090, - help="WebSocket port (default: 9090)", - ) - parser.add_argument( - "--command", - choices=["get_topics", "subscribe"], - help="Command to execute", - ) - parser.add_argument( - "--subscribe", - nargs="+", - metavar="TOPIC", - help="Topics to subscribe to", - ) - parser.add_argument( - "--duration", - type=float, - default=10.0, - help="Duration to receive messages in seconds (default: 10)", - ) - parser.add_argument( - "--verbose", - action="store_true", - help="Print each received message", - ) - - args = parser.parse_args() - - # Create client - client = BridgeClient(args.server, args.port) - - try: - # Connect to server - client.connect() - - # Execute command - if args.command == "get_topics" or (args.command is None and args.subscribe is None): - # Get and display topics - print("\nQuerying available topics...") - topics = client.get_topics() - - print(f"\nAvailable topics ({len(topics)}):") - print(f"{'Topic Name':<50} {'Type':<50}") - print("-" * 100) - for topic in topics: - print(f"{topic['name']:<50} {topic['type']:<50}") - - elif args.subscribe or args.command == "subscribe": - # Subscribe to topics - topics = args.subscribe if args.subscribe else [] - - if not topics: - print("Error: No topics specified for subscription") - return 1 - - print(f"\nSubscribing to {len(topics)} topic(s)...") - schemas = client.subscribe(topics) - - print(f"Successfully subscribed to {len(schemas)} topic(s)") - for topic, schema_obj in schemas.items(): - # Handle both old (string) and new (object) schema formats - if isinstance(schema_obj, dict): - encoding = schema_obj.get("encoding", "unknown") - definition = schema_obj.get("definition", "") - def_preview = definition[:50] + "..." if len(definition) > 50 else definition - def_preview = def_preview.replace("\n", " ") - print(f" - {topic} (encoding={encoding}, schema={def_preview})") - else: - # Legacy string format - print(f" - {topic}") - - # Start heartbeat - client.start_heartbeat() - - # Receive messages - print(f"\nReceiving messages for {args.duration} seconds...") - print("Press Ctrl+C to stop\n") - - start_time = time.time() - msg_count = 0 - - try: - while time.time() - start_time < args.duration: - messages = client.receive_messages(timeout=0.1) - - if messages: - msg_count += len(messages) - - if args.verbose: - for msg in messages: - now_ns = int(time.time() * 1_000_000_000) - latency_ns = now_ns - msg["timestamp_ns"] - latency_ms = latency_ns / 1_000_000 - - print( - f"[{datetime.now().strftime('%H:%M:%S.%f')[:-3]}] " - f"{msg['topic']}: {len(msg['data'])} bytes, " - f"latency: {latency_ms:.2f} ms" - ) - - except KeyboardInterrupt: - print("\n\nInterrupted by user") - - # Print statistics - client.print_statistics() - - except Exception as e: - print(f"Error: {e}") - return 1 - - finally: - client.disconnect() - - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests/integration/ws_reader.py b/tests/integration/ws_reader.py deleted file mode 100755 index c54e306..0000000 --- a/tests/integration/ws_reader.py +++ /dev/null @@ -1,243 +0,0 @@ -#!/usr/bin/env python3 -""" -WebSocket reader for the pj_bridge smoke test. - -Connects to a running pj-bridge snap on ws://localhost:9090, subscribes to -/hello_world, receives binary frames from the bridge, ZSTD-decompresses them, -parses the CDR-serialized std_msgs/String payload, and asserts the data -contains "hello". - -Protocol reference: plotjuggler_bridge/docs/API.md - -Binary frame layout (16-byte header, little-endian): - [0:4] magic = 0x42524A50 ("PJRB") - [4:8] message_count - [8:12] uncompressed_size - [12:16] flags (reserved, 0) -Followed by a ZSTD-compressed payload. - -Payload — for each message: - uint16 topic_name_len - bytes topic_name (UTF-8) - uint64 timestamp (nanoseconds) - uint32 msg_data_len - bytes msg_data (CDR-serialized) - -std_msgs/String CDR layout (little-endian, eProsima ROS2 convention): - [0:2] 0x00 0x01 (CDR representation ID — little-endian) - [2:4] 0x00 0x00 (padding) - [4:8] uint32 string_len (includes null terminator) - [8:] string bytes + null terminator - -Usage: - pip install websockets zstandard - python3 ws_reader.py [--url ws://localhost:9090] [--topic /hello_world] [--timeout 20] -""" - -import argparse -import asyncio -import json -import struct -import sys -import time - -try: - import websockets -except ImportError: - sys.exit("ERROR: 'websockets' not installed — run: pip install websockets") - -try: - import zstandard as zstd -except ImportError: - sys.exit("ERROR: 'zstandard' not installed — run: pip install zstandard") - - -# Binary frame magic ("PJRB" in little-endian uint32) -_MAGIC = 0x42524A50 - - -def _parse_header(data: bytes) -> tuple[int, int, int] | None: - """Return (message_count, uncompressed_size, flags) or None if invalid.""" - if len(data) < 16: - return None - magic, msg_count, uncompressed_size, flags = struct.unpack_from(" bytes: - dctx = zstd.ZstdDecompressor() - # max_output hint is optional; zstandard infers size from the ZSTD frame header - return dctx.decompress(data, max(max_output, 1)) - - -def _parse_payload(payload: bytes, msg_count: int, target_topic: str) -> str | None: - """ - Walk the payload and return the decoded string for target_topic, or None. - """ - offset = 0 - for _ in range(msg_count): - if offset + 2 > len(payload): - break - name_len = struct.unpack_from(" len(payload): - break - topic_name = payload[offset : offset + name_len].decode("utf-8", errors="replace") - offset += name_len - - if offset + 8 > len(payload): - break - # timestamp (uint64, nanoseconds) — skip - offset += 8 - - if offset + 4 > len(payload): - break - msg_len = struct.unpack_from(" len(payload): - break - msg_data = payload[offset : offset + msg_len] - offset += msg_len - - if topic_name == target_topic: - return _decode_ros2_string(msg_data) - - return None - - -def _decode_ros2_string(data: bytes) -> str | None: - """ - Decode a CDR-serialized std_msgs/String. - - Layout (little-endian ROS 2 CDR): - [0:4] CDR header (0x00 0x01 0x00 0x00 for little-endian) - [4:8] uint32 length (includes null terminator) - [8..] utf-8 bytes + null terminator - """ - if len(data) < 8: - return None - # Bytes 0-3: CDR encapsulation header — skip - str_len = struct.unpack_from(" bool: - """ - Connect to ws_url, subscribe to topic, wait up to timeout seconds for a - message containing "hello". Returns True on success. - """ - print(f"Connecting to {ws_url} …") - async with websockets.connect(ws_url, open_timeout=10) as ws: - print("Connected.") - - # Background heartbeat — required every 1 s or server times out in 10 s. - # Started AFTER the initial handshake to avoid heartbeat ACKs racing - # with get_topics / subscribe responses on ws.recv(). - async def _heartbeat(): - seq = 0 - while True: - await ws.send(json.dumps({"command": "heartbeat", "id": f"hb{seq}"})) - seq += 1 - await asyncio.sleep(1.0) - - hb_task: asyncio.Task | None = None - - try: - # 1. Discover available topics - await ws.send(json.dumps({"command": "get_topics", "id": "gt1"})) - raw = await asyncio.wait_for(ws.recv(), timeout=10.0) - resp = json.loads(raw) - available = [t["name"] for t in resp.get("topics", [])] - print(f"Available topics ({len(available)}): {available}") - - if topic not in available: - print(f" WARN: {topic!r} not yet in topic list — will subscribe anyway " - f"(bridge may forward it once the publisher appears)") - - # 2. Subscribe - await ws.send(json.dumps({"command": "subscribe", "topics": [topic], "id": "s1"})) - raw = await asyncio.wait_for(ws.recv(), timeout=10.0) - resp = json.loads(raw) - status = resp.get("status", "") - if status not in ("success", "partial_success"): - # If topic not yet visible, that is ok — keep waiting for binary - print(f" Subscription response: {resp}") - else: - schemas = resp.get("schemas", {}) - print(f" Subscribed. Schema keys: {list(schemas.keys())}") - - # Start heartbeat only after handshake is complete - hb_task = asyncio.create_task(_heartbeat()) - - # 3. Wait for a binary frame containing our topic - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - remaining = deadline - time.monotonic() - try: - frame = await asyncio.wait_for(ws.recv(), timeout=min(2.0, remaining)) - except asyncio.TimeoutError: - continue - - if isinstance(frame, str): - # Heartbeat ack or other JSON — ignore - continue - - # Binary frame - header = _parse_header(frame) - if header is None: - print(f" WARN: received binary frame with invalid header " - f"({len(frame)} bytes), skipping") - continue - - msg_count, uncompressed_size, _ = header - try: - payload = _decompress(frame[16:], uncompressed_size) - except Exception as exc: - print(f" WARN: ZSTD decompress failed: {exc}") - continue - - value = _parse_payload(payload, msg_count, topic) - if value is not None: - print(f" Decoded message on {topic!r}: {value!r}") - if "hello" in value.lower(): - print("PASS: 'hello world' received and decoded through the bridge!") - return True - else: - print(f" WARN: message received but does not contain 'hello': {value!r}") - - print(f"FAIL: timed out after {timeout:.0f}s waiting for a message on {topic!r}") - return False - - finally: - if hb_task is not None: - hb_task.cancel() - await asyncio.gather(hb_task, return_exceptions=True) - - -def main(): - parser = argparse.ArgumentParser(description="pj_bridge smoke-test WebSocket reader") - parser.add_argument("--url", default="ws://localhost:9090", - help="WebSocket URL of the running pj_bridge (default: ws://localhost:9090)") - parser.add_argument("--topic", default="/hello_world", - help="ROS 2 topic to verify (default: /hello_world)") - parser.add_argument("--timeout", type=float, default=20.0, - help="Seconds to wait for a message before failing (default: 20)") - args = parser.parse_args() - - ok = asyncio.run(run(args.url, args.topic, args.timeout)) - sys.exit(0 if ok else 1) - - -if __name__ == "__main__": - main() From f00423b393154710bba21c40c3613860387d6813 Mon Sep 17 00:00:00 2001 From: Guillaume Beuzeboc Date: Fri, 16 Oct 2026 15:06:17 +0200 Subject: [PATCH 20/25] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7391ad3..c3fe4f5 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ pj-bridge.pj-bridge-ros2 --ros-args -p port:=9090 ``` > **Note**: The snap bundles ROS2 Jazzy and uses UDP-only DDS transport automatically — -> no extra ROS2 installation is required on the machine running the bridge. +> no extra ROS 2 installation is required on the machine running the bridge. The snap is built, tested and published by CI via the [canonical/robotics-actions-workflows](https://github.com/canonical/robotics-actions-workflows) From 00de68aa50b976c02c0fa893f2765ed5ff8d3aea Mon Sep 17 00:00:00 2001 From: Guillaumebeuzeboc Date: Fri, 16 Oct 2026 16:20:04 +0200 Subject: [PATCH 21/25] comments from the pr --- .github/workflows/snap.yaml | 13 +------------ README.md | 17 ++--------------- snap/snapcraft.yaml | 7 ++----- 3 files changed, 5 insertions(+), 32 deletions(-) diff --git a/.github/workflows/snap.yaml b/.github/workflows/snap.yaml index e942d90..d3d4fb1 100644 --- a/.github/workflows/snap.yaml +++ b/.github/workflows/snap.yaml @@ -5,7 +5,7 @@ # Promotion jazzy/candidate -> jazzy/stable lives in snap-promote.yaml. # # SETUP: store the credentials from -# snapcraft export-login --snaps pj-bridge --channels edge,candidate - +# snapcraft export-login --snaps pj-bridge # as the SNAPSTORE_LOGIN repository secret. # ============================================================================= @@ -46,24 +46,13 @@ jobs: https://github.com/vi/websocat/releases/download/v1.13.0/websocat.x86_64-unknown-linux-musl sudo chmod +x /usr/local/bin/websocat - # UDP-only FastRTPS profile so the publisher snap's DDS crosses the - # bridge snap namespace boundary. Must be a non-hidden path so the - # ros2-cli snap can read it through the 'home' interface. - cp snap/local/fastrtps_no_shm.xml "$HOME/fastrtps_no_shm.xml" - export FASTRTPS_DEFAULT_PROFILES_FILE="$HOME/fastrtps_no_shm.xml" - pj-bridge.pj-bridge-ros2 --ros-args -p port:=9090 \ > /tmp/pj_bridge.log 2>&1 & BRIDGE_PID=$! - sleep 5 - if ! kill -0 "$BRIDGE_PID" 2>/dev/null; then - echo "ERROR: bridge exited immediately"; cat /tmp/pj_bridge.log; exit 1 - fi ros2-cli.ros2 topic pub -r 1 /hello_world std_msgs/msg/String \ "data: 'hello world'" > /tmp/pub_hello.log 2>&1 & PUB_PID=$! - sleep 4 # Drive a single subscribe over the WebSocket (no payload parsing). echo '{"command":"subscribe","topics":["/hello_world"],"id":"s1"}' \ diff --git a/README.md b/README.md index c3fe4f5..d6648be 100644 --- a/README.md +++ b/README.md @@ -99,21 +99,8 @@ sudo snap install pj-bridge pj-bridge.pj-bridge-ros2 --ros-args -p port:=9090 ``` -> **Note**: The snap bundles ROS2 Jazzy and uses UDP-only DDS transport automatically — -> no extra ROS 2 installation is required on the machine running the bridge. - -The snap is built, tested and published by CI via the -[canonical/robotics-actions-workflows](https://github.com/canonical/robotics-actions-workflows) -reusable workflows (`.github/workflows/snap.yaml`): branch pushes release to -`jazzy/edge`, tags to `jazzy/candidate`, and `snap-promote.yaml` is a manual -gate to `jazzy/stable`. Publishing needs a `SNAPSTORE_LOGIN` repository secret: - -```bash -snapcraft export-login --snaps pj-bridge --channels edge,candidate - -``` - -The end-to-end test publishes with the `ros2cli` snap (no host ROS install) and -runs as the `snap-test-script` in `.github/workflows/snap.yaml`. +The snap bundles ROS 2 Jazzy and uses UDP-only DDS transport automatically. +No extra ROS 2 installation is required on the machine running the bridge. ### AppImage diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 46434df..48e9184 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -16,8 +16,8 @@ description: > confinement: strict base: core24 -# ROS2 typesupport and middleware libraries are loaded via dlopen at runtime -# by the ROS2 plugin system; the library linter cannot detect this. +# ROS 2 typesupport and middleware libraries are loaded via dlopen at runtime +# by the ROS 2 plugin system; the library linter cannot detect this. lint: ignore: - library @@ -34,9 +34,6 @@ parts: if echo "$version" | grep -q '+git'; then grade=devel; else grade=stable; fi craftctl set version="$version" craftctl set grade="$grade" - # ROS deps come from package.xml via rosdep (colcon plugin + ros2-jazzy - # extension). Only list what package.xml does not: git (override-pull) and - # the IXWebSocket TLS/zlib build deps pulled in via FetchContent. build-packages: - git - libssl-dev From e8f48ae8e2759cc659166ff91629aa2a9a95b497 Mon Sep 17 00:00:00 2001 From: Guillaumebeuzeboc Date: Fri, 16 Oct 2026 16:33:55 +0200 Subject: [PATCH 22/25] ci: retry websocket subscribe --- .github/workflows/snap.yaml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/snap.yaml b/.github/workflows/snap.yaml index d3d4fb1..0880905 100644 --- a/.github/workflows/snap.yaml +++ b/.github/workflows/snap.yaml @@ -54,9 +54,15 @@ jobs: "data: 'hello world'" > /tmp/pub_hello.log 2>&1 & PUB_PID=$! - # Drive a single subscribe over the WebSocket (no payload parsing). - echo '{"command":"subscribe","topics":["/hello_world"],"id":"s1"}' \ - | timeout 5 websocat ws://localhost:9090 || true + # Retry while the WebSocket starts up and DDS discovers the topic. + for attempt in {1..10}; do + echo '{"command":"subscribe","topics":["/hello_world"],"id":"s1"}' \ + | timeout 5 websocat ws://localhost:9090 || true + if grep -q "subscribed to topic '/hello_world'" /tmp/pj_bridge.log; then + break + fi + sleep 1 + done set +e grep -q "subscribed to topic '/hello_world'" /tmp/pj_bridge.log From ff22de71ab710307995a8bee4c1dbbadacef00e7 Mon Sep 17 00:00:00 2001 From: Guillaumebeuzeboc Date: Mon, 19 Oct 2026 16:32:10 +0200 Subject: [PATCH 23/25] feat: add ROS 2 messages for the strictly confined snap --- README.md | 2 +- snap/local/CMakeLists.txt | 5 ++ snap/local/package.xml | 143 ++++++++++++++++++++++++++++++++++++++ snap/snapcraft.yaml | 12 +++- 4 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 snap/local/CMakeLists.txt create mode 100644 snap/local/package.xml diff --git a/README.md b/README.md index d6648be..58788e9 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ Pre-built snaps are available from the [Snap Store](https://snapcraft.io/pj-brid ```bash sudo snap install pj-bridge -# Run (add arguments if different from default) +# Run pj-bridge.pj-bridge-ros2 --ros-args -p port:=9090 ``` diff --git a/snap/local/CMakeLists.txt b/snap/local/CMakeLists.txt new file mode 100644 index 0000000..aad2c68 --- /dev/null +++ b/snap/local/CMakeLists.txt @@ -0,0 +1,5 @@ +cmake_minimum_required(VERSION 3.14) +project(pj_bridge_snap) + +find_package(ament_cmake REQUIRED) +ament_package() diff --git a/snap/local/package.xml b/snap/local/package.xml new file mode 100644 index 0000000..86ca212 --- /dev/null +++ b/snap/local/package.xml @@ -0,0 +1,143 @@ + + + + pj_bridge_snap + 0.0.0 + Additional runtime middleware and interfaces for the pj-bridge snap + ubuntu-robotics + AGPL-3.0 + + ament_cmake + + + rmw_cyclonedds_cpp + rmw_fastrtps_cpp + + ackermann_msgs + action_msgs + actionlib_msgs + automotive_autonomy_msgs + automotive_navigation_msgs + automotive_platform_msgs + autoware_auto_msgs + can_msgs + cartographer_ros_msgs + cascade_lifecycle_msgs + control_msgs + controller_manager_msgs + depthai_ros_msgs + diagnostic_msgs + dwb_msgs + dynamixel_workbench_msgs + four_wheel_steering_msgs + foxglove_msgs + gazebo_msgs + geographic_msgs + geometry_msgs + gps_msgs + graph_msgs + grasping_msgs + grbl_msgs + grid_map_msgs + irobot_create_msgs + leo_msgs + lgsvl_msgs + lifecycle_msgs + map_msgs + marti_can_msgs + marti_common_msgs + marti_dbw_msgs + marti_introspection_msgs + marti_nav_msgs + marti_perception_msgs + marti_sensor_msgs + marti_status_msgs + marti_visualization_msgs + mavros_msgs + micro_ros_diagnostic_msgs + micro_ros_msgs + microstrain_inertial_msgs + moveit_msgs + mrpt_msgs + nao_command_msgs + nao_sensor_msgs + nav_2d_msgs + nav_msgs + nav2_msgs + nmea_msgs + novatel_gps_msgs + object_recognition_msgs + octomap_msgs + pal_statistics_msgs + pcl_msgs + pendulum_msgs + phidgets_msgs + plansys2_msgs + plotjuggler_msgs + radar_msgs + rc_common_msgs + rc_reason_msgs + rclpy_message_converter_msgs + rcss3d_agent_msgs + realsense2_camera_msgs + rmf_api_msgs + rmf_building_map_msgs + rmf_charger_msgs + rmf_dispenser_msgs + rmf_door_msgs + rmf_fleet_msgs + rmf_ingestor_msgs + rmf_lift_msgs + rmf_obstacle_msgs + rmf_scheduler_msgs + rmf_site_map_msgs + rmf_task_msgs + rmf_traffic_msgs + rmf_visualization_msgs + rmf_workcell_msgs + robot_calibration_msgs + rosapi_msgs + rosbridge_msgs + rosgraph_msgs + rviz_2d_overlay_msgs + sensor_msgs + shape_msgs + smacc2_msgs + soccer_vision_2d_msgs + soccer_vision_3d_msgs + soccer_vision_attribute_msgs + statistics_msgs + std_msgs + stereo_msgs + system_modes_msgs + teleop_tools_msgs + tf2_msgs + trajectory_msgs + turtlebot3_msgs + ublox_msgs + ublox_ubx_msgs + udp_msgs + unique_identifier_msgs + ur_dashboard_msgs + ur_msgs + urg_node_msgs + velodyne_msgs + vision_msgs + vision_msgs_layers + visualization_msgs + webots_ros2_msgs + wiimote_msgs + + + builtin_interfaces + composition_interfaces + example_interfaces + rcl_interfaces + ros_gz_interfaces + rosbag2_interfaces + type_description_interfaces + + + ament_cmake + + diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 48e9184..839acf8 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -39,12 +39,22 @@ parts: - libssl-dev - zlib1g-dev + snap-dependencies: + # A separate manifest keeps the broad runtime dependencies (msgs & rmw) + plugin: colcon + source: snap/local + after: [pj_bridge] + colcon-packages: [pj_bridge_snap] + stage: + - -opt/ros/snap/local_setup.sh + - -opt/ros/snap/setup.sh + fastrtps-config: plugin: dump source: snap/local organize: fastrtps_no_shm.xml: etc/pj-bridge/fastrtps_no_shm.xml - prime: + stage: - etc/pj-bridge/fastrtps_no_shm.xml apps: From 8e7401b08699e7b5efdfd812fc74781f28e07d39 Mon Sep 17 00:00:00 2001 From: Guillaumebeuzeboc Date: Mon, 19 Oct 2026 16:43:54 +0200 Subject: [PATCH 24/25] fix: update snapcraft description --- snap/snapcraft.yaml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 839acf8..5b4c7df 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -9,10 +9,15 @@ issues: https://github.com/PlotJuggler/plotjuggler_bridge/issues source-code: https://github.com/PlotJuggler/plotjuggler_bridge website: https://github.com/PlotJuggler/plotjuggler_bridge description: > - ROS2 bridge server that forwards middleware topic content over WebSocket to + ROS 2 bridge server that forwards middleware topic content over WebSocket to PlotJuggler clients and any other WebSocket consumer. + Clients subscribe to topics and receive ZSTD-compressed aggregated messages at 50 Hz without needing a full middleware installation. + + Call the snap with: + pj-bridge.pj-bridge-ros2 --ros-args -p port:=9090 + confinement: strict base: core24 From 8d330f4665ce3245c1599135d7020273ed35ffda Mon Sep 17 00:00:00 2001 From: Guillaumebeuzeboc Date: Mon, 19 Oct 2026 17:06:37 +0200 Subject: [PATCH 25/25] ci: build on ARM as well the CI --- .github/workflows/snap.yaml | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/snap.yaml b/.github/workflows/snap.yaml index 0880905..391ff87 100644 --- a/.github/workflows/snap.yaml +++ b/.github/workflows/snap.yaml @@ -33,6 +33,7 @@ jobs: secrets: snapstore-login: ${{ secrets.SNAPSTORE_LOGIN }} with: + runs-on: '["ubuntu-latest", "ubuntu-24.04-arm"]' snap-track: jazzy # Packaging check: start the bridge snap, have a ros2-cli snap publish a # topic, drive one WebSocket subscribe, and confirm the bridge served it. @@ -41,9 +42,14 @@ jobs: set -euxo pipefail sudo snap install ros2-cli --channel jazzy - # websocat has no snap; grab the pinned static musl binary. + # websocat has no snap; grab the pinned static musl binary for this runner. + case "$(uname -m)" in + x86_64) websocat_arch=x86_64 ;; + aarch64) websocat_arch=aarch64 ;; + *) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;; + esac sudo curl -fsSL -o /usr/local/bin/websocat \ - https://github.com/vi/websocat/releases/download/v1.13.0/websocat.x86_64-unknown-linux-musl + "https://github.com/vi/websocat/releases/download/v1.13.0/websocat.${websocat_arch}-unknown-linux-musl" sudo chmod +x /usr/local/bin/websocat pj-bridge.pj-bridge-ros2 --ros-args -p port:=9090 \ @@ -69,7 +75,7 @@ jobs: TEST_EXIT=$? set -e - kill "$BRIDGE_PID" "$PUB_PID" 2>/dev/null || true + kill "${BRIDGE_PID}" "${PUB_PID}" 2>/dev/null || true echo "--- Bridge log ---"; cat /tmp/pj_bridge.log echo "--- Publisher log ---"; cat /tmp/pub_hello.log - exit "$TEST_EXIT" + exit "${TEST_EXIT}"