Skip to content

Camera Streams with MediaMTX

MediaMTX serves both of the robot’s camera feeds as RTSP on port 8554. This is the camera server the tablet’s camera view connects to, and it should be the only process on the robot that opens the USB camera.

Path Source Delivered
rtsp://<robot-ip>:8554/static USB camera H.264, 1280x720 at 30 fps
rtsp://<robot-ip>:8554/ptz PTZ camera relay The camera’s own codec, unchanged

The two paths stream differently, and the difference matters when you inspect the robot:

  • /static publishes from the moment MediaMTX starts and runs continuously while the service is up (runOnInit). The USB pipeline is always working, even with nobody watching.
  • /ptz is opened only while someone is watching (sourceOnDemand) and closed again afterwards.

The tablet dials the robot (the robot never needs the tablet’s address), and RTSP is served on the single port 8554. The tablet derives both URLs from the robot’s address; Application Architecture covers how it uses them.

Run these checks on the robot as its Linux user — normally robot. The group list must contain video, or nothing can open the camera:

Terminal window
id # the group list must contain "video"
v4l2-ctl --list-devices # the USB camera, normally /dev/video0

The USB camera must offer MJPEG at 1280x720 and 30 fps:

Terminal window
v4l2-ctl -d /dev/video0 --list-formats-ext | grep -A4 "1280x720"

Expect a 1280x720 block whose MJPG entry lists Discrete 0.033s (30.000 fps). Prove the exact mode works by capturing 30 frames:

Terminal window
gst-launch-1.0 -q v4l2src device=/dev/video0 num-buffers=30 \
! image/jpeg,width=1280,height=720,framerate=30/1 \
! fakesink

Silence means it worked. If it fails with not-negotiated, the camera cannot do that mode and the pipeline’s caps have to be changed to match what it can.

Check the codec nodes and the GStreamer elements the pipeline needs:

Terminal window
v4l2-ctl -d /dev/video10 --list-formats-out # decoder: H264 and MJPG must appear
v4l2-ctl -d /dev/video11 --list-formats-out # encoder: raw formats (YU12, YV12, NV12, NV21, …)
gst-inspect-1.0 v4l2h264enc
gst-inspect-1.0 h264parse
gst-inspect-1.0 jpegdec
gst-inspect-1.0 rtspclientsink # if missing: sudo apt install -y gstreamer1.0-plugins-bad

Confirm the PTZ camera is reachable from the robot. Replace <PTZ-CAMERA-IP> with the camera’s real address; this is the URL the config relays:

Terminal window
ffprobe -rtsp_transport tcp -show_streams rtsp://<PTZ-CAMERA-IP>/stream-1.sdp | grep -E "codec_name|width"

The camera must not be in use elsewhere while you test.

The id check above is the user/group check that outlives the preflight: the service unit runs as robot with SupplementaryGroups=video. If the robot’s Linux user is not robot, adjust the unit’s User= and Group= and the chown below accordingly before enabling it.

Download the pinned arm64 release on the robot:

Terminal window
curl -fL -o /tmp/mediamtx.tar.gz \
https://github.com/bluenviron/mediamtx/releases/download/v1.21.0/mediamtx_v1.21.0_linux_arm64.tar.gz

Verify the download, then install it:

Terminal window
echo "a8113b5928ba1a934b81557b61b8a07954b76921a4b567d54c7f086f8b39d9a2 /tmp/mediamtx.tar.gz" | sha256sum -c -

Expect OK; if not, download it again.

Terminal window
sudo tar -xzf /tmp/mediamtx.tar.gz -C /usr/local/bin mediamtx
sudo chmod 755 /usr/local/bin/mediamtx
/usr/local/bin/mediamtx --version # expect: v1.21.0
rm /tmp/mediamtx.tar.gz

If the robot has no internet access, download the same URL on a workstation, verify the same checksum there, then scp mediamtx_v1.21.0_linux_arm64.tar.gz robot@10.0.0.23:/tmp/ and run the same extract commands on the robot.

Write /etc/mediamtx/mediamtx.yml from the published template, then edit the one placeholder:

Terminal window
sudo mkdir -p /etc/mediamtx
sudo curl -fsSL -o /etc/mediamtx/mediamtx.yml https://hausbots-docs.celeriscode.com/downloads/mediamtx/mediamtx.yml
sudo nano /etc/mediamtx/mediamtx.yml # replace rtsp://<PTZ-CAMERA-IP>/stream-1.sdp

If the robot has no internet, download the template on the workstation and scp it across, then sudo install -m 0644 mediamtx.yml /etc/mediamtx/mediamtx.yml.

Reference — the template as downloaded:

# HausBots robot camera server (MediaMTX v1.21.0).
# Replace the PTZ source below with the camera's real RTSP URL before starting.
# Verify it first from the robot: ffprobe -rtsp_transport tcp <url>
rtsp: yes
rtspAddress: :8554
# RTSP only: the other servers are off so MediaMTX never generates a TLS cert.
hls: no
webrtc: no
rtmp: no
moq: no
api: no
metrics: no
pprof: no

paths:
  # USB camera, published as H.264. Must stay on one line.
  # This pipeline publishes from MediaMTX start and runs continuously while
  # the service is up (runOnInit) — not only while a client is watching.
  # MJPEG decode is software here (~17% of one core at 720p30); the hardware
  # JPEG decoder stalls on this kernel, so keep jpegdec.
  static:
    runOnInit: gst-launch-1.0 -e v4l2src device=/dev/video0 ! image/jpeg,width=1280,height=720,framerate=30/1 ! jpegdec ! videoconvert ! v4l2h264enc extra-controls="controls,h264_i_frame_period=30,video_bitrate=2500000" ! h264parse config-interval=1 ! rtspclientsink location=rtsp://127.0.0.1:8554/static
    runOnInitRestart: yes
  # PTZ camera, forwarded untouched (no decode, no re-encode).
  # sourceOnDemand opens the camera only while someone is watching.
  ptz:
    source: rtsp://<PTZ-CAMERA-IP>/stream-1.sdp
    sourceOnDemand: yes
    sourceOnDemandCloseAfter: 30s

