SITLDroneService (src/service/sitl_drone_service.py, ~1500 lines) is the heaviest service in the gateway. It turns a single POST /api/v1/drone {type: sitl} request into a running 3-container Docker stack — an ArduPilot SITL simulator, a CORE container (MAVROS + rosbridge + video), and a gamepad container running the agent — plus a shared Redis container. Once the stack is up, the gateway connects rosbridge to it and treats it identically to a physical drone: the same DroneControlService command dispatch, the same telemetry emission, the same Janus video room. That interchangeability is the whole point of SITL.
This page documents the container topology, the arithmetic that derives every port from a single container number, the local-vs-remote Docker split (including ECR auth), the readiness waits that make creation a long synchronous operation, the default ArduPilot params applied on boot, and the reverse-order cleanup that runs on any failure.
SITLDroneService is lazily constructed by get_service('sitl') in src/application/app.py and only if ENABLE_SITL=true. Its __init__ then applies a second gate — is_server_environment — so both must pass before a container is ever started. See Service Layer & get_service Factory.Two gates before anything starts
SITL creation is guarded twice. Understanding both saves a lot of “why is nothing happening” debugging.ENABLE_SITL gate (factory level)
get_service('sitl') in src/application/app.py:104 only instantiates SITLDroneService when settings.ENABLE_SITL is true. Otherwise it logs a warning and raises UnknownDroneTypeException.is_server_environment gate (constructor level)
In
__init__ (sitl_drone_service.py:23), if not IS_LOCAL_ENVIRONMENT and not REMOTE_DOCKER_ENABLED, the service sets is_server_environment = True and skips Docker client init entirely. save() and delete() then raise DroneCreationFailed (“SITL drone creation is not supported in server environments”). This is why the production Fargate gateway never spawns SITL — it is neither local nor pointed at a remote Docker host.DEPLOYMENT_ENVIRONMENT=local(local Docker viadocker.from_env()), orREMOTE_DOCKER_ENABLED=true(remote Docker over the office server, with ECR auth).
The container stack
SITL is not one container. The SITL image runs no ROS — CORE does. Four moving parts:| Container | Name pattern | Image (env) | Role |
|---|---|---|---|
| SITL | SKYHUB_SITL_{n}_{name} | SITL_IMAGE_NAME (ardupilot) | ArduPilot SITL binary via supervisord; emits MAVLink, routes to CORE/gamepad/GCS |
| CORE | SKYHUB_CORE_{n}_{name} | CORE_IMAGE_NAME → APP_IMAGE_NAME (skyhub-app:latest) | The ROS half of the application image: MAVROS + rosbridge + video streaming + battery SOC. This is what the gateway connects to. |
| Gamepad | SKYHUB_GAMEPAD_{n}_{name} | GAMEPAD_IMAGE_NAME → APP_IMAGE_NAME (skyhub-app:latest) | The agent half of the same image: manual control, mock camera, execution/upload callbacks |
| Redis | skyhub-redis (preferred) or SKYHUB_SITL_REDIS | SITL_REDIS_IMAGE (redis:7-alpine) | Shared across all SITL instances; agent + CORE state bus |
{n} is the container number (1..101); {name} is the user-supplied drone name with spaces replaced by _. Companion names are derived by string substitution in _get_companion_container_names() (sitl_drone_service.py:234) — SKYHUB_SITL_ → SKYHUB_CORE_ / SKYHUB_GAMEPAD_ — so the number and name must stay identical across the three containers or delete/cleanup will miss containers.
SKYHUB_ROLE: two containers, one image
The core and agent halves ship as a single image (APP_IMAGE_NAME, default skyhub-app:latest). SKYHUB_ROLE selects which supervisord programs a container runs: vehicle (both halves — the image default, and what an aircraft runs), core (ROS only), agent (agent only).
SITL keeps them as two containers on purpose — separate CPU/memory caps per half, and on Fargate the agent container is essential=false so it can die without killing the task. That makes the role variable mandatory rather than cosmetic:
start_core_containersets"SKYHUB_ROLE": "core"(sitl_drone_service.py:712)start_gamepad_containersets"SKYHUB_ROLE": "agent"(sitl_drone_service.py:549)
vehicle: the gamepad container would also start MAVROS, rosbridge and the drone node, competing with the CORE container for the same MAVLink endpoint, and the CORE container would run a second copy of the agent talking to the same Redis and the same API.
CORE_IMAGE_NAME and GAMEPAD_IMAGE_NAME still exist, but both default to APP_IMAGE_NAME. They are per-half overrides so a deployment can pin one half to an older image without a code change.
Container numbering and port math
find_available_container_number() (sitl_drone_service.py:262) lists all containers (including stopped) whose name starts with SKYHUB_SITL_, parses the number from parts[2], and returns the first free integer starting at 1. It hard-caps at 101 (raises ResourceNotFoundException beyond that). A Docker listing failure raises DroneCreationFailed rather than falling back to 1.
The whole method runs under a lock and records the number in _reserved_container_numbers, because the Docker listing alone races: gunicorn runs a single gevent worker in which every Docker/HTTP call yields, so two concurrent creates would otherwise see the same gap and collide on rosbridge port, MAVLink ports, ROS_DOMAIN_ID and the skysim slot. Callers must call release_container_number() once the container exists or creation has failed.
Every other port is pure arithmetic off that number n. There is no port registry — collisions are prevented only by n being unique.
| Resource | Formula | n=1 | n=2 | Where |
|---|---|---|---|---|
Drone row port / rosbridge | 9090 + n | 9091 | 9092 | save() + start_core_container |
ArduPilot SITL_INSTANCE | n - 1 (TCP 5760 + N*10) | 0 | 1 | start_container |
CORE MAVLink (FCUURL) | 14600 + (n-1) | 14600 | 14601 | start_core_container |
| Gamepad MAVLink | 14777 + (n-1) | 14777 | 14778 | start_container / gamepad |
| GCS MAVLink | 14551 + (n-1) | 14551 | 14552 | start_container |
Gamepad WS port (GAMEPAD_WS_PORT) | 5001 + n | 5002 | 5003 | start_gamepad_container |
CORE sshd (SKYHUB_SSH_PORT) | 3220 + n | 3221 | 3222 | start_core_container |
ROS_DOMAIN_ID | n | 1 | 2 | start_container / CORE |
FATAL for the life of the container. ROSBRIDGE_PORT, GAMEPAD_WS_PORT and SKYHUB_SSH_PORT are all per-container for the same reason.
The gamepad
ports = {"5001/tcp": gamepad_ws_port} dict is computed but never passed to containers.run — the gamepad container relies on host or shared-namespace networking instead, so the WS port is not published as an explicit Docker port mapping. ROS_DOMAIN_ID = n is what keeps each CORE’s ROS 2 DDS graph isolated from its neighbours.Local vs remote Docker networking
The single biggest branch in this service isuse_remote_docker (REMOTE_DOCKER_ENABLED). It changes the Docker client, the network mode of every container, and how the gateway later reaches rosbridge.
- Local Docker (DEPLOYMENT_ENVIRONMENT=local)
- Remote Docker (REMOTE_DOCKER_ENABLED=true)
- Docker client:
docker.from_env(); no ECR auth (ecr_auth_config = None). - All containers run with
network_mode = "host". rosbridge therefore listens directly on host port9090+n. host.docker.internalis added viaextra_hosts(host-gateway) for CORE/gamepad.- CORE
SKYHUB_SERVER_URL = http://localhost:7080(local WHIP). - The gateway reaches rosbridge at
host.docker.internal:{port}— seeDroneControlService._get_client(drone_control_service.py:565). - The application image must already exist locally —
start_core_container/start_gamepad_containerraiseDroneCreationFailedonImageNotFoundbecause there is no ECR to pull from.
Creation sequence
save() (sitl_drone_service.py:986) is async, long, and synchronous internally (it blocks on healthchecks and readiness probes). The order matters and every failure branch triggers reverse-order cleanup.
1. Quota + numbering
1. Quota + numbering
get_drones_by_type(user_id, 'sitl') is counted against USER_SITL_MAX_COUNT (default 3) → DroneCreationFailed("SITL count exceeded."). Note this is a per-user Docker capacity cap, distinct from the billing limit (can_user_add_vehicle, free tier = 1 SITL) enforced earlier in the route — see Stripe Billing & Vehicle Limits. Then find_available_container_number() picks n.2. Redis
2. Redis
ensure_redis_container() prefers the docker-compose skyhub-redis container, falls back to legacy SKYHUB_SITL_REDIS, and only creates a new redis:7-alpine (port 6379, --requirepass if REDIS_PASSWORD set) if neither exists. It is shared by every SITL instance.3. SITL container + healthcheck
3. SITL container + healthcheck
start_container() validates vehicle_type (rover→Rover, copter/arducopter→ArduCopter), builds the env (see table below), and runs supervisord. Then __check_container_state() polls: if the image declares a Docker healthcheck it waits up to 40 × 2s = 80s for healthy (else DroneCreationFailed); with no healthcheck it just verifies running.4. Gamepad container
4. Gamepad container
start_gamepad_container() starts the application image pinned to SKYHUB_ROLE=agent — without that it would default to the vehicle role and start a second MAVROS/rosbridge alongside CORE. IP_OVRIDE (the typo is intentional and load-bearing — it matches the image’s own config) is set to the SITL container name, used as the drone’s Redis key namespace. MOCK_CAMERA_ENABLED=true generates test images/video. Failure here cleans up the SITL container and aborts.5. CORE container + rosbridge wait
5. CORE container + rosbridge wait
start_core_container() runs the application image with SKYHUB_ROLE=core — MAVROS + rosbridge on 9090+n, sshd on 3220+n — then blocks in _wait_for_rosbridge() for up to 30s, GET-ing http://{host}:{port}/ and treating HTTP 400 as “ready” (rosbridge returns 400 to non-WebSocket requests). Timeout only warns — it does not fail creation. Failure of containers.run cleans up SITL + gamepad.6. DB row, video, assets, params
6. DB row, video, assets, params
super().save() persists the Drone with ip == mac == container_name and port = 9090+n, then commits. A Janus room (id == drone.id) + PIN + create_access_token(identity=drone.id) room token are created, update_video_room(..., status="START") auto-starts video (physical drones do not — they defer). Demo assets and _set_default_params follow; both are wrapped in try/except and are non-fatal.Per-container environment
The env dicts are the real contract with each image. Highlights (seestart_container, start_gamepad_container, start_core_container):
- SITL
- CORE
- Gamepad (agent)
VEHICLE_TYPE (ArduCopter/Rover), SITL_INSTANCE=n-1, GAMEPAD_MAVLINK_PORT, GCS_MAVLINK_PORT, CORE_MAVLINK_PORT, MAVLINK_HOST_IP=127.0.0.1, WHIP_SERVER_URL, VIDEO_STREAM_DRONE_STATE, DOCKER_HOST. Command: supervisord -c /etc/supervisor/conf.d/supervisord.conf.Default ArduPilot params
After boot,_set_default_params() (sitl_drone_service.py:1352) sleeps a fixed 5s (MAVROS param services register after rosbridge is up) and pushes a param batch through DroneControlService.set_params. Everything not marked hardcoded comes from a SITL_* env var in src/application/settings.py:
| Param | Source / default | Purpose |
|---|---|---|
LOG_BACKEND_TYPE | 1 (hardcoded) | Enable file logging (required for SITL) |
LOG_FILE_DSRMROT | 1 (hardcoded) | New log per flight on disarm |
FENCE_ENABLE | SITL_FENCE_ENABLE (1) | Geofence on |
FENCE_TYPE | SITL_FENCE_TYPE (7) | Alt + circle + polygon |
FENCE_ACTION | SITL_FENCE_ACTION (1) | RTL on breach |
FENCE_ALT_MAX | SITL_FENCE_ALT_MAX (100) | 100 m ceiling |
FENCE_RADIUS | SITL_FENCE_RADIUS (10000) | 10 km circle |
WP_YAW_BEHAVIOR | 3 (hardcoded) | Nose along track |
SIM_BATT_VOLTAGE | SITL_BATT_VOLTAGE (25.2) | Full 6S at 4.20 V/cell |
BATT_CAPACITY | SITL_BATT_CAPACITY (27000) | mAh |
BATT_LOW_VOLT / BATT_CRT_VOLT / BATT_ARM_VOLT | SITL_BATT_* (all 0) | Voltage failsafes disabled |
BATT_FS_LOW_ACT | SITL_BATT_FS_LOW_ACT (2) | RTL on low battery |
BATT_FS_CRT_ACT | SITL_BATT_FS_CRT_ACT (3) | LAND on critical battery |
SIM_SPEEDUP), navigation and airframe params (WPNAV_SPEED, RTL_ALT, MOT_THST_HOVER, FRAME_CLASS/FRAME_TYPE), the coulomb-count failsafes (BATT_LOW_MAH / BATT_CRT_MAH, derived from SITL_BATT_LOW_PCT / SITL_BATT_CRT_PCT) and the camera backend.
The battery defaults model a T-Drones M690 Pro 6S 27 Ah pack — the aircraft the simulated fleet stands in for. The old 3S 12.587 V / 3300 mAh values were a generic small quad and made every simulated sortie roughly a fifth of a real one. The voltage failsafes are set to
0 deliberately: they fired off a different number than the supervisors reason with, so the autopilot RTL’d aircraft the relay still considered to have margin.FENCE_RADIUS used to default to 300 m, which silently made most real missions unflyable — a 1 km survey box puts its corners ~707 m from home, ArduPilot refused AUTO with “Circle fence breached” and FENCE_ACTION sent the aircraft to RTL, while push_mission and set_mode both answered 200. FENCE_ALT_MAX still caps altitude.On --model json vehicles (skysim) the SIM_BATT_* params are inert: the JSON backend overwrites voltage and current every frame, so the pack is configured at the skysim end.set_params routes through the pooled rosbridge connection like any other command — see DroneControlService & Rosbridge Dispatch and Rosbridge Connection & Reconnect.
Deletion and cleanup ordering
Both intentional deletion and failure cleanup stop containers in reverse dependency order: core → gamepad → sitl. Preserve this — CORE holds the rosbridge/DDS session and should go first.delete(drone)(sitl_drone_service.py:1235): guardsis_server_environment, deletes all S3 assets (non-fatal), readscontainer_namefromdrone.mac, derives companion names, thenstop_core_container→stop_gamepad_container→ stop/remove the SITL container (30s timeout), and finallysuper().delete()removes the DB row. The shared Redis is left running._cleanup_containers(sitl, gamepad, core)(sitl_drone_service.py:863): the failure path used insidesave(). Each stage ofsave()passes progressively more containers to clean up, so a gamepad failure removes only SITL, a CORE failure removes SITL + gamepad, and a post-start DB failure removes all three.
Gotchas for future editors
ip == mac == container name (not a real IP)
ip == mac == container name (not a real IP)
For SITL, both
Drone.ip and Drone.mac store the container name (e.g. SKYHUB_SITL_1_myDrone), never an IP. DroneControlService._get_client translates it at connect time: remote → SITL_HOST, local → host.docker.internal. Any code that assumes Drone.ip parses as an IP breaks for SITL.Container numbers are reserved, not just observed
Container numbers are reserved, not just observed
Numbers are allocated under a lock and held in
_reserved_container_numbers until release_container_number() is called. Listing Docker alone is not enough — the gevent worker yields on every Docker call, so two concurrent creates would pick the same gap and collide on every derived port. Keep the reservation/release pairing if you refactor numbering.Numbering caps at 101, not USER_SITL_MAX_COUNT
Numbering caps at 101, not USER_SITL_MAX_COUNT
The 1..101 range is a global container-number ceiling; the per-user cap is the separate
USER_SITL_MAX_COUNT (default 3). They are different limits.SITL auto-starts video; physical does not
SITL auto-starts video; physical does not
SITL
save() pushes update_video_room(status="START"); PhysicalDroneService.save() leaves the equivalent call commented out (“do this when there is a connection”). This asymmetry is intentional — see Janus Video Rooms & On-Drone Video Control.IP_OVRIDE typo is load-bearing
IP_OVRIDE typo is load-bearing
The agent env var is
IP_OVRIDE (missing E). It matches the image’s own config; “fixing” the spelling on this side without changing the image will break the drone’s Redis namespace resolution. The core half correctly uses IP_OVERRIDE.Related pages
Factory + inheritance: Service Layer & get_service Factory · Command dispatch: DroneControlService & Rosbridge Dispatch · Pool wiring: Connection Pool & Startup Wiring · Env reference: Gateway Environment Variables · The simulator image itself: SITL Simulator and on-drone Microservices & Container Profiles.

