mmtx integration / server installer

This commit is contained in:
legop3
2025-11-13 23:05:41 -05:00
parent 8c79c70520
commit a24e487c51
10 changed files with 345 additions and 1 deletions
+20
View File
@@ -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 servers `/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.
+1 -1
View File
@@ -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
+2
View File
@@ -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"
+3
View File
@@ -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');
+137
View File
@@ -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."
+2
View File
@@ -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>
+20
View File
@@ -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;
}
});
+52
View File
@@ -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();
});
+61
View File
@@ -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,
};
+47
View File
@@ -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 });
}
});
});