A Windows ground station for FPV over WiFi (wfb-ng) using an ESP32-C5 USB radio dongle and a PC-side decoder. The PC runs wfb-gs: a tray application that talks to the dongle over a serial TLV protocol, decrypts wfb-ng video on the host, and publishes low-latency streams through ZLMediaKit (RTSP / WebRTC) plus in-browser WebCodecs and optional D3D11 native fullscreen.
| Layer | Role |
|---|---|
| ESP32-C5 firmware | WiFi monitor-mode capture, 802.11 frame forwarding, channel/MAC filtering, “seen remotes” discovery |
Ground station (wfb-gs) |
Serial adapter management, wfb-ng decrypt + FEC, media muxing, HTTP/JSON-RPC UI, RTSP/WebRTC server |
Web UI (web-ui) |
Vue 3 SPA: adapter control, listen/unlisten, live preview, media source browser, firmware flash |
Decryption happens on the PC, not on the ESP32. The dongle is a radio front-end.
flowchart TB
subgraph Airside["Air side (OpenIPC / wfb-ng)"]
TX["FPV transmitter\n(H264/H265 over wfb-ng)"]
end
subgraph Dongle["ESP32-C5 dongle (firmware/)"]
WIFI["WiFi monitor capture"]
TLV["USB Serial JTAG\nTLV protocol"]
WIFI --> TLV
end
subgraph GS["Ground station — wfb-gs.exe (src/)"]
ADAPT["RadioAdapterMgr\nEsp32UartRadioAdapter"]
HUB["RadioDataHub"]
DEC["WFBDecoder\n(FEC + Aggregator)"]
ZLM["ZLMediaKit Muxer\nRTSP / WebRTC"]
NALU["NaluWsHub\n/ws/nalu"]
D3D["NativeD3dPlayer\nD3D11 fullscreen"]
HTTP["HttpService\nUI + JSON-RPC"]
RPC["Controller"]
TLV --> ADAPT --> HUB --> DEC
DEC --> ZLM
DEC --> NALU
DEC --> D3D
HTTP --> RPC
RPC --> ADAPT
RPC --> DEC
end
subgraph UI["Web UI (web-ui/)"]
VUE["Vue 3 SPA\nStatus · MediaSources"]
WC["WebCodecs player"]
RTC["WebRTC player"]
end
TX -.-> WIFI
HTTP --> VUE
NALU --> WC
ZLM --> RTC
RPC --> VUE
802.11 frames (WiFi air)
→ ESP32-C5 promiscuous capture
→ USB serial TLV (RSSI + channel + raw 802.11)
→ Esp32UartRadioAdapter → RadioFrame
→ RadioDataHub (dispatch by source MAC)
→ WFBDecoder::input
→ IEEE 802.11 payload extraction + channel_id match
→ wfb-ng decrypt + Reed–Solomon FEC (libsodium)
→ RTP depacketize → H.264 / H.265 NALU
→ parallel outputs:
① ZLM MultiMediaSourceMuxer → RTSP / WebRTC
② NaluWsHub → WebSocket (WebCodecs in browser)
③ NativeD3dPlayer → D3D11 hardware decode + present
Default channel_id (must match the air side):
channel_id = (link_id << 8) | radio_port
OpenIPC default link_id is 7669206.
ESP-WFB-FPV/
├── src/ # Ground station C++ source (wfb-gs)
│ ├── app/ # Entry, AppContext, tray, browser launcher
│ ├── ctrl/ # JSON-RPC Controller
│ ├── radio/ # Serial TLV, adapters, device poll, firmware flash
│ ├── wfb/ # wfb-ng decoder (FEC, Aggregator, IEEE 802.11 helpers)
│ ├── net/ # HTTP, WebSocket (/ws/nalu), RPC routing
│ ├── render/ # Native D3D11 player
│ └── util/ # MAC, RemoteId
├── firmware/ # ESP32-C5 firmware (ESP-IDF)
│ └── main/ # WiFi capture, serial protocol, status LED
├── web-ui/ # Vue 3 + Vite source
├── web/ # Build output: single-file index.html (embedded in exe)
├── 3rd/ # ZLMediaKit, libsodium, OpenSSL, libsrtp
├── conf/ # gs.default.json (copied next to exe on build)
├── tools/ # esptool.exe (bundled for OTA flash RPC)
├── build-all.ps1 # One-shot build: web-ui + wfb-gs + firmware
└── CMakeLists.txt # Ground station build
Central lifecycle and service wiring:
- Binds HTTP on
127.0.0.1(random port) with auth token - Starts RTSP on port 554 (falls back if busy)
- Starts WebRTC UDP when
ENABLE_WEBRTCis on - Owns
RadioAdapterMgr,DevicePoller, embedded UI resource - Opens the UI in Edge/Chrome app mode (
--app=http://127.0.0.1:<port>/?token=...)
| Component | Purpose |
|---|---|
RadioAdapter |
Abstract dongle: connect, set channel, start/stop capture, seen remotes |
Esp32UartRadioAdapter |
ESP32 UART/JTAG implementation |
RadioAdapterMgr |
Factory registry, COM port scan, hot-plug |
TlvCodec |
Host-side TLV encode/decode (mirrors firmware/main/wfb_tlv.h) |
RadioDataHub |
Fan-out captured frames to WFBDecoder listeners by source MAC |
DevicePoller |
Periodic USB serial discovery (separate event thread) |
FirmwareFlasher |
OTA flash via bundled esptool.exe |
Connect sequence: RESET → VERSION → SET_WIFI_CHANNEL → START_LISTEN
One WFBDecoder instance per active listen session:
Aggregator— wfb-ng packet reassembly + libsodium decryptfec.c— Reed–Solomon FEC (from fpv4win)- ZLM
RtpCodec→ Annex-B NALU frames - Registers media as
app=wfb,stream=<remote_id>
Single entry: POST /rpc?token=<token>
| Namespace | Methods |
|---|---|
sys.* |
ping, getInfo (name, version, local_ip, rtsp_port, http_port) |
radio.* |
listAdapters, connect, disconnect, setWifiChannel, getSeen, flashFirmware, flashStatus |
wfb.* |
listen, unlisten |
media.* |
list, nativeFullscreen, nativeFullscreenOff, nativeFullscreenStatus |
| Path | Consumer |
|---|---|
GET / |
Embedded Vue UI |
POST /rpc |
All ground-station control |
WS /ws/nalu?stream=<id>&token= |
Browser WebCodecs (low latency) |
POST /index/api/webrtc?app=&stream=&type=play |
Browser WebRTC (ZLM) |
rtsp://<lan_ip>:554/wfb/<remote_id> |
External RTSP clients (VLC, etc.) |
RPC media.nativeFullscreen |
D3D11 native window (ESC to exit) |
Target: ESP32-C5 (USB Serial JTAG, WiFi 6).
| Module | File | Role |
|---|---|---|
| Main | app_main.c |
Boot, start WiFi + serial tasks |
| WiFi | wfb_wifi.c |
Promiscuous mode, channel filter, MAC filter, seen table, TX |
| Serial | wfb_serial.c |
TLV command/response handling |
| Protocol | wfb_tlv.h, wfb_proto.h |
Shared protocol with host |
The firmware does not decrypt wfb-ng. It forwards raw 802.11 MPDU payloads that match the configured filters.
Output: firmware/build/esp-wfb-fpv.bin
Vue 3 + Vite. Production build uses vite-plugin-singlefile → one self-contained web/index.html embedded in the exe (no external assets).
- Sidebar — title FPV Station, tabs:
- Status — adapters, remotes, connect/disconnect, listen/unlisten, WebCodecs players, log, firmware flash modals
- MediaSources — all ZLM media sources, RTSP URLs, live preview grid
src/api/rpc.js— JSON-RPC transportsrc/api/api.js— typed wrappers for every RPC methodsrc/composables/useGroundStation.js— adapter polling, sessions, playerssrc/composables/useMediaSources.js— media list + server info polling
| Source | Player | Path |
|---|---|---|
app=wfb |
WcPlayer.vue |
WebSocket NALU → WebCodecs |
| Other ZLM apps | WebRtcPlayer.vue |
ZLM WebRTC API |
| Any wfb stream | RPC | Native D3D11 fullscreen |
Identifies one wfb-ng logical link on the air side:
struct RemoteId {
MacAddr src; // transmitter MAC (OpenIPC WiFi MAC)
uint32_t link_id; // default 7669206 (OpenIPC)
};String form (ZLM stream id, session key):
57:42:75:05:d6:00_7669206
- Each ESP32 dongle = one
RadioAdapter(id = COM port name) - Multiple dongles can run in parallel on different channels
- Each adapter can hold multiple listen sessions (different remotes)
- MAC filters are merged per adapter (
rebuildAdapterFilter)
Created by wfb.listen with:
mac,link_id,adapter_idkeypair_b64(wfb-ngrx.key)codec:H264orH265
Stopped by wfb.unlisten, adapter disconnect, or firmware flash.
Keypairs are saved under <exe_dir>/keys/listen_<remote_id>.key.
- Windows: VS 2022 (x64), CMake 3.16+
- Node.js: for
web-ui - ESP-IDF 5.5+: for firmware (optional)
.\build-all.ps1Options: -SkipWeb, -SkipGs, -SkipFirmware, -Config Release|Debug, -IdfPath d:\esp-idf-v5.5.5
REM 1. Web UI → single embedded HTML
cd web-ui
npm install
npm run build
cd ..
REM 2. Ground station
cmake -S . -B build -G "Visual Studio 17 2022" -A x64
cmake --build build --config Release --target wfb-gs
REM 3. Firmware (ESP-IDF environment required)
cd firmware
idf.py set-target esp32c5
idf.py buildOutputs:
| Artifact | Path |
|---|---|
| Ground station | build/bin/Release/wfb-gs.exe |
| Embedded UI | web/index.html (built from web-ui/) |
| Firmware | firmware/build/esp-wfb-fpv.bin |
cd web-ui
npm run devSet VITE_GS_URL=http://127.0.0.1:<http_port> to proxy /rpc and /ws to a running wfb-gs instance.
| Library | Location | Used for |
|---|---|---|
| ZLMediaKit | 3rd/ZLMediaKit/ |
RTSP, WebRTC, RTP, HTTP stack |
| libsodium | 3rd/libsodium/ |
wfb-ng crypto (static link) |
| OpenSSL | 3rd/openssl/ |
WebRTC DTLS, self-signed cert |
| libsrtp | 3rd/libsrtp/ |
WebRTC SRTP |
| Windows SDK | — | D3D11, Media Foundation, Win32 serial, tray |
ZLMediaKit is built as a CMake subdirectory with WebRTC and OpenSSL enabled; HLS/FFmpeg/player modules are disabled for a lean ground-station build.
- Plug in ESP32-C5 dongle (VID
303A/ PID1001). - Launch
wfb-gs.exe— single-instance tray app; browser opens automatically. - Status tab → Connect adapter → pick WiFi channel.
- Select a seen remote → Listen → choose
rx.key, link_id, codec. - Video appears in WebCodecs players; RTSP/WebRTC URLs are on the MediaSources tab.
HTTP listens on localhost only. RTSP binds on all interfaces (use sys.getInfo → local_ip + rtsp_port for LAN URLs).
- wfb-ng FEC / Aggregator lineage from fpv4win
- Media stack: ZLMediaKit
- Crypto: libsodium
