diff --git a/README.md b/README.md index 77632bf2..220e768b 100644 --- a/README.md +++ b/README.md @@ -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. - `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). ## 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. -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 cd ~/MultiRoombaRover -sudo ./pi/install_roverd.sh --mediamtx +sudo ./pi/install_roverd.sh ``` -Then point each rover's `/etc/roverd.yaml` at `ws://:8080/rover`, set `name` to the rover’s ID, and make sure the Pi’s mediaMTX is reachable at `http://: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. -Use the “Restart Camera” button if you enable media management so roverd can bounce the mediamtx service remotely. +Then point each rover's `/etc/roverd.yaml` at `ws://: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://:8889/whip/`. Use the “Restart Camera” button if you enable media management so roverd can bounce the publisher 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. ## 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 - 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` - 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 @@ -87,7 +86,7 @@ authHTTPExclude: 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 diff --git a/dist/roverd b/dist/roverd index 31b897ba..61bd8d92 100755 Binary files a/dist/roverd and b/dist/roverd differ diff --git a/docs/pi-deployment.md b/docs/pi-deployment.md index 4b34461c..2b597647 100644 --- a/docs/pi-deployment.md +++ b/docs/pi-deployment.md @@ -36,15 +36,15 @@ Once the binary (and repo) are on the Pi, run the helper script from the repo ro ```bash cd ~/MultiRoombaRover -sudo ./pi/install_roverd.sh --mediamtx +sudo ./pi/install_roverd.sh ``` 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) - 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: @@ -52,10 +52,8 @@ Flags: |------|---------| | `-b PATH` | use a different roverd binary (defaults to `dist/roverd`) | | `-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://:8889/whip/` (host + scheme are derived from `serverUrl`). ## 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. -`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. -If you set `media.manage: true` in `/etc/roverd.yaml`, make sure the `roverd` service account can invoke `systemctl ` (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 ` (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`. -2. Copy the provided config: - ```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://: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. +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. +Use `sudo systemctl status whip-publisher` to watch the pipeline logs; the unit auto-restarts whenever the network drops or FFmpeg exits. ## Server + UI diff --git a/pi/bin/whip-publisher.sh b/pi/bin/whip-publisher.sh new file mode 100644 index 00000000..1a6ed9e3 --- /dev/null +++ b/pi/bin/whip-publisher.sh @@ -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 diff --git a/pi/install_roverd.sh b/pi/install_roverd.sh index a6bb7f76..bf023abd 100755 --- a/pi/install_roverd.sh +++ b/pi/install_roverd.sh @@ -6,8 +6,6 @@ set -euo pipefail BINARY_SRC="dist/roverd" CONFIG_SRC="pi/roverd/roverd.sample.yaml" -INSTALL_MEDIAMTX=0 -MEDIAMTX_VERSION="1.15.3" usage() { cat <<'USAGE' @@ -17,15 +15,13 @@ Options: -b, --binary Path to the roverd binary (default: dist/roverd) -c, --config Source config to install if /etc/roverd.yaml is missing (default: pi/roverd/roverd.sample.yaml) - --mediamtx Install/download mediaMTX (binary, config, systemd unit) - --mediamtx-version Override mediaMTX release tag (default: 1.15.3) -h, --help Show this help text The script must run from the repository root and as root (sudo). It will: * create system users/groups if needed * install /usr/local/bin/roverd and /etc/roverd.yaml - * install /etc/systemd/system/roverd.service and enable the service - * optionally install mediaMTX config/unit when --mediamtx is set + * install /usr/local/bin/whip-publisher and its systemd unit + * enable roverd.service and whip-publisher.service USAGE } @@ -39,14 +35,6 @@ while [[ $# -gt 0 ]]; do CONFIG_SRC="${2:-}" shift 2 ;; - --mediamtx) - INSTALL_MEDIAMTX=1 - shift - ;; - --mediamtx-version) - MEDIAMTX_VERSION="${2:-}" - shift 2 - ;; -h|--help) usage exit 0 @@ -74,6 +62,13 @@ if [[ ! -f "$CONFIG_SRC" ]]; then exit 1 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() { local user="$1" local groups="${2:-}" @@ -92,52 +87,7 @@ log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" } -detect_mediamtx_asset() { - 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" +ensure_user roverd "dialout,gpio,video" install -o roverd -g roverd -m 0755 "$BINARY_SRC" /usr/local/bin/roverd log "Installed roverd binary" @@ -154,36 +104,32 @@ fi install -m 0644 pi/systemd/roverd.service /etc/systemd/system/roverd.service log "Installed roverd systemd unit" -systemctl daemon-reload -systemctl enable roverd.service -if [[ $CONFIG_EXISTS -eq 1 ]]; then - systemctl restart roverd.service - log "Restarted roverd.service" -else - log "Skipped auto-start because config is the sample; edit $CONFIG_DEST then run: sudo systemctl restart roverd" +# Install WHIP publisher assets +install -D -o root -g root -m 0755 pi/bin/whip-publisher.sh /usr/local/bin/whip-publisher +install -m 0644 pi/systemd/whip-publisher.service /etc/systemd/system/whip-publisher.service +install -d -o roverd -g roverd /var/lib/roverd +if [[ ! -f /var/lib/roverd/whip.env ]]; then + cat > /var/lib/roverd/whip.env <<'ENV' +# 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 -if [[ $INSTALL_MEDIAMTX -eq 1 ]]; then - if [[ ! -f "pi/mediamtx/mediamtx.yml" ]]; then - echo "pi/mediamtx/mediamtx.yml missing, cannot install mediaMTX config" >&2 - exit 1 - fi - if [[ ! -f "pi/systemd/mediamtx.service" ]]; then - echo "pi/systemd/mediamtx.service missing, cannot install mediaMTX unit" >&2 - exit 1 - fi - -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" +systemctl daemon-reload +systemctl enable roverd.service +systemctl enable whip-publisher.service +if [[ $CONFIG_EXISTS -eq 1 ]]; then + systemctl restart roverd.service + systemctl restart whip-publisher.service + log "Restarted roverd + WHIP publisher" +else + log "Skipped auto-start because config is the sample; edit $CONFIG_DEST then run: sudo systemctl restart roverd whip-publisher" fi log "Install complete" diff --git a/pi/mediamtx/mediamtx.yml b/pi/mediamtx/mediamtx.yml deleted file mode 100644 index 9b741517..00000000 --- a/pi/mediamtx/mediamtx.yml +++ /dev/null @@ -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 diff --git a/pi/roverd/cmd/roverd/main.go b/pi/roverd/cmd/roverd/main.go index 5972aa51..db257c71 100644 --- a/pi/roverd/cmd/roverd/main.go +++ b/pi/roverd/cmd/roverd/main.go @@ -21,6 +21,9 @@ func main() { if err != nil { 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) defer cancel() diff --git a/pi/roverd/config.go b/pi/roverd/config.go index 04b2f6d3..5b02d5b0 100644 --- a/pi/roverd/config.go +++ b/pi/roverd/config.go @@ -3,9 +3,8 @@ package roverd import ( "errors" "fmt" - "net" + "net/url" "os" - "strings" "time" "gopkg.in/yaml.v3" @@ -55,14 +54,16 @@ type BatteryConfig struct { } type MediaConfig struct { - WhepURL string `yaml:"whepUrl" json:"whepUrl,omitempty"` - WhepPort int `yaml:"whepPort" json:"-"` - WhepPath string `yaml:"whepPath" json:"-"` - LegacyPublish string `yaml:"publishUrl,omitempty" json:"-"` + PublishURL string `yaml:"publishUrl" json:"publishUrl,omitempty"` + PublishPort int `yaml:"publishPort" json:"-"` Manage bool `yaml:"manage"` Service string `yaml:"service"` HealthURL string `yaml:"healthUrl"` 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 { @@ -93,9 +94,12 @@ func LoadConfig(path string) (*Config, error) { }, }, Media: MediaConfig{ - WhepPort: 8889, - WhepPath: "/rovercam/whep", + PublishPort: 8889, HealthInterval: Duration{Duration: 30 * time.Second}, + VideoWidth: 1280, + VideoHeight: 720, + VideoFPS: 30, + VideoBitrate: 3000000, }, } 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 { cfg.Media.HealthInterval = Duration{Duration: 30 * time.Second} } - if cfg.Media.WhepURL == "" { - cfg.Media.WhepURL = cfg.Media.LegacyPublish + if cfg.Media.VideoWidth <= 0 { + cfg.Media.VideoWidth = 1280 } - if cfg.Media.WhepURL == "" { - ip, err := detectPrimaryIPv4() + if cfg.Media.VideoHeight <= 0 { + 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 { - return nil, fmt.Errorf("derive whepUrl: %w", err) + return nil, fmt.Errorf("derive publishUrl: %w", err) } - path := cfg.Media.WhepPath - 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) + cfg.Media.PublishURL = derived } return &cfg, nil } -func ensureLeadingSlash(path string) string { - if !strings.HasPrefix(path, "/") { - return "/" + path +func derivePublishURL(serverURL, roverName string, port int) (string, error) { + if roverName == "" { + return "", errors.New("missing rover name for publishUrl") } - return path -} - -func effectivePort(port int) int { - if port <= 0 { - return 8889 - } - return port -} - -func detectPrimaryIPv4() (string, error) { - ifaces, err := net.Interfaces() + parsed, err := url.Parse(serverURL) if err != nil { return "", err } - for _, iface := range ifaces { - if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { - continue - } - 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 - } + host := parsed.Hostname() + if host == "" { + return "", errors.New("serverUrl missing host") } - 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 } diff --git a/pi/roverd/media_env.go b/pi/roverd/media_env.go new file mode 100644 index 00000000..e522a2f8 --- /dev/null +++ b/pi/roverd/media_env.go @@ -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 +} diff --git a/pi/roverd/media_supervisor.go b/pi/roverd/media_supervisor.go index d70c5d86..f4558ee4 100644 --- a/pi/roverd/media_supervisor.go +++ b/pi/roverd/media_supervisor.go @@ -19,26 +19,36 @@ type MediaSupervisor struct { } 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 == "" { return nil } - if cfg.HealthURL == "" { - cfg.HealthURL = "http://127.0.0.1:9997/v3/paths/list" - } interval := cfg.HealthInterval.Duration if interval <= 0 { interval = 30 * time.Second } + var client *http.Client + if cfg.HealthURL != "" { + client = &http.Client{Timeout: 5 * time.Second} + } return &MediaSupervisor{ cfg: cfg, logger: logger, - client: &http.Client{Timeout: 5 * time.Second}, + client: client, checkInterval: interval, } } 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 } go func() { @@ -66,6 +76,9 @@ func (m *MediaSupervisor) HandleAction(ctx context.Context, action string) error if m == nil { return errors.New("media supervisor disabled") } + if err := UpdatePublisherEnv(m.cfg); err != nil { + return err + } switch action { case "start", "stop", "restart", "reload", "status": return m.runSystemctl(ctx, action) @@ -89,6 +102,9 @@ func (m *MediaSupervisor) checkAndRepair() error { } 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) if err != nil { m.logger.Printf("media supervisor: health request: %v", err) diff --git a/pi/roverd/roverd.sample.yaml b/pi/roverd/roverd.sample.yaml index ed1deddd..fa6bf08d 100644 --- a/pi/roverd/roverd.sample.yaml +++ b/pi/roverd/roverd.sample.yaml @@ -15,7 +15,13 @@ battery: urgent: 1650 maxWheelSpeed: 350 media: - manage: false - service: mediamtx.service - healthUrl: http://127.0.0.1:9997/v3/paths/list + publishUrl: http://192.168.0.86:8889/whip/roomba-alpha + publishPort: 8889 + videoWidth: 1280 + videoHeight: 720 + videoFps: 30 + videoBitrate: 3000000 + manage: true + service: whip-publisher.service + healthUrl: "" healthInterval: 30s diff --git a/pi/systemd/mediamtx.service b/pi/systemd/mediamtx.service deleted file mode 100644 index cc84e4d7..00000000 --- a/pi/systemd/mediamtx.service +++ /dev/null @@ -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 diff --git a/pi/systemd/whip-publisher.service b/pi/systemd/whip-publisher.service new file mode 100644 index 00000000..c98f931b --- /dev/null +++ b/pi/systemd/whip-publisher.service @@ -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 diff --git a/server/config.example.yaml b/server/config.example.yaml index e819c253..816e5c32 100644 --- a/server/config.example.yaml +++ b/server/config.example.yaml @@ -9,4 +9,3 @@ admins: lockdown: true media: whepBaseUrl: "http://192.168.0.86:8889/whep" - mediamtxApiUrl: "http://127.0.0.1:9997" diff --git a/server/index.js b/server/index.js index cc280108..dee223b0 100644 --- a/server/index.js +++ b/server/index.js @@ -14,7 +14,6 @@ require('./src/services/roverManager'); require('./src/services/commandService'); require('./src/services/roverConnectionService'); require('./src/services/assignmentService'); -require('./src/services/mediaBridgeService'); require('./src/services/videoSessions'); require('./src/services/videoAuthService'); require('./src/services/videoSocketService'); diff --git a/server/src/services/mediaBridgeService.js b/server/src/services/mediaBridgeService.js deleted file mode 100644 index a2ef74cc..00000000 --- a/server/src/services/mediaBridgeService.js +++ /dev/null @@ -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; -}