Skip to content

feat(temporal): collision-response workflow + MQTT bridge - #15

Merged
bkearns merged 6 commits into
mainfrom
feat/collision-response-workflow
May 5, 2026
Merged

feat(temporal): collision-response workflow + MQTT bridge#15
bkearns merged 6 commits into
mainfrom
feat/collision-response-workflow

Conversation

@bkearns

@bkearns bkearns commented May 5, 2026

Copy link
Copy Markdown
Contributor

Wires the full demo: contact sensor in Gazebo → MQTT collision event → Temporal workflow → MQTT twist commands → rover backs up + turns right 90° + drives forward.

Stacks on PR #14 (gazebo + robot split). Merge order: #14 then this.

What's in

Robot container (docker/robot/Dockerfile)

  • Add ros-humble-ros-gz-interfaces, ros-humble-geometry-msgs, python3-paho-mqtt.
  • New bridge_node/collision_publisher.py — subscribes to ROS /contacts (republished from gz /perseverance/contacts), publishes one MQTT event on events/{robot_id}/collision per real impact (2 s debounce).
  • New bridge_node/twist_subscriber.py — subscribes to MQTT cmd/{robot_id}/twist, republishes onto ROS /cmd_vel.
  • paho 1.x API (Ubuntu jammy ships 1.6, no CallbackAPIVersion).

Cloud (cloud/internal/collision/ + cloud/cmd/collision-worker/)

  • CollisionResponse workflow — back -0.30 m/s 3 s → settle → turn -0.50 rad/s 3.14 s (~90° right) → settle → forward 0.40 m/s 5 s → stop.
  • SendTwist activity — republishes the twist at 10 Hz on cmd/{robot_id}/twist so gz DiffDrive's ~0.5 s command timeout doesn't stall the rover; final 0,0 frame at end.
  • MQTTBridge — subscribes to events/+/collision, starts one workflow per inbound event (workflow ID collision-{robot_id}-{ts}).
  • collision-worker cmd hosts both the Temporal worker and the MQTT bridge.

Make targets

  • workers-up / workers-down / workers-status — start, stop, inspect the host-side ota-worker + collision-worker. PID files in .run/.
  • collide — publish a fake collision event via the robot container's paho client (no host MQTT client needed) so the workflow runs without a real impact.

Verified locally

make workers-up
ota-worker        PID …  log .run/ota-worker.log
collision-worker  PID …  log .run/collision-worker.log

make collide
published events/sim-robot-01/collision; check Temporal UI

# collision-worker.log:
collision workflow started workflow_id=collision-sim-robot-01-1778019447315583000

# rover odometry advances through the back / turn / forward phases
# visible at http://localhost:14080/namespaces/default/workflows

Test plan

  • make workers-up → both workers register against their task queues
  • make collide → workflow row appears in Temporal UI
  • rover backs up + turns + drives forward in noVNC view
  • Real contact (drive forward into the boulder) triggers the workflow without manual make collide
  • CI green

bkearns added 6 commits May 5, 2026 15:06
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
User asked for a Temporal job that listens for a robot collision and
backs the rover up, turns right 90 deg, drives forward again. Wires
the full chain.

Robot: ros-humble-ros-gz-interfaces + python3-paho-mqtt; new
collision_publisher (ROS /contacts -> MQTT events/{robot_id}/collision,
2s debounce) and twist_subscriber (MQTT cmd/{robot_id}/twist -> ROS
/cmd_vel). paho 1.x API for jammy.

Cloud cloud/internal/collision + cmd/collision-worker:
- CollisionResponse workflow: back -0.3 m/s 3s, settle, turn -0.5
  rad/s 3.14s (~90 deg right), settle, forward 0.4 m/s 5s, stop.
- SendTwist activity republishes at 10Hz so DiffDrive's ~0.5s
  command timeout does not stall the rover.
- mqttbridge subscribes to events/+/collision and starts a
  workflow per inbound event.

Make: workers-up / workers-down / workers-status to run the host-side
ota-worker + collision-worker. 'make collide' triggers a fake event.
PID files in .run/.

Verified end-to-end: collision-{robot_id}-{ts} workflow visible at
http://localhost:14080; rover odometry advances through the phases.
Demo glue. Rounds out the host-side runner so the OTA story is one
command per scenario, mirroring 'make collide' for the collision
demo.

