diff --git a/README.md b/README.md index ac949730..aa177746 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. `roverd` automatically advertises its local mediaMTX WHEP endpoint (derived from the Pi’s DHCP address), so no video URL configuration is required unless you want to override the defaults. +Then point each rover's `/etc/roverd.yaml` at `ws://:8080/rover`, set `name` to the rover’s ID, and edit `/etc/mediamtx/mediamtx.yml` so `whipPublishURLs` contains `http://192.168.0.86:8889/whip/`. The Pi continuously pushes WebRTC media to the control server; drivers/spectators always watch through the server-side mediaMTX, so no browser ever talks to the Pi directly. 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. @@ -87,9 +87,9 @@ 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` (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. +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. ### Video handshake + diagnostics - Every `video:request` returns `{ url, token }`. The browser posts the SDP offer to `url` and includes `Authorization: Basic base64(token:token)`. mediaMTX forwards the `token` to `/mediamtx/auth`, which checks the socket’s permissions and either returns 200 or 401—no query parameters are involved anymore. -- To see what mediaMTX is pulling, run `npm run check:media` (or `node scripts/checkMedia.js`). It hits `/v3/paths/list` and prints each rover’s `ready` state and byte counters so you can instantly spot bridge issues. +- To see what mediaMTX is ingesting from the Pis, run `npm run check:media` (or `node scripts/checkMedia.js`). It hits `/v3/paths/list` and prints each rover’s `ready` state and byte counters so you can instantly spot publish issues. diff --git a/dist/roverd b/dist/roverd index 44a67747..ab1d26ea 100755 Binary files a/dist/roverd and b/dist/roverd differ diff --git a/docs/pi-deployment.md b/docs/pi-deployment.md index 0963c149..17476b96 100644 --- a/docs/pi-deployment.md +++ b/docs/pi-deployment.md @@ -92,9 +92,9 @@ If you set `media.manage: true` in `/etc/roverd.yaml`, make sure the `roverd` se sudo systemctl enable --now mediamtx.service ``` -The sample config uses the Raspberry Pi camera module as the source and enables the local mediaMTX HTTP API so `roverd` can health-check the service. Edit `/etc/mediamtx/mediamtx.yml` to point its WHIP client at your central media server (see `mediamtx_server_integration.md` for details). +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 includes a placeholder WHIP target pointing at the control server (`http://192.168.0.86:8889/whip/ROVER_ID`). Replace `ROVER_ID` with the value of `name` from `/etc/roverd.yaml` so every rover pushes to a unique publish path. 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`). -Finally, edit `/etc/mediamtx/mediamtx.yml` so the Pi publishes the camera feed to your central control server via WHIP (for example by pointing it at `https://control.example.com/whip/`). The Pi never serves viewers directly—drivers and spectators always watch through the control server. +Once the WHIP URL points at the server, the Pi continuously publishes to `192.168.0.86`; the server-side mediaMTX fans the stream out to every driver/spectator via WHEP—no Pi ever serves viewers directly. ## Server + UI diff --git a/mediamtx_server_integration.md b/mediamtx_server_integration.md deleted file mode 100644 index f0781f1e..00000000 --- a/mediamtx_server_integration.md +++ /dev/null @@ -1,20 +0,0 @@ -# mediaMTX Integration - -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`. -- `roverd` automatically detects the Pi’s own WHEP endpoint (e.g. `http://roomba-alpha.local:8889/whep/rovercam`) and reports a `bridgeWhepUrl` (converted to `whep://.../whep`) to the server. No manual per-rover video URL configuration is required. -- 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 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. They POST their SDP offer to `/whep/` with `Authorization: Basic base64(token:token)`. mediaMTX forwards the `token` to `/mediamtx/auth` (in the `user` field) before allowing the stream to start, and the Node server enforces lockdown/role rules there. - -## Driver / spectator UIs - -- When a user requests video the browser asks the Node server for a session token, and if the current mode/role allows it, the server returns the WHEP URL plus the session id. -- Spectator mode and lockdown are enforced purely on the Node server: no direct Pi URLs are ever exposed, and once a user has a session it remains valid until they disconnect or lockdown revokes it. diff --git a/pi/mediamtx/mediamtx.yml b/pi/mediamtx/mediamtx.yml index 9b741517..39f6c125 100644 --- a/pi/mediamtx/mediamtx.yml +++ b/pi/mediamtx/mediamtx.yml @@ -18,3 +18,6 @@ paths: rpiCameraExposure: long rpiCameraHFlip: false rpiCameraVFlip: false + # Replace ROVER_ID with the rover's configured name before deploy. + whipPublishURLs: + - http://192.168.0.86:8889/whip/ROVER_ID diff --git a/pi/roverd/config.go b/pi/roverd/config.go index aad5162b..f1bcd782 100644 --- a/pi/roverd/config.go +++ b/pi/roverd/config.go @@ -4,7 +4,6 @@ import ( "errors" "fmt" "net" - "net/url" "os" "strings" "time" @@ -60,7 +59,6 @@ type MediaConfig struct { WhepPort int `yaml:"whepPort" json:"-"` WhepPath string `yaml:"whepPath" json:"-"` LegacyPublish string `yaml:"publishUrl,omitempty" json:"-"` - BridgeWhepURL string `yaml:"-" json:"bridgeWhepUrl,omitempty"` Manage bool `yaml:"manage"` Service string `yaml:"service"` HealthURL string `yaml:"healthUrl"` @@ -143,38 +141,9 @@ func LoadConfig(path string) (*Config, error) { scheme := "http" cfg.Media.WhepURL = fmt.Sprintf("%s://%s:%d%s", scheme, ip, effectivePort(cfg.Media.WhepPort), path) } - if bridge, err := buildBridgeURL(cfg.Media.WhepURL); err == nil { - cfg.Media.BridgeWhepURL = bridge - } return &cfg, nil } -func buildBridgeURL(src string) (string, error) { - if src == "" { - return "", errors.New("empty whep url") - } - parsed, err := url.Parse(src) - if err != nil { - return "", err - } - if parsed.Host == "" { - return "", errors.New("missing host") - } - proto := "whep" - if parsed.Scheme == "https" { - proto = "wheps" - } - path := strings.Trim(parsed.Path, "/") - if strings.HasPrefix(path, "whep/") { - path = strings.TrimPrefix(path, "whep/") - } - path = strings.Trim(path, "/") - if path == "" { - path = "rovercam" - } - return fmt.Sprintf("%s://%s/%s/whep", proto, parsed.Host, path), nil -} - func ensureLeadingSlash(path string) string { if !strings.HasPrefix(path, "/") { return "/" + path diff --git a/server/config.example.yaml b/server/config.example.yaml index 60278c84..e819c253 100644 --- a/server/config.example.yaml +++ b/server/config.example.yaml @@ -8,5 +8,5 @@ admins: discord_id: "0987654321" lockdown: true media: - whepBaseUrl: "http://control-server.local:8889/whep" + 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 175fdcd7..00000000 --- a/server/src/services/mediaBridgeService.js +++ /dev/null @@ -1,104 +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(/\/$/, ''); - -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); - }); - } -}); -// guh -// 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 = record.meta.media.bridgeWhepUrl; - if (!source) { - logger.warn( - 'rover %s missing bridgeWhepUrl; video bridge disabled for this rover', - record.id - ); - 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; - } - } -} - -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; -} diff --git a/server/src/services/videoSocketService.js b/server/src/services/videoSocketService.js index 378d9354..f76912d5 100644 --- a/server/src/services/videoSocketService.js +++ b/server/src/services/videoSocketService.js @@ -33,6 +33,9 @@ io.on('connection', (socket) => { if (!roverId) { throw new Error('roverId required'); } + if (!roverManager.rovers.has(roverId)) { + throw new Error('Rover offline'); + } if (!canView(socket, roverId)) { throw new Error('Not authorized for video'); }