The Gateway owns the control plane for live video: it creates one Janus VideoRoom per drone and tells the drone which room to publish into. The actual H264 media never touches the Gateway — the on-drone GStreamer pipeline whip-sinks it to the WHIP server, which registers a publisher in the Janus room, and the Dashboard subscribes over WebRTC. This page covers the two Gateway pieces:
  • VideoService (src/service/video_service.py) — async create/recreate/delete of the Janus VideoRoom itself.
  • DroneControlService video methods (src/service/drone_control_service.py:1047-1098) — publishing /video_room_* rosbridge topics that instruct the drone’s video node.
For the media side of the pipeline see Janus WebRTC SFU, WHIP Ingest Server, on-drone Video Streaming, and the Dashboard Janus/WebRTC client. For how get_client resolves a pooled rosbridge connection and how advertise/publish frames are built, see DroneControlService & Rosbridge Dispatch.

The two-part model

There is a hard split between the room (a Janus resource, managed over Janus’ own HTTP/WS API) and the room details (a small JSON blob pushed to the drone over rosbridge so it knows where to publish).
Room identity is derived from the drone. The Janus room id, drone.video_room_id, are all set equal to drone.id. The room pin becomes drone.video_room_password, and drone.video_room_token is a JWT minted with create_access_token(identity=drone.id). This triple (video_room_id, video_room_password, video_room_token) is what the Dashboard needs to join, and what the drone needs to publish.

VideoService — Janus room lifecycle

VideoService.__init__ reads only settings.JANUS_URL (JANUS_URL, unset by default — see Gateway Environment Variables) and stores it as base_url. Every method opens a fresh JanusSession(base_url=...), attaches a JanusVideoRoomPlugin, and acts on it. All three methods are async and are awaited from the drone services and video routes.
MethodJanus callPurpose
create(drone, pin)plugin.create_room(drone.id, config)Create the room (id == drone.id) with pin.
recreate(drone)destroy_room then create_roomRebuild the room reusing drone.video_room_password as the pin.
delete(drone)destroy_room(drone.video_room_id, "", False)Tear the room down; fault-tolerant.

Room configuration (__get_room_config)

src/service/video_service.py:68-95 returns the exact Janus VideoRoom config. The load-bearing values:
src/service/video_service.py
{
    "description": str(room_description),   # drone.name
    "pin": str(pin),                        # == drone.video_room_password
    "permanent": True,                      # survives Janus restart
    "is_private": False,
    "max_publishers": 6,
    "bitrate": 6000000,                     # 6 Mbps ceiling
    "fir_freq": 3,
    "audiocodec": "opus",
    "videocodec": "h264",                   # H264 only — matches drone HW encode
    "opus_fec": True,
    "record": False,                        # no server-side recording
    "playoutdelay_ext": True,
    "transport_wide_cc_ext": True,
    "h264_profile": VIDEO_PROFILE,          # "42e01f" (baseline)
    "threads": 3,
}
The codec is pinned to H264 with baseline profile 42e01f. The module defines VIDEO_PROFILE three times (64001f high → 4d0032 main → 42e01f baseline); only the last assignment wins, so the effective profile is baseline 42e01f. This must match the profile the on-drone nvv4l2 HW encoder produces — a mismatch here is a common “connects but no picture” failure. If you change codec/profile, change it on the drone’s GStreamer caps at the same time.
record: False means Janus does not record. The rec_dir line is commented out. Recorded clips are produced separately on the drone and land in S3 as HLS — see S3 Assets, HLS Video & Execution Archives.

Fault-tolerant deletion

delete() (src/service/video_service.py:28-54) is deliberately defensive so a stale/missing Janus room never blocks drone deletion:
  • Returns early if drone.video_room_id is falsy.
  • Swallows "No such room" and 426 errors as expected/benign.
  • Never re-raises — any other exception is logged and swallowed with "Continuing with drone deletion despite video room error".
