bridging ig

This commit is contained in:
legop3
2025-11-14 04:23:21 -05:00
parent a90033e933
commit ce99309b5d
5 changed files with 133 additions and 5 deletions
+1 -1
View File
@@ -56,7 +56,7 @@ cd ~/MultiRoombaRover
sudo ./pi/install_roverd.sh --mediamtx sudo ./pi/install_roverd.sh --mediamtx
``` ```
Then point each rover's `/etc/roverd.yaml` at `ws://<server>:8080/rover`, set `name` to the rovers ID, and edit `/etc/mediamtx/mediamtx.yml` so `whipPublishURL` is `http://192.168.0.86:8889/whip/<name>`. 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://<server>:8080/rover`, set `name` to the rovers ID, and make sure the Pis mediaMTX is reachable at `http://<pi-ip>:8889/whep/rovercam` (the default). Each rover advertises that WHEP URL in its `hello` frame, and the servers 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. 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.
+2 -2
View File
@@ -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 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://<pi-ip>:8889/whep/rovercam`. Keep that URL reachable from the control server—`roverd` advertises it automatically and the central media bridge pulls each rovers 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`). 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 ## Server + UI
-2
View File
@@ -18,5 +18,3 @@ paths:
rpiCameraExposure: long rpiCameraExposure: long
rpiCameraHFlip: false rpiCameraHFlip: false
rpiCameraVFlip: false rpiCameraVFlip: false
# Replace ROVER_ID with the rover's configured name before deploy.
whipPublishURL: http://192.168.0.86:8889/whip/Freaky
+1
View File
@@ -14,6 +14,7 @@ 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');
+129
View File
@@ -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;
}