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
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.
The SITL image itself ships no WHIP publisher. The WebRTC-over-HTTP publishing capability (
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) — “SITL no longer runs ROS” (
sitl_drone_service.py:192). The other two containers both come from the skyhub-app image (APP_IMAGE_NAME, default skyhub-app:latest, settings.py:31): one with SKYHUB_ROLE=core running MAVROS + rosbridge (sitl_drone_service.py:712), one with SKYHUB_ROLE=agent (sitl_drone_service.py:549). They stay two containers deliberately — the agent one is essential=false and carries its own CPU cap. 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:
1
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.2
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>.3
Disarm / STOP stops it
On disarm — or a
STOPPED value on /video_room_state — the helper container is stopped.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
1
Build the images
2
Start an instance
localhost:<9090+n>.3
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.4
Tear everything down
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):
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. This covers only the ArduPilot half of what the Gateway runs: the x86 skyhub-app image its other two SITL containers use is built from skyhub_core (buildspec/fargate-app.yml) by CodeBuild.
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).

