core (MAVROS + rosbridge + modules) and ws_proxy/gamepad — 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) builds each SITL drone’s three containers and injects env vars persitl/.env.example.sitl. 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.
| Area | Production (docker-compose.yml) | Local overlay |
|---|---|---|
| Flight controller | Real FCU on /dev/ttyACM0 @115200 | skyhub-sitl ArduPilot binary |
| MAVLink router | mavproxy (mavp2p) | mavlink-router inside the SITL container |
| Network | network_mode: host (WireGuard) | sitl-vpn bridge 10.223.0.0/16 (core/gamepad); SITL stays host-net |
| Redis | redis.skyhub-prod.internal | sitl-redis at 10.223.0.2:6379 |
| VPN IP for Redis channels | resolved from wg0 | IP_OVERRIDE=10.223.1.1 |
| Camera | SIYI ZR30/A8 RTSP | CAMERA_TYPE=TEST, CAMERA_IP= (empty) |
| Video encoder | NVIDIA nvv4l2 | VIDEO_ENCODER=software (x264) |
| Audio | ReSpeaker mic | AUDIO_ENABLED=false |
| GPIO charging | Jetson /dev/gpiochip1 | BOOT_CHARGING_ENABLED=false |
| ArUco landing | env-gated | ARUCO_LANDING_ENABLED=false |
privileged | true | false (core, ws_proxy) |
| Source code | baked into image | hot-mounted ./docker/core:/app, ./docker/gamepad:/app |
| Disabled services | — | mavproxy, camera-proxy, rtk-ntrip, isaac-slam → profiles: ["disabled"] |
docker-compose.local.yml
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 hot-mounted core container, exactly as in production.
Two processes run under supervisord (sitl/supervisord.conf):
-
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:VEHICLE_TYPEBinary Default SITL_MODELDefaults file ArduCopter/copterarducopterquadcopter.parmArduRover/rover/Roverarduroverroverrover.parmArduPlane/planearduplaneplaneplane.parm -
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:
| Port | Proto | Purpose |
|---|---|---|
5760 | TCP | ArduPilot SITL MAVLink server (--serial0 tcp:0) |
9090 | TCP | rosbridge WebSocket (served by the core container) |
14600 | UDP | MAVLink → core MAVROS (CORE_MAVLINK_PORT) |
14777 | UDP | MAVLink → gamepad (GAMEPAD_MAVLINK_PORT) |
14551 | UDP | MAVLink → external GCS, e.g. QGroundControl (GCS_MAVLINK_PORT) |
6379 | TCP | local Redis (sitl-redis) |
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 core 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:
Both core and ws_proxy set 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 core 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 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 both core and ws_proxy 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)
| Component | Detail |
|---|---|
| Router | HILINK HLK-7628N (MediaTek MT7688, MIPS 24KEc @ 580 MHz, 128 MB RAM, 16 MB flash) |
| OS | OpenWrt 23.05.5, kernel 5.15, target ramips/mt76x8 (mipsel_24kc) |
| 4G modem | Quectel EG25-GC, QMI interface /dev/cdc-wdm0, USB ID 2c7c:0125, LTE |
| LAN | 192.168.1.1, bridge br-lan (eth0.1, eth0.2) |
| WAN | wwan0 (4G LTE via the Quectel modem) |
Scripts
Copy the scripts onto the router over SSH, then run them there:From your workstation
| Script | What it does |
|---|---|
disable_wifi.sh | Turns the Wi-Fi radio fully off via uci set wireless.radio0.disabled=1 && wifi down. Used in flight to avoid the Wi-Fi radio contending with LTE / to cut a power draw. |
monitor_health.sh | Logs a single health line (uptime, MemFree/MemAvailable, nf_conntrack_count, load, Wi-Fi signal) — meant to be cron’d to spot slow degradation. |
connect_wifi.sh | Documented as joining an upstream Wi-Fi as a client (SSID Skyhub). Not present in the tree — the README references it but only disable_wifi.sh and monitor_health.sh are committed. |
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):
| Metric | Excellent | Good | Fair | Poor |
|---|---|---|---|---|
| RSSI | > -65 dBm | -65 to -75 | -75 to -85 | < -85 dBm |
| RSRP | > -80 dBm | -80 to -90 | -90 to -100 | < -100 dBm |
| RSRQ | > -10 dB | -10 to -15 | -15 to -20 | < -20 dB |
| SNR | > 20 dB | 13 to 20 | 0 to 13 | < 0 dB |
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.