This is why PhysicalDroneService.delete / SITLDroneService.delete can call await video_service.delete(drone) unconditionally.

Bootstrapping: physical vs SITL differ

Both drone types run the same room-creation block on save() — generate a pin, create the Janus room, persist video_room_id/password/token. They diverge on whether the room details are pushed to the drone at creation time.
SITLDroneService.save (src/service/sitl_drone_service.py:694-715) creates the room and immediately publishes the details to the drone with status: "START", auto-starting the stream:
src/service/sitl_drone_service.py
await self.video_service.create(drone, pin)
drone.video_room_id = drone.id
drone.video_room_password = pin
drone.video_room_token = create_access_token(identity=drone.id)
super().update(drone.to_dict())
self.drone_control_service.update_video_room(
    drone.user_id,
    drone.id,
    {
        "room_number": drone.video_room_id,
        "room_password": drone.video_room_password,
        "room_mgmt_token": drone.video_room_token,
        "status": "START",   # Auto-start video streaming for SITL drones
    },
)
This works because by this point the SITL CORE container’s rosbridge is already up (_wait_for_rosbridge ran earlier in save), so get_client has a live connection to publish onto. See SITL Drone Lifecycle.
This is the single most common “physical drone video won’t start” cause: the drone was registered but never received its room details because the create-time push is intentionally disabled. Trigger a start/restart (which pushes the details) once the drone is online and reachable over the VPN. Do not “fix” this by uncommenting the push at create time — there is no connection then.

DroneControlService — publishing /video_room_* topics

The three control topics are std_msgs/msg/String and are defined in src/utils/mavros_topics.py:21-23:
ConstantTopicPayload
VIDEO_ROOM_DETAILS/video_room_detailsJSON string of {room_number, room_password, room_mgmt_token, status?}
VIDEO_ROOM_STATE/video_room_state"START" or "STOP"
VIDEO_ROOM_SOURCE/video_room_sourcesource string ("CAMERA" / "TEST")
Every method follows the same advertise-then-publish shape. rosbridge requires an advertise op before you can publish to a topic that has no local ROS publisher, so each call sends get_advertise_msg(...) first, then get_publish_msg_without_data_field(...) (src/rosbridge/request_format.py:8-24). All resolve the connection via get_client(user_id, drone_id) from the pool (Connection Pool & Startup Wiring).
src/service/drone_control_service.py:1047-1058. Advertises /video_room_details (advertise id "adv_vrd") then publishes the room-details dict as a JSON string (orjson.dumps(room_details).decode()).
src/service/drone_control_service.py
def update_video_room(self, user_id, drone_id, room_details):
    client = self.get_client(user_id, drone_id)
    self.send_message(client, request_format.get_advertise_msg("adv_vrd", VIDEO_ROOM_DETAILS[0], VIDEO_ROOM_DETAILS[1]))
    self.send_message(client, request_format.get_publish_msg_without_data_field(
        "1", VIDEO_ROOM_DETAILS[0], {"data": orjson.dumps(room_details).decode()}))
Unlike the other three methods, update_video_room does not guard if client:. If get_client returns a falsy client, send_message raises WebSocketConnectionClosedException (“Websocket connection not alive!”) rather than the ResourceNotFoundException("Drone") the siblings raise. Callers should be prepared for either.
src/service/drone_control_service.py:1060-1085. Advertise /video_room_state (advertise id "adv_vrs") then publish {"data": "START"} or {"data": "STOP"}. Both raise ResourceNotFoundException("Drone") when get_client yields no client.These do not send room details — they only toggle streaming on/off for a room the drone already knows about. A drone that never received /video_room_details (e.g. a freshly registered physical drone) has nothing to start.
src/service/drone_control_service.py:1087-1098. Advertise /video_room_source (advertise id "adv_vrsrc") then publish {"data": source}. Switches which on-drone camera the stream uses. Valid sources are validated at the route against VIDEO_ROOM_SOURCES = ["CAMERA", "TEST"] (src/utils/drone_utils.py:3).

