diff --git a/README.md b/README.md index 51cc757f..40778de1 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ cd ~/MultiRoombaRover sudo ./pi/install_roverd.sh --mediamtx ``` -Then point each rover's `/etc/roverd.yaml` at `ws://:8080/rover`, enable the sensor stream from the UI, and drive with WASD. +Then point each rover's `/etc/roverd.yaml` at `ws://:8080/rover`, set (or leave blank to auto-derive) `media.whepUrl` for the Pi’s mediaMTX instance (defaults to `http://:8889/whep/rovercam`), enable the sensor stream from the UI, and drive with WASD. 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. @@ -73,7 +73,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) +- copy `config.example.yaml` to `config.yaml` if needed (edit the file afterwards for admins/media URLs + `media.mediamtxApiUrl`) - 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,4 +87,4 @@ authHTTPExclude: Then restart `mediamtx.service` so WHIP pushes from the Pis stop getting rejected. -Once finished, update `server/config.yaml` with your admin passwords and restart `multirover.service` if you change it. 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, `media.whepBaseUrl` (public playback URL), and the new `media.mediamtxApiUrl` (usually `http://127.0.0.1:9997`). The Node server uses that API to create per-rover pull paths so the central mediaMTX automatically connects to each Pi’s WHEP feed as rovers come and go. 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. diff --git a/dist/dummy1.yml b/dist/dummy1.yml index 3ffd50e8..02517ab4 100644 --- a/dist/dummy1.yml +++ b/dist/dummy1.yml @@ -14,7 +14,7 @@ battery: urgent: 1650 maxWheelSpeed: 350 media: - publishUrl: https://control-server.local/whip/roomba-alpha + whepUrl: http://dummy1.local:8889/whep/rovercam manage: false service: mediamtx.service healthUrl: http://127.0.0.1:9997/v3/paths/list diff --git a/dist/dummy2.yml b/dist/dummy2.yml index 89d64536..0d66a627 100644 --- a/dist/dummy2.yml +++ b/dist/dummy2.yml @@ -14,7 +14,7 @@ battery: urgent: 1650 maxWheelSpeed: 350 media: - publishUrl: https://control-server.local/whip/roomba-alpha + whepUrl: http://dummy2.local:8889/whep/rovercam manage: false service: mediamtx.service healthUrl: http://127.0.0.1:9997/v3/paths/list diff --git a/dist/roverd b/dist/roverd index d34196db..46275343 100755 Binary files a/dist/roverd and b/dist/roverd differ diff --git a/docs/pi-deployment.md b/docs/pi-deployment.md index 6b3398a7..0963c149 100644 --- a/docs/pi-deployment.md +++ b/docs/pi-deployment.md @@ -55,7 +55,7 @@ Flags: | `--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 the `media.publishUrl` that points at your central mediaMTX WHIP endpoint (for example `https://control-server.local/whip/roomba-alpha`). +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/whep/rovercam`. ## Manual installation @@ -65,7 +65,7 @@ If the script installs the sample config, it will remind you to edit `/etc/rover sudo install -o roverd -g roverd -m 0755 dist/roverd /usr/local/bin/roverd sudo install -o roverd -g roverd -m 0640 pi/roverd/roverd.sample.yaml /etc/roverd.yaml ``` - Adjust `/etc/roverd.yaml` for each rover: `name`, `serverUrl` (e.g. `ws://control-server:8080/rover`), serial port path, battery thresholds, GPIO pin for BRC, and the media `publishUrl` that points at the central media server’s WHIP endpoint for that rover. + Adjust `/etc/roverd.yaml` for each rover: `name`, `serverUrl` (e.g. `ws://control-server:8080/rover`), serial port path, battery thresholds, GPIO pin for BRC, and (if needed) the media `whepUrl` override. Otherwise, roverd fills in `http://:8889/whep/rovercam` based on the DHCP-assigned address. 2. Install the systemd unit: ```bash diff --git a/mediamtx_server_integration.md b/mediamtx_server_integration.md index 8be0f70d..28f79a09 100644 --- a/mediamtx_server_integration.md +++ b/mediamtx_server_integration.md @@ -1,17 +1,18 @@ # mediaMTX Integration -Each rover runs mediaMTX locally to capture the Pi camera and publish it upstream, while the control server hosts a central mediaMTX instance that fans video out to drivers and spectators. +Each rover runs mediaMTX locally to capture the Pi camera, and the control server hosts a central mediaMTX instance that fans video out to drivers and spectators. When a rover connects, the Node server asks the central mediaMTX to _pull_ the rover’s local WHEP stream and expose it under the rover’s name. ## Pi (publisher) - mediaMTX samples the Pi camera (`paths.rovercam.source: rpiCamera`) and exposes the HTTP API on `http://127.0.0.1:9997`. -- `/etc/roverd.yaml` contains `media.publishUrl`, pointing at the control server’s WHIP endpoint for that rover (e.g. `https://control.example.com/whip/roomba-alpha`). No auth is required when the Pi network is trusted. +- `/etc/roverd.yaml` contains `media.whepUrl`, pointing at the Pi’s own WHEP endpoint (e.g. `http://roomba-alpha.local:8889/whep/rovercam`). This is what the central server will pull. - The `media.manage` flag keeps the local service alive via `systemctl` and hits the API for health checks (`media.healthUrl`, defaults to `http://127.0.0.1:9997/v3/paths/list`). ## Control server (viewer) -- The central mediaMTX instance accepts WHIP ingest at `/whip/` and serves WHEP playback at `/whep/`. -- The Node server issues viewer sessions (one per socket) via `video:request`, and mediaMTX calls back into `GET /mediamtx/auth?session=&roverId=` before letting a client access `/whep/?session=`. Lockdown mode simply stops minting sessions for non-lockdown admins. +- The central mediaMTX instance serves WHEP playback at `/whep/` and exposes its control API on `http://127.0.0.1:9997`. +- When a rover connects, the Node server calls `POST /v3/config/paths/replace/` and sets `source: whep://:8889/rovercam/whep`. When the rover disconnects, the path is removed. +- Viewers still use `video:request` to obtain a session token. mediaMTX calls back into `/mediamtx/auth` before letting a client access `/whep/?session=...`, and the Node server enforces lockdown/role rules there. ## Driver / spectator UIs diff --git a/pi/roverd/config.go b/pi/roverd/config.go index c0e3d4bb..f1bcd782 100644 --- a/pi/roverd/config.go +++ b/pi/roverd/config.go @@ -3,7 +3,9 @@ package roverd import ( "errors" "fmt" + "net" "os" + "strings" "time" "gopkg.in/yaml.v3" @@ -53,7 +55,10 @@ type BatteryConfig struct { } type MediaConfig struct { - PublishURL string `yaml:"publishUrl"` + WhepURL string `yaml:"whepUrl" json:"whepUrl,omitempty"` + WhepPort int `yaml:"whepPort" json:"-"` + WhepPath string `yaml:"whepPath" json:"-"` + LegacyPublish string `yaml:"publishUrl,omitempty" json:"-"` Manage bool `yaml:"manage"` Service string `yaml:"service"` HealthURL string `yaml:"healthUrl"` @@ -88,6 +93,8 @@ func LoadConfig(path string) (*Config, error) { }, }, Media: MediaConfig{ + WhepPort: 8889, + WhepPath: "/whep/rovercam", HealthInterval: Duration{Duration: 30 * time.Second}, }, } @@ -118,5 +125,69 @@ 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.WhepURL == "" { + ip, err := detectPrimaryIPv4() + if err != nil { + return nil, fmt.Errorf("derive whepUrl: %w", err) + } + path := cfg.Media.WhepPath + if path == "" { + path = "/whep/rovercam" + } + path = ensureLeadingSlash(path) + scheme := "http" + cfg.Media.WhepURL = fmt.Sprintf("%s://%s:%d%s", scheme, ip, effectivePort(cfg.Media.WhepPort), path) + } return &cfg, nil } + +func ensureLeadingSlash(path string) string { + if !strings.HasPrefix(path, "/") { + return "/" + path + } + return path +} + +func effectivePort(port int) int { + if port <= 0 { + return 8889 + } + return port +} + +func detectPrimaryIPv4() (string, error) { + ifaces, err := net.Interfaces() + 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 + } + } + return "", errors.New("no non-loopback IPv4 address found") +} diff --git a/pi/roverd/roverd.sample.yaml b/pi/roverd/roverd.sample.yaml index 958e8988..5a217b07 100644 --- a/pi/roverd/roverd.sample.yaml +++ b/pi/roverd/roverd.sample.yaml @@ -15,7 +15,7 @@ battery: urgent: 1650 maxWheelSpeed: 350 media: - publishUrl: https://control-server.local/whip/roomba-alpha + whepUrl: http://roomba-alpha.local:8889/whep/rovercam manage: false service: mediamtx.service healthUrl: http://127.0.0.1:9997/v3/paths/list diff --git a/pi/roverd/roverd.yaml b/pi/roverd/roverd.yaml index 958e8988..5a217b07 100644 --- a/pi/roverd/roverd.yaml +++ b/pi/roverd/roverd.yaml @@ -15,7 +15,7 @@ battery: urgent: 1650 maxWheelSpeed: 350 media: - publishUrl: https://control-server.local/whip/roomba-alpha + whepUrl: http://roomba-alpha.local:8889/whep/rovercam manage: false service: mediamtx.service healthUrl: http://127.0.0.1:9997/v3/paths/list diff --git a/server/config.example.yaml b/server/config.example.yaml index 0b4ed61b..60278c84 100644 --- a/server/config.example.yaml +++ b/server/config.example.yaml @@ -9,3 +9,4 @@ admins: lockdown: true media: whepBaseUrl: "http://control-server.local:8889/whep" + mediamtxApiUrl: "http://127.0.0.1:9997" diff --git a/server/index.js b/server/index.js index dee223b0..cc280108 100644 --- a/server/index.js +++ b/server/index.js @@ -14,6 +14,7 @@ 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 new file mode 100644 index 00000000..abe4c4cb --- /dev/null +++ b/server/src/services/mediaBridgeService.js @@ -0,0 +1,117 @@ +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(/\/$/, ''); + +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); + }); + } +}); + +// Initialize existing rovers (in case service loads after they connect) +for (const record of rovers.values()) { + syncRover(record).catch((err) => { + logger.error('failed to sync rover %s on init: %s', record.id, err.message); + }); +} + +async function syncRover(record) { + if (!record?.meta?.media) { + await removePath(record?.id); + return; + } + const source = normalizeSource(record.meta.media.whepUrl); + if (!source) { + await removePath(record.id); + return; + } + if (activeSources.get(record.id) === source) { + return; + } + await upsertPath(record.id, source); + activeSources.set(record.id, source); + 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 || !activeSources.has(roverId)) { + activeSources.delete(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 = raw.trim(); + if (!trimmed) return null; + if (/^wheps?:\/\//i.test(trimmed)) { + return trimmed; + } + try { + const parsed = new URL(trimmed); + const protocol = parsed.protocol === 'https:' ? 'wheps' : 'whep'; + return `${protocol}://${parsed.host}${parsed.pathname}${parsed.search || ''}`; + } catch (err) { + logger.warn('invalid WHEP URL: %s (%s)', raw, err.message); + return null; + } +} + +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; +}