From 1b5eab195281ead8c8173e4373e92fbdc9bf100c Mon Sep 17 00:00:00 2001 From: Ben Kearns <35475+bkearns@users.noreply.github.com> Date: Tue, 5 May 2026 15:06:57 -0700 Subject: [PATCH] feat(sim): split sim container into gazebo + robot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls the simulator out of the bundled "sim" container into its own service. The robot's always-on ROS infrastructure (bridge_node + sim_battery) moves into a sibling "robot" service. The OTA-swappable robot software (sim/controllers/*) is unchanged — those images still run as agent-managed containers on the lab network. Three services, all on the same lab network and ROS DDS domain (ROS_DOMAIN_ID=42, RMW=cyclonedds): gazebo - ign gazebo + ros_gz_bridge + Xvfb/x11vnc/noVNC GUI stack. Subscribes to /cmd_vel on ROS DDS and forwards to the gz diff_drive plugin. Now also bridges /perseverance/contacts so collision events are visible ROS-side (groundwork for the Temporal collision-response workflow). robot - bridge_node (rclpy → gRPC) + sim_battery. The agent's persistent peer. agent - unchanged otherwise. BRIDGE_ADDR moves from a Unix socket on a shared volume to TCP `robot:50051` over the lab network. BROKER_URL stays on tcp://mqtt:1883 (lab broker, port 14883 host-side). bridge_node/server.py: new --listen flag accepting either bare host:port (TCP) or unix://path. --socket retained for back-compat. moon.sdf: a 0.9 m boulder at x=8 directly in the rover's forward path. Rover deck has a contact sensor (`bumper`) tied to the gz-sim contact system; it emits Contacts messages on /perseverance/contacts whenever the deck collides. docker/sim/ removed; replaced by docker/gazebo/ + docker/robot/. Worlds + models live under docker/gazebo/ now. Verified locally on Apple Silicon + podman: - gazebo, robot, agent all Up - robot: "bridge listening on 0.0.0.0:50051" - agent: "bridge connected addr=robot:50051" - ros_gz_bridge: 6 bidirectional bridges including /perseverance/contacts ↔ ignition.msgs.Contacts - Lab MQTT, Postgres, Temporal, Registry all healthy --- bridge/bridge_node/server.py | 36 ++++-- docker/gazebo/Dockerfile | 53 ++++++++ docker/gazebo/entrypoint.sh | 92 ++++++++++++++ .../models/perseverance/model.config | 0 .../models/perseverance/model.sdf | 14 +++ docker/{sim => gazebo}/worlds/moon.sdf | 21 ++++ docker/robot/Dockerfile | 41 ++++++ docker/robot/entrypoint.sh | 27 ++++ docker/sim/Dockerfile | 68 ---------- docker/sim/entrypoint.sh | 118 ------------------ .../docker-compose/docker-compose.sim.yml | 73 +++++------ 11 files changed, 309 insertions(+), 234 deletions(-) create mode 100644 docker/gazebo/Dockerfile create mode 100644 docker/gazebo/entrypoint.sh rename docker/{sim => gazebo}/models/perseverance/model.config (100%) rename docker/{sim => gazebo}/models/perseverance/model.sdf (95%) rename docker/{sim => gazebo}/worlds/moon.sdf (87%) create mode 100644 docker/robot/Dockerfile create mode 100644 docker/robot/entrypoint.sh delete mode 100644 docker/sim/Dockerfile delete mode 100644 docker/sim/entrypoint.sh diff --git a/bridge/bridge_node/server.py b/bridge/bridge_node/server.py index 67e2c12..4843f9f 100644 --- a/bridge/bridge_node/server.py +++ b/bridge/bridge_node/server.py @@ -133,17 +133,33 @@ def _on_msg(self, msg: BatteryState) -> None: self.fanout.push(ev) -def serve(socket_path: str) -> None: +def serve(addr: str) -> None: + """Run the bridge gRPC server. + + `addr` accepts either a bare ``host:port`` for TCP, or a path / + ``unix://`` form for a Unix domain socket. Determined by + whether the value contains a colon and no slash. + """ fanout = TopicFanout() server = grpc.server(futures.ThreadPoolExecutor(max_workers=8)) pbg.add_BridgeServicer_to_server(BridgeService(fanout), server) - if os.path.exists(socket_path): - os.unlink(socket_path) - server.add_insecure_port(f"unix://{socket_path}") + if addr.startswith("unix://"): + listen = addr + sock_path = addr[len("unix://"):] + if os.path.exists(sock_path): + os.unlink(sock_path) + elif ":" in addr and "/" not in addr: + listen = addr # bare host:port -> TCP + else: + # bare path -> Unix domain socket + if os.path.exists(addr): + os.unlink(addr) + listen = f"unix://{addr}" + server.add_insecure_port(listen) server.start() - log.info("bridge listening on %s", socket_path) + log.info("bridge listening on %s", listen) if HAVE_ROS: rclpy.init(args=None) @@ -163,10 +179,16 @@ def serve(socket_path: str) -> None: def main() -> None: # pragma: no cover p = argparse.ArgumentParser() - p.add_argument("--socket", default="/run/temporal-hack-bridge.sock") + # --listen is the modern arg (TCP host:port or unix:// path). + # --socket kept for backwards compat with older callers. + p.add_argument("--listen", default=None, + help="gRPC listen address: 'host:port' or 'unix:///path'") + p.add_argument("--socket", default=None, + help="(deprecated) Unix domain socket path. Use --listen.") args = p.parse_args() + addr = args.listen or args.socket or "/run/temporal-hack-bridge.sock" logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s") - serve(args.socket) + serve(addr) if __name__ == "__main__": diff --git a/docker/gazebo/Dockerfile b/docker/gazebo/Dockerfile new file mode 100644 index 0000000..b6362c7 --- /dev/null +++ b/docker/gazebo/Dockerfile @@ -0,0 +1,53 @@ +# Gazebo (simulator) container. +# +# Runs Ignition Fortress + ros_gz_bridge + an Xvfb/x11vnc/noVNC stack +# for browser-served GUI access. NOTHING ROS-application-specific +# lives here — the rover's controllers (drive-circle, drive-figure-eight) +# and the agent's bridge_node live in their own containers and reach +# this one over the lab network's ROS DDS domain. +# +# What's in vs. out: +# IN: gz sim, ros_gz_bridge, Xvfb/x11vnc/novnc/fluxbox, mesa, +# worlds/, models/ +# OUT: bridge_node (rclpy → agent gRPC), sim_battery, controllers +FROM ros:humble-ros-base + +ENV DEBIAN_FRONTEND=noninteractive +ENV ROS_DOMAIN_ID=42 +ENV RMW_IMPLEMENTATION=rmw_cyclonedds_cpp + +RUN apt-get update && apt-get install -y --no-install-recommends \ + wget gnupg lsb-release ca-certificates \ + && wget -qO /usr/share/keyrings/pkgs-osrf-archive-keyring.gpg \ + https://packages.osrfoundation.org/gazebo.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/pkgs-osrf-archive-keyring.gpg] http://packages.osrfoundation.org/gazebo/ubuntu-stable $(lsb_release -cs) main" \ + > /etc/apt/sources.list.d/gazebo-stable.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + ros-humble-rmw-cyclonedds-cpp \ + ros-humble-ros-gz-bridge \ + ignition-fortress \ + libgl1-mesa-dri \ + libgl1-mesa-glx \ + mesa-utils \ + xvfb \ + x11vnc \ + novnc \ + websockify \ + fluxbox \ + net-tools \ + && rm -rf /var/lib/apt/lists/* + +# Sim worlds + models. GZ_SIM_RESOURCE_PATH lets gz-sim resolve +# `model://...` includes at runtime. +COPY docker/gazebo/worlds /opt/sim/worlds +COPY docker/gazebo/models /opt/sim/models +ENV GZ_SIM_RESOURCE_PATH="/opt/sim/models:/opt/sim/worlds" + +COPY docker/gazebo/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# noVNC (browser GUI): http://localhost:14680 +# Raw VNC (any client): localhost:14900 +EXPOSE 5900 6080 +ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker/gazebo/entrypoint.sh b/docker/gazebo/entrypoint.sh new file mode 100644 index 0000000..8c7f72f --- /dev/null +++ b/docker/gazebo/entrypoint.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# gazebo container entrypoint: +# Xvfb -> fluxbox -> x11vnc -> websockify (browser GUI pipe) +# ign gazebo -r -v 4 (sim engine + GUI) +# ros_gz_bridge (subscribes to /cmd_vel +# on ROS DDS, forwards to +# the gz cmd_vel topic) +set -eo pipefail + +# ROS 2's setup.bash references vars under nounset; wrap. +set +u; source /opt/ros/humble/setup.bash; set -u + +SIM_WORLD="${SIM_WORLD:-moon.sdf}" +HEADLESS="${HEADLESS:-0}" +DISPLAY_NUM="${DISPLAY_NUM:-1}" +SCREEN_GEOMETRY="${SCREEN_GEOMETRY:-1920x1200x24}" + +PIDS=() +shutdown() { + echo "[gazebo] shutting down" + for pid in "${PIDS[@]}"; do kill "$pid" 2>/dev/null || true; done + exit 0 +} +trap shutdown SIGINT SIGTERM + +if [ "$HEADLESS" = "1" ]; then + echo "[gazebo] HEADLESS=1; running ign gazebo server only ($SIM_WORLD)" + ign gazebo -s -r -v 4 "$SIM_WORLD" & + PIDS+=($!) +else + echo "[gazebo] starting Xvfb on :$DISPLAY_NUM ($SCREEN_GEOMETRY)" + Xvfb ":$DISPLAY_NUM" -screen 0 "$SCREEN_GEOMETRY" -nolisten tcp & + PIDS+=($!) + sleep 1 + export DISPLAY=":$DISPLAY_NUM" + + fluxbox 2>/dev/null & + PIDS+=($!) + + x11vnc -display "$DISPLAY" -forever -shared -nopw -quiet -rfbport 5900 -bg -o /tmp/x11vnc.log + + websockify --web=/usr/share/novnc 6080 localhost:5900 & + PIDS+=($!) + + echo "[gazebo] launching ign gazebo with GUI: $SIM_WORLD" + export LIBGL_ALWAYS_SOFTWARE=1 + export OGRE2_RTSHADERSYSTEM_WRITE_SHADERS_TO_DISK=0 + ign gazebo -r -v 4 "$SIM_WORLD" & + PIDS+=($!) + + echo + echo " ┌──────────────────────────────────────────────────────────────────────────────┐" + echo " │ Gazebo GUI: http://localhost:14680/vnc.html?autoconnect=1&resize=scale │" + echo " │ Raw VNC: localhost:14900 (no password) │" + echo " └──────────────────────────────────────────────────────────────────────────────┘" + echo +fi + +# Bridge ROS 2 /cmd_vel and odom for the perseverance rover. The +# diff-drive plugin in moon.sdf listens on /model/perseverance/cmd_vel; +# remap so external ROS 2 controllers can publish to plain /cmd_vel. +sleep 4 +case "$SIM_WORLD" in + moon.sdf) + echo "[gazebo] starting ros_gz_bridge: /cmd_vel + odom + /perseverance/contacts" + ros2 run ros_gz_bridge parameter_bridge \ + /model/perseverance/cmd_vel@geometry_msgs/msg/Twist@ignition.msgs.Twist \ + /model/perseverance/odometry@nav_msgs/msg/Odometry@ignition.msgs.Odometry \ + /perseverance/contacts@ros_gz_interfaces/msg/Contacts@ignition.msgs.Contacts \ + --ros-args \ + -r /model/perseverance/cmd_vel:=/cmd_vel \ + -r /model/perseverance/odometry:=/odom \ + -r /perseverance/contacts:=/contacts & + PIDS+=($!) + ;; + diff_drive.sdf|diff_drive_skid.sdf) + echo "[gazebo] starting ros_gz_bridge: /cmd_vel ↔ /model/vehicle_blue/cmd_vel" + ros2 run ros_gz_bridge parameter_bridge \ + /model/vehicle_blue/cmd_vel@geometry_msgs/msg/Twist@ignition.msgs.Twist \ + /model/vehicle_blue/odometry@nav_msgs/msg/Odometry@ignition.msgs.Odometry \ + --ros-args \ + -r /model/vehicle_blue/cmd_vel:=/cmd_vel \ + -r /model/vehicle_blue/odometry:=/odom & + PIDS+=($!) + ;; + *) + echo "[gazebo] no ros_gz_bridge wired for world '$SIM_WORLD'" + ;; +esac + +# Wait for any background process to exit (or signal). +wait -n diff --git a/docker/sim/models/perseverance/model.config b/docker/gazebo/models/perseverance/model.config similarity index 100% rename from docker/sim/models/perseverance/model.config rename to docker/gazebo/models/perseverance/model.config diff --git a/docker/sim/models/perseverance/model.sdf b/docker/gazebo/models/perseverance/model.sdf similarity index 95% rename from docker/sim/models/perseverance/model.sdf rename to docker/gazebo/models/perseverance/model.sdf index ddb92b0..47db1bb 100644 --- a/docker/sim/models/perseverance/model.sdf +++ b/docker/gazebo/models/perseverance/model.sdf @@ -30,6 +30,20 @@ 1.20 1.40 0.40 + + + + 10 + + deck_col + /perseverance/contacts + + + 1.20 1.40 0.40 diff --git a/docker/sim/worlds/moon.sdf b/docker/gazebo/worlds/moon.sdf similarity index 87% rename from docker/sim/worlds/moon.sdf rename to docker/gazebo/worlds/moon.sdf index c9edb95..ff6c368 100644 --- a/docker/sim/worlds/moon.sdf +++ b/docker/gazebo/worlds/moon.sdf @@ -105,6 +105,27 @@ 0.18 0.17 0.16 10.36 0.34 0.32 1 + + + true + 8 0 0.50 0 0 0 + + + 0.90 0.80 0.50 + + + 0.90 0.80 0.50 + + 0.22 0.20 0.17 1 + 0.45 0.41 0.36 1 + 0.05 0.05 0.05 1 + + + + + model://perseverance diff --git a/docker/robot/Dockerfile b/docker/robot/Dockerfile new file mode 100644 index 0000000..2a0e73d --- /dev/null +++ b/docker/robot/Dockerfile @@ -0,0 +1,41 @@ +# Robot container — always-on ROS infrastructure for the simulated +# rover. +# +# What's in vs. out: +# IN: bridge_node (rclpy → gRPC for the agent), sim_battery +# OUT: gz sim, ros_gz_bridge, GUI stack — those live in docker/gazebo/. +# OTA-swappable controllers — those live as separate +# sim/controllers/* images managed by the agent. +# +# This container is *not* the OTA target. It hosts the robot's +# always-on ROS plumbing. The OTA-swappable software runs as a +# sibling container (drive-circle, drive-figure-eight, ...) on the +# same lab network and ROS DDS domain. +FROM ros:humble-ros-base + +ENV DEBIAN_FRONTEND=noninteractive +ENV ROS_DOMAIN_ID=42 +ENV RMW_IMPLEMENTATION=rmw_cyclonedds_cpp +ENV PYTHONUNBUFFERED=1 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ros-humble-rmw-cyclonedds-cpp \ + ros-humble-sensor-msgs \ + python3-pip \ + python3-grpcio \ + python3-grpc-tools \ + && rm -rf /var/lib/apt/lists/* + +# Bridge package (rclpy → gRPC) and sim_battery publisher. +WORKDIR /opt/bridge +COPY bridge/ /opt/bridge/ +RUN python3 -m pip install --no-cache-dir --upgrade "pip>=23.0" \ + && python3 -m pip install --no-cache-dir --break-system-packages /opt/bridge + +COPY docker/robot/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# bridge_node gRPC server (TCP) — agent connects here. +EXPOSE 50051 + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker/robot/entrypoint.sh b/docker/robot/entrypoint.sh new file mode 100644 index 0000000..da66dce --- /dev/null +++ b/docker/robot/entrypoint.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# robot container entrypoint: +# sim_battery (rclpy) — synthetic /battery_state publisher +# bridge_node.server (rclpy) — gRPC peer for the agent on TCP 50051 +set -eo pipefail + +set +u; source /opt/ros/humble/setup.bash; set -u + +LISTEN="${BRIDGE_LISTEN:-0.0.0.0:50051}" + +PIDS=() +shutdown() { + echo "[robot] shutting down" + for pid in "${PIDS[@]}"; do kill "$pid" 2>/dev/null || true; done + exit 0 +} +trap shutdown SIGINT SIGTERM + +# sim_battery is a stand-in /battery_state publisher (Gazebo doesn't +# emit one for our model). Real robots publish their own; this is +# only present in the dev sim. +echo "[robot] starting sim_battery" +python3 -m bridge_node.sim_battery & +PIDS+=($!) + +echo "[robot] starting bridge gRPC on $LISTEN" +exec python3 -m bridge_node.server --listen "$LISTEN" diff --git a/docker/sim/Dockerfile b/docker/sim/Dockerfile deleted file mode 100644 index 1281e1b..0000000 --- a/docker/sim/Dockerfile +++ /dev/null @@ -1,68 +0,0 @@ -# Robot simulation container — Moon world + Ingenuity helicopter. -# -# Base: ros:humble-ros-base (multi-arch — amd64 + arm64 native). -# Sim: Gazebo Harmonic (LTS 2024-2028) from packages.osrfoundation.org. -# Replaces Ignition Fortress; same plugin family (gz-sim-*). -# GUI: Xvfb + fluxbox + x11vnc + noVNC + websockify, browser-served. -# -# Why Harmonic, not Fortress: the AndrejOrsula moon world targets -# gz-sim-* plugin filenames. Harmonic ships them and is arm64-native. -# Why we don't run their Blender procgen here: Blender 4.3 is x86_64- -# only on Linux, and their amd64 image hits a /bin/sh portability bug -# under buildah. The procgen step is a clean follow-up; for now we -# ship a hand-authored moon.sdf + ingenuity.sdf. -FROM ros:humble-ros-base - -ENV DEBIAN_FRONTEND=noninteractive -ENV ROS_DOMAIN_ID=42 -ENV RMW_IMPLEMENTATION=rmw_cyclonedds_cpp - -RUN apt-get update && apt-get install -y --no-install-recommends \ - wget gnupg lsb-release ca-certificates \ - && wget -qO /usr/share/keyrings/pkgs-osrf-archive-keyring.gpg \ - https://packages.osrfoundation.org/gazebo.gpg \ - && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/pkgs-osrf-archive-keyring.gpg] http://packages.osrfoundation.org/gazebo/ubuntu-stable $(lsb_release -cs) main" \ - > /etc/apt/sources.list.d/gazebo-stable.list \ - && apt-get update \ - && apt-get install -y --no-install-recommends \ - ros-humble-rmw-cyclonedds-cpp \ - ros-humble-sensor-msgs \ - ros-humble-ros-gz-bridge \ - ignition-fortress \ - libgl1-mesa-dri \ - libgl1-mesa-glx \ - mesa-utils \ - python3-pip \ - python3-grpcio \ - python3-grpc-tools \ - xvfb \ - x11vnc \ - novnc \ - websockify \ - fluxbox \ - net-tools \ - && rm -rf /var/lib/apt/lists/* - -# Bridge package + deps. -WORKDIR /opt/bridge -COPY bridge/ /opt/bridge/ -RUN python3 -m pip install --no-cache-dir --upgrade "pip>=23.0" \ - && python3 -m pip install --no-cache-dir --break-system-packages /opt/bridge - -# Sim worlds + models. GZ_SIM_RESOURCE_PATH is read by gz-sim to -# resolve `model://...` URIs in include directives. -COPY docker/sim/worlds /opt/sim/worlds -COPY docker/sim/models /opt/sim/models -ENV GZ_SIM_RESOURCE_PATH="/opt/sim/models:/opt/sim/worlds" - -# Sim launch script. -COPY docker/sim/entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh - -VOLUME ["/run/bridge"] - -# noVNC (browser GUI): http://localhost:14680 -# Raw VNC (any client): localhost:14900 -EXPOSE 5900 6080 - -ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker/sim/entrypoint.sh b/docker/sim/entrypoint.sh deleted file mode 100644 index a17efeb..0000000 --- a/docker/sim/entrypoint.sh +++ /dev/null @@ -1,118 +0,0 @@ -#!/usr/bin/env bash -set -eo pipefail - -# ROS 2's setup.bash references variables it doesn't always pre-set -# (AMENT_TRACE_SETUP_FILES, etc.). Disable nounset for the source -# step only; turn it back on for our own logic afterwards. -set +u -source /opt/ros/humble/setup.bash -set -u - -BRIDGE_SOCKET="${BRIDGE_SOCKET:-/run/bridge/temporal-hack-bridge.sock}" -HEADLESS="${HEADLESS:-0}" # default 0: show the GUI via noVNC. - # Set HEADLESS=1 to skip the X stack entirely. -DISPLAY_NUM="${DISPLAY_NUM:-1}" -SCREEN_GEOMETRY="${SCREEN_GEOMETRY:-1920x1200x24}" - -mkdir -p "$(dirname "$BRIDGE_SOCKET")" - -# Process tracker so we can clean up children on SIGTERM. -PIDS=() -shutdown() { - echo "[sim] shutting down" - for pid in "${PIDS[@]}"; do kill "$pid" 2>/dev/null || true; done - exit 0 -} -trap shutdown SIGINT SIGTERM - -SIM_WORLD="${SIM_WORLD:-moon.sdf}" - -if [ "$HEADLESS" = "1" ]; then - echo "[sim] HEADLESS=1; running ign gazebo server only ($SIM_WORLD)" - ign gazebo -s -v 4 "$SIM_WORLD" & - PIDS+=($!) -else - # Start a virtual X display, a minimal window manager, a VNC - # server bound to that display, and a websockify→noVNC bridge so - # the GUI is reachable over a browser at port 6080 (mapped to - # 14680 on the host by docker-compose.sim.yml). - echo "[sim] starting Xvfb on :$DISPLAY_NUM ($SCREEN_GEOMETRY)" - Xvfb ":$DISPLAY_NUM" -screen 0 "$SCREEN_GEOMETRY" -nolisten tcp & - PIDS+=($!) - sleep 1 - export DISPLAY=":$DISPLAY_NUM" - - echo "[sim] starting fluxbox window manager" - fluxbox 2>/dev/null & - PIDS+=($!) - - echo "[sim] starting x11vnc on :5900" - x11vnc -display "$DISPLAY" -forever -shared -nopw -quiet -rfbport 5900 -bg -o /tmp/x11vnc.log - # x11vnc -bg already daemonised; nothing to push to PIDS. - - echo "[sim] starting noVNC websockify on :6080" - # Debian's `novnc` package ships /usr/share/novnc/ + a launcher. - websockify --web=/usr/share/novnc 6080 localhost:5900 & - PIDS+=($!) - - echo "[sim] launching Ignition Fortress with GUI: $SIM_WORLD" - # Software OpenGL is the only path that works in Xvfb (no GPU). - # OGRE2 will use Mesa's llvmpipe and render to the virtual fb. - export LIBGL_ALWAYS_SOFTWARE=1 - export OGRE2_RTSHADERSYSTEM_WRITE_SHADERS_TO_DISK=0 - # -r: start running (not paused). Without this Fortress opens - # paused and the user has to click Play before any movement - # commands have effect. - ign gazebo -r -v 4 "$SIM_WORLD" & - PIDS+=($!) - - echo - echo " ┌──────────────────────────────────────────────────────────────────────────────┐" - echo " │ Gazebo GUI: http://localhost:14680/vnc.html?autoconnect=1&resize=scale │" - echo " │ Raw VNC: localhost:14900 (no password) │" - echo " └──────────────────────────────────────────────────────────────────────────────┘" - echo -fi - -# ROS 2 ↔ Ignition bridge for the diff_drive demo world only. That -# world's `vehicle_blue` model subscribes to /model/vehicle_blue/cmd_vel; -# external ROS 2 controllers publish on /cmd_vel, so we remap. Skip -# this for moon.sdf (no vehicle_blue there) — the bridge would just -# sit waiting for a non-existent topic. -case "$SIM_WORLD" in - diff_drive.sdf|diff_drive_skid.sdf) - sleep 4 - echo "[sim] starting ros_gz_bridge: /cmd_vel ↔ /model/vehicle_blue/cmd_vel" - ros2 run ros_gz_bridge parameter_bridge \ - /model/vehicle_blue/cmd_vel@geometry_msgs/msg/Twist@ignition.msgs.Twist \ - /model/vehicle_blue/odometry@nav_msgs/msg/Odometry@ignition.msgs.Odometry \ - --ros-args \ - -r /model/vehicle_blue/cmd_vel:=/cmd_vel \ - -r /model/vehicle_blue/odometry:=/odom & - PIDS+=($!) - ;; - moon.sdf) - sleep 4 - echo "[sim] starting ros_gz_bridge: /cmd_vel ↔ /model/perseverance/cmd_vel" - ros2 run ros_gz_bridge parameter_bridge \ - /model/perseverance/cmd_vel@geometry_msgs/msg/Twist@ignition.msgs.Twist \ - /model/perseverance/odometry@nav_msgs/msg/Odometry@ignition.msgs.Odometry \ - --ros-args \ - -r /model/perseverance/cmd_vel:=/cmd_vel \ - -r /model/perseverance/odometry:=/odom & - PIDS+=($!) - ;; - *) - echo "[sim] no ros_gz_bridge wired for world '$SIM_WORLD'" - ;; -esac - -# Synthetic battery (TurtleBot3 sim doesn't emit /battery_state). -sleep 6 -python3 -m bridge_node.sim_battery & -PIDS+=($!) - -# Bridge: blocks foreground; this is the canonical lifetime of the -# container. -echo "[sim] starting bridge on $BRIDGE_SOCKET" -exec python3 -m bridge_node.server --socket "$BRIDGE_SOCKET" diff --git a/installer/docker-compose/docker-compose.sim.yml b/installer/docker-compose/docker-compose.sim.yml index 9b4f37f..8ebf412 100644 --- a/installer/docker-compose/docker-compose.sim.yml +++ b/installer/docker-compose/docker-compose.sim.yml @@ -1,77 +1,69 @@ -# Sim overlay: brings up one virtual robot — Gazebo + TurtleBot3 + -# the bridge node — and an agent container that consumes the bridge's -# gRPC stream and reports telemetry to the lab MQTT broker. +# Sim overlay — composed onto the lab project. # -# Composed on top of docker-compose.yml under the same project name, -# so the sim and lab containers share a network and the agent can -# reach the broker at `mqtt:1883` (container-internal port — never -# the host-side port mapping). +# Three services: +# gazebo - the simulator + ros_gz_bridge + browser GUI (noVNC) +# robot - always-on ROS infrastructure: bridge_node (rclpy → +# agent gRPC) + sim_battery +# agent - the Go agent; OTAs robot-app images # -# Use: make sim-up (lab cluster + sim + agent) -# make sim-down -# make sim-logs +# The OTA-swappable robot software (controllers in sim/controllers/*) +# is *not* a compose service — the agent spawns those containers on +# demand and joins them to this same network + ROS DDS domain. services: - sim: + gazebo: build: - # Build context is the repo root so the bridge/ tree can be COPY'd. context: ../.. - dockerfile: docker/sim/Dockerfile - image: temporal-hack/sim:dev + dockerfile: docker/gazebo/Dockerfile + image: temporal-hack/gazebo:dev environment: - TURTLEBOT3_MODEL: burger ROS_DOMAIN_ID: 42 - # HEADLESS=0 means the GUI is served over noVNC. To run gzserver - # only (no GUI, no X stack), set HEADLESS=1 in the env. + RMW_IMPLEMENTATION: rmw_cyclonedds_cpp HEADLESS: "${SIM_HEADLESS:-0}" - BRIDGE_SOCKET: /run/bridge/temporal-hack-bridge.sock + SIM_WORLD: "${SIM_WORLD:-moon.sdf}" ports: - # Browser GUI (noVNC): http://localhost:14680/vnc.html?autoconnect=1 - "${SIM_NOVNC_PORT:-14680}:6080" - # Raw VNC (TigerVNC, macOS Screen Sharing, etc.) at localhost:14900 - "${SIM_VNC_PORT:-14900}:5900" - volumes: - - bridge-sock:/run/bridge + + robot: + build: + context: ../.. + dockerfile: docker/robot/Dockerfile + image: temporal-hack/robot:dev + environment: + ROS_DOMAIN_ID: 42 + RMW_IMPLEMENTATION: rmw_cyclonedds_cpp + expose: + - "50051" agent: image: golang:1.22-bookworm depends_on: mqtt: condition: service_healthy - sim: + robot: condition: service_started working_dir: /src/agent environment: ROBOT_ID: sim-robot-01 - # mqtt and sim are sibling services on the same Compose network; - # the agent uses the canonical container-internal ports. + # Lab broker (back to default port 14883 inside the network is 1883). BROKER_URL: tcp://mqtt:1883 BUFFER_PATH: /var/lib/agent/buffer.db - BRIDGE_ADDR: unix:///run/bridge/temporal-hack-bridge.sock + # Bridge moved off Unix-domain socket into a sibling container; + # talk to it over the lab network on the canonical gRPC port. + BRIDGE_ADDR: robot:50051 # OTA-spawned robot-app containers must join the lab network and - # share the sim's ROS_DOMAIN_ID so they can publish /cmd_vel into - # the same DDS partition the ros_gz_bridge listens on. Without - # this, OTA runs the container on the default bridge network and - # the new behaviour never reaches the simulated robot. + # share ROS_DOMAIN_ID so /cmd_vel reaches the gazebo container's + # ros_gz_bridge. OTA_RUN_ARGS: "--network=temporal-hack-lab_default,-e,ROS_DOMAIN_ID=42,-e,RMW_IMPLEMENTATION=rmw_cyclonedds_cpp" - # The repo is bind-mounted read-only. Go's workspace mode tries - # to update go.work.sum on `go run` and fails; turn the workspace - # off so the agent module's own go.mod/go.sum are authoritative. GOWORK: "off" - # Use a writable cache dir; module cache and build cache go to a - # named volume so we don't fight the read-only bind mount. GOCACHE: /var/cache/go-build GOMODCACHE: /var/cache/go-mod volumes: - ../..:/src:ro - - bridge-sock:/run/bridge - agent-data:/var/lib/agent - go-cache:/var/cache/go-build - go-mod:/var/cache/go-mod - # Agent needs the container-runtime socket on the host to - # perform OTA. CONTAINER_SOCK is supplied by the Makefile at - # compose time (resolves to docker.sock or podman.sock). - ${CONTAINER_SOCK:-/var/run/docker.sock}:/var/run/docker.sock - # apt is needed to install the C toolchain for go-sqlite3 (cgo). command: > bash -c " apt-get update -qq && apt-get install -y -qq build-essential >/dev/null && @@ -80,7 +72,6 @@ services: " volumes: - bridge-sock: agent-data: go-cache: go-mod: