mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
go back to server pulling streaming
This commit is contained in:
@@ -56,7 +56,7 @@ cd ~/MultiRoombaRover
|
||||
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 Pi’s 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.
|
||||
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
|
||||
- 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`
|
||||
- write `/etc/mediamtx/mediamtx.yml` that points to the Node server’s `/mediamtx/auth` webhook
|
||||
- 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.
|
||||
|
||||
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 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.
|
||||
|
||||
Vendored
+1
-1
@@ -14,7 +14,7 @@ battery:
|
||||
urgent: 1650
|
||||
maxWheelSpeed: 350
|
||||
media:
|
||||
publishUrl: https://control-server.local/whip/roomba-alpha
|
||||
whepUrl: http://dummy1.local:8889/whep/rovercam
|
||||
manage: false
|
||||
service: mediamtx.service
|
||||
healthUrl: http://127.0.0.1:9997/v3/paths/list
|
||||
|
||||
Vendored
+1
-1
@@ -14,7 +14,7 @@ battery:
|
||||
urgent: 1650
|
||||
maxWheelSpeed: 350
|
||||
media:
|
||||
publishUrl: https://control-server.local/whip/roomba-alpha
|
||||
whepUrl: http://dummy2.local:8889/whep/rovercam
|
||||
manage: false
|
||||
service: mediamtx.service
|
||||
healthUrl: http://127.0.0.1:9997/v3/paths/list
|
||||
|
||||
Vendored
BIN
Binary file not shown.
@@ -55,7 +55,7 @@ Flags:
|
||||
| `--mediamtx` | download/install mediaMTX plus the provided config + unit |
|
||||
| `--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 Pi’s primary IPv4 plus `:8889/whep/rovercam`.
|
||||
|
||||
## 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 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 server’s 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:
|
||||
```bash
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
# 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 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`.
|
||||
- `/etc/roverd.yaml` contains `media.publishUrl`, pointing at the control server’s 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 Pi’s 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`).
|
||||
|
||||
## Control server (viewer)
|
||||
|
||||
- The central mediaMTX instance accepts WHIP ingest at `/whip/<roverId>` and serves WHEP playback at `/whep/<roverId>`.
|
||||
- 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.
|
||||
- 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. 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
|
||||
|
||||
|
||||
+72
-1
@@ -3,7 +3,9 @@ package roverd
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
@@ -53,7 +55,10 @@ type BatteryConfig 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"`
|
||||
Service string `yaml:"service"`
|
||||
HealthURL string `yaml:"healthUrl"`
|
||||
@@ -88,6 +93,8 @@ func LoadConfig(path string) (*Config, error) {
|
||||
},
|
||||
},
|
||||
Media: MediaConfig{
|
||||
WhepPort: 8889,
|
||||
WhepPath: "/whep/rovercam",
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ battery:
|
||||
urgent: 1650
|
||||
maxWheelSpeed: 350
|
||||
media:
|
||||
publishUrl: https://control-server.local/whip/roomba-alpha
|
||||
whepUrl: http://roomba-alpha.local:8889/whep/rovercam
|
||||
manage: false
|
||||
service: mediamtx.service
|
||||
healthUrl: http://127.0.0.1:9997/v3/paths/list
|
||||
|
||||
@@ -15,7 +15,7 @@ battery:
|
||||
urgent: 1650
|
||||
maxWheelSpeed: 350
|
||||
media:
|
||||
publishUrl: https://control-server.local/whip/roomba-alpha
|
||||
whepUrl: http://roomba-alpha.local:8889/whep/rovercam
|
||||
manage: false
|
||||
service: mediamtx.service
|
||||
healthUrl: http://127.0.0.1:9997/v3/paths/list
|
||||
|
||||
@@ -9,3 +9,4 @@ admins:
|
||||
lockdown: true
|
||||
media:
|
||||
whepBaseUrl: "http://control-server.local:8889/whep"
|
||||
mediamtxApiUrl: "http://127.0.0.1:9997"
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user