Skip to main content
ADOS carries live HD video from the drone’s camera to a browser using open-source parts at every stage: V4L2 for capture, ffmpeg for encode, WFB-ng for the radio hop, MediaMTX for serving, WebRTC for delivery. On the aircraft the ados-video unit supervises the encoder and the local MediaMTX instance. On the ground station ados-groundlink owns the radio receiver and the fan-out, and its --ground-video mode (the ados-mediamtx-gs unit) runs a second MediaMTX instance that reads the radio stream and serves the browser.

The hop map

This is the list to measure against. Numbers in brackets are the hop labels used by ados diag video.
Telemetry does not share this path. MAVLink, node status and identity ride the auxiliary lane (radio ports 2 and 3, UDP 5602 and 5603), separate from video (radio port 0) and from the frequency-hop control plane (radio port 1, UDP 5803 and 5810). A dark video feed with a live telemetry link is therefore a normal combination, and it points at hops 2 to 10. main is the canonical path name on both nodes. MediaMTX serves WHEP at 127.0.0.1:8889/main/whep, on loopback only; browsers reach it through ados-control at :8080/whep. Nothing publishes to /ados, so a zero byte count on that path is not evidence of a video fault.

Stage 1: Capture

The agent scans /dev/video* at boot and supports MIPI CSI cameras and USB UVC cameras through V4L2. It sets resolution, framerate and pixel format before starting the encoder. The shipped default is 1280x720 at 30 fps. The encoder choice comes from the board’s HAL profile. Rockchip boards with a media engine can use mpph264enc through GStreamer. Boards exposing a V4L2 hardware encoder use h264_v4l2m2m through ffmpeg. Everything else falls back to libx264.

Stage 2: Encode

One process does the encode and both publishes. The default path (USB and IP cameras) is a single ffmpeg whose output is ffmpeg’s tee muxer: branch A is the RTSP publish into the local MediaMTX, branch B is the RTP copy the radio reads.

Parameter sets repeat before every IDR

The RTSP muxer advertises AVFMT_GLOBALHEADER, which makes libx264 put SPS and PPS only in extradata (the SDP) and never in the stream. A browser that loses sync then has nothing in-band to re-bootstrap from and freezes on its last decoded frame while the PeerConnection still reads connected. Every encode arm repeats the parameter sets in-band per IDR to close that: dump_extra=freq=keyframe on libx264 and h264_v4l2m2m, --inline on rpicam-vid, h264parse config-interval=1 on the GStreamer arms plus mpph264enc header-mode=1 at the encoder. The ground ingest re-applies the same bitstream filter, which is a no-op against a current drone and a repair against an older one.

There is no keyframe-on-demand, by design

The GOP is the recovery mechanism. A decoder that loses sync re-bootstraps at the next IDR, which is at most 500 ms away at 30 fps with -g 15. There is no PLI or keyframe-request path. The shipped encoder leaves (ffmpeg CLI, rpicam-vid, gst-launch-1.0) expose no runtime control channel an IDR could be requested through, and MediaMTX does not forward a WebRTC receiver’s PLI to an RTSP publisher. The most a request could save is the 0 to 500 ms a decoder waits, so about 250 ms expected, on an event that only happens after loss the FEC did not recover. Respawning the encoder to force an IDR costs a 1 to 2 second blackout, which is strictly worse than waiting.

H.264 is the default codec

H.264 is the shipped wire codec. H.265 saves bandwidth at the same quality, and video.camera.codec accepts it, but browser WebRTC support for H.265 is not consistent enough to ship as a default and Mission Control’s player pins avc1.640029. Test your playback path before switching.

Stage 3: WFB-ng transport

WFB-ng puts an RTL8812EU adapter in monitor mode and injects FEC-encoded packets on 5 GHz. This is not standard WiFi: no association, no handshake, no retransmission, no CSMA/CA backoff. The framing between the encoder and the radio is RTP (RFC 6184), payload type 96, SSRC 0xCAFE, pkt_size 1316, on UDP 5600. RTP is the contract, not a detail: a lost RTP packet costs at most one NAL fragment, where a lost chunk of a raw byte stream corrupts everything up to the next start code. The receiver depayloads against the SDP in its MediaMTX path config (rtpSDP, shown in Stage 4).
On CSI boards the encoder is rpicam-vid or gst-launch-1.0 publishing through a shell pipeline, and there the RTP copy is still produced by a separate tap process with its own watchdog. Check which encoder is running (the encoder field in camera-state.json, or the Video row of ados status) before reasoning about hop 4.
The MCS ladder cap is a statement of what has been measured, not of what the radio can do. The rung table is arithmetic from standard 802.11n 20 MHz required-SNR figures, and nothing in the tree has measured what the vendored rtl8812eu driver actually applies. Raising the cap without a range sweep is how a link that looks perfect on a bench dies at 150 m.

