The SITL (Software-In-The-Loop) simulator (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 WHIPJanus 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.
This repository is mid-migration and its docs drift from its code. CLAUDE.md describes a supervisord-managed four-service container and helper scripts (scripts/sitl.sh, scripts/mavros.sh, scripts/healthcheck.sh) that are not checked in to this snapshot. The runtime that is checked in — ardupilot_entrypoint.sh — simply backgrounds the four processes directly. Meanwhile the Gateway launches the built image with command = ["supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"] (see sitl_drone_service.py:209), so the published image does ship a supervisord config baked in. Treat ardupilot_entrypoint.sh as the ground-truth local-dev entrypoint and the supervisord path as the production image default.

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
source /opt/ros/humble/setup.bash
ROS_DOMAIN_ID=$ROS_DOMAIN_ID ros2 launch mavros apm.launch fcu_url:='udp://:14550@' &
ROS_DOMAIN_ID=$ROS_DOMAIN_ID ros2 launch rosbridge_server rosbridge_websocket_launch.xml &
python3 /ardupilot/video_stream_node.py &
source /home/ardupilot/.ardupilot_env
cd /ardupilot
./Tools/autotest/sim_vehicle.py --mavproxy-args="--master tcp:localhost:5760" \
  --out=127.0.0.1:14550 -v ArduCopter -l 42.1403890,24.7645490,0,0 -w
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

ProcessPort / channelPurpose
sim_vehicle.py (ArduPilot SITL)TCP localhost:5760MAVLink master consumed by MAVProxy
MAVProxy --out → MAVROS fcu_urlUDP 14550MAVLink telemetry/commands between SITL and MAVROS
rosbridge_serverWS 9090rosbridge topic/service bridge — the Gateway’s sole control+telemetry channel
video_stream_node.py GStreamer sourceUDP 8554 (MPEG-TS H264)Camera feed pushed into the WHIP publisher
video_stream_node.py → WHIPHTTP 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.
StageFileFromAddsTags
BaseDockerfile.baseros:humble-ros-baseArduPilot toolchain + ARM GCC 10, ros-humble-mavros/mavros-extras/mavros-msgs, ros-humble-rosbridge-server, Micro-XRCE-DDS-Gen, pip build toolsardupilot_humble_base:v1 · ECR skyhub-sitl-base-on-prem-dev
SITLDockerfile.ardupilotardupilot_humble_baseclones + 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
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 number 1..101, names the container ardupilot_<n>, maps host port 9090 + n → container 9090, and sets ROS_DOMAIN_ID=n. It mounts the local ardupilot_entrypoint.sh over the image’s default so edits take effect without a rebuild.
  • Gateway (sitl_drone_service.py): same 1..101 search (find_available_container_number, capped at 101), prefix SKYHUB_SITL_<n>_<drone_name>, CONTAINER_PORT_START_RANGE = 9090 so the rosbridge port is 9090 + n, ROS_DOMAIN_ID = n, resource limits, and restart_policy = always. It maps VEHICLE_TYPE aliases (roverRover, copter/arducopterArduCopter) 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:
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

cd skyhub_sitl
./scripts/create_sitl.sh   # builds ardupilot_humble_base:v1 then ardupilot:latest
2

Start an instance

./scripts/start_sitl.sh    # auto-picks number n, runs on host port 9090+n
The chosen container number and port are printed at the end. Point a rosbridge client (or the Gateway) at localhost:<9090+n>.
3

Inspect a running container

docker exec -it ardupilot_<n> bash
docker logs -f ardupilot_<n>   # backgrounded MAVROS / rosbridge / video output
The checked-in 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

./scripts/stop_sitl.sh     # docker rm -f every ardupilot_* container
For a hardware-free end-to-end flow (Dashboard → Gateway → SITL), the Gateway spawns SITL for you when you 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 to DOCKER_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):
VariableDefaultEffect
VEHICLE_TYPERover (per CLAUDE.md; ArduCopter hardcoded in ardupilot_entrypoint.sh)ArduPilot vehicle model
ROS_DOMAIN_IDcontainer numberROS 2 DDS isolation between instances
WHIP_SERVER_URLhttp://172.17.0.1:7080WHIP ingest endpoint
VIDEO_STREAM_DRONE_STATEARMEDDocumented/passed trigger state — not read by the checked-in video_stream_node.py (it keys off msg.armed only)
BUILDLOGS/tmp/buildlogsArduPilot 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.
The workflow triggers on push to develop, but its build job is gated by if: github.ref == 'refs/heads/development' (build.yml:9) — a branch-name mismatch that means the job never runs on the actual push branch. The Deploy job that CLAUDE.md describes (WireGuard connect → ECR pull → cleanup cron) is not present in the checked-in workflow either. A future editor fixing CI should reconcile both.

Gotchas

  • Docs vs. code drift. CLAUDE.md (supervisord, four scripts/*.sh services, a healthcheck.sh) describes a design that is only partially checked in. Trust ardupilot_entrypoint.sh, Dockerfile.*, and scripts/{create,start,stop}_sitl.sh for the local repo; trust sitl_drone_service.py for what the Gateway actually runs.
  • VEHICLE_TYPE is 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 8554 MPEG-TS source and host Docker access. Without a producer feeding 8554, whipsink has nothing to publish; the node also shells out to run a live777-client sibling container.
  • CI branch gate is broken (develop push vs. refs/heads/development condition).