RTSP is the only server enabled: HLS, WebRTC, RTMP, the API, metrics, and pprof are off, so MediaMTX never generates the self-signed TLS pair it would otherwise need, and there is no second service to secure.

The two paths are built very differently:

  • /static decodes the camera’s MJPEG in software (jpegdec), converts it, and encodes H.264 on the VideoCore (v4l2h264enc), then publishes the result back to MediaMTX over loopback with rtspclientsink. Because it is a runOnInit command, it runs from service start — about 17% of one core at 720p30 — whether or not anyone is watching. runOnInitRestart: yes brings the pipeline back if it exits.
  • /ptz is forwarded untouched: no decode, no re-encode, so the tablet gets the camera’s own codec. sourceOnDemand opens the camera only while a client is connected, and closes it 30 seconds after the last one leaves.

Replace the ptz source: URL — rtsp://<PTZ-CAMERA-IP>/stream-1.sdp — with the address that answered the ffprobe above. Leave the static pipeline on one line.

Install the unit, create its working directory, and enable it:

Terminal window
sudo curl -fsSL -o /etc/systemd/system/mediamtx.service https://hausbots-docs.celeriscode.com/downloads/mediamtx/mediamtx.service
sudo mkdir -p /var/lib/mediamtx && sudo chown robot:robot /var/lib/mediamtx
sudo systemctl daemon-reload
sudo systemctl enable --now mediamtx
systemctl is-active mediamtx # expect: active

If the robot’s Linux user is not robot, adjust User=, Group=, and the chown accordingly before enabling.

Reference — the unit as downloaded:

[Unit]
Description=MediaMTX RTSP server (HB2 cameras)
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=robot
Group=robot
SupplementaryGroups=video
WorkingDirectory=/var/lib/mediamtx
ExecStart=/usr/local/bin/mediamtx /etc/mediamtx/mediamtx.yml
Restart=always
RestartSec=2

[Install]
WantedBy=multi-user.target

The unit runs MediaMTX as robot, adds the video group so the USB camera can be opened, and restarts it after a failure.

Only if ufw is active:

Terminal window
sudo ufw status | head -1 # only continue if it says "Status: active"
sudo ufw allow 8554/tcp
sudo ufw allow 8000:8001/udp

MediaMTX answers RTSP on 8554/tcp and RTP/RTCP on UDP 8000–8001 for clients that negotiate UDP transport. Clients kept on -rtsp_transport tcp carry the media inside the TCP connection and only need the first rule.

Confirm MediaMTX started listening:

Terminal window
journalctl -u mediamtx -n 20 --no-pager | grep RTSP # a [RTSP] line on :8554

From another machine on the same network (the robot’s address, e.g. 10.0.0.23):

Terminal window
ffprobe -rtsp_transport tcp -show_streams rtsp://10.0.0.23:8554/static \
| grep -E "codec_name|width|height|r_frame_rate"

Expect exactly:

codec_name=h264
width=1280
height=720
r_frame_rate=30/1
Terminal window
ffprobe -rtsp_transport tcp -show_streams rtsp://10.0.0.23:8554/ptz | grep -E "codec_name|width"

Expect the PTZ camera’s own codec and size, unchanged from the check above.

Then watch both:

Terminal window
ffplay -rtsp_transport tcp rtsp://10.0.0.23:8554/static
ffplay -rtsp_transport tcp rtsp://10.0.0.23:8554/ptz
Command Expect
systemctl is-active mediamtx active
pgrep -fa gst-launch while MediaMTX runs one gst-launch-1.0 process (the /static pipeline publishes from service start)
sudo systemctl stop mediamtx, then pgrep -fa gst-launch no output
journalctl -u mediamtx -n 50 | grep "path ptz" after watching /ptz, then leaving started on demand, then stopped: … not needed by anyone
top -bn1 | head -12 while MediaMTX runs gst-launch-1.0 around 17% of one core
sudo reboot, then ffprobe both paths again both play with no manual step
Symptom Fix
Pipeline exits at once, Device or resource busy Something else holds the camera — stop it, then sudo systemctl restart mediamtx.
no element "rtspclientsink" sudo apt install -y gstreamer1.0-plugins-bad
/ptz connects but no video The source: URL is wrong or unreachable; re-run the ffprobe against it from the robot, fix /etc/mediamtx/mediamtx.yml, then restart.
not-negotiated from v4l2h264enc The camera cannot do the mode; re-check the MJPEG 720p30 check and adjust the caps.
Stream stutters Lower video_bitrate to 1500000, and keep clients on -rtsp_transport tcp.
Whatever you changed, revert Restore /etc/mediamtx/mediamtx.yml (reference above) and sudo systemctl restart mediamtx.

After commissioning, send the output of these commands:

Terminal window
/usr/local/bin/mediamtx --version
ffprobe -rtsp_transport tcp -show_streams rtsp://127.0.0.1:8554/static | grep -E "codec_name|width|height|r_frame_rate"
ffprobe -rtsp_transport tcp -show_streams rtsp://127.0.0.1:8554/ptz | grep -E "codec_name|width|height"
systemctl is-active mediamtx