skyhub service running both halves (MAVROS + rosbridge + modules, and the agent) — on a plain x86 workstation with no Jetson, no flight controller, and no camera. The mechanism is a Docker Compose overlay, docker-compose.local.yml, that stacks on top of the production docker-compose.yml and swaps the hardware-bound pieces for software: an ArduPilot SITL container instead of a real FCU, a local Redis instead of redis.skyhub-prod.internal, a Docker bridge network instead of WireGuard, the TEST camera and a software encoder instead of a SIYI/NVIDIA pipeline, and disabled ArUco/audio/GPIO.
This page covers that overlay in detail. It then covers the separate, unrelated topic the same repo owns: the OpenWrt + Quectel 4G LTE router scripts (utils/openwrt/) that give a drone in the field an internet uplink back to the cloud.
For how the Gateway Service spins up SITL containers programmatically (the path a real user hits when they click “add SITL drone”), see SITL Drone Lifecycle and the SITL Simulator ecosystem page — that is a distinct flow from the manual overlay below. For the services themselves, start at the SkyCore Drone OS Overview and Microservices & Container Profiles.
There are two ways to get SITL running locally, and they are configured differently:
- Gateway-orchestrated —
skyhub_gateway_service(ENABLE_SITL=true,DEPLOYMENT_ENVIRONMENT=local) runs the ArduPilot SITL container plus two containers from the sameskyhub-appimage (SKYHUB_ROLE=coreandSKYHUB_ROLE=agent,sitl_drone_service.py:549,712), injecting env vars persitl/.env.example.sitl. The agent container is deliberatelyessential=falsewith its own CPU cap, so an upload-path OOM cannot take MAVROS and rosbridge down. Ports are auto-calculated per container number. Covered in SITL Drone Lifecycle. - Manual overlay — you run
docker compose -f docker-compose.yml -f docker-compose.local.yml up -dyourself. This is the developer/off-hardware path documented here.
Prerequisites & quick start
Persitl/README.md: Docker 20.10+, Compose v2+, ~20 GB free disk (ArduPilot builds from source), 8 GB+ RAM.
The overlay’s sitl service builds skyhub-sitl:local from sitl/Dockerfile.sitl, which is FROM skyhub-sitl-base:latest — so build the base image first, then bring the stack up:
Build + run (from repo root)
sitl service (docker-compose.local.yml:47-52):
Optional: pick vehicle + spawn point
What the overlay changes
docker-compose.local.yml is a thin override — it does not redefine the services, it patches them. Everything below is verbatim from that file.
The two hardware-only services are neutralised by parking them on a Compose profile nobody activates:
docker-compose.local.yml
docker-compose.local.yml defines no volumes for skyhub, so a code change needs a rebuild.
Inside the SITL container
Theskyhub-sitl image is deliberately minimal. sitl/Dockerfile.base is Ubuntu 22.04 + build tooling + pymavlink — no ROS. sitl/Dockerfile.sitl then clones ArduPilot (ARDUPILOT_REF=master) and builds arducopter, ardurover, and arduplane for the sitl board, then builds mavlink-router from source. ROS 2 (MAVROS + rosbridge) is not in this container — it lives in the skyhub container, exactly as in production.
Three processes run under supervisord (sitl/supervisord.conf):
Why
gcs-pump exists. ArduPilot’s --model json backend will not progress past early boot without a heartbeating GCS peer. MAVROS waits for the autopilot and the autopilot waits for a GCS — a deadlock that leaves /mavros/* services permanently absent. sitl/scripts/gcs_pump.py pumps GCS heartbeats into the router to break it. Its endpoint is the only one configured Mode = Server (127.0.0.1:${GCS_PUMP_PORT}, default 14900 + SITL_INSTANCE); the other three are Mode = Normal, meaning the router sends to them and nothing can initiate a session inbound. The program exits immediately and stays down for non-json models.-
sitl.sh(sitl/scripts/sitl.sh) mapsVEHICLE_TYPEto a binary + model + default params, thenexecs ArduPilot with--serial0 tcp:0so MAVLink is served on TCP 5760 (5760 + SITL_INSTANCE*10for multi-instance). Vehicle mapping: -
mavlink-router.sh(sitl/scripts/mavlink-router.sh) waits for the SITL TCP port, auto-detects the Docker host gateway IP (MAVLINK_HOST_IPoverride →ip routedefault →/proc/net/route→172.17.0.1fallback), then generates/tmp/mavlink-router.confand runsmavlink-routerdagainst it.mavlink-routerreplaces MAVProxy/mavp2p here (~5 MB vs ~100 MB). See MAVLink Routing (mavp2p) for the production router this substitutes for.
SITL port map
Fromsitl/README.md and the generated router config:
MAVROS SITL tuning lives in
sitl/scripts/mavros_params.yaml — notably conn_timeout: 30.0 (raised from 10.0 for Docker/CPU overhead) and timesync_rate: 0.0 (SITL uses synthetic time). Stream rates are raised to 10 Hz by sitl/scripts/set_stream_rates.sh, which waits up to 120 s for /mavros/state to report connected: true before calling /mavros/set_stream_rate — the same bootstrap the production skyhub container performs.
Mock VPN, IP_OVERRIDE & Redis namespacing
In production every Redis channel is prefixed by the drone’s WireGuardwg0 IP (see Redis Message Bus and User VPN). Locally there is no wg0, so the overlay fakes it with a bridge network and a fixed IP:
The skyhub service sets IP_OVERRIDE=10.223.1.1 and REDIS_HOST=10.223.0.2. IP_OVERRIDE replaces the wg0 IP lookup, so all IP-namespaced channels become 10.223.1.1:gamepad_input, 10.223.1.1:output, etc.
Differences from production (behavior deltas)
Because the overlay swaps hardware for software, several code paths take their non-hardware branch:TEST camera + software encode
CAMERA_TYPE=TEST makes CameraFactory build a GStreamer test-pattern source; VIDEO_ENCODER=software selects x264enc instead of NVIDIA nvv4l2. The application image bakes ARM/Jetson GStreamer paths, so the SITL env template also overrides GST_PLUGIN_PATH to the x86_64 plugin dir. Detail: Video Streaming.Video gate = CONNECTED
VIDEO_STREAM_DRONE_STATE=CONNECTED starts the stream as soon as MAVROS connects, rather than waiting for ARMED — convenient for testing since you don’t have to arm to see video.No GPIO / privileged
privileged: false and devices: [] — BOOT_CHARGING_ENABLED=false guards the Jetson.GPIO import so the charging module no-ops on x86 instead of crashing.Guided control still works
Manual gamepad → GUIDED velocity control is fully exercisable against SITL (mode/arm/velocity setpoints are all simulated). See Guided Velocity Control & Safety Model and Vehicle Commands.
Known divergences
Troubleshooting
SITL won't start / port 5760 in use
SITL won't start / port 5760 in use
Check
docker exec skyhub-sitl cat /var/log/sitl/sitl.log and sitl_error.log. A stale ArduPilot process holding 5760, or a failed image build, are the usual causes — kill the process or rebuild skyhub-sitl:local. The container healthcheck is pgrep -f "ardu(copter|rover|plane)".MAVROS not connecting to the FCU
MAVROS not connecting to the FCU
Give SITL 30+ seconds. Confirm
mavlink-router connected to SITL’s TCP server (/var/log/sitl/mavlink-router.log, look for the TCP client open), that ROS_DOMAIN_ID matches across containers (default 1, and now genuinely honoured — the MAVROS hardcode is gone), and that RMW_IMPLEMENTATION=rmw_cyclonedds_cpp. Then re-check the FCU port divergence in Known divergences.No ROS topics visible from the host
No ROS topics visible from the host
DDS discovery needs the same domain:
export ROS_DOMAIN_ID=1 && export RMW_IMPLEMENTATION=rmw_cyclonedds_cpp before ros2 topic list.Redis messages never arrive
Redis messages never arrive
Confirm
IP_OVERRIDE is set on skyhub and that whatever publishes to the drone uses the same 10.223.1.1 prefix. Remember video_stream_state / video_stream_status_request are global (unprefixed).Field connectivity — OpenWrt 4G LTE uplink
A drone flown beyond Wi-Fi range still has to reach the cloud (rosbridge to the Gateway, video to WHIP, Redis, presigned asset uploads). SkyCore’s answer in the field is a small OpenWrt router with a cellular modem, joined to the same WireGuard plane as everything else. The management scripts live inutils/openwrt/ and run on the router, not on the Jetson.
Hardware (utils/openwrt/README.md)
Scripts
Copy the scripts onto the router over SSH, then run them there:From your workstation
monitor_health.sh is designed to run on a schedule so you can correlate connection drop-outs with resource exhaustion:
On the router — log health every 5 minutes
conntrack approaching 15360 → connection table full, reboot; high load → CPU overload. The MT7628 SoC exposes no temperature sensor, so thermal monitoring is not available.
4G signal monitoring
Signal is read straight off the Quectel modem withuqmi against the QMI device:
On the router — cellular diagnostics
utils/openwrt/README.md):
The recorded office baseline (2025-11-26, both MIMO antennas connected) is RSSI -73 dBm / RSRP -96 dBm / RSRQ -8 dB / SNR 12.6 dB — a “Good” link. If field signal drops well below these, reposition antennas or the aircraft before blaming the software path.
This LTE uplink carries the WireGuard tunnel that in turn carries rosbridge, video, and Redis. It sits upstream of everything on the Network & VPN Topology page and the User VPN service — a weak cellular link degrades telemetry latency and video before any application-level tuning can help.

