mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
whip whep whap
This commit is contained in:
@@ -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 UI’s 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 can’t 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 UI’s 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 can’t 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 rover’s ID, and make sure the Pi’s mediaMTX is reachable at `http://<pi-ip>:8889/rovercam/whep` (the default). Each rover advertises that WHEP URL in its `hello` frame, and the server’s 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 rover’s ID, and (optionally) override `media.publishUrl` if your control server isn’t `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 server’s 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 server’s `/mediamtx/auth` webhook
|
- write `/etc/mediamtx/mediamtx.yml` that points to the Node server’s `/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
Binary file not shown.
+9
-23
@@ -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 Pi’s 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 Pi’s 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 you’re 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 you’re 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 rover’s 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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
@@ -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"
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user