Targets:
  controlplane-up / -down / -status — start/stop the controlplane HTTP
    API binary against the lab Temporal + TSDB. PID file in .run/.
  ota-circle      — build sim/controllers/drive-circle, push to the
    lab registry (localhost:14050), and POST a rollout to
    /v1/ota/rollouts targeting sim-robot-01.
  ota-figure-eight — same for drive-figure-eight.
  ota-status      — GET /v1/ota/rollouts (jq if present).

Override OTA_REGISTRY / OTA_ROBOT_ID / OTA_CP_HOST to retarget.

Verified: 'make controlplane-up' brings the API up; healthz 200;
ota-status returns null pre-rollout.
User reported the OTA rollout was stuck. Two fixes:

1. agent/internal/ota/docker.go — auto-detect docker vs podman.
   AGENT_CONTAINER_BIN env override > docker on PATH > podman.
   Pull(): if podman, append --tls-verify=false so localhost:14050
   (HTTP-only lab registry) doesn't get rejected.

2. New native-host agent mode. Rootless podman bind-mounts the
   daemon socket as a non-stat-able file (idmap remaps owner
   UID); even userns_mode=keep-id + chmod 666 didn't get container
   root through. Side-stepping by running the agent as a Go binary
   on the host:
   - docker-compose.sim.yml: robot publishes :50051 to host;
     in-container agent profile-gated under in-container-agent.
   - Makefile: agent-up / agent-down / agent-status targets.

Verified: make agent-up + make ota-circle ->
  rollout completed; robot-app container Up running
  localhost:14050/robot-app:circle-v1; rover circles in noVNC.
User reported rollout jobs not finishing. Root cause: the cloud
MQTT bridge routes acks like this:

  PHASE_ROLLED_BACK -> {rollout_id}-robot-{robot_id}-rollback
  (everything else) -> {rollout_id}-robot-{robot_id}

When a rollback command was issued for the very first OTA on a
robot (or any case where rollback's prev-digest is empty), the
agent's runRollback:
  - tried Docker.Rollback(ctx, prev='') -> error
  - published PHASE_FAILED with detail
  - PHASE_FAILED was routed to OTASingleRobot (already exited),
    NOT to the -rollback workflow that was waiting for a phase
    signal
  - The rollback workflow stayed blocked on its 5-minute timer
  - The parent OTASingleRobot was waiting on the rollback child's
    completion -> the parent OTARollout never RecordRolloutEnded
  - The DB row showed 'pending' for ~5 minutes per failed rollout

Fix: agent's runRollback always emits PHASE_ROLLED_BACK, with the
failure mode captured in detail when rollback couldn't actually
restore. The rollback workflow always terminates within ms of the
agent processing the command; the parent unblocks; the rollout
status moves to its terminal value (canary_failed / aborted /
completed) immediately.

Verified: post-fix, ota-circle and ota-figure-eight runs land in
~1 second with status=completed. Stale 'pending' rows from before
this fix will resolve naturally as their 5-minute timers fire,
or via curl POST /v1/ota/rollouts/{id}/abort.
…t make targets

The make-target inventory has grown a lot since the last doc pass:
agent-up/-down/-status, workers-up/-down/-status, controlplane-up/-
down/-status, ota-circle, ota-figure-eight, ota-status, collide,
sim-drive-{fwd,back,left,right,stop}, sim-gui, sim-up-headless,
container-info, plus the host-vs-container split (the agent now
runs as a native macOS Go binary by default).

README.md
- New service-shape diagram showing gazebo + robot + agent + workers
  + controlplane + lab cluster.
- Lab quickstart is now four make targets in sequence (sim-up,
  agent-up, workers-up, controlplane-up).
- Drive demo, OTA demo, Collision demo each get a one-command
  recipe.
- Full make-target reference grouped by purpose.
- Default lab ports table includes the new robot:50051, controlplane
  :8081, gazebo :14680/:14900.

ONBOARDING.md
- Section 7 (run end-to-end) replaced with the new make-target
  flow + .run/ PID file convention.
- Section 9 covers OTA + Collision demos as one command each.
- Troubleshooting table refreshed with the issues actually hit
  on this machine: docker-not-on-PATH, podman HTTPS pull rejection,
  rollouts stuck pending, robot port not published.

CLAUDE.md
- Build/test/run section rewritten to enumerate the host-side
  processes and demo helpers.

installer/README.md
- Stale 5xxx-port table replaced with the current 14xxx lab + 2xxxx
  CI matrix, plus a sim-only ports section (14680, 14900, 50051)
  and a host-side processes section (8081).
@bkearns
bkearns merged commit dce8d96 into main May 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant