diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 588666e..e39e9ac 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -163,3 +163,139 @@ jobs:
if: always()
working-directory: demos/multi_ecu_aggregation
run: docker compose --profile ci down
+
+ build-and-test-ota:
+ needs: lint
+ runs-on: ubuntu-24.04
+ steps:
+ - name: Show triggering source
+ if: github.event_name == 'repository_dispatch'
+ run: |
+ SHA="${{ github.event.client_payload.sha }}"
+ RUN_URL="${{ github.event.client_payload.run_url }}"
+ echo "## Triggered by ros2_medkit" >> "$GITHUB_STEP_SUMMARY"
+ echo "- Commit: \`${SHA:-unknown}\`" >> "$GITHUB_STEP_SUMMARY"
+ if [ -n "$RUN_URL" ]; then
+ echo "- Run: [View triggering run]($RUN_URL)" >> "$GITHUB_STEP_SUMMARY"
+ else
+ echo "- Run: (URL not provided)" >> "$GITHUB_STEP_SUMMARY"
+ fi
+
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Build and start OTA demo
+ working-directory: demos/ota_nav2_sensor_fix
+ run: docker compose up -d --build
+
+ - name: Run smoke tests
+ run: ./tests/smoke_test_ota.sh
+
+ - name: Diagnostics on failure
+ if: failure()
+ run: ./tests/dump_ota_diagnostics.sh ota_diagnostics.log
+
+ - name: Upload diagnostics
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: ota-diagnostics-smoke
+ path: ota_diagnostics.log
+
+ - name: Teardown
+ if: always()
+ working-directory: demos/ota_nav2_sensor_fix
+ run: docker compose down
+
+ # Actually runs the OTA demo's own test suites (they never run in the demo
+ # up/smoke jobs). The C++ gtest is built and executed inside the plugin test
+ # stage of Dockerfile.gateway (a failing test fails the docker build); the two
+ # pytest suites run natively.
+ plugin-tests:
+ needs: lint
+ runs-on: ubuntu-24.04
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Run ota_update_plugin C++ gtest (in image build)
+ working-directory: demos/ota_nav2_sensor_fix
+ run: docker build --target ota-plugin-test -f Dockerfile.gateway .
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+
+ - name: Install Python test dependencies
+ run: pip install pytest fastapi 'uvicorn[standard]' httpx
+
+ - name: Run ota_update_server pytest suite
+ working-directory: demos/ota_nav2_sensor_fix/ota_update_server
+ run: pytest tests
+
+ - name: Run pack_artifact pytest suite
+ working-directory: demos/ota_nav2_sensor_fix/scripts
+ run: pytest test_pack_artifact.py
+
+ # Separate job from build-and-test-ota: this one drives the full
+ # latch/publish/apply/clear narrative through the operator scripts. The
+ # entrypoint auto-applies broken_lidar_3_0_0 at boot; send-goal.sh then
+ # drives the robot into the phantom sector so Nav2 cannot make progress
+ # and navigate_to_pose aborts, which the log + action-status bridges
+ # surface as ACTION_NAVIGATE_TO_POSE_ABORTED on bt-navigator (latched,
+ # with a freeze-frame + MCAP capture). publish-fix.sh registers the
+ # forward hotfix fixed_lidar_3_0_1 (not in the boot catalog), then
+ # apply-fix.sh swaps scan_sensor_node to it, but the fault stays
+ # latched (no self-heal) until clear-fault.sh explicitly clears it, and
+ # only then does a fresh send-goal.sh resume clean. Catches regressions
+ # in that loop (phantom not stalling Nav2, the bridges not promoting the
+ # failure, fault_manager latching/capture, the fault clearing itself on
+ # apply). Slower than the API-only smoke job because it has
+ # to wait for nav2 lifecycle to settle and for /cmd_vel to actually
+ # fire, so it's split out and can fail in isolation without blocking
+ # the quick OTA-endpoint check.
+ ota-demo-narrative:
+ needs: lint
+ runs-on: ubuntu-24.04
+ steps:
+ - name: Show triggering source
+ if: github.event_name == 'repository_dispatch'
+ run: |
+ SHA="${{ github.event.client_payload.sha }}"
+ RUN_URL="${{ github.event.client_payload.run_url }}"
+ echo "## Triggered by ros2_medkit" >> "$GITHUB_STEP_SUMMARY"
+ echo "- Commit: \`${SHA:-unknown}\`" >> "$GITHUB_STEP_SUMMARY"
+ if [ -n "$RUN_URL" ]; then
+ echo "- Run: [View triggering run]($RUN_URL)" >> "$GITHUB_STEP_SUMMARY"
+ fi
+
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Build and start OTA demo
+ working-directory: demos/ota_nav2_sensor_fix
+ # docker compose up --build runs the multi-stage build for
+ # ota_update_server which produces the catalog + tarballs
+ # internally - no separate "build artifacts on host" step
+ # needed (and the host wouldn't have ros2_medkit_msgs anyway).
+ run: docker compose up -d --build
+
+ - name: Run demo narrative smoke
+ run: ./tests/smoke_test_demo_narrative.sh
+
+ - name: Diagnostics on failure
+ if: failure()
+ run: ./tests/dump_ota_diagnostics.sh ota_diagnostics.log
+
+ - name: Upload diagnostics
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: ota-diagnostics-narrative
+ path: ota_diagnostics.log
+
+ - name: Teardown
+ if: always()
+ working-directory: demos/ota_nav2_sensor_fix
+ run: docker compose down
diff --git a/README.md b/README.md
index c21c948..72c0ea6 100644
--- a/README.md
+++ b/README.md
@@ -49,6 +49,7 @@ All demos support:
| [TurtleBot3 Integration](demos/turtlebot3_integration/) | Full ros2_medkit integration with TurtleBot3 and Nav2 | SOVD-compliant API, manifest-based discovery, fault management | ✅ Ready |
| [MoveIt Pick-and-Place](demos/moveit_pick_place/) | Panda 7-DOF arm with MoveIt 2 manipulation and ros2_medkit | Planning fault detection, controller monitoring, joint limits | ✅ Ready |
| [Multi-ECU Aggregation](demos/multi_ecu_aggregation/) | Multi-ECU peer aggregation with 3 ECUs (perception, planning, actuation), mDNS discovery, cross-ECU functions | Peer aggregation, mDNS discovery, cross-ECU functions | ✅ Ready |
+| [OTA over SOVD - nav2 sensor fix](demos/ota_nav2_sensor_fix/) | Dev-grade OTA plugin showing the SOVD `/updates` lifecycle - a bad lidar update breaks Nav2, publish + apply a forward hotfix over SOVD | SOVD-spec register + update, native binary swap, fork+exec process management, Foxglove panel + curl scripts | ✅ Ready |
### Quick Start
@@ -150,6 +151,37 @@ cd demos/multi_ecu_aggregation
- Unified SOVD-compliant REST API spanning all ECUs
- Web UI for browsing aggregated entity hierarchy
+#### OTA over SOVD Demo (Dev-grade Update / Publish-a-Hotfix)
+
+End-to-end demo of the SOVD `/updates` resource: a regressing sensor update
+(`broken_lidar_3_0_0`) is auto-applied at boot and breaks perception. Nav2
+cannot make progress and `navigate_to_pose` aborts; two generic bridges surface
+that failure as SOVD faults on `bt-navigator` and `controller-server` (the
+sensor node never reports itself). The operator downloads the captured MCAP,
+sees the phantom, publishes and applies the forward hotfix
+(`fixed_lidar_3_0_1`, not a rollback), and clears the latched fault - all
+without SSH, all spec-compliant.
+
+```bash
+cd demos/ota_nav2_sensor_fix
+./run-demo.sh # build artifacts + bring up gateway/plugin/update server
+./check-demo.sh # show registered updates + per-id status + live process state
+./send-goal.sh # drive into the phantom sector; Nav2 stalls, navigate_to_pose aborts
+./publish-fix.sh # register fixed_lidar_3_0_1 (SOVD POST /updates) - not in the boot catalog
+./apply-fix.sh # broken_lidar_3_0_0 -> fixed_lidar_3_0_1 (the headline: apply the published hotfix)
+./clear-fault.sh # operator clear of the latched bt-navigator/controller-server faults
+./stop-demo.sh
+```
+
+**Features:**
+
+- Dev-grade `ota_update_plugin` C++ gateway plugin (UpdateProvider + GatewayPlugin)
+- SOVD ISO 17978-3 compliant `/updates` resource: kind derived from
+ `updated_components` / `added_components` / `removed_components` metadata
+- Native binary swap + `fork+exec` process management (no containers, no signing)
+- Foxglove Studio panel mirrors the same SOVD client patterns as the web UI
+- Pairs with the [`ros2_medkit_foxglove_extension`](https://github.com/selfpatch/ros2_medkit_foxglove_extension) Updates panel
+
## Getting Started
### Prerequisites
@@ -209,9 +241,11 @@ Each demo has automated smoke tests that verify the gateway starts and the REST
./tests/smoke_test.sh # Sensor diagnostics (full API coverage + fault injection + beacons)
./tests/smoke_test_turtlebot3.sh # TurtleBot3 (discovery, data, operations, scripts, triggers, logs)
./tests/smoke_test_moveit.sh # MoveIt pick-and-place (discovery, data, operations, scripts, triggers, logs)
+./tests/smoke_test_multi_ecu.sh # Multi-ECU aggregation (per-ECU discovery + aggregated view)
+./tests/smoke_test_ota.sh # OTA over SOVD (catalog, /updates spec shape, prepare/execute, process swap)
```
-CI runs all 4 demos in parallel - each job builds the Docker image, starts the container, and runs the smoke tests against it. See [CI workflow](.github/workflows/ci.yml).
+CI runs all demos in parallel - each job builds the Docker image, starts the container, and runs the smoke tests against it. See [CI workflow](.github/workflows/ci.yml).
## Related Projects
diff --git a/demos/ota_nav2_sensor_fix/Dockerfile.gateway b/demos/ota_nav2_sensor_fix/Dockerfile.gateway
new file mode 100644
index 0000000..1c71475
--- /dev/null
+++ b/demos/ota_nav2_sensor_fix/Dockerfile.gateway
@@ -0,0 +1,250 @@
+# Copyright 2026 bburda
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Builds the ros2_medkit gateway, the ota_update_plugin, and the demo ROS 2
+# packages (including the RB-Theron AMR driving in the AWS warehouse under
+# Nav2) into a single ROS 2 Jazzy image. Plugin loads at gateway startup via
+# /etc/ros2_medkit/gateway_config.yaml. scan_sensor_node (fixed_lidar at
+# boot, broken_lidar once the entrypoint auto-applies the OTA regression)
+# owns /scan; the publish-then-apply hotfix flow swaps the process back at
+# runtime via the plugin.
+#
+# The gateway clone below is the FULL ros2_medkit workspace with no
+# --packages-select/--packages-up-to filter, so ros2_medkit_log_bridge and
+# ros2_medkit_action_status_bridge - the generic bridges demo.launch.py uses
+# to turn Nav2's own failure into SOVD faults - build and install
+# automatically alongside the gateway; no separate build step is needed for
+# them here.
+
+FROM ros:jazzy AS builder
+
+ARG GATEWAY_REPO=https://github.com/selfpatch/ros2_medkit.git
+ARG GATEWAY_REF=main
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ git \
+ python3-colcon-common-extensions \
+ python3-rosdep \
+ build-essential \
+ cmake \
+ curl \
+ ca-certificates \
+ && rm -rf /var/lib/apt/lists/*
+
+RUN rosdep init || true
+RUN rosdep update --rosdistro=jazzy
+
+WORKDIR /ws/src
+RUN git clone --depth=1 --branch ${GATEWAY_REF} ${GATEWAY_REPO} ros2_medkit
+
+# Fetch the Robotnik RB-Theron description + sensors at build time (pinned commits),
+# instead of vendoring their mesh-heavy trees in the repo (see THIRD_PARTY_NOTICES.md).
+# Both are BSD-3-Clause; each clone's own LICENSE travels with it. The unused base
+# meshes (128 MB for 9 other robots) are pruned so only the RB-Theron geometry the
+# demo renders ships in the image. The RB-Theron xacro pulls only the SICK
+# picoScan120, VectorNav IMU and RealSense D435 sensor macros, so robotnik_sensors is
+# kept whole (its mesh bulk IS those three sensors).
+ARG ROBOTNIK_DESC_REF=751059edd6af3a9c083018cfaee59e4496d46580
+ARG ROBOTNIK_SENS_REF=e5186c343910b86a924201edb256f79eb0f73295
+RUN git clone --filter=blob:none https://github.com/RobotnikAutomation/robotnik_description.git && \
+ git -C robotnik_description checkout --quiet "${ROBOTNIK_DESC_REF}" && \
+ rm -rf robotnik_description/.git && \
+ find robotnik_description/meshes/bases -mindepth 1 -maxdepth 1 -type d ! -name rbtheron -exec rm -rf {} + && \
+ git clone --filter=blob:none https://github.com/RobotnikAutomation/robotnik_sensors.git && \
+ git -C robotnik_sensors checkout --quiet "${ROBOTNIK_SENS_REF}" && \
+ rm -rf robotnik_sensors/.git
+
+# Copy demo packages (broken_lidar, fixed_lidar, ota_nav2_sensor_fix_demo)
+# and the OTA plugin from the build context.
+# ota_nav2_sensor_fix_demo/models/aws_small_warehouse/worlds/warehouse.sdf
+# is ours (a gz-port that references the AWS models below via model:// URIs); the AWS
+# meshes themselves are fetched next, not vendored.
+COPY ros2_packages /tmp/ros2_packages
+RUN cp -r /tmp/ros2_packages/. /ws/src/ && rm -rf /tmp/ros2_packages
+COPY ota_update_plugin /ws/src/ota_update_plugin
+
+# Fetch the AWS RoboMaker small_warehouse models at build time (pinned commit,
+# MIT-0; see THIRD_PARTY_NOTICES.md) into the demo package's model tree, instead of
+# vendoring their COLLADA meshes in the repo. The meshes are upstream-verbatim; our gz
+# port is worlds/warehouse.sdf (committed) - upstream ships file://models/ paths, gz
+# resolves model:// against GZ_SIM_RESOURCE_PATH, so we rewrite the scheme. A handful
+# of upstream models also carry inertia tensors that violate the triangle inequality
+# (ixx+iyy < izz etc.), which libsdformat 14.x rejects fatally ("A link named link has
+# invalid inertia." -> "Failed to load a world."). We rewrite only the offending
+# tensors to a valid uniform diagonal so the world loads; these are static fixtures
+# (floor, roof, shelves), so the exact inertia is immaterial to the demo.
+ARG AWS_WAREHOUSE_REF=ee0af733315e78432408c3cd98d378ecee5f767c
+# The inertia-fix script: rewrite only the inertia tensors that fail libsdformat 14.x
+# validation (non-positive principal moment, or triangle-inequality violation) to a
+# valid uniform diagonal. Written to a file so the multi-command RUN stays a clean
+# &&-chain (no heredoc mixed with line continuations).
+RUN printf '%s\n' \
+ 'import re, sys' \
+ 'VALID = "100000100001000"' \
+ 'def p(blk, k):' \
+ ' m = re.search(r"<%s>([-\d.eE]+)%s>" % (k, k), blk)' \
+ ' return float(m.group(1)) if m else None' \
+ 'def bad(blk):' \
+ ' ixx, iyy, izz = (p(blk, k) for k in ("ixx", "iyy", "izz"))' \
+ ' if None in (ixx, iyy, izz):' \
+ ' return False' \
+ ' if min(ixx, iyy, izz) <= 0:' \
+ ' return True' \
+ ' return ixx + iyy < izz or iyy + izz < ixx or ixx + izz < iyy' \
+ 'for f in sys.argv[1:]:' \
+ ' t = open(f).read()' \
+ ' new = re.sub(r".*?", lambda m: VALID if bad(m.group(0)) else m.group(0), t, flags=re.S)' \
+ ' if new != t:' \
+ ' open(f, "w").write(new)' \
+ ' print("fixed inertia in", f)' \
+ > /tmp/fix_inertia.py
+RUN cd /ws/src && \
+ git clone --filter=blob:none https://github.com/aws-robotics/aws-robomaker-small-warehouse-world.git aws_up && \
+ git -C aws_up checkout --quiet "${AWS_WAREHOUSE_REF}" && \
+ cp -r aws_up/models ota_nav2_sensor_fix_demo/models/aws_small_warehouse/models && \
+ sed -i 's#file://models/#model://#g' ota_nav2_sensor_fix_demo/models/aws_small_warehouse/models/*/model.sdf && \
+ python3 /tmp/fix_inertia.py ota_nav2_sensor_fix_demo/models/aws_small_warehouse/models/*/model.sdf && \
+ rm -rf aws_up
+
+WORKDIR /ws
+# rosdep needs the apt cache populated to install gateway dependencies
+# (nlohmann-json3-dev, libcpp-httplib-dev, etc.), plus xacro / ros-gz / robot_state_publisher
+# / gz_ros2_control / ros2_control / ros2_controllers / diff_drive_controller /
+# joint_state_broadcaster for the RB-Theron chain - resolved automatically from the
+# ota_nav2_sensor_fix_demo + robotnik_description + robotnik_sensors package.xml
+# exec_depends above, not a hardcoded apt list. robotnik_description also declares
+# exec_depend on ur_description / ur_simulation_gz (used by OTHER Robotnik robots we
+# don't build); those two rosdep keys don't resolve on Jazzy, so skip them - same fix
+# the upstream warehouse recipe applies. --dependency-types + -DBUILD_TESTING=OFF skip
+# robotnik's test-only deps (liburdfdom-tools, launch_testing_*) we don't need either.
+RUN apt-get update
+RUN . /opt/ros/jazzy/setup.sh && \
+ rosdep install --from-paths src --ignore-src -r -y --rosdistro=jazzy \
+ --dependency-types exec --dependency-types build --dependency-types buildtool \
+ --skip-keys ur_description \
+ --skip-keys ur_simulation_gz && \
+ colcon build \
+ --packages-up-to \
+ ros2_medkit_gateway ros2_medkit_fault_manager \
+ ros2_medkit_log_bridge ros2_medkit_action_status_bridge \
+ ota_update_plugin broken_lidar fixed_lidar health_check \
+ ota_nav2_sensor_fix_demo \
+ --cmake-args -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=OFF && \
+ rm -rf /var/lib/apt/lists/*
+
+
+# Test stage: rebuild ONLY the OTA plugin with tests enabled and run its gtest
+# suite (ament_add_gtest -> test_ota_update_plugin). Because it is FROM builder,
+# the heavy gateway + demo build above is reused as-is; only the plugin
+# recompiles with BUILD_TESTING=ON. A failing gtest makes colcon test-result
+# return non-zero, which fails this image build - CI drives it via
+# `docker build --target ota-plugin-test`. The runtime stage below is a
+# separate FROM and is unaffected; a full build/compose build (which targets
+# the final stage) never runs this stage.
+FROM builder AS ota-plugin-test
+WORKDIR /ws
+RUN . /opt/ros/jazzy/setup.sh && \
+ . /ws/install/setup.sh && \
+ colcon build --packages-select ota_update_plugin \
+ --cmake-args -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON && \
+ colcon test --packages-select ota_update_plugin \
+ --event-handlers console_direct+ && \
+ colcon test-result --verbose
+
+
+FROM ros:jazzy
+
+# Runtime dependencies. Beyond the gateway/plugin bare minimum we also pull in
+# Nav2, gz-sim, and the ros2_control / gz_ros2_control chain so the container
+# can self-host the visual demo (RB-Theron AMR + headless Gazebo + Nav2) - no
+# external sim required, the OTA story becomes "Foxglove sees a stuck robot,
+# run an update, robot unsticks". The RB-Theron description + sensors are
+# cloned from Robotnik in the builder stage; the AWS warehouse world is baked
+# there too, so no turtlebot3-* runtime packages are needed here.
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ ros-jazzy-rclcpp \
+ ros-jazzy-rclcpp-lifecycle \
+ ros-jazzy-sensor-msgs \
+ ros-jazzy-visualization-msgs \
+ ros-jazzy-launch-ros \
+ ros-jazzy-test-msgs \
+ ros-jazzy-foxglove-bridge \
+ ros-jazzy-nav2-bringup \
+ ros-jazzy-nav2-bt-navigator \
+ ros-jazzy-nav2-controller \
+ ros-jazzy-nav2-planner \
+ ros-jazzy-nav2-behaviors \
+ ros-jazzy-nav2-costmap-2d \
+ ros-jazzy-nav2-lifecycle-manager \
+ ros-jazzy-nav2-map-server \
+ ros-jazzy-nav2-amcl \
+ ros-jazzy-ros-gz-sim \
+ ros-jazzy-ros-gz-bridge \
+ ros-jazzy-rmw-cyclonedds-cpp \
+ ros-jazzy-rosbag2-cpp \
+ ros-jazzy-rosbag2-storage-default-plugins \
+ ros-jazzy-rosbag2-storage-mcap \
+ libcpp-httplib-dev \
+ libsystemd-dev \
+ nlohmann-json3-dev \
+ curl \
+ procps \
+ ros-jazzy-xacro \
+ ros-jazzy-robot-state-publisher \
+ ros-jazzy-gz-ros2-control \
+ ros-jazzy-ros2-control \
+ ros-jazzy-ros2-controllers \
+ ros-jazzy-controller-manager \
+ ros-jazzy-joint-state-broadcaster \
+ ros-jazzy-diff-drive-controller \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY --from=builder /ws/install /ws/install
+COPY gateway_config.yaml /etc/ros2_medkit/gateway_config.yaml
+COPY manifest.yaml /etc/ros2_medkit/manifest.yaml
+
+# nav2 loads its built-in default nav_to_pose behavior tree (the
+# default_nav_to_pose_bt_xml_filename param does not reliably override it under
+# this bringup), whose recovery subtree (spin, 5s wait, back-up, 6 retries)
+# makes a phantom-stuck robot thrash for 20-30s before navigate_to_pose aborts.
+# Overwrite that default with our fast-abort tree (recovery reduced to a costmap
+# clear, single retry) so the stall aborts cleanly in ~15s no matter which tree
+# nav2 ends up loading.
+RUN cp /ws/install/ota_nav2_sensor_fix_demo/share/ota_nav2_sensor_fix_demo/config/navigate_to_pose_fast_abort.xml \
+ /opt/ros/jazzy/share/nav2_bt_navigator/behavior_trees/navigate_to_pose_w_replanning_and_recovery.xml
+
+# Pre-create the fragments directory so the gateway's manifest manager
+# scans an existing (empty) dir at boot rather than logging "missing
+# fragments_dir" warnings. Plugin writes / removes yaml files here at
+# OTA install / uninstall time.
+RUN mkdir -p /etc/ros2_medkit/manifest_fragments
+# Rosbag capture storage_path for ros2_medkit_fault_manager's RosbagCapture.
+RUN mkdir -p /var/lib/ros2_medkit/rosbags
+COPY entrypoint.sh /usr/local/bin/entrypoint.sh
+RUN chmod +x /usr/local/bin/entrypoint.sh
+
+# RMW: jazzy's apt-shipped nav2_msgs fastrtps typesupport pulls
+# eprosima::fastcdr::Cdr::serialize(uint32_t), which the bundled
+# ros-jazzy-fastcdr 2.2.5 does NOT export - amcl/controller_server segfault
+# at startup. Switch to cyclonedds, which doesn't use the broken typesupport.
+#
+# GZ_SIM_RESOURCE_PATH (RB-Theron + AWS warehouse - actively used by
+# demo.launch.py's spawn + world load): the AWS models baked into the demo
+# package's own share dir at Docker build time, plus the robotnik_description /
+# robotnik_sensors share dirs (colcon-built into /ws/install by the builder
+# stage) so gz can resolve both model:// (AWS warehouse fixtures) and
+# package:// (RB-Theron meshes) URIs. demo.launch.py's own launch-time
+# AppendEnvironmentVariable adds the same warehouse models dir again
+# defensively for source-mounted runs.
+ENV ROS_DOMAIN_ID=42 \
+ GZ_SIM_RESOURCE_PATH=/ws/install/ota_nav2_sensor_fix_demo/share/ota_nav2_sensor_fix_demo/models/aws_small_warehouse/models:/ws/install/robotnik_description/share:/ws/install/robotnik_sensors/share \
+ HEADLESS=true \
+ RMW_IMPLEMENTATION=rmw_cyclonedds_cpp
+
+EXPOSE 8080 8765
+ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
diff --git a/demos/ota_nav2_sensor_fix/README.md b/demos/ota_nav2_sensor_fix/README.md
new file mode 100644
index 0000000..e6480c8
--- /dev/null
+++ b/demos/ota_nav2_sensor_fix/README.md
@@ -0,0 +1,271 @@
+# OTA over SOVD - nav2 sensor fix demo
+
+End-to-end demo: a `ros2_medkit` gateway with a dev-grade OTA plugin that
+demonstrates a real update / publish-a-hotfix loop on a ROS 2 node without
+SSH-ing into the robot.
+
+## What this shows
+
+The headline scene is a diagnostic loop, not just a button-press update:
+
+1. The robot boots on the known-good lidar (`fixed_lidar` running as
+ `scan_sensor_node`) - clean `/scan`, no fault.
+2. At container startup a routine-looking software update
+ (`broken_lidar_3_0_0`) is **auto-applied** - the entrypoint swaps
+ `scan_sensor_node` over to `broken_lidar` before the mission even
+ starts. This is the root cause the operator will have to find later.
+3. An operator sends a nav goal with `./send-goal.sh`. The robot drives,
+ and the operator's Foxglove view drops (narrative: the operator is away
+ and the viewer link is lost for a few minutes). Only the operator's view
+ goes dark - the robot, the on-robot gateway, and the fault manager keep
+ running and capturing the whole time.
+4. While driving, `broken_lidar` overlays a blocking phantom sector onto
+ the real `/scan_sim` data straight ahead - real obstacles elsewhere in
+ the scan stay visible. Nav2 genuinely cannot make progress:
+ `controller_server` logs "Failed to make progress" and
+ `navigate_to_pose` aborts. Two generic `ros2_medkit` bridges (not a
+ custom fault in the scan node) turn Nav2's own failure into SOVD
+ faults: `ros2_medkit_action_status_bridge` reports
+ `ACTION_NAVIGATE_TO_POSE_ABORTED` on `bt-navigator` (the headline
+ fault) and `ros2_medkit_log_bridge` reports a
+ `LOG_CONTROLLER_SERVER_*` fault on `controller-server` (supporting).
+ `ros2_medkit_fault_manager` confirms both immediately, captures a
+ freeze-frame + MCAP recording under the `bt-navigator` fault's
+ `environment_data.snapshots`, and **latches** it - no self-healing.
+5. The operator reconnects: the robot is stopped, and the fault is red
+ (latched).
+6. The operator downloads the MCAP capture
+ (`GET /apps/bt-navigator/bulk-data/rosbags/ACTION_NAVIGATE_TO_POSE_ABORTED`)
+ and opens it in Foxglove - the phantom blocking the path is visible in
+ the replayed `/scan`. The Faults Dashboard panel's freeze-frame shows
+ the same state at confirmation time.
+ To confirm the root cause rather than guess, the operator runs the
+ `health-check` app's single `run_health_checks` operation from the
+ Operations tab (all four checks default to enabled): localization and
+ drivetrain come back healthy, but the report shows lidar failed with a
+ stuck sector and costmap flags an obstacle ahead that is not in the map -
+ it is the lidar, not something downstream.
+7. `GET /api/v1/updates` shows only `broken_lidar_3_0_0` - the suspect
+ recent change, and the fix the operator needs is not registered yet.
+8. The operator publishes the hotfix with `./publish-fix.sh` (SOVD
+ `POST /api/v1/updates`, registering `fixed_lidar_3_0_1` - a **forward**
+ fix, version 3.0.1 > the bad 3.0.0, not a rollback to a previous build).
+ `GET /api/v1/updates` now shows both `broken_lidar_3_0_0` and
+ `fixed_lidar_3_0_1`.
+9. The operator applies the fix with `./apply-fix.sh` (prepare + execute
+ `fixed_lidar_3_0_1`) - `scan_sensor_node` swaps from `broken_lidar` to
+ `fixed_lidar` and `/scan` is clean again. The fault stays **latched** -
+ applying the fix does not clear the DTC.
+10. The operator clears the faults with `./clear-fault.sh` (an explicit
+ `DELETE /apps/bt-navigator/faults/ACTION_NAVIGATE_TO_POSE_ABORTED` plus
+ a clear-all `DELETE /apps/controller-server/faults` for the
+ content-hashed `LOG_*` code) - the Faults Dashboard goes green.
+11. The operator resumes the mission with `./send-goal.sh` - the robot
+ reaches the goal.
+
+The update is SOVD ISO 17978-3 compliant - the kind is derived from
+`updated_components` in the update package metadata.
+
+## Quickstart
+
+```bash
+# Build artifacts + start gateway, plugin, demo nodes, update server.
+./run-demo.sh
+```
+
+The first run pulls `ros:jazzy`, installs the Nav2 + gz-sim runtime, clones
+the Robotnik RB-Theron + AWS small-warehouse assets (~3 GB) and builds the
+gateway from source - takes ~15-20 minutes on a fresh cache. Subsequent runs
+reuse the layer cache.
+
+In another terminal, drive the demo:
+
+```bash
+./check-demo.sh # at a glance: scan node, applied updates, faults
+./send-goal.sh # send a nav goal (mission start / resume)
+./publish-fix.sh # register fixed_lidar_3_0_1 (SOVD POST /updates) - not in the boot catalog
+./apply-fix.sh # broken_lidar -> fixed_lidar_3_0_1 (prepare + execute the published fix)
+./clear-fault.sh # operator clear of the latched bt-navigator/controller-server faults
+./trigger-bad-update.sh # re-arm broken_lidar_3_0_0 (normally auto-applied at boot)
+./stop-demo.sh # tear down
+```
+
+`publish-fix.sh` issues a SOVD `POST /updates` to register the held-back
+`fixed_lidar_3_0_1` hotfix. `apply-fix.sh` and `trigger-bad-update.sh` issue
+SOVD `PUT /updates/{id}/prepare` then `/execute` and print the resulting
+status plus the live process list; `apply-fix.sh` guards on the fix being
+registered first and tells you to run `./publish-fix.sh` if it isn't.
+`clear-fault.sh` issues a plain SOVD
+`DELETE /apps/bt-navigator/faults/ACTION_NAVIGATE_TO_POSE_ABORTED` plus a
+clear-all `DELETE /apps/controller-server/faults` (the `LOG_*` code there
+is content-hashed, so it is cleared by entity rather than by exact code).
+`send-goal.sh` sends the goal through the `/navigate_to_pose` action inside the
+gateway container (an action client that waits for the server and confirms the
+goal is accepted, so a transient publisher never drops it before nav2 sees it).
+
+Port overrides (set as env vars before `./run-demo.sh`):
+
+- `OTA_GATEWAY_PORT` - gateway HTTP API (default `8080`)
+- `OTA_FOXGLOVE_BRIDGE_PORT` - foxglove_bridge WebSocket (default `8765`)
+
+Tear down: `docker compose down`.
+
+## Diagnosing the incident over SOVD
+
+Everything in the loop above is also doable with plain `curl` - the
+Foxglove panels (next section) are a convenience layer on top of the same
+SOVD REST calls.
+
+```bash
+API=http://localhost:8080/api/v1
+
+# 1. Which update is applied to scan_sensor_node right now - the suspect
+# recent change. Only the bad update is in the boot catalog; the fix
+# is not registered yet.
+curl -s "${API}/updates" | jq -r '.items[]'
+curl -s "${API}/updates/broken_lidar_3_0_0/status" | jq .
+
+# 2. Is ACTION_NAVIGATE_TO_POSE_ABORTED confirmed on bt-navigator (the
+# headline fault)? Also check controller-server for the supporting
+# LOG_* fault. (default filter = PREFAILED + CONFIRMED)
+curl -s "${API}/apps/bt-navigator/faults" | jq .
+curl -s "${API}/apps/controller-server/faults" | jq .
+
+# 3. Fault detail - freeze-frame + the MCAP link live under
+# environment_data.snapshots.
+curl -s "${API}/apps/bt-navigator/faults/ACTION_NAVIGATE_TO_POSE_ABORTED" | jq .
+
+# 4. Download the MCAP recording and open it in Foxglove.
+curl -O -J "${API}/apps/bt-navigator/bulk-data/rosbags/ACTION_NAVIGATE_TO_POSE_ABORTED"
+
+# 4b. Confirm the root cause with the single run_health_checks operation
+# (all four checks default to enabled). localization + drivetrain come
+# back healthy; the report line for lidar shows a stuck sector.
+curl -s -X POST -H 'Content-Type: application/json' -d '{}' \
+ "${API}/apps/health-check/operations/run_health_checks/executions" | jq '.parameters // .'
+
+# 5. Publish the forward hotfix (or use ./publish-fix.sh). It is not in the
+# boot catalog - you register it by POSTing its descriptor, a JSON you
+# provide by hand (see updates/README.md for the fields), via SOVD
+# POST /updates.
+curl -fsS -X POST -H 'Content-Type: application/json' \
+ -d @updates/fixed_lidar_3_0_1.json "${API}/updates"
+curl -s "${API}/updates" | jq -r '.items[]'
+
+# 6. Apply the published fix (or use ./apply-fix.sh).
+curl -fsS -X PUT -H 'Content-Type: application/json' -d '{}' \
+ "${API}/updates/fixed_lidar_3_0_1/prepare"
+curl -fsS -X PUT -H 'Content-Type: application/json' -d '{}' \
+ "${API}/updates/fixed_lidar_3_0_1/execute"
+
+# 7. Both faults are still latched after applying the fix - clear them
+# explicitly (or use ./clear-fault.sh). The controller-server LOG_*
+# code is content-hashed, so clear it with a clear-all on the entity
+# instead of a hardcoded code.
+curl -X DELETE "${API}/apps/bt-navigator/faults/ACTION_NAVIGATE_TO_POSE_ABORTED"
+curl -X DELETE "${API}/apps/controller-server/faults"
+
+# 8. Resume the mission (or use ./send-goal.sh).
+```
+
+## Foxglove Studio visualization
+
+The gateway container bakes in a Robotnik RB-Theron AMR + Nav2 stack running
+on top of headless Gazebo in the AWS small-warehouse world. `foxglove_bridge`
+runs on port `8765` and exposes the full topic set: `/tf`, `/tf_static`,
+`/scan`, `/odom`, `/map`, `/cmd_vel`, `/global_costmap/costmap`,
+`/local_costmap/costmap`, etc. - so a Foxglove **3D** panel renders the actual
+robot in the warehouse out of the box.
+
+1. Open Foxglove Studio -> **Open connection** -> **Foxglove WebSocket** ->
+ `ws://localhost:8765`. The Topics panel should list all of the topics
+ above.
+2. Drop in a **3D** panel. In its settings set **Scene -> Mesh up axis ->
+ Z**, then reload (Ctrl-R). The mesh geometry carries no up-axis metadata so
+ Foxglove defaults to Y-up, which renders the robot rotated with its parts
+ scattered; Z-up puts it upright and assembled. You should then see the
+ RB-Theron sitting in the AWS small-warehouse world.
+ Shortly after boot, the auto-applied `broken_lidar_3_0_0` update swaps
+ `scan_sensor_node` over to `broken_lidar` - a forward sector of `/scan`
+ starts reporting a phantom close return (a stuck lidar sector). Nav2 cannot
+ get past the phantom, so the robot stalls and `navigate_to_pose` aborts -
+ the failure the demo's narrative pivots on.
+3. Install the [`ros2_medkit_foxglove_extension`](https://github.com/selfpatch/ros2_medkit_foxglove_extension)
+ (`npm run local-install` in that repo, or drag-and-drop the `.foxe`
+ onto Foxglove). It ships three panels: Entity Browser, Faults Dashboard,
+ and **ros2_medkit Updates**.
+4. Add the **Faults Dashboard** panel. Once the robot stalls at the
+ phantom (see "Driving the robot" below), `ACTION_NAVIGATE_TO_POSE_ABORTED`
+ shows up CONFIRMED on `bt-navigator` (the headline fault) alongside a
+ `LOG_CONTROLLER_SERVER_*` fault on `controller-server` (supporting), and
+ both stay latched. Expand the `bt-navigator` fault to see the
+ freeze-frame snapshot (`/scan`, `/cmd_vel`, `/local_costmap/costmap`)
+ captured at confirmation, plus the downloadable MCAP recording.
+5. Add the **ros2_medkit Updates** panel and set its `baseUrl` to
+ `http://localhost:8080/api/v1` (or the port you picked via
+ `OTA_GATEWAY_PORT`). `broken_lidar_3_0_0` shows as the update applied
+ to `scan_sensor_node` - the fix is not registered yet. Run
+ `./publish-fix.sh` in a terminal to register `fixed_lidar_3_0_1`
+ (SOVD `POST /updates`); it then appears in the panel. Click **Prepare**
+ then **Execute** for `fixed_lidar_3_0_1` to apply it - the 3D panel
+ should show the phantom return disappearing as `broken_lidar` is killed
+ and `fixed_lidar` starts. The Faults Dashboard entry stays red until you
+ also clear it.
+
+### Driving the robot to make the narrative reproducible
+
+The demo doesn't auto-publish a navigation goal - that keeps it
+deterministic for CI. Use `./send-goal.sh` to drive the loop yourself:
+
+```bash
+./send-goal.sh # defaults to (1.5, 1.0); pass x y to override
+```
+
+`send-goal.sh` `docker exec`s into the gateway container (sourcing the ROS
+overlay itself, since `docker exec` skips the image entrypoint) and sends the
+goal through the `/navigate_to_pose` action. Foxglove's **3D** panel
+also has a built-in "Publish" tool - select pose mode, click a point ahead
+of the robot, and Foxglove publishes `/goal_pose` for you.
+
+While `broken_lidar_3_0_0` is applied (the boot default), driving toward
+the phantom blocks the path and stalls Nav2 - `navigate_to_pose` aborts
+and confirms `ACTION_NAVIGATE_TO_POSE_ABORTED` on `bt-navigator` (plus a
+supporting `LOG_*` fault on `controller-server`). Watch the Faults
+Dashboard panel or poll `GET /apps/bt-navigator/faults` and
+`GET /apps/controller-server/faults`. After `./publish-fix.sh`,
+`./apply-fix.sh`, and `./clear-fault.sh`, send the goal again and the robot
+reaches it with a clean `/scan`.
+
+## Disclosures
+
+This is **dev-grade** OTA. Deliberately missing for production:
+
+- No artifact signing or signature verification
+- Staged swap: the new build is extracted and verified into a staging dir
+ before the live install is replaced and the old process is killed, but
+ no rollback-respawn if the new process fails to spawn
+- No A/B partition rollout
+- No fleet-wide staged rollout
+- No persistent update state across gateway restarts
+- No automated health-gated rollback policy
+- No audit log
+
+**Security model & demo scope.** This is a self-contained docker-compose
+demo on a trusted local network, driven with `curl` against a catalog
+authored locally by `scripts/build_artifacts.sh`. The update API is
+unauthenticated by design (bound to `0.0.0.0:8080`, origins `[*]`, updates
+enabled) so the demo stays curl-drivable. A production deployment would add:
+
+- An authenticated, TLS-terminated update API in place of the open,
+ plaintext one
+- A signed catalog and a per-artifact `sha256` verified before extraction,
+ with artifacts fetched only from the configured origin (not an arbitrary
+ URL taken from the catalog)
+- The gateway running as a non-root user with dropped capabilities
+ (`cap_drop: [ALL]`); `no-new-privileges` is already set in
+ `docker-compose.yml`
+
+Perfect for: prototypes, lab robots, internal demos, dev environments.
+
+For production-grade OTA (rollout safety, signing, A/B partitions,
+fleet-aware staging), reach out.
diff --git a/demos/ota_nav2_sensor_fix/THIRD_PARTY_NOTICES.md b/demos/ota_nav2_sensor_fix/THIRD_PARTY_NOTICES.md
new file mode 100644
index 0000000..9dcfb59
--- /dev/null
+++ b/demos/ota_nav2_sensor_fix/THIRD_PARTY_NOTICES.md
@@ -0,0 +1,16 @@
+# Third-party notices
+
+These assets are **fetched at Docker build time** from their upstream repositories at
+the pinned commits below (see `Dockerfile.gateway`), not vendored in this repo - only
+our RB-Theron wrapper (`ros2_packages/ota_nav2_sensor_fix_demo/urdf/warehouse_rbtheron.urdf.xacro`),
+our gz-port world (`ros2_packages/ota_nav2_sensor_fix_demo/models/aws_small_warehouse/worlds/warehouse.sdf`),
+and the build-time `file://models/` -> `model://` rewrite are ours. The pinned commits
+are the source of truth; the Dockerfile passes them as the `ROBOTNIK_DESC_REF` /
+`ROBOTNIK_SENS_REF` / `AWS_WAREHOUSE_REF` build args. Each upstream's license travels
+with its clone (a `LICENSE` file at the repo root).
+
+| Component | Source | License | Commit |
+| --- | --- | --- | --- |
+| Robotnik RB-Theron description | https://github.com/RobotnikAutomation/robotnik_description (jazzy-devel) | BSD-3-Clause | 751059edd6af3a9c083018cfaee59e4496d46580 |
+| Robotnik sensors | https://github.com/RobotnikAutomation/robotnik_sensors (jazzy-devel) | BSD-3-Clause | e5186c343910b86a924201edb256f79eb0f73295 |
+| AWS small warehouse world | https://github.com/aws-robotics/aws-robomaker-small-warehouse-world (ros2) | MIT-0 | ee0af733315e78432408c3cd98d378ecee5f767c |
diff --git a/demos/ota_nav2_sensor_fix/apply-fix.sh b/demos/ota_nav2_sensor_fix/apply-fix.sh
new file mode 100755
index 0000000..97e64b5
--- /dev/null
+++ b/demos/ota_nav2_sensor_fix/apply-fix.sh
@@ -0,0 +1,42 @@
+#!/bin/bash
+# Apply the published remediation update (fixed_lidar_3_0_1) via SOVD prepare/execute.
+# Uses spec endpoints PUT /updates/{id}/prepare then PUT /updates/{id}/execute.
+
+set -eu
+
+GATEWAY_URL="${OTA_GATEWAY_URL:-http://localhost:${OTA_GATEWAY_PORT:-8080}}"
+API="${GATEWAY_URL}/api/v1"
+ID="fixed_lidar_3_0_1"
+
+if ! curl -fsS "${API}/health" >/dev/null 2>&1; then
+ echo "Gateway not reachable at ${GATEWAY_URL}. Start it with: ./run-demo.sh"
+ exit 1
+fi
+
+# The fix is not in the boot catalog - it has to be published first.
+if ! curl -fsS "${API}/updates" 2>/dev/null | grep -q "${ID}"; then
+ echo "${ID} is not registered yet. Publish it first with: ./publish-fix.sh"
+ exit 1
+fi
+
+echo "Update: ${ID}"
+echo " PUT /updates/${ID}/prepare"
+curl -fsS -X PUT -H 'Content-Type: application/json' -d '{}' \
+ "${API}/updates/${ID}/prepare" >/dev/null
+sleep 3
+
+echo " PUT /updates/${ID}/execute"
+curl -fsS -X PUT -H 'Content-Type: application/json' -d '{}' \
+ "${API}/updates/${ID}/execute" >/dev/null
+sleep 5
+
+echo ""
+echo "Status after execute:"
+curl -fsS "${API}/updates/${ID}/status" | (jq . 2>/dev/null || cat)
+
+if docker ps --format '{{.Names}}' | grep -q '^ota_demo_gateway$'; then
+ echo ""
+ echo "Live processes:"
+ docker exec ota_demo_gateway pgrep -af 'broken_lidar_node|fixed_lidar_node' \
+ 2>/dev/null | grep -v 'pgrep' | sed 's/^/ /' || true
+fi
diff --git a/demos/ota_nav2_sensor_fix/artifacts/.gitignore b/demos/ota_nav2_sensor_fix/artifacts/.gitignore
new file mode 100644
index 0000000..ae78201
--- /dev/null
+++ b/demos/ota_nav2_sensor_fix/artifacts/.gitignore
@@ -0,0 +1,2 @@
+*.tar.gz
+catalog.json
diff --git a/demos/ota_nav2_sensor_fix/check-demo.sh b/demos/ota_nav2_sensor_fix/check-demo.sh
new file mode 100755
index 0000000..0cafd81
--- /dev/null
+++ b/demos/ota_nav2_sensor_fix/check-demo.sh
@@ -0,0 +1,88 @@
+#!/bin/bash
+# Show the live state of the OTA demo at a glance: which lidar build
+# scan_sensor_node is running, the applied updates + their statuses, and
+# the current Nav2 faults on bt-navigator + controller-server (so a latched
+# ACTION_NAVIGATE_TO_POSE_ABORTED / LOG_* fault and the bad update that
+# caused it are both visible in one shot).
+
+set -eu
+
+GATEWAY_URL="${OTA_GATEWAY_URL:-http://localhost:${OTA_GATEWAY_PORT:-8080}}"
+API="${GATEWAY_URL}/api/v1"
+
+if ! command -v curl >/dev/null 2>&1; then
+ echo "curl is required"
+ exit 1
+fi
+
+if ! curl -fsS "${API}/health" >/dev/null 2>&1; then
+ echo "Gateway not reachable at ${GATEWAY_URL}. Start it with: ./run-demo.sh"
+ exit 1
+fi
+
+JQ_AVAILABLE="false"
+if command -v jq >/dev/null 2>&1; then
+ JQ_AVAILABLE="true"
+fi
+
+GATEWAY_RUNNING="false"
+if docker ps --format '{{.Names}}' | grep -q '^ota_demo_gateway$'; then
+ GATEWAY_RUNNING="true"
+fi
+
+echo "Gateway: ${GATEWAY_URL}"
+echo "Health: $(curl -fsS "${API}/health" | head -c 200)"
+echo ""
+
+echo "Scan sensor (scan_sensor_node):"
+if [[ "$GATEWAY_RUNNING" == "true" ]]; then
+ SCAN_PROC=$(docker exec ota_demo_gateway pgrep -af 'broken_lidar_node|fixed_lidar_node' \
+ 2>/dev/null | grep -v 'pgrep' || true)
+ if echo "$SCAN_PROC" | grep -q 'broken_lidar_node'; then
+ echo " broken_lidar_node running - REGRESSED build applied (root cause)"
+ elif echo "$SCAN_PROC" | grep -q 'fixed_lidar_node'; then
+ echo " fixed_lidar_node running - known-good build"
+ else
+ echo " (no scan sensor process found)"
+ fi
+else
+ echo " ota_demo_gateway container not running"
+fi
+echo ""
+
+echo "Applied updates (GET /updates, GET /updates/{id}/status):"
+if [[ "$JQ_AVAILABLE" == "true" ]]; then
+ for id in $(curl -fsS "${API}/updates" | jq -r '.items[]'); do
+ status=$(curl -fsS "${API}/updates/${id}/status" 2>/dev/null || echo '{"status":""}')
+ echo " ${id}: $(echo "$status" | jq -c '{status, progress}')"
+ done
+else
+ curl -fsS "${API}/updates"
+fi
+echo ""
+
+echo "Current Nav2 faults (GET /apps/bt-navigator/faults, /apps/controller-server/faults):"
+for entity in "apps/bt-navigator" "apps/controller-server"; do
+ echo " ${entity}:"
+ FAULTS_JSON=$(curl -fsS "${API}/${entity}/faults" 2>/dev/null || echo '{"items":[]}')
+ if [[ "$JQ_AVAILABLE" == "true" ]]; then
+ FAULT_COUNT=$(echo "$FAULTS_JSON" | jq '.items | length')
+ if [[ "$FAULT_COUNT" -eq 0 ]]; then
+ echo " (none - clean)"
+ else
+ echo "$FAULTS_JSON" | jq -r '.items[] | " \(.fault_code): \(.status)"'
+ fi
+ else
+ echo " $FAULTS_JSON"
+ fi
+done
+echo ""
+
+echo "Plugin-managed processes inside gateway container:"
+if [[ "$GATEWAY_RUNNING" == "true" ]]; then
+ docker exec ota_demo_gateway pgrep -af \
+ 'broken_lidar_node|fixed_lidar_node' \
+ 2>/dev/null | grep -v 'pgrep' | sed 's/^/ /' || echo " (none)"
+else
+ echo " ota_demo_gateway container not running"
+fi
diff --git a/demos/ota_nav2_sensor_fix/clear-fault.sh b/demos/ota_nav2_sensor_fix/clear-fault.sh
new file mode 100755
index 0000000..ae1ba07
--- /dev/null
+++ b/demos/ota_nav2_sensor_fix/clear-fault.sh
@@ -0,0 +1,24 @@
+#!/bin/bash
+# Operator clear of the latched Nav2 faults after the fix is applied.
+# bt-navigator's ACTION_NAVIGATE_TO_POSE_ABORTED is a stable, bridge-generated
+# code, so it is cleared by code. controller-server's LOG_CONTROLLER_SERVER_*
+# code is content-derived (the hash can change between runs), so it is
+# cleared with a clear-all on the entity instead of a hardcoded code. Both
+# faults are latched (no self-heal); this is the deliberate operator
+# acknowledge step.
+set -eu
+API="${OTA_GATEWAY_URL:-http://localhost:${OTA_GATEWAY_PORT:-8080}}/api/v1"
+NAV_ENTITY="apps/bt-navigator"
+NAV_CODE="ACTION_NAVIGATE_TO_POSE_ABORTED"
+CONTROLLER_ENTITY="apps/controller-server"
+
+echo "DELETE /${NAV_ENTITY}/faults/${NAV_CODE}"
+curl -fsS -X DELETE "${API}/${NAV_ENTITY}/faults/${NAV_CODE}" -o /dev/null -w ' HTTP %{http_code}\n'
+
+echo "DELETE /${CONTROLLER_ENTITY}/faults (clear-all - the LOG_* code is content-hashed)"
+curl -fsS -X DELETE "${API}/${CONTROLLER_ENTITY}/faults" -o /dev/null -w ' HTTP %{http_code}\n'
+
+echo "Remaining faults on ${NAV_ENTITY}:"
+curl -fsS "${API}/${NAV_ENTITY}/faults" | (jq -r '.items[].fault_code' 2>/dev/null || cat) | sed 's/^/ /'
+echo "Remaining faults on ${CONTROLLER_ENTITY}:"
+curl -fsS "${API}/${CONTROLLER_ENTITY}/faults" | (jq -r '.items[].fault_code' 2>/dev/null || cat) | sed 's/^/ /'
diff --git a/demos/ota_nav2_sensor_fix/docker-compose.yml b/demos/ota_nav2_sensor_fix/docker-compose.yml
new file mode 100644
index 0000000..4cb65cc
--- /dev/null
+++ b/demos/ota_nav2_sensor_fix/docker-compose.yml
@@ -0,0 +1,51 @@
+# Copyright 2026 bburda
+# Apache 2.0
+#
+# Two-service stack: the gateway (with ota_update_plugin baked in plus the
+# nav2 + RB-Theron + headless Gazebo + foxglove_bridge orchestration) and the
+# FastAPI artifact server. The gateway image is hefty (~5GB) because it bakes
+# the simulator in - the trade-off is `docker compose up` produces a robot
+# Foxglove can render, no external sim setup required.
+
+services:
+ gateway:
+ image: selfpatch/ota_demo_gateway:dev
+ build:
+ context: .
+ dockerfile: Dockerfile.gateway
+ container_name: ota_demo_gateway
+ networks: [otanet]
+ ports:
+ - "${OTA_GATEWAY_PORT:-8080}:8080"
+ - "${OTA_FOXGLOVE_BRIDGE_PORT:-8765}:8765"
+ environment:
+ ROS_DOMAIN_ID: 42
+ HEADLESS: "true"
+ # Gazebo / DDS appreciate generous shared memory; without this
+ # /dev/shm fills and gz-sim tends to wedge on shutdown.
+ shm_size: "2gb"
+ # OTA execute() runs kill/extract/spawn inside this container. This is a
+ # trusted-local demo (see README "Security model & demo scope"), but block
+ # privilege escalation as a free, no-UX-cost hardening. cap_drop:[ALL] and a
+ # non-root USER are documented as production hardening rather than applied
+ # here - they risk breaking gz-sim / DDS in this all-in-one demo container.
+ security_opt:
+ - no-new-privileges:true
+ tty: true
+ stdin_open: true
+ depends_on:
+ - ota_update_server
+
+ ota_update_server:
+ image: selfpatch/ota_update_server:dev
+ build:
+ context: .
+ dockerfile: ota_update_server/Dockerfile
+ container_name: ota_demo_update_server
+ networks: [otanet]
+ ports:
+ - "9000:9000"
+
+networks:
+ otanet:
+ driver: bridge
diff --git a/demos/ota_nav2_sensor_fix/entrypoint.sh b/demos/ota_nav2_sensor_fix/entrypoint.sh
new file mode 100755
index 0000000..f60a738
--- /dev/null
+++ b/demos/ota_nav2_sensor_fix/entrypoint.sh
@@ -0,0 +1,39 @@
+#!/usr/bin/env bash
+# Copyright 2026 bburda
+# Apache 2.0
+#
+# Container entrypoint: hands off to the ota_nav2_sensor_fix_demo launch file
+# which orchestrates everything (RB-Theron AMR + Nav2 + headless Gazebo +
+# foxglove_bridge + fault_manager + gateway w/ ota_update_plugin). Once the
+# gateway is up, this script auto-applies broken_lidar_3_0_0 so the mission
+# starts on the regressed lidar the operator has to diagnose.
+
+set -e
+
+# shellcheck disable=SC1091
+source /opt/ros/jazzy/setup.bash
+# shellcheck disable=SC1091
+source /ws/install/setup.bash
+
+# Default to headless; an operator on a workstation can flip via env var.
+HEADLESS_ARG="${HEADLESS:-true}"
+
+# Simulate a routine software update that regressed the lidar: once the
+# gateway is healthy and the plugin has registered the catalog, apply
+# broken_lidar_3_0_0 so scan_sensor_node is running the bad build before
+# the mission starts. The operator later finds it in /updates, publishes
+# the forward hotfix (fixed_lidar_3_0_1) with publish-fix.sh, and applies
+# it with apply-fix.sh.
+(
+ API="http://localhost:8080/api/v1"
+ for _ in $(seq 1 60); do
+ if curl -fsS "${API}/updates" 2>/dev/null | grep -q 'broken_lidar_3_0_0'; then break; fi
+ sleep 2
+ done
+ curl -fsS -X PUT -H 'Content-Type: application/json' -d '{}' "${API}/updates/broken_lidar_3_0_0/prepare" >/dev/null 2>&1 || true
+ sleep 3
+ curl -fsS -X PUT -H 'Content-Type: application/json' -d '{}' "${API}/updates/broken_lidar_3_0_0/execute" >/dev/null 2>&1 || true
+) &
+
+exec ros2 launch ota_nav2_sensor_fix_demo demo.launch.py \
+ "headless:=${HEADLESS_ARG}"
diff --git a/demos/ota_nav2_sensor_fix/gateway_config.yaml b/demos/ota_nav2_sensor_fix/gateway_config.yaml
new file mode 100644
index 0000000..bb85917
--- /dev/null
+++ b/demos/ota_nav2_sensor_fix/gateway_config.yaml
@@ -0,0 +1,62 @@
+# Copyright 2026 bburda
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+#
+# Gateway configuration for the OTA nav2 sensor-fix demo.
+# Enables /updates endpoints and loads ota_update_plugin which polls the
+# update server's /catalog at boot and exposes Update / Install / Uninstall
+# operations over the SOVD HTTP API.
+
+ros2_medkit_gateway:
+ ros__parameters:
+ server:
+ host: "0.0.0.0"
+ port: 8080
+
+ refresh_interval_ms: 2000
+
+ # CORS so an external Foxglove panel or browser can hit the API.
+ cors:
+ allowed_origins: ["*"]
+ allowed_methods: ["GET", "PUT", "POST", "DELETE", "OPTIONS"]
+ allowed_headers: ["Content-Type", "Accept"]
+ allow_credentials: false
+ max_age_seconds: 86400
+
+ discovery:
+ # Hybrid: manifest defines areas/components/apps/functions, runtime
+ # fills in topics/services/params, and OTA-deployed apps land via
+ # manifest fragments dropped in fragments_dir below.
+ mode: "hybrid"
+ manifest_path: "/etc/ros2_medkit/manifest.yaml"
+ manifest_strict_validation: false
+ manifest:
+ # ota_update_plugin writes one fragment per Install operation
+ # here and calls notify_entities_changed; the gateway re-merges
+ # the base manifest + every yaml in this dir on each reload.
+ # Path is shared with the plugin via plugins.ota_update_plugin
+ # .fragments_dir below - keep them in lockstep.
+ fragments_dir: "/etc/ros2_medkit/manifest_fragments"
+ runtime:
+ # Manifest already defines the rbtheron Component; suppress the
+ # host-derived default Component so the entity tree shows a single
+ # component instead of rbtheron + the host.
+ default_component:
+ enabled: false
+ # Manifest defines functions; the auto-gen-from-namespaces path
+ # produces single-host noise because the nav2 stack lives at /.
+ create_functions_from_namespaces: false
+
+ # Enable /updates endpoints; provider supplied by ota_update_plugin below.
+ updates:
+ enabled: true
+
+ plugins: ["ota_update_plugin"]
+ plugins.ota_update_plugin.path: "/ws/install/ota_update_plugin/lib/ota_update_plugin/ota_update_plugin.so"
+ plugins.ota_update_plugin.catalog_url: "http://ota_update_server:9000"
+ plugins.ota_update_plugin.staging_dir: "/tmp/ota_staging"
+ plugins.ota_update_plugin.install_dir: "/ws/install"
+ # Same path the gateway has under discovery.manifest.fragments_dir.
+ # Plugin drops one yaml per Install and removes it on Uninstall.
+ plugins.ota_update_plugin.fragments_dir: "/etc/ros2_medkit/manifest_fragments"
diff --git a/demos/ota_nav2_sensor_fix/manifest.yaml b/demos/ota_nav2_sensor_fix/manifest.yaml
new file mode 100644
index 0000000..5c258fa
--- /dev/null
+++ b/demos/ota_nav2_sensor_fix/manifest.yaml
@@ -0,0 +1,282 @@
+# Copyright 2026 bburda. Apache-2.0.
+#
+# SOVD manifest for the OTA over SOVD nav2 sensor-fix demo.
+#
+# Hybrid discovery: the manifest defines components + apps + functions,
+# and runtime discovery fills in topics/services/params for those
+# entities.
+#
+# Hierarchy used for this demo:
+# - One Component: the robot itself. SOVD lets a manifest declare
+# multiple components (e.g. one per ECU on a vehicle), but here
+# everything runs on a single host so a flat single-component model
+# is honest and avoids fake "lidar-sensor / nav2-motion / ..."
+# subdivisions that don't correspond to anything you can actually
+# swap independently in this demo.
+# - Apps are the ROS 2 nodes - one entry per node we want the operator
+# to see. They all live on the single robot component.
+# - Functions are capability groupings - "Autonomous Navigation",
+# "Perception" etc. - that pull apps together by what they deliver,
+# orthogonally to where they run.
+#
+# Areas are intentionally left out. SOVD allows them, but they only add
+# value when there's a meaningful zone partition (powertrain / body /
+# chassis on a vehicle ECU mesh, or multi-robot tenancy). For a single
+# robot they just regroup the same component a second time.
+
+manifest_version: "1.0"
+
+metadata:
+ name: "ota-nav2-sensor-fix"
+ description: "OTA over SOVD demo - RB-Theron AMR + Nav2 + AWS warehouse (headless Gazebo) + ros2_medkit gateway"
+ version: "0.1.0"
+
+config:
+ # Nav2 spins up internal helper nodes we deliberately don't manifest
+ # (per-action _rclcpp_node clients, transform_listener_impl_* per TF
+ # listener) - tolerate them instead of failing discovery.
+ unmanifested_nodes: warn
+ # Pull in topics, services, params from the running graph for entities
+ # the manifest declares.
+ inherit_runtime_resources: true
+
+# =============================================================================
+# COMPONENTS - one per OTA boundary. For this demo there's a single host
+# so a single component owns every app.
+# =============================================================================
+components:
+ - id: rbtheron
+ name: "RB-Theron AMR"
+ type: "platform"
+ description: "Robotnik RB-Theron AMR running headless Gazebo (AWS small-warehouse world), Nav2, the ros2_medkit gateway, and the OTA-managed sensor stack."
+
+# =============================================================================
+# APPS - one per ROS 2 node we care about (skip nav2-internal _rclcpp_node
+# helpers and transform_listener_impl_* - they're plumbing, not user-facing)
+# =============================================================================
+apps:
+ # ── LiDAR / perception ────────────────────────────────────────────
+ - id: scan-sensor-node
+ name: "Scan Sensor"
+ category: "sensor"
+ is_located_on: rbtheron
+ description: "LaserScan publisher (broken_lidar pre-OTA, fixed_lidar post-OTA)"
+ ros_binding: { node_name: scan_sensor_node, namespace: / }
+
+ - id: ros-gz-bridge
+ name: "ROS-Gazebo Bridge"
+ category: "simulation"
+ is_located_on: rbtheron
+ description: "Bridges /scan_sim and /clock from gz-sim"
+ ros_binding: { node_name: ros_gz_bridge, namespace: / }
+
+ # ── Robot platform ────────────────────────────────────────────────
+ - id: robot-state-publisher
+ name: "Robot State Publisher"
+ category: "platform"
+ is_located_on: rbtheron
+ description: "Publishes the robot URDF TF tree"
+ ros_binding: { node_name: robot_state_publisher, namespace: / }
+
+ # ── Nav2 motion ───────────────────────────────────────────────────
+ - id: bt-navigator
+ name: "BT Navigator"
+ category: "navigation"
+ is_located_on: rbtheron
+ description: "Behavior Tree navigator - hosts navigate_to_pose action"
+ ros_binding: { node_name: bt_navigator, namespace: / }
+
+ - id: planner-server
+ name: "Planner Server"
+ category: "navigation"
+ is_located_on: rbtheron
+ description: "Global path planner"
+ ros_binding: { node_name: planner_server, namespace: / }
+
+ - id: controller-server
+ name: "Controller Server"
+ category: "navigation"
+ is_located_on: rbtheron
+ description: "Local path follower"
+ ros_binding: { node_name: controller_server, namespace: / }
+
+ - id: smoother-server
+ name: "Smoother Server"
+ category: "navigation"
+ is_located_on: rbtheron
+ description: "Path smoothing"
+ ros_binding: { node_name: smoother_server, namespace: / }
+
+ - id: route-server
+ name: "Route Server"
+ category: "navigation"
+ is_located_on: rbtheron
+ description: "Route planning"
+ ros_binding: { node_name: route_server, namespace: / }
+
+ - id: behavior-server
+ name: "Behavior Server"
+ category: "navigation"
+ is_located_on: rbtheron
+ description: "Recovery behaviors"
+ ros_binding: { node_name: behavior_server, namespace: / }
+
+ - id: waypoint-follower
+ name: "Waypoint Follower"
+ category: "navigation"
+ is_located_on: rbtheron
+ description: "Sequenced waypoint navigation"
+ ros_binding: { node_name: waypoint_follower, namespace: / }
+
+ - id: velocity-smoother
+ name: "Velocity Smoother"
+ category: "navigation"
+ is_located_on: rbtheron
+ description: "/cmd_vel smoothing"
+ ros_binding: { node_name: velocity_smoother, namespace: / }
+
+ - id: collision-monitor
+ name: "Collision Monitor"
+ category: "navigation"
+ is_located_on: rbtheron
+ description: "Emergency stop on imminent collision"
+ ros_binding: { node_name: collision_monitor, namespace: / }
+
+ - id: docking-server
+ name: "Docking Server"
+ category: "navigation"
+ is_located_on: rbtheron
+ description: "Approach + dock action"
+ ros_binding: { node_name: docking_server, namespace: / }
+
+ - id: global-costmap
+ name: "Global Costmap"
+ category: "navigation"
+ is_located_on: rbtheron
+ description: "Static + obstacle costmap for planning"
+ ros_binding: { node_name: global_costmap, namespace: /global_costmap }
+
+ - id: local-costmap
+ name: "Local Costmap"
+ category: "navigation"
+ is_located_on: rbtheron
+ description: "Local rolling costmap for control"
+ ros_binding: { node_name: local_costmap, namespace: /local_costmap }
+
+ - id: lifecycle-manager-navigation
+ name: "Lifecycle Manager (Navigation)"
+ category: "navigation"
+ is_located_on: rbtheron
+ description: "Nav2 motion lifecycle orchestration"
+ ros_binding: { node_name: lifecycle_manager_navigation, namespace: / }
+
+ # ── Nav2 localization ─────────────────────────────────────────────
+ - id: amcl
+ name: "AMCL"
+ category: "localization"
+ is_located_on: rbtheron
+ description: "Adaptive Monte Carlo Localization"
+ ros_binding: { node_name: amcl, namespace: / }
+
+ - id: map-server
+ name: "Map Server"
+ category: "localization"
+ is_located_on: rbtheron
+ description: "Static map publisher"
+ ros_binding: { node_name: map_server, namespace: / }
+
+ - id: lifecycle-manager-localization
+ name: "Lifecycle Manager (Localization)"
+ category: "localization"
+ is_located_on: rbtheron
+ description: "Localization lifecycle orchestration"
+ ros_binding: { node_name: lifecycle_manager_localization, namespace: / }
+
+ # ── Diagnostics ───────────────────────────────────────────────────
+ - id: medkit-gateway
+ name: "ros2_medkit Gateway"
+ category: "gateway"
+ is_located_on: rbtheron
+ description: "SOVD REST gateway, hosts the OTA plugin"
+ ros_binding: { node_name: ros2_medkit_gateway, namespace: / }
+
+ - id: medkit-fault-manager
+ name: "Fault Manager"
+ category: "diagnostics"
+ is_located_on: rbtheron
+ description: "Fault aggregation + storage"
+ ros_binding: { node_name: fault_manager, namespace: / }
+
+ # ── Visualization ─────────────────────────────────────────────────
+ - id: foxglove-bridge
+ name: "Foxglove Bridge"
+ category: "visualization"
+ is_located_on: rbtheron
+ description: "WebSocket bridge on :8765"
+ ros_binding: { node_name: foxglove_bridge, namespace: / }
+
+ # ── Diagnostics (operator-invoked) ────────────────────────────────
+ - id: health-check
+ name: "Health Check"
+ category: "diagnostics"
+ is_located_on: rbtheron
+ description: "Operator-invoked differential-diagnosis operations (lidar/localization/drivetrain/costmap) for root-cause confirmation"
+ ros_binding: { node_name: health_check, namespace: / }
+
+# =============================================================================
+# FUNCTIONS - what the user actually selects in the tree to ask
+# "is this capability working?"
+# =============================================================================
+functions:
+ - id: autonomous-navigation
+ name: "Autonomous Navigation"
+ category: "mobility"
+ description: "Plan + drive to a goal pose - the headline OTA demo capability"
+ hosted_by:
+ - bt-navigator
+ - planner-server
+ - controller-server
+ - smoother-server
+ - route-server
+ - behavior-server
+ - waypoint-follower
+ - velocity-smoother
+ - collision-monitor
+ - docking-server
+ - global-costmap
+ - local-costmap
+ - lifecycle-manager-navigation
+
+ - id: localization
+ name: "Localization"
+ category: "mobility"
+ description: "Where is the robot in the map - AMCL + map server"
+ hosted_by:
+ - amcl
+ - map-server
+ - lifecycle-manager-localization
+
+ - id: perception
+ name: "Perception"
+ category: "sensing"
+ description: "LaserScan stream feeding nav2 - the OTA target"
+ hosted_by:
+ - scan-sensor-node
+ - ros-gz-bridge
+
+ - id: fleet-diagnostics
+ name: "Fleet Diagnostics"
+ category: "diagnostics"
+ description: "SOVD REST surface + fault aggregation + operator health checks (lidar / localization / drivetrain / costmap) for root-cause confirmation"
+ hosted_by:
+ - medkit-gateway
+ - medkit-fault-manager
+ - health-check
+
+ - id: live-telemetry
+ name: "Live Telemetry"
+ category: "observability"
+ description: "Foxglove bridge + URDF publisher feeding the 3D panel"
+ hosted_by:
+ - foxglove-bridge
+ - robot-state-publisher
diff --git a/demos/ota_nav2_sensor_fix/ota_update_plugin/CMakeLists.txt b/demos/ota_nav2_sensor_fix/ota_update_plugin/CMakeLists.txt
new file mode 100644
index 0000000..050ca09
--- /dev/null
+++ b/demos/ota_nav2_sensor_fix/ota_update_plugin/CMakeLists.txt
@@ -0,0 +1,86 @@
+# Copyright 2026 bburda
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+cmake_minimum_required(VERSION 3.16)
+project(ota_update_plugin CXX)
+
+if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
+ add_compile_options(-Wall -Wextra -Wpedantic -Wshadow -Wconversion)
+endif()
+
+set(CMAKE_CXX_STANDARD 17)
+set(CMAKE_CXX_STANDARD_REQUIRED ON)
+set(CMAKE_POSITION_INDEPENDENT_CODE ON)
+
+find_package(ament_cmake REQUIRED)
+find_package(ros2_medkit_cmake REQUIRED)
+include(ROS2MedkitCompat)
+find_package(ros2_medkit_gateway REQUIRED)
+find_package(nlohmann_json REQUIRED)
+
+# CatalogClient uses cpp-httplib for HTTP. Use gateway's vendored copy as fallback.
+set(_gw_vendored "${ros2_medkit_gateway_DIR}/../vendored/cpp_httplib")
+medkit_find_cpp_httplib(VENDORED_DIR "${_gw_vendored}")
+unset(_gw_vendored)
+
+# Static core library: plugin + tests both link against this.
+add_library(ota_update_plugin_core STATIC
+ src/ota_update_plugin.cpp
+ src/catalog_client.cpp
+ src/operation_dispatcher.cpp
+ src/process_runner.cpp
+)
+target_include_directories(ota_update_plugin_core
+ PUBLIC
+ $
+ $
+)
+ament_target_dependencies(ota_update_plugin_core ros2_medkit_gateway)
+target_link_libraries(ota_update_plugin_core
+ PUBLIC
+ nlohmann_json::nlohmann_json
+ cpp_httplib_target
+)
+set_target_properties(ota_update_plugin_core PROPERTIES POSITION_INDEPENDENT_CODE ON)
+
+# MODULE target: loaded via dlopen at runtime by PluginManager.
+# Symbols from gateway_lib are resolved from the host process at runtime.
+add_library(ota_update_plugin MODULE src/plugin_exports.cpp)
+target_link_libraries(ota_update_plugin PRIVATE ota_update_plugin_core)
+set_target_properties(ota_update_plugin PROPERTIES
+ PREFIX ""
+ OUTPUT_NAME "ota_update_plugin"
+)
+# Allow unresolved symbols - they resolve from the host process at runtime
+target_link_options(ota_update_plugin PRIVATE
+ -Wl,--unresolved-symbols=ignore-all
+)
+
+install(TARGETS ota_update_plugin
+ LIBRARY DESTINATION lib/${PROJECT_NAME}
+)
+install(DIRECTORY include/ DESTINATION include)
+
+if(BUILD_TESTING)
+ find_package(ament_cmake_gtest REQUIRED)
+ ament_add_gtest(test_ota_update_plugin
+ test/test_operation_dispatcher.cpp
+ test/test_catalog_client.cpp
+ test/test_plugin_smoke.cpp
+ )
+ target_link_libraries(test_ota_update_plugin ota_update_plugin_core)
+ target_include_directories(test_ota_update_plugin PRIVATE src)
+endif()
+
+ament_package()
diff --git a/demos/ota_nav2_sensor_fix/ota_update_plugin/include/ota_update_plugin/ota_update_plugin.hpp b/demos/ota_nav2_sensor_fix/ota_update_plugin/include/ota_update_plugin/ota_update_plugin.hpp
new file mode 100644
index 0000000..145da10
--- /dev/null
+++ b/demos/ota_nav2_sensor_fix/ota_update_plugin/include/ota_update_plugin/ota_update_plugin.hpp
@@ -0,0 +1,129 @@
+// Copyright 2026 bburda
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#pragma once
+
+#include