HTTP surface

Video-room control is exposed through src/routes/video_room_routes.py plus one action in src/routes/drone_routes.py. All are JWT-authenticated (@jwt_required()) except POST /video_room, which authenticates with the drone’s video-room token.
Method & pathAuthBehavior
POST /video_roomAuthorization: Bearer <video_room_token>Looks up the drone via get_drones_by_video_room_id_and_token(room_number, token), then video_service.create(drone, drone.video_room_password). 402 if no auth header, 400 if room_number missing or no matching drone.
GET /video_room/<drone_id>/startJWT?update=trueupdate_video_room(...status:"START") (re-pushes details); otherwise start_video_stream.
GET /video_room/<drone_id>/stopJWTstop_video_stream.
GET /video_room/<drone_id>/restartJWTstop_video_streamsleep(0.3)video_service.recreate(drone)update_video_room(...START)start_video_stream.
POST /drone/action/video-sourceJWTBody {drone_id, source}; validates source in ["CAMERA","TEST"] then update_video_source.
start?update=true is the recommended path to bring a physical drone’s video up for the first time — it re-pushes the room details that were skipped at create time, then starts the stream in one call.

Restart is a deliberate double-publish

restart_video_stream (src/routes/video_room_routes.py:159-213) recreates the Janus room and then publishes both /video_room_details (with status: "START") and /video_room_state ("START"). The trailing start_video_stream is a documented compatibility shim:
src/routes/video_room_routes.py
# TODO: FIXME: this is for compatibility with SITL drones
# pre-state machine implementation
# remove once SITL and drones use the same video node implementation
Physical drones react to the details payload’s status; older SITL video nodes need the separate /video_room_state START. Preserve both until the drone-side video nodes converge.
Route docstring vs. real validation. The OpenAPI docstring for POST /drone/action/video-source advertises enum: [main, thermal], but the code validates against VIDEO_ROOM_SOURCES = ["CAMERA", "TEST"]. The code is authoritative — sending main/thermal returns 400 Invalid source. Fix the docstring, not the check, if you touch this.

End-to-end: creating and starting video

1

Create the room

On drone save(), VideoService.create(drone, pin) opens the Janus VideoRoom id == drone.id (H264 baseline). The Gateway stores video_room_id, video_room_password (the pin), and video_room_token (JWT identity=drone.id) on the Drone row.
2

Push room details to the drone

SITL pushes automatically on create (status: "START"). Physical does not — the push is deferred to the first start?update=true / restart, once the drone is reachable over rosbridge.
3

Drone publishes media to WHIP

The on-drone video node builds a GStreamer pipeline (HW nvv4l2 encode) and whip-sinks H264 to the WHIP server, which registers a publisher in the Janus room. See Video Streaming.
4

Dashboard subscribes over WebRTC

The Dashboard joins the same room id with video_room_token and attaches the remote track. See App State & Video.
5

Switch source / stop as needed

POST /drone/action/video-source {source:"CAMERA"|"TEST"} swaps the camera; stop publishes /video_room_state "STOP".

Gotchas to preserve

The update_video_room call in PhysicalDroneService.save is commented out because a new physical drone has no rosbridge connection yet. Room details reach it later via start?update=true / restart. Do not uncomment the create-time push.
It has no if client: guard, so a dead/absent connection surfaces as WebSocketConnectionClosedException from send_message, whereas start/stop/update_video_source raise ResourceNotFoundException("Drone").
Janus room id, video_room_id are all drone.id. Anything that renumbers drones or reuses ids must also recreate the Janus room, or the Dashboard will subscribe to a stale/mismatched room.
VIDEO_PROFILE is set three times in video_service.py; only 42e01f (baseline) takes effect. Keep it aligned with the drone’s encoder caps.
VideoService.delete swallows all errors (and specifically ignores No such room / 426) so drone deletion is never blocked by Janus state.