mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
push! PUSH!!
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`, 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://<server>: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/<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.
|
||||||
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.
|
||||||
|
|
||||||
@@ -87,9 +87,9 @@ authHTTPExclude:
|
|||||||
|
|
||||||
Then restart `mediamtx.service` so WHIP pushes from the Pis stop getting rejected.
|
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
|
### 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.
|
- 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.
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
@@ -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 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`).
|
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/<roverName>`). 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
|
## Server + UI
|
||||||
|
|
||||||
|
|||||||
@@ -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/<roverId>` 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/<roverId>` and sets `source: whep://<pi-ip>: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/<roverId>` 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.
|
|
||||||
@@ -18,3 +18,6 @@ paths:
|
|||||||
rpiCameraExposure: long
|
rpiCameraExposure: long
|
||||||
rpiCameraHFlip: false
|
rpiCameraHFlip: false
|
||||||
rpiCameraVFlip: false
|
rpiCameraVFlip: false
|
||||||
|
# Replace ROVER_ID with the rover's configured name before deploy.
|
||||||
|
whipPublishURLs:
|
||||||
|
- http://192.168.0.86:8889/whip/ROVER_ID
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -60,7 +59,6 @@ type MediaConfig struct {
|
|||||||
WhepPort int `yaml:"whepPort" json:"-"`
|
WhepPort int `yaml:"whepPort" json:"-"`
|
||||||
WhepPath string `yaml:"whepPath" json:"-"`
|
WhepPath string `yaml:"whepPath" json:"-"`
|
||||||
LegacyPublish string `yaml:"publishUrl,omitempty" json:"-"`
|
LegacyPublish string `yaml:"publishUrl,omitempty" json:"-"`
|
||||||
BridgeWhepURL string `yaml:"-" json:"bridgeWhepUrl,omitempty"`
|
|
||||||
Manage bool `yaml:"manage"`
|
Manage bool `yaml:"manage"`
|
||||||
Service string `yaml:"service"`
|
Service string `yaml:"service"`
|
||||||
HealthURL string `yaml:"healthUrl"`
|
HealthURL string `yaml:"healthUrl"`
|
||||||
@@ -143,38 +141,9 @@ func LoadConfig(path string) (*Config, error) {
|
|||||||
scheme := "http"
|
scheme := "http"
|
||||||
cfg.Media.WhepURL = fmt.Sprintf("%s://%s:%d%s", scheme, ip, effectivePort(cfg.Media.WhepPort), path)
|
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
|
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 {
|
func ensureLeadingSlash(path string) string {
|
||||||
if !strings.HasPrefix(path, "/") {
|
if !strings.HasPrefix(path, "/") {
|
||||||
return "/" + path
|
return "/" + path
|
||||||
|
|||||||
@@ -8,5 +8,5 @@ admins:
|
|||||||
discord_id: "0987654321"
|
discord_id: "0987654321"
|
||||||
lockdown: true
|
lockdown: true
|
||||||
media:
|
media:
|
||||||
whepBaseUrl: "http://control-server.local:8889/whep"
|
whepBaseUrl: "http://192.168.0.86:8889/whep"
|
||||||
mediamtxApiUrl: "http://127.0.0.1:9997"
|
mediamtxApiUrl: "http://127.0.0.1:9997"
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ 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');
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -33,6 +33,9 @@ io.on('connection', (socket) => {
|
|||||||
if (!roverId) {
|
if (!roverId) {
|
||||||
throw new Error('roverId required');
|
throw new Error('roverId required');
|
||||||
}
|
}
|
||||||
|
if (!roverManager.rovers.has(roverId)) {
|
||||||
|
throw new Error('Rover offline');
|
||||||
|
}
|
||||||
if (!canView(socket, roverId)) {
|
if (!canView(socket, roverId)) {
|
||||||
throw new Error('Not authorized for video');
|
throw new Error('Not authorized for video');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user