skyhub_whip) is the ingest front door for live drone video. It is a SkyHub fork of Meetecho’s simple-whip-server — a small Node.js/Express service that implements the WHIP (WebRTC-HTTP Ingestion Protocol) REST API and bridges each ingested WebRTC stream to a Janus VideoRoom publisher.
On-drone GStreamer pipelines whipsink their H264 stream into a WHIP endpoint over plain HTTP; WHIP negotiates the WebRTC PeerConnection with Janus on the publisher’s behalf and drops the media into the room. The Dashboard then subscribes to that same room over WebRTC. WHIP handles only the publish (ingest) side — the subscribe side and the SFU itself live in Janus (see Janus WebRTC SFU).
WHIP is a stateless-ish relay of signalling: it holds endpoint bookkeeping in memory (
endpoints{} / resources{} in src/server.js) and proxies WebRTC negotiation to Janus. It never touches media RTP itself — that flows drone → Janus directly once ICE completes.Where it sits in the video plane
The upstream orchestration (Janus room creation, pushing/video_room_details to the drone) is owned by the Gateway — see Janus Video Rooms & On-Drone Video Control. The on-drone camera/pipeline side is documented in Video Streaming (RTSP → WHIP/WebRTC), and the browser subscribe side in App State & Video (Janus/WebRTC).
Runtime & configuration
Configuration lives insrc/config.js; only a few values are environment-driven. The listen port 7080 is hardcoded in config — it is not an env var.
| Env var | Default | Purpose |
|---|---|---|
JANUS_ADDRESS | ws://127.0.0.1:8188 | Janus WebSocket API backend WHIP drives (config.janus.address) |
ROOM_TIMEOUT | 180 (seconds) | unusedEndpointTimeout — how often the stale-endpoint cleaner runs. The 3-minute idle window it enforces is hardcoded (src/server.js:111) and does not change with this value; the 180s default just happens to equal 3 min |
GATEWAY_SERVICE_IP | 10.69.0.3 | Gateway address WHIP calls back to auto-create a missing Janus room (src/server.js:39) |
GATEWAY_SERVICE_PORT | 5000 | Gateway port for that callback |
src/config.js):
port: 7080— the REST API bind port.rest: '/whip'— base path prefix for every route below.allowTrickle: true— trickle-ICE viaPATCHis accepted (disable to force a405).strictETags: false— ETag mismatches are logged but not rejected; flip totruefor spec-strict behavior.iceServers: []— empty, so WHIP returns no ICE-serverLinkheaders by default. STUN/TURN is instead configured on the publisher side (the drone’swhipsink stun-server=…) and inside Janus.
REST API (/whip)
Every path is prefixed with /whip. Endpoint and resource state is held in memory, so a WHIP restart drops all endpoints (the Gateway/drone re-create them via /create on the next stream).
| Method & path | Purpose | Notable responses |
|---|---|---|
GET /whip/healthcheck | Liveness probe | 200 |
GET /whip/endpoints | List all configured endpoints (unauthenticated — testbed) | 200 JSON array |
POST /whip/create | Register an ingest endpoint mapped to a Janus room | 200; 400 bad args / already exists; 403 no Bearer |
OPTIONS /whip/endpoint/:id | CORS preflight; emits ICE Link headers | 204 |
POST /whip/endpoint/:id | Publish: post an SDP offer → becomes a VideoRoom publisher | 201 SDP answer; 404/403/406/503 |
GET /whip/endpoint/:id | Fetch an endpoint’s JSON state | 200 / 404 |
HEAD/PUT /whip/endpoint/:id | Disallowed by spec | 405 |
DELETE /whip/endpoint/:id | Permanently destroy the endpoint | 200 / 404 |
PATCH /whip/resource/:rid | Trickle ICE candidates / ICE restart | 204 trickle, 200 restart |
DELETE /whip/resource/:rid | Tear down the PeerConnection, keep the endpoint | 200 / 404 |
GET/HEAD/POST/PUT /whip/resource/:rid | Disallowed by spec | 405 |
GET /whip/room_number/:room_number | List endpoints bound to a room (marked TODO: secure) | 200 |
DELETE /whip/room_number/:room_number | Destroy every endpoint bound to a room (marked TODO: secure) | 200 |
GET /whip/free/:endpoint_id | Is an endpoint id free to reuse? | 200 free / 403 in use |
web/) is served at the server root (http://<host>:7080/) for visually creating/listing/tearing down endpoints — it cannot publish or view media.
The upstream README says publish returns
200 OK. The SkyHub fork actually writes 201 with the SDP answer body (res.writeHeader(201, …) in src/server.js), alongside the Location, ETag, and Accept-Patch: application/trickle-ice-sdpfrag headers. Treat 201 as success for a publish.Endpoint lifecycle
Create the endpoint
The publisher
POSTs { "id": "<uuid>", "room": <number> } to /whip/create. The physical/SkyCore publisher sends an Authorization: Bearer <token> header, and WHIP rejects a missing/empty Bearer with 403 (src/server.js:243). The SITL node currently omits the Authorization header entirely (skyhub_sitl/video_stream_node.py:58), so a real SITL /create would hit that 403 — an existing SITL-side gap. id and room are both required. WHIP checks whether the Janus room already exists via isRoomExists (a VideoRoom exists request), and if not, calls back to the Gateway to create it (see below). On success the endpoint is stored enabled: false and /whip/endpoint/<id> becomes live.Publish an SDP offer
POST the WebRTC SDP offer to /whip/endpoint/<id> with Content-Type: application/sdp (body must contain v=0, else 406). If the endpoint carries a token, a matching Bearer is required (403). Janus must be connected or you get 503. WHIP attaches a VideoRoom handle, sends joinandconfigure as a publisher (display name = the endpoint label), and returns the SDP answer with a Location: /whip/resource/<rid> header.Trickle ICE / restart
Send ICE candidates as
Content-Type: application/trickle-ice-sdpfrag via PATCH /whip/resource/<rid> (204). If the ufrag/pwd changed, WHIP treats it as an ICE restart, rewrites the stored offer, and returns 200 with fresh server ICE credentials. Requires allowTrickle: true.Auth model
WHIP auth is intentionally light and per-endpoint:/whip/createrequires any non-emptyBearertoken — it is passed straight through to the Gateway room-create callback, which validates it against the drone’s video-room token./whip/endpoint/:id,PATCH/DELETE /resource/:id, andDELETE /endpoint/:idonly enforce a Bearer if that endpoint was created with atoken. SITL’s/createpayload omitstoken, so its endpoints are effectively open./whip/endpoints,/whip/room_number/*, and/whip/free/*are unauthenticated and flaggedTODO:FIXME: Secure and improveinsrc/server.js. Do not expose WHIP directly to the internet — it lives behind the VPN/jumphost. See Authentication & Security Model.
The WHIP → Janus bridge
src/whip-janus.js is the Janus API stack. On boot it opens a janus-protocol WebSocket to JANUS_ADDRESS, creates a Janus session, and starts 15s keep-alives. It verifies the janus.plugin.videoroom plugin is present and detects multistream (Janus version ≥ 1000). Publishing is a two-step VideoRoom flow:
attacha handle tojanus.plugin.videoroom.- Send
joinandconfigurewithptype: "publisher",audio: true,video: true,display: <label>, and the SDPjsep; Janus returns the SDP answer WHIP relays back to the drone.
recipient (with room secret/adminKey) triggers an rtp_forward so the same publisher can be mirrored to a raw RTP sink — unused by the default SkyHub flow but available. If Janus emits a hangup, WHIP fires the endpoint’s teardown callback and clears its state. If the Janus WebSocket drops, WHIP tears down all endpoints and reconnects every 2s.
Room auto-creation callback
WHIP will not create a Janus room on its own. IfisRoomExists reports the room is missing during /create or /endpoint, WHIP POSTs to the Gateway:
create_video_room handler (src/routes/video_room_routes.py) resolves the drone by room id + token and calls VideoService.create(...) to create the room in Janus, then returns 200 so WHIP proceeds to publish. This is why WHIP needs a route back to the Gateway (default 10.69.0.3:5000, the core WireGuard plane) — see the video API in Billing, Calendar, VPN, Video & Isaac Sim API. In normal operation the Gateway has already created the room when the drone was provisioned, so this callback is a self-healing fallback.
How drones and SITL publish
Both real drones and SITL speak the samewhipsink contract; only the pipeline and hostname differ.
- SITL container
- Physical drone (SkyCore)
skyhub_sitl/video_stream_node.py subscribes to the /video_room_details rosbridge topic. On receipt it POSTs /whip/create with a generated id (uuid) and the room_number, saving the details to .janus_room_details/room.json. When MAVROS reports the vehicle armed, it spawns a live777-client container that runs a software GStreamer pipeline:video_stream_node.py
WHIP_SERVER_URL defaults to http://172.17.0.1:7080 (the Docker bridge gateway). See SITL Simulator.Stale-endpoint cleanup
WHIP runs a cleaner on an interval ofunusedEndpointTimeout seconds (ROOM_TIMEOUT, default 180s). Any endpoint that is not enabled and whose lastEnabled timestamp is older than 3 minutes has its Janus publisher session removed and its sdpOffer/ice/resource/etag cleared. Endpoints that never published (lastEnabled == 0) are skipped. This prevents leaked VideoRoom publishers when a drone disarms or drops without a clean DELETE.
Troubleshooting: a camera won’t publish
POST /whip/endpoint returns 404 Invalid endpoint ID
POST /whip/endpoint returns 404 Invalid endpoint ID
The endpoint was never created. Confirm the drone/SITL sent
POST /whip/create first (check GET /whip/endpoints or GET /whip/free/<id>). For SITL this is triggered by the /video_room_details rosbridge message — verify the Gateway pushed room details to the drone.POST /whip/endpoint returns 406 Unsupported content type
POST /whip/endpoint returns 406 Unsupported content type
The publish request lacked
Content-Type: application/sdp or the body did not contain v=0. This is a malformed offer from the GStreamer whipsink.500 Error creating / checking video room
500 Error creating / checking video room
The room did not exist and the Gateway callback (
/api/v1/video_room) failed or was unreachable. Verify GATEWAY_SERVICE_IP/GATEWAY_SERVICE_PORT and that the forwarded Bearer token maps to a real drone video room.Publish succeeds (201) but the Dashboard shows no frames
Publish succeeds (201) but the Dashboard shows no frames
Signalling worked but media/ICE did not connect. WHIP is out of the picture at this point — the RTP path is drone → Janus. Check STUN/TURN reachability and Janus ICE config. This is a Janus-side issue: see Janus WebRTC SFU.
Adding a new video source
To wire a new camera or sender to WHIP, the sender must: (1) obtain aroom_id/room_number and token from the Gateway’s video-room provisioning; (2) POST /whip/create with that id + room and a Bearer token; (3) build a GStreamer pipeline that RTP-payloads H264 (rtph264pay pt=96 ssrc=2) and terminates in whipsink whip-endpoint=<WHIP>/whip/endpoint/<room_id> (add auth-token= if the endpoint was created with a token, and a stun-server= for NAT traversal). Follow the existing camera classes in SkyCore as templates — see Video Streaming (RTSP → WHIP/WebRTC). WHIP itself needs no code change; it is source-agnostic as long as the offer is a standard WHIP H264 publish.
Deployment
WHIP is packaged as a small Node.js image (Dockerfile uses node:alpine; Dockerfile.ecr uses public.ecr.aws/docker/library/node:20-alpine), installs prod deps (npm install --production), and runs npm start — which sets DEBUG=whip:*,-whip:debug,janus:*,-janus:debug,-janus:vdebug for logging (the debug/vdebug namespaces are excluded; the verbose whip:*,janus:* set is the separate start-debug script, package.json:29). CI (.github/workflows/build.yml) runs SonarCloud on PRs and, on the development branch, builds and pushes the image to ECR with Slack notifications. In production it runs on the ECS cluster behind Cloud Map service discovery (whip.skyhub-{env}.internal) alongside Janus — see ECS Fargate Services. For the whole video plane end-to-end, start at the SkyHub Ecosystem Overview and Real-time Transport Channels.
