skyhub_janus packages the meetecho janus-gateway WebRTC server as the SkyHub video plane’s Selective Forwarding Unit (SFU). A drone (physical SkyCore or SITL) publishes exactly one H264 stream into a Janus VideoRoom; Janus fans that single stream out to every Dashboard viewer subscribed to the room. It carries no MAVLink, no telemetry, and no control — video flows entirely out-of-band from the Gateway rosbridge control path.
Three parties touch Janus, and they do not all use the same port:
Gateway
Creates and destroys one room per drone over the HTTP API (
:8088 /janus).WHIP server
Registers the drone’s H264 ingest as a room publisher over the WebSocket API (
:8188).Dashboard
Joins the room as a WebRTC subscriber over the WebSocket API (
wss :8188).Role in the video pipeline
The Gateway only ever manages the room; the actual media never passes through the Gateway. See Gateway → Janus video rooms for the server-side room lifecycle and WHIP for the ingest half.Built from meetecho source
There is no vendored Janus binary — the image compiles it.Dockerfile is a two-stage Ubuntu Focal build that first builds Janus’ native dependencies from pinned sources, then Janus itself:
Dockerfile
- Compiled-in features are fixed at build time: WebSockets transport, post-processing, and the RabbitMQ + GELF event handlers. All loggers are disabled (
--disable-all-loggers). Adding a transport or event handler that isn’t in this./configureline requires a rebuild, not just a config flip. janus-gatewayis cloned unpinned (git clone … .gitwith no branch/tag), so each image build tracks upstreammasterHEAD. Pin a tag here if you need reproducible builds.- Two Dockerfiles exist:
Dockerfile(pulls base images from Docker Hub) andDockerfile.ecr(identical build, but base images frompublic.ecr.aws/ubuntu/ubuntu:focalfor CI)..github/workflows/build.ymlbuilds on pushes todevelopmentand pushes the image to ECR asskyhub-janus-on-prem-dev(and the test UI asskyhub-janus-ui-on-prem-dev). - The container runs Janus as an unprivileged
appuser (uid 999) viaentrypoint.sh.
Boot: dockerize renders the templates
entrypoint.sh uses dockerize to copy the config templates into place, then launches Janus:
entrypoint.sh
entrypoint.sh also has optional RabbitMQ gating: if RABBITMQ_ENABLED=true or RABBITMQ_EVENTHANDLER_ENABLED=true, dockerize waits (-wait tcp://host:port -timeout 60s) for the broker before starting Janus.
Ports & transports
The effective transport config comes fromtemplates/janus.transport.websockets.jcfg and templates/janus.transport.http.jcfg. Only 8088, 8089, 8188 and the 10000–10099/udp media range (the bolded rows) are published by the Gateway’s docker-compose.yml; the WSS (8989) and admin ports stay container-internal.
| Port | Transport | Purpose | Published |
|---|---|---|---|
| 8088 | HTTP /janus | Janus REST API — Gateway room CRUD via JANUS_URL | yes |
| 8089 | HTTPS /janus | TLS REST (disabled in template, port mapped) | yes |
| 8188 | WebSocket API | WHIP publisher + Dashboard subscriber signalling | yes |
| 8989 | WSS API | TLS WebSocket API (wss = false, disabled) | no |
| 10000–10099/udp | RTP/RTCP | WebRTC media (rtp_port_range in janus.jcfg) | yes |
| 7088 | Admin HTTP /admin | Admin/monitor API (admin_secret = "janusoverlord") | no |
| 7188 | Admin WebSocket | Admin/monitor over WS | no |
Ports 8088 and 8188 speak the same Janus API over different transports. The Gateway’s Python
janus_client talks HTTP on :8088; WHIP and the browser use WebSockets on :8188. There is no functional difference in the API surface, only the transport.Configuration templates
All runtime config lives intemplates/*.jcfg. The ones that matter for video:
janus.jcfg — core, NAT & media
| Setting | Value | Notes |
|---|---|---|
events.broadcast | true | Event handlers globally armed (individual handlers still off) |
media.rtp_port_range | "10000-10099" | Must match the published UDP range |
media.min_nack_queue | 200 | ms |
nat.nat_1_1_mapping | "localhost" | Placeholder — must be the public/reachable IP in real deployments |
nat.keep_private_host | "true" | Also advertise the private host candidate |
nat.full_trickle | false | Half-trickle (Janus sends its candidates in the SDP) |
nat.ice_lite / nat.ice_tcp | false | Full ICE, UDP only |
nat.ice_ignore_list | "vmnet" | Skip VMware interfaces |
admin_secret | "janusoverlord" | Admin API secret (default — rotate for prod) |
STUN / ICE reality
This is the config most relevant to “video connects but shows no frames,” so be precise:- Server-side, Janus has no
stun_serverconfigured (the templatenatblock omits it). It advertises host candidates only, rewritten bynat_1_1_mapping = "localhost". In production behind the WireGuard jumphost / nginx,nat_1_1_mapping = "localhost"means Janus hands remote browsers a media candidate pointing at localhost — signalling completes, but media never arrives. If you deploy Janus reachable from real browsers, setnat_1_1_mappingto the machine’s public IP (and open UDP 10000–10099). - The Google STUN that actually works is on the browser side: the Dashboard sets
janusIceServers: ['stun:stun.l.google.com:19302']in its Angular environment (environment*.ts), and connects tojanusGatewayUrl(e.g.wss://prod.skyhub.ai:8188). That STUN gathers the viewer’s srflx candidates, not Janus’.
The VideoRoom model
Every drone gets its own room whose id equalsdrone.id (integer ids — string_ids = false). The room is created by the Gateway’s VideoService, not by a static config file, using the janus_client library over the HTTP API:
src/service/video_service.py
config built in VideoService.__get_room_config (src/service/video_service.py:68):
| Field | Value | Meaning |
|---|---|---|
description | drone.name | Human label |
pin | video_room_password | Join PIN handed to WHIP + Dashboard |
videocodec | "h264" | Must match the drone’s H264 pipeline |
h264_profile | "42e01f" | Baseline profile (VIDEO_PROFILE) |
audiocodec | "opus" (opus_fec) | Audio, unused by video-only feeds |
max_publishers | 6 | Concurrent senders |
bitrate | 6000000 | 6 Mbps target |
fir_freq | 3 | Keyframe (FIR) request cadence, seconds |
permanent | true | Persist across restarts |
is_private | false / record false | Listed, not recorded |
{room_number, room_password, room_mgmt_token} to the drone over the rosbridge topic /video_room_details, and toggles streaming with /video_room_state (via the GET /video_room/<id>/start|stop|restart routes in src/routes/video_room_routes.py). Room lifecycle detail lives in Gateway video rooms.
templates/janus.plugin.videoroom.jcfg also defines a static room-1234 “Demo Room” (h264, bitrate = 6000). It is a leftover demo target, unrelated to drone rooms — real drones always get an API-created per-drone room. Do not assume drone video uses room 1234.Event handlers & observability
Janus can stream live core/plugin events (sessions, JSEP, WebRTC state, media stats,slowlink, etc.) to an external sink. The RabbitMQ and GELF handlers are compiled in but disabled by default — every handler template ships with enabled = false:
Enable the RabbitMQ event handler
Enable the RabbitMQ event handler
Edit
templates/janus.eventhandler.rabbitmqevh.jcfg: set enabled = true, point host/port at your broker, and keep events = "all" (or narrow it). It publishes to exchange wembrane with route key from-janus-events. Then set RABBITMQ_EVENTHANDLER_ENABLED=true (+ RABBITMQ_EVENTHANDLER_HOST/PORT) so entrypoint.sh waits for the broker before boot. Rebuild the image.Enable the GELF event handler
Enable the GELF event handler
Edit
templates/janus.eventhandler.gelfevh.jcfg: set enabled = true, backend = "<graylog-host>", port, protocol = "tcp". GELF ships JSON events to Graylog-compatible collectors. Rebuild.Live admin/monitor API
Live admin/monitor API
The admin API is enabled on HTTP
:7088 (/admin) and WS :7188 with admin_secret = "janusoverlord", but neither port is published by docker-compose.yml. To inspect live sessions/handles, publish 7088 and query /admin with the secret, or docker exec into the container.events.broadcast = true in janus.jcfg, events = true on the VideoRoom plugin), so enabling a handler is only a matter of flipping its enabled flag and pointing it at a sink.
Debugging “connects but shows no frames”
Codec / profile mismatch
The room forces h264, baseline
42e01f. If the drone’s GStreamer pipeline encodes a profile the room or the viewer can’t decode, the subscriber attaches but renders nothing. Confirm the on-drone pipeline is H264 baseline — see drone video streaming.ICE / nat_1_1_mapping = localhost
The template advertises
localhost as Janus’ host candidate. For any browser not on the Janus host, signalling succeeds but media has no route. Set nat_1_1_mapping to the public IP and rebuild. This is the single most common cause of black video in a remote deployment.Blocked media ports
WebRTC media uses UDP 10000–10099. If that range isn’t open end-to-end (host firewall, security group, or the jumphost), ICE connectivity checks fail and no RTP arrives.
Wrong room pin / token
The Dashboard must join with the same
video_room_password (pin) the Gateway generated. A stale/mismatched pin rejects the subscribe. If the room was destroyed, use GET /video_room/<id>/restart (which recreates the room, VideoService.recreate).Publisher never joined
“No frames” often means the publisher half failed. Check that WHIP registered a publisher on
:8188 — if the drone never posted its SDP offer, the room has no source. Continue in WHIP.Test UI
janus-test-ui/ is a small Alpine + darkhttpd image that serves the stock meetecho Janus demo pages (including videoroom.html) for manually poking a room. It is a debugging convenience only — the SkyHub Dashboard uses its own JanusService, not these demos.
Gotchas
- Env vars are inert. The
JANUS_*values indocker-compose.ymlare not wired into the static templates — configure Janus by editingtemplates/*.jcfgand rebuilding, not through the environment. - Two config layers, template wins. The
Dockerfilesedof Google STUN intojanus.jcfgis overwritten at boot bytemplates/janus.jcfg; treat the templates as the single source of truth. - Unpinned upstream.
janus-gatewaybuilds frommasterHEAD; a rebuild can silently pick up breaking upstream changes. - Default secrets.
admin_secret("janusoverlord") andapi_secret/token auth are the meetecho sample defaults — token auth is commented out, so rooms are gated only by the per-roompin. Harden before exposing Janus publicly. - Room id == drone id. Deleting/recreating a drone reuses the same room id;
VideoService.deletetolerates “No such room” (426) errors so a missing room never blocks drone deletion. - Media ports fixed at 10000–10099. Changing the range means editing both
janus.jcfgand thedocker-compose.ymlUDP mapping.
Related pages
WHIP Ingest Server
The ingest front door that turns a drone’s H264 into a Janus publisher.
Gateway Video Rooms
Server-side room lifecycle,
/video_room_details, start/stop.Dashboard Video
How the Angular
JanusService subscribes and renders the feed.On-Drone Video Streaming
The GStreamer → WHIP pipeline that produces the H264 source.

