mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
bridging ig
This commit is contained in:
@@ -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 rover’s 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 rover’s ID, and make sure the Pi’s mediaMTX is reachable at `http://<pi-ip>: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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -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 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`).
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user