Stage 4: Ground receive and serve

wfb_rx -p 0 decodes the radio packets and FEC-recovers gaps, then the fan-out forwards them to the ground MediaMTX on UDP 5600 and to the on-box display on 5605. No ffmpeg sits in between. ados-groundlink --ground-video writes a MediaMTX config whose main path has a native UDP RTP source and the SDP that describes it:
The same process supervises MediaMTX. It restarts MediaMTX 3 seconds after any exit, with no attempt cap. A watchdog polls every 2 seconds and restarts MediaMTX when its inbound byte count stalls for 12 seconds while the radio statistics (fresh within 10 seconds) show packets still arriving. MediaMTX does no transcoding. The H.264 stream passes through to WebRTC as it arrived. Both MediaMTX instances are rendered from one config generator, so the air and ground documents cannot drift apart silently. The file is written to /etc/ados/mediamtx.yml.
Do not hand-edit /etc/ados/mediamtx.yml. MediaMTX rejects an unrecognised key outright, so a typo does not degrade the node, it leaves the node with no media server at all. Change video.* in /etc/ados/config.yaml and let the agent regenerate the file.

WebRTC and ICE

The relevant MediaMTX keys, with the values the LAN profiles get:
UDP ICE is pinned to a single port, 8189, so a firewall rule is one line. STUN is configured only on the cloud-relay profile. On a LAN session the browser connects on host candidates, so every STUN entry would be pure handshake latency before the first frame. The relay profile carries five STUN servers and a TCP ICE candidate on 8189, because a relay can face a network that blocks UDP.

Stage 5: Browser decode

The browser receives H.264 RTP over WebRTC and decodes with the platform hardware decoder. Mission Control’s src/lib/video/webrtc-client.ts handles WHEP negotiation, ICE gathering, track attachment and connection health. Video reaches a browser either from the ground station over the radio hop above, or over the cloud relay described below.

Video transport modes

Mission Control’s transport switcher offers four modes: Under p2p-mqtt the browser and the agent’s ados-cloud service exchange SDP over MQTT:
On an offer, the agent posts the SDP to its local WHEP endpoint (http://localhost:8889/main/whep) and publishes MediaMTX’s answer on that session’s own answer topic, so concurrent viewers never see each other’s SDP. A close message, or the browser’s MQTT last will when it vanishes, ends the WHEP session. Once the exchange completes the media flows peer-to-peer through STUN-negotiated NAT traversal; the broker carries signalling only, never video. The full topic list is on Cloud infrastructure.
p2p-mqtt needs internet access on both sides. Behind symmetric NAT, which some cellular carriers use, ICE negotiation can fail outright. The transport switcher reports the failing stage.

What is measured, and what is not

One latency number in this stack has been measured, and it is not the number an operator means by “latency”. Publish-side, stamp to readback, sim bench, 2026-09-15. The SEI probe injects a timestamp after the encoder and reads it back from the drone’s own rtsp://localhost:8554/main. That number covers four things: the publish mux, the RTSP publish, MediaMTX on the drone, and the readback demux. It excludes capture, encode, the radio hop, the ground receive, the ground MediaMTX and the browser. Those are most of what an operator experiences. There is no glass-to-glass figure for ADOS, and this is not one. Do not read the 74 ms as end to end, and do not add per-stage guesses to it to manufacture a total. A real air-to-ground figure needs the same probe run on the ground node against its own rtsp://127.0.0.1:8554/main with clock-aligned nodes. A real glass-to-glass figure needs a camera-side marker and a photograph of the viewer. Neither has been done. Two things to know before quoting any reading from the probe:
  • It is off by default and gated on video.wfb.sei_latency, because it splices a Python stage into the byte path. Bench profiles only.
  • /api/video/latency reads the tap’s sidecar file with no freshness gate, so a stale reading can look live. Confirm an injector is actually in the byte path before trusting a number.
For per-hop health rather than latency, ados diag video samples the reliable cumulative counters at each hop twice and attributes a stall to the hop that stopped. That is the supported first move on a dark feed. See Diagnostics.

Recording

Recording happens inside MediaMTX, writing fMP4 segments with no extra encode pass. It is off by default; video.recording.enabled turns it on and video.recording.retention bounds how long segments are kept. The segment set on disk is the pre-roll ring, and clips are cut out of it after the fact with ados record clip. See Video pipeline.

Next steps

Video pipeline

Configure the camera, encoder and recording on a node.

Diagnostics

Run the per-hop video verdict with ados diag video.

Cloud infrastructure

The MQTT topics and the Convex status path.
Last modified on October 11, 2026