From ce99309b5db7460162e23f878e0af153b0b6a69e Mon Sep 17 00:00:00 2001 From: legop3 Date: Fri, 14 Nov 2025 04:23:21 -0500 Subject: [PATCH] bridging ig --- README.md | 2 +- docs/pi-deployment.md | 4 +- pi/mediamtx/mediamtx.yml | 2 - server/index.js | 1 + server/src/services/mediaBridgeService.js | 129 ++++++++++++++++++++++ 5 files changed, 133 insertions(+), 5 deletions(-) create mode 100644 server/src/services/mediaBridgeService.js diff --git a/README.md b/README.md index 4d29206a..31bb0221 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`, set `name` to the rover’s ID, and edit `/etc/mediamtx/mediamtx.yml` so `whipPublishURL` is `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. +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/whep/rovercam` (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. 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. diff --git a/docs/pi-deployment.md b/docs/pi-deployment.md index 25596b10..2057e84f 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, enables the local mediaMTX HTTP API so `roverd` can health-check the service, and includes a placeholder WHIP target pointing at the control server (`whipPublishURL: 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. +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/whep/rovercam`. 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`). -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. +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 diff --git a/pi/mediamtx/mediamtx.yml b/pi/mediamtx/mediamtx.yml index daadc512..9b741517 100644 --- a/pi/mediamtx/mediamtx.yml +++ b/pi/mediamtx/mediamtx.yml @@ -18,5 +18,3 @@ paths: rpiCameraExposure: long rpiCameraHFlip: false rpiCameraVFlip: false - # Replace ROVER_ID with the rover's configured name before deploy. - whipPublishURL: http://192.168.0.86:8889/whip/Freaky 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..ee33466e --- /dev/null +++ b/server/src/services/mediaBridgeService.js @@ -0,0 +1,129 @@ +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; + if (/^wheps?:\/\//i.test(trimmed)) { + return trimmed; + } + try { + const parsed = new URL(trimmed); + const protocol = parsed.protocol === 'https:' ? 'wheps' : 'whep'; + let pathname = parsed.pathname || '/'; + if (!pathname.startsWith('/')) { + pathname = `/${pathname}`; + } + return `${protocol}://${parsed.host}${pathname}${parsed.search || ''}`; + } catch (err) { + logger.warn('invalid WHEP URL provided by rover (%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; +}