whip whep whap

This commit is contained in:
legop3
2025-11-14 05:14:30 -05:00
parent ab3b49b13e
commit a35bdad805
16 changed files with 238 additions and 380 deletions
+6 -7
View File
@@ -17,7 +17,7 @@ pi provisioning:
- `pi/roverd`: tiny Go daemon that bridges the Create 2 serial port, BRC pin, and the control server via WebSockets. - `pi/roverd`: tiny Go daemon that bridges the Create 2 serial port, BRC pin, and the control server via WebSockets.
- `server`: Node.js process that terminates rover sockets, relays commands to/from the Socket.IO UI, and serves `public/`. - `server`: Node.js process that terminates rover sockets, relays commands to/from the Socket.IO UI, and serves `public/`.
- `pi/systemd` / `pi/mediamtx`: ready-to-drop systemd units and a minimal mediaMTX config for WebRTC publishing (roverd can optionally supervise the mediamtx service). - `pi/bin` / `pi/systemd`: helper scripts + systemd units for the rover (roverd itself plus the WHIP publisher that streams video to the server).
- `docs/pi-deployment.md`: per-rover build + install instructions (cross-compiling on Fedora 43, deploying roverd + mediaMTX). - `docs/pi-deployment.md`: per-rover build + install instructions (cross-compiling on Fedora 43, deploying roverd + mediaMTX).
## Quick start ## Quick start
@@ -49,15 +49,14 @@ The dummy binary connects to the Node server, emits simulated sensor frames, and
Before running the Node server, copy `server/config.example.yaml` to `server/config.yaml` and customize the admin records (password hashes, Discord IDs, lockdown permission). Those credentials are used by the driver UIs login panel—only admins can toggle locks/modes, and lockdown admins retain access when the system enters lockdown mode. The spectator page (future) can set `role:set` to `spectator`, and the server enforces all permissions server-side so client tweaks cant grant extra control. Before running the Node server, copy `server/config.example.yaml` to `server/config.yaml` and customize the admin records (password hashes, Discord IDs, lockdown permission). Those credentials are used by the driver UIs login panel—only admins can toggle locks/modes, and lockdown admins retain access when the system enters lockdown mode. The spectator page (future) can set `role:set` to `spectator`, and the server enforces all permissions server-side so client tweaks cant grant extra control.
Deploy a rover by copying the repo + `dist/roverd` to the Pi and running the helper (it will also fetch mediaMTX when `--mediamtx` is set): Deploy a rover by copying the repo + `dist/roverd` to the Pi and running the helper (it installs roverd plus the WHIP publisher service):
```bash ```bash
cd ~/MultiRoombaRover cd ~/MultiRoombaRover
sudo ./pi/install_roverd.sh --mediamtx sudo ./pi/install_roverd.sh
``` ```
Then point each rover's `/etc/roverd.yaml` at `ws://<server>:8080/rover`, set `name` to the rovers ID, and make sure the Pis mediaMTX is reachable at `http://<pi-ip>:8889/rovercam/whep` (the default). Each rover advertises that WHEP URL in its `hello` frame, and the servers media bridge automatically creates a matching pull path so mediaMTX on 192.168.0.86 fans the stream out to drivers/spectators—browsers never talk to the Pi directly. Then point each rover's `/etc/roverd.yaml` at `ws://<server>:8080/rover`, set `name` to the rovers ID, and (optionally) override `media.publishUrl` if your control server isnt `192.168.0.86`. The new WHIP publisher service (`whip-publisher.service`) continuously captures the Pi camera with `libcamera-vid`, pipes it through FFmpeg, and publishes straight into the servers mediaMTX instance at `http://<server>:8889/whip/<name>`. Use the “Restart Camera” button if you enable media management so roverd can bounce the publisher service remotely.
Use the “Restart Camera” button if you enable media management so roverd can bounce the mediamtx service remotely.
Heads-up: the BRC pulser now uses libgpiod; make sure the `roverd` service account is in the `gpio` group (or otherwise allowed to access `/dev/gpiochip*`) and set `brc.gpioChip` if your hardware exposes a different chip name. Heads-up: the BRC pulser now uses libgpiod; make sure the `roverd` service account is in the `gpio` group (or otherwise allowed to access `/dev/gpiochip*`) and set `brc.gpioChip` if your hardware exposes a different chip name.
## Fedora server deployment ## Fedora server deployment
@@ -73,7 +72,7 @@ The script must be executed via `sudo` from the user that owns the repo. It will
- install Node.js/npm plus curl/tar - install Node.js/npm plus curl/tar
- run `npm install --production` - run `npm install --production`
- copy `config.example.yaml` to `config.yaml` if needed (edit the file afterwards for admins/media URLs + `media.mediamtxApiUrl`) - copy `config.example.yaml` to `config.yaml` if needed (edit the file afterwards for admins + `media.whepBaseUrl`)
- download mediaMTX v1.15.3 and drop it into `/usr/local/bin` - download mediaMTX v1.15.3 and drop it into `/usr/local/bin`
- write `/etc/mediamtx/mediamtx.yml` that points to the Node servers `/mediamtx/auth` webhook - write `/etc/mediamtx/mediamtx.yml` that points to the Node servers `/mediamtx/auth` webhook
- create + enable `mediamtx.service` and `multirover.service`, both running as your repo user and pointing at the clone directly - create + enable `mediamtx.service` and `multirover.service`, both running as your repo user and pointing at the clone directly
@@ -87,7 +86,7 @@ authHTTPExclude:
Then restart `mediamtx.service` so WHIP pushes from the Pis stop getting rejected. Then restart `mediamtx.service` so WHIP pushes from the Pis stop getting rejected.
Once finished, update `server/config.yaml` with your admin passwords, `media.whepBaseUrl` (`http://192.168.0.86:8889/whep`), and `media.mediamtxApiUrl` (usually `http://127.0.0.1:9997`). The Node server hits that API for health checks and token hooks while mediaMTX fans WHIP publishers back out as WHEP viewers. Restart `multirover.service` whenever you edit the config. To pull updates later, just `git pull`, re-run `npm install --production` inside `server/`, and restart the service—no need to rerun the installer. Once finished, update `server/config.yaml` with your admin passwords and `media.whepBaseUrl` (`http://192.168.0.86:8889/whep`). Restart `multirover.service` whenever you edit the config. To pull updates later, just `git pull`, re-run `npm install --production` inside `server/`, and restart the service—no need to rerun the installer.
### Video handshake + diagnostics ### Video handshake + diagnostics
Vendored
BIN
View File
Binary file not shown.
+9 -23
View File
@@ -36,15 +36,15 @@ Once the binary (and repo) are on the Pi, run the helper script from the repo ro
```bash ```bash
cd ~/MultiRoombaRover cd ~/MultiRoombaRover
sudo ./pi/install_roverd.sh --mediamtx sudo ./pi/install_roverd.sh
``` ```
What the script does: What the script does:
- creates the `roverd` (and optionally `mediamtx`) service users if missing and installs `/usr/local/bin/roverd` - creates the `roverd` service account (dialout/gpio/video groups) if missing and installs `/usr/local/bin/roverd`
- copies `pi/roverd/roverd.sample.yaml` to `/etc/roverd.yaml` if the file is absent (existing configs are left untouched) - copies `pi/roverd/roverd.sample.yaml` to `/etc/roverd.yaml` if the file is absent (existing configs are left untouched)
- installs/enables the `roverd.service` systemd unit, restarting it automatically when a config already exists - installs/enables the `roverd.service` systemd unit, restarting it automatically when a config already exists
- when `--mediamtx` is passed, downloads the requested mediaMTX release for the Pis architecture, installs it to `/usr/local/bin/mediamtx`, drops the sample config + unit, enables the service, and restarts it - installs `/usr/local/bin/whip-publisher`, drops `whip-publisher.service`, and enables it so video is published automatically on boot
Flags: Flags:
@@ -52,10 +52,8 @@ Flags:
|------|---------| |------|---------|
| `-b PATH` | use a different roverd binary (defaults to `dist/roverd`) | | `-b PATH` | use a different roverd binary (defaults to `dist/roverd`) |
| `-c PATH` | seed `/etc/roverd.yaml` from another template | | `-c PATH` | seed `/etc/roverd.yaml` from another template |
| `--mediamtx` | download/install mediaMTX plus the provided config + unit |
| `--mediamtx-version X.Y.Z` | override the mediaMTX release tag (default `1.15.3`) |
If the script installs the sample config, it will remind you to edit `/etc/roverd.yaml` before manually restarting the service: set `name`, `serverUrl`, serial device, BRC pin, battery thresholds, and optionally override `media.whepUrl`. When left blank, roverd automatically uses the Pis primary IPv4 plus `:8889/rovercam/whep`. If the script installs the sample config, it will remind you to edit `/etc/roverd.yaml` before manually restarting the service: set `name`, `serverUrl`, serial device, BRC pin, battery thresholds, and optionally override `media.publishUrl`. When left blank, roverd automatically publishes to `http://<server-host>:8889/whip/<name>` (host + scheme are derived from `serverUrl`).
## Manual installation ## Manual installation
@@ -75,26 +73,14 @@ If the script installs the sample config, it will remind you to edit `/etc/rover
``` ```
`roverd` requires access to `/dev/ttyAMA0` and `/dev/gpiochip*`; keeping it under its own user ensures the rest of the system stays isolated—just make sure the account belongs to the `dialout` and `gpio` groups so it can reach the UART and libgpiod. `roverd` requires access to `/dev/ttyAMA0` and `/dev/gpiochip*`; keeping it under its own user ensures the rest of the system stays isolated—just make sure the account belongs to the `dialout` and `gpio` groups so it can reach the UART and libgpiod.
`mediamtx` needs read access to the camera devices (`/dev/media*`, `/dev/video*`), so the install script adds its service account to the `video` group; if you created the user manually, make sure it belongs to `video`. The WHIP publisher needs read access to the camera devices (`/dev/media*`, `/dev/video*`), so the install script adds the `roverd` service account to the `video` group; if you created the user manually, make sure it belongs to `video`.
**BRC note:** configure `brc.gpioPin` (and `brc.gpioChip` if youre not using `gpiochip0`) and ensure the `roverd` user has permission to toggle that line—no root privileges are required anymore. **BRC note:** configure `brc.gpioPin` (and `brc.gpioChip` if youre not using `gpiochip0`) and ensure the `roverd` user has permission to toggle that line—no root privileges are required anymore.
If you set `media.manage: true` in `/etc/roverd.yaml`, make sure the `roverd` service account can invoke `systemctl <action> <media.service>` (either run the unit as root or grant sudo privileges for that command). If you set `media.manage: true` in `/etc/roverd.yaml`, make sure the `roverd` service account can invoke `systemctl <action> <media.service>` (the installer wires `whip-publisher.service` to run as `roverd`, so no sudo tweaks are required unless you rename it).
## Configuring mediaMTX ## WHIP publisher details
1. Download the latest mediaMTX release for ARMv7 and place the binary at `/usr/local/bin/mediamtx`. The `whip-publisher.service` unit runs `/usr/local/bin/whip-publisher`, a small shell wrapper that pipes `libcamera-vid` into FFmpeg and POSTs the result to `media.publishUrl`. roverd writes `/var/lib/roverd/whip.env` on startup (and whenever you restart the service) so the publisher inherits the correct `WHIP_URL`, resolution, FPS, and bitrate. Tweak those knobs under `media.videoWidth`, `media.videoHeight`, `media.videoFps`, and `media.videoBitrate` in `/etc/roverd.yaml` if you need different camera settings.
2. Copy the provided config: Use `sudo systemctl status whip-publisher` to watch the pipeline logs; the unit auto-restarts whenever the network drops or FFmpeg exits.
```bash
sudo useradd -r -s /usr/sbin/nologin mediamtx || true
sudo install -d -o mediamtx -g mediamtx /etc/mediamtx
sudo install -o mediamtx -g mediamtx -m 0644 pi/mediamtx/mediamtx.yml /etc/mediamtx/mediamtx.yml
sudo install -m 0644 pi/systemd/mediamtx.service /etc/systemd/system/mediamtx.service
sudo systemctl daemon-reload
sudo systemctl enable --now mediamtx.service
```
The sample config uses the Raspberry Pi camera module as the source, enables the local mediaMTX HTTP API so `roverd` can health-check the service, and serves WebRTC playback at `http://<pi-ip>:8889/rovercam/whep`. Keep that URL reachable from the control server—`roverd` advertises it automatically and the central media bridge pulls each rovers feed over WHEP.
Expose the mediaMTX HTTP API locally (default `http://127.0.0.1:9997`) and set `media.healthUrl` so `roverd` can monitor the pipeline; `media.service` should match the systemd unit name (default `mediamtx.service`).
With that in place, the Pi only talks to the server over the trusted LAN while the server-side mediaMTX distributes video to every driver and spectator.
## Server + UI ## Server + UI
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
set -euo pipefail
ENV_FILE="${WHIP_ENV_FILE:-/var/lib/roverd/whip.env}"
if [[ ! -f "$ENV_FILE" ]]; then
echo "Environment file ${ENV_FILE} missing; cannot publish" >&2
exit 1
fi
# shellcheck disable=SC1090
source "$ENV_FILE"
: "${WHIP_URL:?WHIP_URL not set in ${ENV_FILE}}"
VIDEO_WIDTH="${VIDEO_WIDTH:-1280}"
VIDEO_HEIGHT="${VIDEO_HEIGHT:-720}"
VIDEO_FPS="${VIDEO_FPS:-30}"
VIDEO_BITRATE="${VIDEO_BITRATE:-3000000}"
LIBCAMERA_BIN="${LIBCAMERA_BIN:-/usr/bin/libcamera-vid}"
FFMPEG_BIN="${FFMPEG_BIN:-/usr/bin/ffmpeg}"
if [[ ! -x "$LIBCAMERA_BIN" ]]; then
echo "libcamera-vid not found at ${LIBCAMERA_BIN}" >&2
exit 1
fi
if [[ ! -x "$FFMPEG_BIN" ]]; then
echo "ffmpeg not found at ${FFMPEG_BIN}" >&2
exit 1
fi
run_pipeline() {
"${LIBCAMERA_BIN}" \
--inline \
--timeout 0 \
--width "${VIDEO_WIDTH}" \
--height "${VIDEO_HEIGHT}" \
--framerate "${VIDEO_FPS}" \
--bitrate "${VIDEO_BITRATE}" \
--codec h264 \
--denoise cdn_off \
--nopreview \
--output - |
"${FFMPEG_BIN}" \
-hide_banner \
-loglevel warning \
-f h264 \
-i pipe:0 \
-c:v copy \
-an \
-f whip \
"${WHIP_URL}"
}
while true; do
if run_pipeline; then
exit 0
fi
echo "WHIP publisher exited, restarting in 2s..." >&2
sleep 2
done
+34 -88
View File
@@ -6,8 +6,6 @@ set -euo pipefail
BINARY_SRC="dist/roverd" BINARY_SRC="dist/roverd"
CONFIG_SRC="pi/roverd/roverd.sample.yaml" CONFIG_SRC="pi/roverd/roverd.sample.yaml"
INSTALL_MEDIAMTX=0
MEDIAMTX_VERSION="1.15.3"
usage() { usage() {
cat <<'USAGE' cat <<'USAGE'
@@ -17,15 +15,13 @@ Options:
-b, --binary <path> Path to the roverd binary (default: dist/roverd) -b, --binary <path> Path to the roverd binary (default: dist/roverd)
-c, --config <path> Source config to install if /etc/roverd.yaml is missing -c, --config <path> Source config to install if /etc/roverd.yaml is missing
(default: pi/roverd/roverd.sample.yaml) (default: pi/roverd/roverd.sample.yaml)
--mediamtx Install/download mediaMTX (binary, config, systemd unit)
--mediamtx-version <v> Override mediaMTX release tag (default: 1.15.3)
-h, --help Show this help text -h, --help Show this help text
The script must run from the repository root and as root (sudo). It will: The script must run from the repository root and as root (sudo). It will:
* create system users/groups if needed * create system users/groups if needed
* install /usr/local/bin/roverd and /etc/roverd.yaml * install /usr/local/bin/roverd and /etc/roverd.yaml
* install /etc/systemd/system/roverd.service and enable the service * install /usr/local/bin/whip-publisher and its systemd unit
* optionally install mediaMTX config/unit when --mediamtx is set * enable roverd.service and whip-publisher.service
USAGE USAGE
} }
@@ -39,14 +35,6 @@ while [[ $# -gt 0 ]]; do
CONFIG_SRC="${2:-}" CONFIG_SRC="${2:-}"
shift 2 shift 2
;; ;;
--mediamtx)
INSTALL_MEDIAMTX=1
shift
;;
--mediamtx-version)
MEDIAMTX_VERSION="${2:-}"
shift 2
;;
-h|--help) -h|--help)
usage usage
exit 0 exit 0
@@ -74,6 +62,13 @@ if [[ ! -f "$CONFIG_SRC" ]]; then
exit 1 exit 1
fi fi
if ! command -v libcamera-vid >/dev/null 2>&1; then
log "WARNING: libcamera-vid not found in PATH; install libcamera-utils for video publishing"
fi
if ! command -v ffmpeg >/dev/null 2>&1; then
log "WARNING: ffmpeg not found in PATH; install ffmpeg for WHIP publishing"
fi
ensure_user() { ensure_user() {
local user="$1" local user="$1"
local groups="${2:-}" local groups="${2:-}"
@@ -92,52 +87,7 @@ log() {
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*"
} }
detect_mediamtx_asset() { ensure_user roverd "dialout,gpio,video"
local arch
arch="$(uname -m)"
case "$arch" in
armv7l|armv6l)
echo "mediamtx_v${MEDIAMTX_VERSION}_linux_armv7.tar.gz"
;;
aarch64|arm64)
echo "mediamtx_v${MEDIAMTX_VERSION}_linux_arm64.tar.gz"
;;
x86_64|amd64)
echo "mediamtx_v${MEDIAMTX_VERSION}_linux_amd64.tar.gz"
;;
*)
echo ""
;;
esac
}
install_mediamtx_binary() {
if ! command -v curl >/dev/null 2>&1; then
echo "curl is required to download mediaMTX" >&2
exit 1
fi
local asset
asset="$(detect_mediamtx_asset)"
if [[ -z "$asset" ]]; then
echo "Unsupported architecture $(uname -m) for mediaMTX download" >&2
exit 1
fi
local url="https://github.com/bluenviron/mediamtx/releases/download/v${MEDIAMTX_VERSION}/${asset}"
local tmpdir
tmpdir="$(mktemp -d)"
log "Downloading mediaMTX ${MEDIAMTX_VERSION} (${asset})"
if ! curl -fsSL "$url" -o "${tmpdir}/${asset}"; then
echo "Failed to download mediaMTX from ${url}" >&2
rm -rf "$tmpdir"
exit 1
fi
tar -xzf "${tmpdir}/${asset}" -C "$tmpdir" mediamtx
install -o mediamtx -g mediamtx -m 0755 "${tmpdir}/mediamtx" /usr/local/bin/mediamtx
log "Installed /usr/local/bin/mediamtx"
rm -rf "$tmpdir"
}
ensure_user roverd "dialout,gpio"
install -o roverd -g roverd -m 0755 "$BINARY_SRC" /usr/local/bin/roverd install -o roverd -g roverd -m 0755 "$BINARY_SRC" /usr/local/bin/roverd
log "Installed roverd binary" log "Installed roverd binary"
@@ -154,36 +104,32 @@ fi
install -m 0644 pi/systemd/roverd.service /etc/systemd/system/roverd.service install -m 0644 pi/systemd/roverd.service /etc/systemd/system/roverd.service
log "Installed roverd systemd unit" log "Installed roverd systemd unit"
systemctl daemon-reload # Install WHIP publisher assets
systemctl enable roverd.service install -D -o root -g root -m 0755 pi/bin/whip-publisher.sh /usr/local/bin/whip-publisher
if [[ $CONFIG_EXISTS -eq 1 ]]; then install -m 0644 pi/systemd/whip-publisher.service /etc/systemd/system/whip-publisher.service
systemctl restart roverd.service install -d -o roverd -g roverd /var/lib/roverd
log "Restarted roverd.service" if [[ ! -f /var/lib/roverd/whip.env ]]; then
else cat > /var/lib/roverd/whip.env <<'ENV'
log "Skipped auto-start because config is the sample; edit $CONFIG_DEST then run: sudo systemctl restart roverd" # Managed by roverd; placeholder values will be overwritten at runtime.
WHIP_URL=http://192.168.0.86:8889/whip/CHANGE_ME
VIDEO_WIDTH=1280
VIDEO_HEIGHT=720
VIDEO_FPS=30
VIDEO_BITRATE=3000000
ENV
chown roverd:roverd /var/lib/roverd/whip.env
chmod 0640 /var/lib/roverd/whip.env
fi fi
if [[ $INSTALL_MEDIAMTX -eq 1 ]]; then systemctl daemon-reload
if [[ ! -f "pi/mediamtx/mediamtx.yml" ]]; then systemctl enable roverd.service
echo "pi/mediamtx/mediamtx.yml missing, cannot install mediaMTX config" >&2 systemctl enable whip-publisher.service
exit 1 if [[ $CONFIG_EXISTS -eq 1 ]]; then
fi systemctl restart roverd.service
if [[ ! -f "pi/systemd/mediamtx.service" ]]; then systemctl restart whip-publisher.service
echo "pi/systemd/mediamtx.service missing, cannot install mediaMTX unit" >&2 log "Restarted roverd + WHIP publisher"
exit 1 else
fi log "Skipped auto-start because config is the sample; edit $CONFIG_DEST then run: sudo systemctl restart roverd whip-publisher"
ensure_user mediamtx "video"
install_mediamtx_binary
install -d -o mediamtx -g mediamtx /etc/mediamtx
install -o mediamtx -g mediamtx -m 0644 pi/mediamtx/mediamtx.yml /etc/mediamtx/mediamtx.yml
install -m 0644 pi/systemd/mediamtx.service /etc/systemd/system/mediamtx.service
log "Installed mediamtx config and unit"
systemctl daemon-reload
systemctl enable mediamtx.service
systemctl restart mediamtx.service
log "Restarted mediamtx.service"
fi fi
log "Install complete" log "Install complete"
-20
View File
@@ -1,20 +0,0 @@
logLevel: info
api: yes
apiAddress: 127.0.0.1:9997
apiEncryption: no
webrtc: yes
webrtcLocalUDPAddress: :8189
webrtcLocalTCPAddress: :8189
webrtcAdditionalHosts: [ "localhost" ]
paths:
rovercam:
source: rpiCamera
rpiCameraWidth: 1280
rpiCameraHeight: 720
rpiCameraFPS: 30
rpiCameraBitrate: 1500000
rpiCameraIDRPeriod: 30
rpiCameraExposure: long
rpiCameraHFlip: false
rpiCameraVFlip: false
+3
View File
@@ -21,6 +21,9 @@ func main() {
if err != nil { if err != nil {
log.Fatalf("load config: %v", err) log.Fatalf("load config: %v", err)
} }
if err := roverd.UpdatePublisherEnv(cfg.Media); err != nil {
log.Fatalf("prepare media env: %v", err)
}
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel() defer cancel()
+45 -61
View File
@@ -3,9 +3,8 @@ package roverd
import ( import (
"errors" "errors"
"fmt" "fmt"
"net" "net/url"
"os" "os"
"strings"
"time" "time"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@@ -55,14 +54,16 @@ type BatteryConfig struct {
} }
type MediaConfig struct { type MediaConfig struct {
WhepURL string `yaml:"whepUrl" json:"whepUrl,omitempty"` PublishURL string `yaml:"publishUrl" json:"publishUrl,omitempty"`
WhepPort int `yaml:"whepPort" json:"-"` PublishPort int `yaml:"publishPort" json:"-"`
WhepPath string `yaml:"whepPath" json:"-"`
LegacyPublish string `yaml:"publishUrl,omitempty" json:"-"`
Manage bool `yaml:"manage"` Manage bool `yaml:"manage"`
Service string `yaml:"service"` Service string `yaml:"service"`
HealthURL string `yaml:"healthUrl"` HealthURL string `yaml:"healthUrl"`
HealthInterval Duration `yaml:"healthInterval"` HealthInterval Duration `yaml:"healthInterval"`
VideoWidth int `yaml:"videoWidth" json:"-"`
VideoHeight int `yaml:"videoHeight" json:"-"`
VideoFPS int `yaml:"videoFps" json:"-"`
VideoBitrate int `yaml:"videoBitrate" json:"-"`
} }
type Config struct { type Config struct {
@@ -93,9 +94,12 @@ func LoadConfig(path string) (*Config, error) {
}, },
}, },
Media: MediaConfig{ Media: MediaConfig{
WhepPort: 8889, PublishPort: 8889,
WhepPath: "/rovercam/whep",
HealthInterval: Duration{Duration: 30 * time.Second}, HealthInterval: Duration{Duration: 30 * time.Second},
VideoWidth: 1280,
VideoHeight: 720,
VideoFPS: 30,
VideoBitrate: 3000000,
}, },
} }
if err := yaml.Unmarshal(data, &cfg); err != nil { if err := yaml.Unmarshal(data, &cfg); err != nil {
@@ -125,69 +129,49 @@ func LoadConfig(path string) (*Config, error) {
if cfg.Media.Manage && cfg.Media.HealthInterval.Duration <= 0 { if cfg.Media.Manage && cfg.Media.HealthInterval.Duration <= 0 {
cfg.Media.HealthInterval = Duration{Duration: 30 * time.Second} cfg.Media.HealthInterval = Duration{Duration: 30 * time.Second}
} }
if cfg.Media.WhepURL == "" { if cfg.Media.VideoWidth <= 0 {
cfg.Media.WhepURL = cfg.Media.LegacyPublish cfg.Media.VideoWidth = 1280
} }
if cfg.Media.WhepURL == "" { if cfg.Media.VideoHeight <= 0 {
ip, err := detectPrimaryIPv4() cfg.Media.VideoHeight = 720
}
if cfg.Media.VideoFPS <= 0 {
cfg.Media.VideoFPS = 30
}
if cfg.Media.VideoBitrate <= 0 {
cfg.Media.VideoBitrate = 3000000
}
if cfg.Media.PublishPort <= 0 {
cfg.Media.PublishPort = 8889
}
if cfg.Media.PublishURL == "" {
derived, err := derivePublishURL(cfg.ServerURL, cfg.Name, cfg.Media.PublishPort)
if err != nil { if err != nil {
return nil, fmt.Errorf("derive whepUrl: %w", err) return nil, fmt.Errorf("derive publishUrl: %w", err)
} }
path := cfg.Media.WhepPath cfg.Media.PublishURL = derived
if path == "" {
path = "/rovercam/whep"
}
path = ensureLeadingSlash(path)
scheme := "http"
cfg.Media.WhepURL = fmt.Sprintf("%s://%s:%d%s", scheme, ip, effectivePort(cfg.Media.WhepPort), path)
} }
return &cfg, nil return &cfg, nil
} }
func ensureLeadingSlash(path string) string { func derivePublishURL(serverURL, roverName string, port int) (string, error) {
if !strings.HasPrefix(path, "/") { if roverName == "" {
return "/" + path return "", errors.New("missing rover name for publishUrl")
} }
return path parsed, err := url.Parse(serverURL)
}
func effectivePort(port int) int {
if port <= 0 {
return 8889
}
return port
}
func detectPrimaryIPv4() (string, error) {
ifaces, err := net.Interfaces()
if err != nil { if err != nil {
return "", err return "", err
} }
for _, iface := range ifaces { host := parsed.Hostname()
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { if host == "" {
continue return "", errors.New("serverUrl missing host")
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
var ip net.IP
switch v := addr.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
}
if ip == nil || ip.IsLoopback() {
continue
}
ip = ip.To4()
if ip == nil {
continue
}
return ip.String(), nil
}
} }
return "", errors.New("no non-loopback IPv4 address found") scheme := "http"
if parsed.Scheme == "wss" || parsed.Scheme == "https" {
scheme = "https"
}
if port <= 0 {
port = 8889
}
return fmt.Sprintf("%s://%s:%d/whip/%s", scheme, host, port, url.PathEscape(roverName)), nil
} }
+32
View File
@@ -0,0 +1,32 @@
package roverd
import (
"bytes"
"fmt"
"os"
"path/filepath"
)
const publisherEnvPath = "/var/lib/roverd/whip.env"
func UpdatePublisherEnv(cfg MediaConfig) error {
if cfg.PublishURL == "" {
return fmt.Errorf("media publishUrl missing")
}
if cfg.VideoWidth <= 0 || cfg.VideoHeight <= 0 || cfg.VideoFPS <= 0 || cfg.VideoBitrate <= 0 {
return fmt.Errorf("invalid media dimensions/bitrate")
}
if err := os.MkdirAll(filepath.Dir(publisherEnvPath), 0o755); err != nil {
return err
}
var buf bytes.Buffer
fmt.Fprintf(&buf, "WHIP_URL=%s\n", cfg.PublishURL)
fmt.Fprintf(&buf, "VIDEO_WIDTH=%d\n", cfg.VideoWidth)
fmt.Fprintf(&buf, "VIDEO_HEIGHT=%d\n", cfg.VideoHeight)
fmt.Fprintf(&buf, "VIDEO_FPS=%d\n", cfg.VideoFPS)
fmt.Fprintf(&buf, "VIDEO_BITRATE=%d\n", cfg.VideoBitrate)
if err := os.WriteFile(publisherEnvPath, buf.Bytes(), 0o640); err != nil {
return err
}
return nil
}
+21 -5
View File
@@ -19,26 +19,36 @@ type MediaSupervisor struct {
} }
func NewMediaSupervisor(cfg MediaConfig, logger *log.Logger) *MediaSupervisor { func NewMediaSupervisor(cfg MediaConfig, logger *log.Logger) *MediaSupervisor {
if err := UpdatePublisherEnv(cfg); err != nil {
logger.Printf("media supervisor: update env failed: %v", err)
}
if !cfg.Manage || cfg.Service == "" { if !cfg.Manage || cfg.Service == "" {
return nil return nil
} }
if cfg.HealthURL == "" {
cfg.HealthURL = "http://127.0.0.1:9997/v3/paths/list"
}
interval := cfg.HealthInterval.Duration interval := cfg.HealthInterval.Duration
if interval <= 0 { if interval <= 0 {
interval = 30 * time.Second interval = 30 * time.Second
} }
var client *http.Client
if cfg.HealthURL != "" {
client = &http.Client{Timeout: 5 * time.Second}
}
return &MediaSupervisor{ return &MediaSupervisor{
cfg: cfg, cfg: cfg,
logger: logger, logger: logger,
client: &http.Client{Timeout: 5 * time.Second}, client: client,
checkInterval: interval, checkInterval: interval,
} }
} }
func (m *MediaSupervisor) Start(ctx context.Context) { func (m *MediaSupervisor) Start(ctx context.Context) {
if m == nil || m.cfg.HealthURL == "" { if m == nil {
return
}
if err := UpdatePublisherEnv(m.cfg); err != nil {
m.logger.Printf("media supervisor: update env failed: %v", err)
}
if m.cfg.HealthURL == "" || m.client == nil {
return return
} }
go func() { go func() {
@@ -66,6 +76,9 @@ func (m *MediaSupervisor) HandleAction(ctx context.Context, action string) error
if m == nil { if m == nil {
return errors.New("media supervisor disabled") return errors.New("media supervisor disabled")
} }
if err := UpdatePublisherEnv(m.cfg); err != nil {
return err
}
switch action { switch action {
case "start", "stop", "restart", "reload", "status": case "start", "stop", "restart", "reload", "status":
return m.runSystemctl(ctx, action) return m.runSystemctl(ctx, action)
@@ -89,6 +102,9 @@ func (m *MediaSupervisor) checkAndRepair() error {
} }
func (m *MediaSupervisor) checkHealth(ctx context.Context) bool { func (m *MediaSupervisor) checkHealth(ctx context.Context) bool {
if m.client == nil || m.cfg.HealthURL == "" {
return true
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, m.cfg.HealthURL, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, m.cfg.HealthURL, nil)
if err != nil { if err != nil {
m.logger.Printf("media supervisor: health request: %v", err) m.logger.Printf("media supervisor: health request: %v", err)
+9 -3
View File
@@ -15,7 +15,13 @@ battery:
urgent: 1650 urgent: 1650
maxWheelSpeed: 350 maxWheelSpeed: 350
media: media:
manage: false publishUrl: http://192.168.0.86:8889/whip/roomba-alpha
service: mediamtx.service publishPort: 8889
healthUrl: http://127.0.0.1:9997/v3/paths/list videoWidth: 1280
videoHeight: 720
videoFps: 30
videoBitrate: 3000000
manage: true
service: whip-publisher.service
healthUrl: ""
healthInterval: 30s healthInterval: 30s
-15
View File
@@ -1,15 +0,0 @@
[Unit]
Description=mediaMTX WebRTC publisher
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/mediamtx /etc/mediamtx/mediamtx.yml
Restart=on-failure
RestartSec=5
User=mediamtx
Group=mediamtx
[Install]
WantedBy=multi-user.target
+17
View File
@@ -0,0 +1,17 @@
[Unit]
Description=Rover WHIP Publisher
After=network-online.target roverd.service
Wants=network-online.target
[Service]
Type=simple
User=roverd
Group=roverd
WorkingDirectory=/var/lib/roverd
EnvironmentFile=/var/lib/roverd/whip.env
ExecStart=/usr/local/bin/whip-publisher
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target
-1
View File
@@ -9,4 +9,3 @@ admins:
lockdown: true lockdown: true
media: media:
whepBaseUrl: "http://192.168.0.86:8889/whep" whepBaseUrl: "http://192.168.0.86:8889/whep"
mediamtxApiUrl: "http://127.0.0.1:9997"
-1
View File
@@ -14,7 +14,6 @@ require('./src/services/roverManager');
require('./src/services/commandService'); require('./src/services/commandService');
require('./src/services/roverConnectionService'); require('./src/services/roverConnectionService');
require('./src/services/assignmentService'); require('./src/services/assignmentService');
require('./src/services/mediaBridgeService');
require('./src/services/videoSessions'); require('./src/services/videoSessions');
require('./src/services/videoAuthService'); require('./src/services/videoAuthService');
require('./src/services/videoSocketService'); require('./src/services/videoSocketService');
-156
View File
@@ -1,156 +0,0 @@
const { loadConfig } = require('../helpers/configLoader');
const logger = require('../globals/logger').child('mediaBridge');
const { managerEvents, rovers } = require('./roverManager');
const mediaConfig = loadConfig().media || {};
const apiBase = (mediaConfig.mediamtxApiUrl || '').replace(/\/$/, '');
const RESYNC_INTERVAL_MS = 60000;
if (!apiBase) {
logger.info('media bridge disabled (media.mediamtxApiUrl not set)');
return;
}
const activeSources = new Map(); // roverId -> { source }
managerEvents.on('rover', (evt) => {
if (evt.action === 'upsert' && evt.record) {
syncRover(evt.record).catch((err) => {
logger.error('failed to sync rover %s: %s', evt.roverId, err.message);
});
} else if (evt.action === 'removed') {
removePath(evt.roverId).catch((err) => {
logger.error('failed to remove rover %s path: %s', evt.roverId, err.message);
});
}
});
// ensure existing rovers are bridged even if this module loads after them
resyncAll(true);
const interval = setInterval(() => resyncAll(true), RESYNC_INTERVAL_MS);
if (interval.unref) interval.unref();
async function resyncAll(force = false) {
for (const record of rovers.values()) {
syncRover(record, { force }).catch((err) => {
logger.error('failed to resync rover %s: %s', record.id, err.message);
});
}
}
async function syncRover(record, { force = false } = {}) {
if (!record?.id) return;
const source = normalizeSource(record?.meta?.media?.whepUrl);
if (!source) {
await removePath(record.id);
return;
}
const current = activeSources.get(record.id);
if (!force && current?.source === source) {
return;
}
await upsertPath(record.id, source);
activeSources.set(record.id, { source, syncedAt: Date.now() });
if (current?.source === source && force) {
logger.info('bridge path refreshed for %s', record.id);
} else {
logger.info('bridge path ready for %s -> %s', record.id, source);
}
}
async function upsertPath(roverId, source) {
const body = {
source,
sourceOnDemand: false,
};
try {
await callApi('POST', `/v3/config/paths/replace/${encodeURIComponent(roverId)}`, body);
} catch (err) {
if (err.status === 404) {
await callApi('POST', `/v3/config/paths/add/${encodeURIComponent(roverId)}`, body);
} else {
throw err;
}
}
}
async function removePath(roverId) {
if (!roverId) {
return;
}
activeSources.delete(roverId);
try {
await callApi('POST', `/v3/config/paths/delete/${encodeURIComponent(roverId)}`, {});
logger.info('bridge path removed for %s', roverId);
} catch (err) {
if (err.status !== 404) {
throw err;
}
}
}
function normalizeSource(raw) {
if (!raw) return null;
const trimmed = String(raw).trim();
if (!trimmed) return null;
try {
const parsed = new URL(trimmed);
let protocol = parsed.protocol;
if (!protocol || protocol === 'http:') {
protocol = 'whep:';
} else if (protocol === 'https:') {
protocol = 'wheps:';
} else if (protocol !== 'whep:' && protocol !== 'wheps:') {
throw new Error('unsupported protocol');
}
const host = parsed.host;
if (!host) {
throw new Error('missing host');
}
let pathname = parsed.pathname || '/';
pathname = normalizeWhepPath(pathname);
return `${protocol.slice(0, -1)}://${host}${pathname}${parsed.search || ''}`;
} catch (err) {
logger.warn('invalid WHEP URL provided by rover (%s): %s', raw, err.message);
return null;
}
}
function normalizeWhepPath(pathname) {
let result = pathname || '/';
if (!result.startsWith('/')) {
result = `/${result}`;
}
result = result.replace(/\/+/g, '/');
result = result.replace(/\/+$/, '');
if (result.startsWith('/whep/')) {
result = `/${result.slice(6)}`;
} else if (result === '/whep') {
result = '/rovercam';
}
if (!result || result === '/') {
result = '/rovercam';
}
if (!result.endsWith('/whep')) {
result = `${result}/whep`;
}
return result;
}
async function callApi(method, path, body) {
const url = `${apiBase}${path}`;
const res = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const text = await res.text().catch(() => '');
const err = new Error(`HTTP ${res.status} ${text}`);
err.status = res.status;
throw err;
}
return res.headers.get('content-type')?.includes('application/json') ? res.json() : null;
}