go back to server pulling streaming

This commit is contained in:
legop3
2025-11-14 01:11:41 -05:00
parent 7e7d644822
commit 1adaf6c5f6
12 changed files with 205 additions and 14 deletions
+3 -3
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`, enable the sensor stream from the UI, and drive with WASD. Then point each rover's `/etc/roverd.yaml` at `ws://<server>:8080/rover`, set (or leave blank to auto-derive) `media.whepUrl` for the Pis mediaMTX instance (defaults to `http://<pi-ip>:8889/whep/rovercam`), enable the sensor stream from the UI, and drive with WASD.
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.
@@ -73,7 +73,7 @@ The script must be executed via `sudo` from the user that owns the repo. It will
- install Node.js/npm plus curl/tar - install Node.js/npm plus curl/tar
- run `npm install --production` - run `npm install --production`
- copy `config.example.yaml` to `config.yaml` if needed (edit the file afterwards for admins/media URLs) - copy `config.example.yaml` to `config.yaml` if needed (edit the file afterwards for admins/media URLs + `media.mediamtxApiUrl`)
- download mediaMTX v1.15.3 and drop it into `/usr/local/bin` - download mediaMTX v1.15.3 and drop it into `/usr/local/bin`
- write `/etc/mediamtx/mediamtx.yml` that points to the Node servers `/mediamtx/auth` webhook - write `/etc/mediamtx/mediamtx.yml` that points to the Node servers `/mediamtx/auth` webhook
- create + enable `mediamtx.service` and `multirover.service`, both running as your repo user and pointing at the clone directly - create + enable `mediamtx.service` and `multirover.service`, both running as your repo user and pointing at the clone directly
@@ -87,4 +87,4 @@ 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 and restart `multirover.service` if you change it. 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` (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 Pis 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.
+1 -1
View File
@@ -14,7 +14,7 @@ battery:
urgent: 1650 urgent: 1650
maxWheelSpeed: 350 maxWheelSpeed: 350
media: media:
publishUrl: https://control-server.local/whip/roomba-alpha whepUrl: http://dummy1.local:8889/whep/rovercam
manage: false manage: false
service: mediamtx.service service: mediamtx.service
healthUrl: http://127.0.0.1:9997/v3/paths/list healthUrl: http://127.0.0.1:9997/v3/paths/list
+1 -1
View File
@@ -14,7 +14,7 @@ battery:
urgent: 1650 urgent: 1650
maxWheelSpeed: 350 maxWheelSpeed: 350
media: media:
publishUrl: https://control-server.local/whip/roomba-alpha whepUrl: http://dummy2.local:8889/whep/rovercam
manage: false manage: false
service: mediamtx.service service: mediamtx.service
healthUrl: http://127.0.0.1:9997/v3/paths/list healthUrl: http://127.0.0.1:9997/v3/paths/list
Vendored
BIN
View File
Binary file not shown.
+2 -2
View File
@@ -55,7 +55,7 @@ Flags:
| `--mediamtx` | download/install mediaMTX plus the provided config + unit | | `--mediamtx` | download/install mediaMTX plus the provided config + unit |
| `--mediamtx-version X.Y.Z` | override the mediaMTX release tag (default `1.15.3`) | | `--mediamtx-version X.Y.Z` | override the mediaMTX release tag (default `1.15.3`) |
If the script installs the sample config, it will remind you to edit `/etc/roverd.yaml` before manually restarting the service: set `name`, `serverUrl`, serial device, BRC pin, battery thresholds, and the `media.publishUrl` that points at your central mediaMTX WHIP endpoint (for example `https://control-server.local/whip/roomba-alpha`). If the script installs the sample config, it will remind you to edit `/etc/roverd.yaml` before manually restarting the service: set `name`, `serverUrl`, serial device, BRC pin, battery thresholds, and optionally override `media.whepUrl`. When left blank, roverd automatically uses the Pis primary IPv4 plus `:8889/whep/rovercam`.
## Manual installation ## Manual installation
@@ -65,7 +65,7 @@ If the script installs the sample config, it will remind you to edit `/etc/rover
sudo install -o roverd -g roverd -m 0755 dist/roverd /usr/local/bin/roverd sudo install -o roverd -g roverd -m 0755 dist/roverd /usr/local/bin/roverd
sudo install -o roverd -g roverd -m 0640 pi/roverd/roverd.sample.yaml /etc/roverd.yaml sudo install -o roverd -g roverd -m 0640 pi/roverd/roverd.sample.yaml /etc/roverd.yaml
``` ```
Adjust `/etc/roverd.yaml` for each rover: `name`, `serverUrl` (e.g. `ws://control-server:8080/rover`), serial port path, battery thresholds, GPIO pin for BRC, and the media `publishUrl` that points at the central media servers WHIP endpoint for that rover. Adjust `/etc/roverd.yaml` for each rover: `name`, `serverUrl` (e.g. `ws://control-server:8080/rover`), serial port path, battery thresholds, GPIO pin for BRC, and (if needed) the media `whepUrl` override. Otherwise, roverd fills in `http://<pi-ip>:8889/whep/rovercam` based on the DHCP-assigned address.
2. Install the systemd unit: 2. Install the systemd unit:
```bash ```bash
+5 -4
View File
@@ -1,17 +1,18 @@
# mediaMTX Integration # mediaMTX Integration
Each rover runs mediaMTX locally to capture the Pi camera and publish it upstream, while the control server hosts a central mediaMTX instance that fans video out to drivers and spectators. 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 rovers local WHEP stream and expose it under the rovers name.
## Pi (publisher) ## Pi (publisher)
- mediaMTX samples the Pi camera (`paths.rovercam.source: rpiCamera`) and exposes the HTTP API on `http://127.0.0.1:9997`. - mediaMTX samples the Pi camera (`paths.rovercam.source: rpiCamera`) and exposes the HTTP API on `http://127.0.0.1:9997`.
- `/etc/roverd.yaml` contains `media.publishUrl`, pointing at the control servers WHIP endpoint for that rover (e.g. `https://control.example.com/whip/roomba-alpha`). No auth is required when the Pi network is trusted. - `/etc/roverd.yaml` contains `media.whepUrl`, pointing at the Pis own WHEP endpoint (e.g. `http://roomba-alpha.local:8889/whep/rovercam`). This is what the central server will pull.
- 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`). - 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) ## Control server (viewer)
- The central mediaMTX instance accepts WHIP ingest at `/whip/<roverId>` and serves WHEP playback at `/whep/<roverId>`. - The central mediaMTX instance serves WHEP playback at `/whep/<roverId>` and exposes its control API on `http://127.0.0.1:9997`.
- The Node server issues viewer sessions (one per socket) via `video:request`, and mediaMTX calls back into `GET /mediamtx/auth?session=<id>&roverId=<name>` before letting a client access `/whep/<roverId>?session=<id>`. Lockdown mode simply stops minting sessions for non-lockdown admins. - 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. mediaMTX calls back into `/mediamtx/auth` before letting a client access `/whep/<roverId>?session=...`, and the Node server enforces lockdown/role rules there.
## Driver / spectator UIs ## Driver / spectator UIs
+72 -1
View File
@@ -3,7 +3,9 @@ package roverd
import ( import (
"errors" "errors"
"fmt" "fmt"
"net"
"os" "os"
"strings"
"time" "time"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@@ -53,7 +55,10 @@ type BatteryConfig struct {
} }
type MediaConfig struct { type MediaConfig struct {
PublishURL string `yaml:"publishUrl"` WhepURL string `yaml:"whepUrl" json:"whepUrl,omitempty"`
WhepPort int `yaml:"whepPort" json:"-"`
WhepPath string `yaml:"whepPath" json:"-"`
LegacyPublish string `yaml:"publishUrl,omitempty" json:"-"`
Manage bool `yaml:"manage"` Manage bool `yaml:"manage"`
Service string `yaml:"service"` Service string `yaml:"service"`
HealthURL string `yaml:"healthUrl"` HealthURL string `yaml:"healthUrl"`
@@ -88,6 +93,8 @@ func LoadConfig(path string) (*Config, error) {
}, },
}, },
Media: MediaConfig{ Media: MediaConfig{
WhepPort: 8889,
WhepPath: "/whep/rovercam",
HealthInterval: Duration{Duration: 30 * time.Second}, HealthInterval: Duration{Duration: 30 * time.Second},
}, },
} }
@@ -118,5 +125,69 @@ func LoadConfig(path string) (*Config, error) {
if cfg.Media.Manage && cfg.Media.HealthInterval.Duration <= 0 { if cfg.Media.Manage && cfg.Media.HealthInterval.Duration <= 0 {
cfg.Media.HealthInterval = Duration{Duration: 30 * time.Second} cfg.Media.HealthInterval = Duration{Duration: 30 * time.Second}
} }
if cfg.Media.WhepURL == "" {
cfg.Media.WhepURL = cfg.Media.LegacyPublish
}
if cfg.Media.WhepURL == "" {
ip, err := detectPrimaryIPv4()
if err != nil {
return nil, fmt.Errorf("derive whepUrl: %w", err)
}
path := cfg.Media.WhepPath
if path == "" {
path = "/whep/rovercam"
}
path = ensureLeadingSlash(path)
scheme := "http"
cfg.Media.WhepURL = fmt.Sprintf("%s://%s:%d%s", scheme, ip, effectivePort(cfg.Media.WhepPort), path)
}
return &cfg, nil return &cfg, nil
} }
func ensureLeadingSlash(path string) string {
if !strings.HasPrefix(path, "/") {
return "/" + path
}
return path
}
func effectivePort(port int) int {
if port <= 0 {
return 8889
}
return port
}
func detectPrimaryIPv4() (string, error) {
ifaces, err := net.Interfaces()
if err != nil {
return "", err
}
for _, iface := range ifaces {
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
var ip net.IP
switch v := addr.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
}
if ip == nil || ip.IsLoopback() {
continue
}
ip = ip.To4()
if ip == nil {
continue
}
return ip.String(), nil
}
}
return "", errors.New("no non-loopback IPv4 address found")
}
+1 -1
View File
@@ -15,7 +15,7 @@ battery:
urgent: 1650 urgent: 1650
maxWheelSpeed: 350 maxWheelSpeed: 350
media: media:
publishUrl: https://control-server.local/whip/roomba-alpha whepUrl: http://roomba-alpha.local:8889/whep/rovercam
manage: false manage: false
service: mediamtx.service service: mediamtx.service
healthUrl: http://127.0.0.1:9997/v3/paths/list healthUrl: http://127.0.0.1:9997/v3/paths/list
+1 -1
View File
@@ -15,7 +15,7 @@ battery:
urgent: 1650 urgent: 1650
maxWheelSpeed: 350 maxWheelSpeed: 350
media: media:
publishUrl: https://control-server.local/whip/roomba-alpha whepUrl: http://roomba-alpha.local:8889/whep/rovercam
manage: false manage: false
service: mediamtx.service service: mediamtx.service
healthUrl: http://127.0.0.1:9997/v3/paths/list healthUrl: http://127.0.0.1:9997/v3/paths/list
+1
View File
@@ -9,3 +9,4 @@ admins:
lockdown: true lockdown: true
media: media:
whepBaseUrl: "http://control-server.local:8889/whep" whepBaseUrl: "http://control-server.local:8889/whep"
mediamtxApiUrl: "http://127.0.0.1:9997"
+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');
+117
View File
@@ -0,0 +1,117 @@
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);
});
}
});
// 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 = normalizeSource(record.meta.media.whepUrl);
if (!source) {
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;
}
}
}
function normalizeSource(raw) {
if (!raw) return null;
const trimmed = 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';
return `${protocol}://${parsed.host}${parsed.pathname}${parsed.search || ''}`;
} catch (err) {
logger.warn('invalid WHEP URL: %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;
}