VideoService(src/service/video_service.py) — async create/recreate/delete of the Janus VideoRoom itself.DroneControlServicevideo 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.
| Method | Janus call | Purpose |
|---|---|---|
create(drone, pin) | plugin.create_room(drone.id, config) | Create the room (id == drone.id) with pin. |
recreate(drone) | destroy_room then create_room | Rebuild 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
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_idis falsy. - Swallows
"No such room"and426errors as expected/benign. - Never re-raises — any other exception is logged and swallowed with
"Continuing with drone deletion despite video room error".
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 onsave() — 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.
- SITL (pushes on create)
- Physical (push is commented out)
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
_wait_for_rosbridge ran earlier in save), so get_client has a live connection to publish onto. See SITL Drone Lifecycle.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:
| Constant | Topic | Payload |
|---|---|---|
VIDEO_ROOM_DETAILS | /video_room_details | JSON string of {room_number, room_password, room_mgmt_token, status?} |
VIDEO_ROOM_STATE | /video_room_state | "START" or "STOP" |
VIDEO_ROOM_SOURCE | /video_room_source | source string ("CAMERA" / "TEST") |
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).
update_video_room(user_id, drone_id, room_details)
update_video_room(user_id, drone_id, room_details)
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
start_video_stream / stop_video_stream(user_id, drone_id)
start_video_stream / stop_video_stream(user_id, drone_id)
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.update_video_source(user_id, drone_id, source)
update_video_source(user_id, drone_id, source)
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 throughsrc/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 & path | Auth | Behavior |
|---|---|---|
POST /video_room | Authorization: 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>/start | JWT | ?update=true → update_video_room(...status:"START") (re-pushes details); otherwise start_video_stream. |
GET /video_room/<drone_id>/stop | JWT | stop_video_stream. |
GET /video_room/<drone_id>/restart | JWT | stop_video_stream → sleep(0.3) → video_service.recreate(drone) → update_video_room(...START) → start_video_stream. |
POST /drone/action/video-source | JWT | Body {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
status; older SITL video nodes need the separate /video_room_state START. Preserve both until the drone-side video nodes converge.
End-to-end: creating and starting video
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.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.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.Dashboard subscribes over WebRTC
The Dashboard joins the same room id with
video_room_token and attaches the remote track. See App State & Video.Gotchas to preserve
Physical video is not pushed at creation — this is intentional
Physical video is not pushed at creation — this is intentional
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.update_video_room raises a different exception than its siblings
update_video_room raises a different exception than its siblings
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").Room id is drone.id everywhere
Room id is drone.id everywhere
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.H264 profile is duplicated; last assignment wins
H264 profile is duplicated; last assignment wins
VIDEO_PROFILE is set three times in video_service.py; only 42e01f (baseline) takes effect. Keep it aligned with the drone’s encoder caps.delete() never raises
delete() never raises
VideoService.delete swallows all errors (and specifically ignores No such room / 426) so drone deletion is never blocked by Janus state.
