skyhub_sitl) is a Dockerized ArduPilot + ROS 2 Humble stack that stands in for a physical drone. Each container runs a simulated flight controller, exposes the exact same rosbridge/topic contract as SkyCore Drone OS, and streams video through the same WHIP → Janus path. Because the interface is identical, the Gateway treats a SITL container and a real drone interchangeably — the only differences are that the Gateway spawns and destroys SITL containers over the Docker API and connects to them at DOCKER_HOST_IP instead of a WireGuard drone IP.
This page documents the image itself: how it is built, what runs inside a container, its ports, and how the video node works. The Gateway-side orchestration (container numbering, the three-container stack, ownership/limits, reverse-order cleanup) lives on SITL Drone Lifecycle.
Container internals
A single SITL container runs a self-contained flight stack.ardupilot_entrypoint.sh starts three ROS/streaming processes in the background, then runs the ArduPilot simulator in the foreground:
ardupilot_entrypoint.sh
The checked-in entrypoint hardcodes
-v ArduCopter and a fixed home position 42.1403890,24.7645490 (Plovdiv, Bulgaria) and ignores the VEHICLE_TYPE env var. The -w flag wipes stored parameters on every boot. The Gateway’s production image (via supervisord) is what actually honors VEHICLE_TYPE.Runtime processes and ports
| Process | Port / channel | Purpose |
|---|---|---|
sim_vehicle.py (ArduPilot SITL) | TCP localhost:5760 | MAVLink master consumed by MAVProxy |
MAVProxy --out → MAVROS fcu_url | UDP 14550 | MAVLink telemetry/commands between SITL and MAVROS |
rosbridge_server | WS 9090 | rosbridge topic/service bridge — the Gateway’s sole control+telemetry channel |
video_stream_node.py GStreamer source | UDP 8554 (MPEG-TS H264) | Camera feed pushed into the WHIP publisher |
video_stream_node.py → WHIP | HTTP 7080 (WHIP_SERVER_URL) | WebRTC ingest, default http://172.17.0.1:7080 |
ROS_DOMAIN_ID is set to the container number so concurrent instances stay isolated on the ROS 2 DDS bus.
Image build
The image is a two-stage Docker build.scripts/create_sitl.sh builds both stages; CI pushes them to ECR.
| Stage | File | From | Adds | Tags |
|---|---|---|---|---|
| Base | Dockerfile.base | ros:humble-ros-base | ArduPilot toolchain + ARM GCC 10, ros-humble-mavros/mavros-extras/mavros-msgs, ros-humble-rosbridge-server, Micro-XRCE-DDS-Gen, pip build tools | ardupilot_humble_base:v1 · ECR skyhub-sitl-base-on-prem-dev |
| SITL | Dockerfile.ardupilot | ardupilot_humble_base | clones + builds ArduPilot (./waf configure --board=sitl, waf-light build --target bin/arducopter), video_stream_node.py, seeds .janus_room_details/room.json with {"is_stopped": true} | ardupilot:latest · ECR skyhub-sitl-on-prem-dev |
whipsink) lives in the external live777-client:latest encoder container that video_stream_node.py spawns as a sibling (see Video streaming node below) — the same whipsink mechanism the on-drone video pipeline uses.
Container naming & port allocation
Two callers create containers, using different prefixes but the same arithmetic:- Local dev (
scripts/start_sitl.sh): scans for a free number1..101, names the containerardupilot_<n>, maps host port9090 + n→ container9090, and setsROS_DOMAIN_ID=n. It mounts the localardupilot_entrypoint.shover the image’s default so edits take effect without a rebuild. - Gateway (
sitl_drone_service.py): same1..101search (find_available_container_number, capped at 101), prefixSKYHUB_SITL_<n>_<drone_name>,CONTAINER_PORT_START_RANGE = 9090so the rosbridge port is9090 + n,ROS_DOMAIN_ID = n, resource limits, andrestart_policy = always. It mapsVEHICLE_TYPEaliases (rover→Rover,copter/arducopter→ArduCopter) before passing them to the container.
In the Gateway’s current three-container model, the SITL container runs only ArduPilot (via supervisord) and a separate CORE container runs MAVROS + rosbridge — “SITL no longer runs ROS” (
sitl_drone_service.py:192). The self-contained single-container layout described above is what the skyhub_sitl repo’s own scripts and ardupilot_entrypoint.sh produce, and what CLAUDE.md documents. See SITL Drone Lifecycle for the split-container orchestration.Video streaming node
video_stream_node.py is a small ROS 2 node that bridges the simulated camera to WHIP:
Room details arrive
The Gateway publishes Janus room details to the
/video_room_details rosbridge topic (see Janus Video Rooms). The node’s store_callback (video_stream_node.py:91) writes them to .janus_room_details/room.json and registers the endpoint via POST {WHIP_SERVER_URL}/whip/create.Arm triggers streaming
The node subscribes to
/mavros/state. The checked-in node keys strictly off the MAVROS msg.armed flag (video_stream_node.py:113) — it never reads VIDEO_STREAM_DRONE_STATE (that var is documented in CLAUDE.md and passed by the Gateway, but the checked-in node ignores it). When armed goes true, stream_callback (video_stream_node.py:104) launches a live777-client:latest helper container whose GStreamer pipeline reads MPEG-TS H264 from udpsrc port=8554, re-encodes with x264enc bitrate=6000, and whipsinks to {WHIP_SERVER_URL}/whip/endpoint/<room_id>.The node spawns the encoder as a sibling Docker container via
docker.from_env(), so the SITL container needs access to the host Docker socket, and it expects an external producer to feed MPEG-TS H264 into UDP 8554 (on a real drone this is the camera). This node is thin and rough — the production-quality video path is the on-drone video-streaming module; here it exists mainly to prove the WHIP contract end-to-end.Local development
Start an instance
localhost:<9090+n>.Inspect a running container
ardupilot_entrypoint.sh backgrounds its processes to the container’s stdout, so docker logs is how you tail them. The /var/log/vehicle/*.log files only exist in the supervisord-managed production image.POST /api/v1/drone {type: sitl} with ENABLE_SITL=true — see Drone Management & Control Actions and SITL Drone Lifecycle.
How the Gateway connects
Once a container is up, the Gateway opens a pooled rosbridge connection toDOCKER_HOST_IP:(9090 + n) and dispatches commands and telemetry subscriptions through DroneControlService exactly as it would for a physical drone. Video-room creation, /video_room_details push, and the WHIP→Janus fan-out are also identical. The interchangeability is the entire point: a flight issue seen in the field can be reproduced in SITL without touching the Gateway’s command paths.
Relevant environment variables (defined image-side; Gateway equivalents documented under Gateway Environment Variables):
| Variable | Default | Effect |
|---|---|---|
VEHICLE_TYPE | Rover (per CLAUDE.md; ArduCopter hardcoded in ardupilot_entrypoint.sh) | ArduPilot vehicle model |
ROS_DOMAIN_ID | container number | ROS 2 DDS isolation between instances |
WHIP_SERVER_URL | http://172.17.0.1:7080 | WHIP ingest endpoint |
VIDEO_STREAM_DRONE_STATE | ARMED | Documented/passed trigger state — not read by the checked-in video_stream_node.py (it keys off msg.armed only) |
BUILDLOGS | /tmp/buildlogs | ArduPilot build-log location |
CI/CD
.github/workflows/build.yml builds both image stages and pushes them to ECR (skyhub-sitl-base-on-prem-dev, skyhub-sitl-on-prem-dev), then sends a Slack notification.
Gotchas
- Docs vs. code drift.
CLAUDE.md(supervisord, fourscripts/*.shservices, ahealthcheck.sh) describes a design that is only partially checked in. Trustardupilot_entrypoint.sh,Dockerfile.*, andscripts/{create,start,stop}_sitl.shfor the local repo; trustsitl_drone_service.pyfor what the Gateway actually runs. VEHICLE_TYPEis honored only in the supervisord image path — the checked-in entrypoint is hardcoded to ArduCopter at a fixed home location.- Container number cap is 101. Both the local script and the Gateway stop searching past
ardupilot_101/SKYHUB_SITL_101. - Video needs a UDP
8554MPEG-TS source and host Docker access. Without a producer feeding8554,whipsinkhas nothing to publish; the node also shells out to run alive777-clientsibling container. - CI branch gate is broken (
developpush vs.refs/heads/developmentcondition).

