mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
mmtx integration / server installer
This commit is contained in:
@@ -59,3 +59,23 @@ 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.
|
||||
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.
|
||||
|
||||
## Fedora server deployment
|
||||
|
||||
Run the installer from inside the `server/` directory after cloning the repo onto your Fedora 43 Server box:
|
||||
|
||||
```bash
|
||||
cd ~/MultiRoombaRover/server
|
||||
sudo ./install_server.sh
|
||||
```
|
||||
|
||||
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)
|
||||
- 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
|
||||
|
||||
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.
|
||||
|
||||
@@ -11,7 +11,7 @@ Each rover runs mediaMTX locally to capture the Pi camera and publish it upstrea
|
||||
## 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) and mediamtx calls back into the Node server to authorize new WHEP connections. Lockdown mode simply stops minting sessions for non-lockdown admins.
|
||||
- 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.
|
||||
|
||||
## Driver / spectator UIs
|
||||
|
||||
|
||||
@@ -7,3 +7,5 @@ admins:
|
||||
password_hash: "$2b$10$n0L0oe1ZQy7IgM.FvVAzb.aXz43uaZWFiT0wr.05uNoVIDLawmrCG" # password: lockdownpass
|
||||
discord_id: "0987654321"
|
||||
lockdown: true
|
||||
media:
|
||||
whepBaseUrl: "http://control-server.local:8889/whep"
|
||||
|
||||
@@ -14,4 +14,7 @@ require('./src/services/roverManager');
|
||||
require('./src/services/commandService');
|
||||
require('./src/services/roverConnectionService');
|
||||
require('./src/services/assignmentService');
|
||||
require('./src/services/videoSessions');
|
||||
require('./src/services/videoAuthService');
|
||||
require('./src/services/videoSocketService');
|
||||
require('./src/services/httpServer');
|
||||
|
||||
Executable
+137
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
MEDIAMTX_VERSION="1.15.3"
|
||||
MEDIAMTX_BASE_URL="https://github.com/bluenviron/mediamtx/releases/download/v${MEDIAMTX_VERSION}"
|
||||
MEDIAMTX_BIN="/usr/local/bin/mediamtx"
|
||||
MEDIAMTX_CONF_DIR="/etc/mediamtx"
|
||||
MEDIAMTX_CONFIG="$MEDIAMTX_CONF_DIR/mediamtx.yml"
|
||||
MEDIAMTX_SERVICE="/etc/systemd/system/mediamtx.service"
|
||||
MULTIROVER_SERVICE="/etc/systemd/system/multirover.service"
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "This installer must be run with sudo/root." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${SUDO_USER:-}" || "${SUDO_USER}" == "root" ]]; then
|
||||
echo "Run this script via 'sudo' from the normal user that owns the repo." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TARGET_USER="$SUDO_USER"
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
SERVER_DIR="$SCRIPT_DIR"
|
||||
CONFIG_PATH="$SERVER_DIR/config.yaml"
|
||||
|
||||
echo "[1/6] Installing dependencies..."
|
||||
dnf install -y nodejs npm curl tar >/dev/null
|
||||
NODE_BIN="$(command -v node)"
|
||||
|
||||
echo "[2/6] Installing Node production deps..."
|
||||
runuser -u "$TARGET_USER" -- bash -c "cd '$SERVER_DIR' && npm install --production"
|
||||
|
||||
if [[ ! -f "$CONFIG_PATH" ]]; then
|
||||
cp "$SERVER_DIR/config.example.yaml" "$CONFIG_PATH"
|
||||
chown "$TARGET_USER":"$TARGET_USER" "$CONFIG_PATH"
|
||||
echo "Copied config.example.yaml to config.yaml; edit it before exposing the service."
|
||||
fi
|
||||
|
||||
tmpdir=$(mktemp -d)
|
||||
trap 'rm -rf "$tmpdir"' EXIT
|
||||
|
||||
arch=$(uname -m)
|
||||
case "$arch" in
|
||||
x86_64|amd64)
|
||||
mediamtx_pkg="mediamtx_v${MEDIAMTX_VERSION}_linux_amd64.tar.gz"
|
||||
;;
|
||||
aarch64)
|
||||
mediamtx_pkg="mediamtx_v${MEDIAMTX_VERSION}_linux_arm64.tar.gz"
|
||||
;;
|
||||
armv7l)
|
||||
mediamtx_pkg="mediamtx_v${MEDIAMTX_VERSION}_linux_armv7.tar.gz"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported architecture: $arch" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "[3/6] Installing mediaMTX ${MEDIAMTX_VERSION}..."
|
||||
curl -L "$MEDIAMTX_BASE_URL/$mediamtx_pkg" -o "$tmpdir/mediamtx.tgz"
|
||||
tar -xzf "$tmpdir/mediamtx.tgz" -C "$tmpdir" mediamtx
|
||||
install -m 0755 "$tmpdir/mediamtx" "$MEDIAMTX_BIN"
|
||||
|
||||
mkdir -p "$MEDIAMTX_CONF_DIR"
|
||||
if [[ ! -f "$MEDIAMTX_CONFIG" ]]; then
|
||||
cat > "$MEDIAMTX_CONFIG" <<'CFG'
|
||||
logLevel: info
|
||||
api: yes
|
||||
apiAddress: 0.0.0.0:9997
|
||||
webrtc: yes
|
||||
webrtcLocalUDPAddress: :8189
|
||||
webrtcLocalTCPAddress: :8189
|
||||
|
||||
authMethods: [read]
|
||||
authWebhookURL: http://127.0.0.1:8080/mediamtx/auth
|
||||
|
||||
paths:
|
||||
all:
|
||||
source: publisher
|
||||
CFG
|
||||
fi
|
||||
chown -R "$TARGET_USER":"$TARGET_USER" "$MEDIAMTX_CONF_DIR"
|
||||
|
||||
echo "[4/6] Writing systemd units..."
|
||||
cat > "$MEDIAMTX_SERVICE" <<EOF
|
||||
[Unit]
|
||||
Description=mediaMTX WebRTC Server
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
User=$TARGET_USER
|
||||
Group=$TARGET_USER
|
||||
WorkingDirectory=$MEDIAMTX_CONF_DIR
|
||||
ExecStart=$MEDIAMTX_BIN $MEDIAMTX_CONFIG
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
cat > "$MULTIROVER_SERVICE" <<EOF
|
||||
[Unit]
|
||||
Description=Multi-Roomba Rover control server
|
||||
After=network-online.target mediamtx.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
User=$TARGET_USER
|
||||
Group=$TARGET_USER
|
||||
WorkingDirectory=$SERVER_DIR
|
||||
Environment=NODE_ENV=production
|
||||
Environment=SERVER_CONFIG=$CONFIG_PATH
|
||||
ExecStart=$NODE_BIN $SERVER_DIR/index.js
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
chmod 644 "$MEDIAMTX_SERVICE" "$MULTIROVER_SERVICE"
|
||||
|
||||
echo "[5/6] Enabling services..."
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now mediamtx.service
|
||||
systemctl enable --now multirover.service
|
||||
|
||||
echo "[6/6] Done."
|
||||
echo
|
||||
echo "Services installed:"
|
||||
echo " mediamtx.service (WebRTC fan-out)"
|
||||
echo " multirover.service (Node.js control server)"
|
||||
echo
|
||||
echo "Update $CONFIG_PATH to set admins, lockdown settings, and media parameters."
|
||||
@@ -61,6 +61,8 @@
|
||||
<h3>Sensor Frames</h3>
|
||||
<div id="currentRover">Active rover: --</div>
|
||||
<div id="activeDriver">Driver: --</div>
|
||||
<button id="videoRequest">Get Video URL</button>
|
||||
<div id="videoLink"></div>
|
||||
<pre id="sensorOutput"></pre>
|
||||
<p>Use WASD keys to drive the selected rover. Shift increases speed.</p>
|
||||
<div id="alerts"></div>
|
||||
|
||||
@@ -13,6 +13,8 @@ registerModule('services/roverUI', (require, exports) => {
|
||||
const modeSelect = document.getElementById('modeSelect');
|
||||
const activeDriverEl = document.getElementById('activeDriver');
|
||||
const currentRoverEl = document.getElementById('currentRover');
|
||||
const videoBtn = document.getElementById('videoRequest');
|
||||
const videoLinkEl = document.getElementById('videoLink');
|
||||
|
||||
let roster = [];
|
||||
let lastSelection = null;
|
||||
@@ -100,6 +102,21 @@ registerModule('services/roverUI', (require, exports) => {
|
||||
}
|
||||
});
|
||||
|
||||
videoBtn?.addEventListener('click', () => {
|
||||
const roverId = state.getSelected();
|
||||
if (!roverId) {
|
||||
if (videoLinkEl) videoLinkEl.textContent = 'Select a rover first';
|
||||
return;
|
||||
}
|
||||
socket.emit('video:request', { roverId }, (resp = {}) => {
|
||||
if (resp.error) {
|
||||
videoLinkEl.textContent = `Video error: ${resp.error}`;
|
||||
return;
|
||||
}
|
||||
videoLinkEl.innerHTML = `Video URL: <a href=\"${resp.url}\" target=\"_blank\">${resp.url}</a>`;
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('connect', () => {
|
||||
statusEl.textContent = 'Connected';
|
||||
});
|
||||
@@ -120,6 +137,9 @@ registerModule('services/roverUI', (require, exports) => {
|
||||
if (sensorOutput && (clearOutput || roverId !== lastSelection)) {
|
||||
sensorOutput.textContent = roverId ? 'Waiting for sensor data...' : '';
|
||||
}
|
||||
if (clearOutput && videoLinkEl) {
|
||||
videoLinkEl.textContent = '';
|
||||
}
|
||||
lastSelection = roverId;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
const { app } = require('../globals/http');
|
||||
const io = require('../globals/io');
|
||||
const logger = require('../globals/logger').child('videoAuth');
|
||||
const videoSessions = require('./videoSessions');
|
||||
const { getMode, MODES } = require('./modeManager');
|
||||
const { isAdmin, isLockdownAdmin, getRole } = require('./roleService');
|
||||
const roverManager = require('./roverManager');
|
||||
|
||||
function canView(socket) {
|
||||
const mode = getMode();
|
||||
if (!socket) {
|
||||
return false;
|
||||
}
|
||||
if (mode === MODES.LOCKDOWN) {
|
||||
return isLockdownAdmin(socket);
|
||||
}
|
||||
if (mode === MODES.ADMIN) {
|
||||
return isAdmin(socket);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
app.get('/mediamtx/auth', (req, res) => {
|
||||
const { session: sessionId, roverId } = req.query;
|
||||
if (!sessionId || !roverId) {
|
||||
logger.warn('auth missing session or rover');
|
||||
return res.status(401).end();
|
||||
}
|
||||
|
||||
const info = videoSessions.getSession(sessionId);
|
||||
if (!info || info.roverId !== roverId) {
|
||||
logger.warn('invalid session %s', sessionId);
|
||||
return res.status(401).end();
|
||||
}
|
||||
const socket = io.sockets.sockets.get(info.socketId);
|
||||
if (!socket) {
|
||||
videoSessions.revokeSession(sessionId);
|
||||
return res.status(401).end();
|
||||
}
|
||||
if (!canView(socket)) {
|
||||
return res.status(401).end();
|
||||
}
|
||||
// optionally ensure non-admin drivers only view their rover
|
||||
const role = getRole(socket);
|
||||
if (role !== 'spectator' && !isAdmin(socket)) {
|
||||
if (!roverManager.isDriver(roverId, socket)) {
|
||||
return res.status(401).end();
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).end();
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const io = require('../globals/io');
|
||||
|
||||
const sessions = new Map(); // sessionId -> { socketId, roverId }
|
||||
const socketSessions = new Map(); // socketId -> Set(sessionId)
|
||||
|
||||
function createSession(socket, roverId) {
|
||||
const sessionId = uuidv4();
|
||||
sessions.set(sessionId, { socketId: socket.id, roverId });
|
||||
if (!socketSessions.has(socket.id)) {
|
||||
socketSessions.set(socket.id, new Set());
|
||||
}
|
||||
socketSessions.get(socket.id).add(sessionId);
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
function getSession(sessionId) {
|
||||
return sessions.get(sessionId);
|
||||
}
|
||||
|
||||
function revokeSession(sessionId) {
|
||||
const info = sessions.get(sessionId);
|
||||
if (!info) return;
|
||||
sessions.delete(sessionId);
|
||||
const bucket = socketSessions.get(info.socketId);
|
||||
if (bucket) {
|
||||
bucket.delete(sessionId);
|
||||
if (bucket.size === 0) {
|
||||
socketSessions.delete(info.socketId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function revokeBySocket(socketId) {
|
||||
const bucket = socketSessions.get(socketId);
|
||||
if (!bucket) return;
|
||||
for (const sessionId of bucket) {
|
||||
sessions.delete(sessionId);
|
||||
}
|
||||
socketSessions.delete(socketId);
|
||||
}
|
||||
|
||||
function revokeWhere(predicate) {
|
||||
for (const [sessionId, info] of Array.from(sessions.entries())) {
|
||||
if (predicate(info)) {
|
||||
revokeSession(sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('disconnect', () => revokeBySocket(socket.id));
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
createSession,
|
||||
getSession,
|
||||
revokeSession,
|
||||
revokeBySocket,
|
||||
revokeWhere,
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
const io = require('../globals/io');
|
||||
const logger = require('../globals/logger').child('videoSocket');
|
||||
const { getMode, MODES } = require('./modeManager');
|
||||
const { isAdmin, isLockdownAdmin, getRole } = require('./roleService');
|
||||
const videoSessions = require('./videoSessions');
|
||||
const roverManager = require('./roverManager');
|
||||
const { loadConfig } = require('../helpers/configLoader');
|
||||
|
||||
const config = loadConfig();
|
||||
const mediaConfig = config.media || {};
|
||||
|
||||
function canView(socket, roverId) {
|
||||
const mode = getMode();
|
||||
if (mode === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
|
||||
return false;
|
||||
}
|
||||
if (mode === MODES.ADMIN && !isAdmin(socket)) {
|
||||
return false;
|
||||
}
|
||||
const role = getRole(socket);
|
||||
if (role === 'spectator' || isAdmin(socket)) {
|
||||
return true;
|
||||
}
|
||||
return roverManager.isDriver(roverId, socket);
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('video:request', ({ roverId } = {}, cb = () => {}) => {
|
||||
try {
|
||||
if (!mediaConfig.whepBaseUrl) {
|
||||
throw new Error('Server video base URL missing');
|
||||
}
|
||||
if (!roverId) {
|
||||
throw new Error('roverId required');
|
||||
}
|
||||
if (!canView(socket, roverId)) {
|
||||
throw new Error('Not authorized for video');
|
||||
}
|
||||
const sessionId = videoSessions.createSession(socket, roverId);
|
||||
const url = `${mediaConfig.whepBaseUrl.replace(/\/$/, '')}/${roverId}?session=${sessionId}`;
|
||||
cb({ url });
|
||||
} catch (err) {
|
||||
logger.warn('video request failed: %s', err.message);
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user