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. 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. 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.
## 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) ## Control server (viewer)
- The central mediaMTX instance accepts WHIP ingest at `/whip/<roverId>` and serves WHEP playback at `/whep/<roverId>`. - 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 ## Driver / spectator UIs
+2
View File
@@ -7,3 +7,5 @@ admins:
password_hash: "$2b$10$n0L0oe1ZQy7IgM.FvVAzb.aXz43uaZWFiT0wr.05uNoVIDLawmrCG" # password: lockdownpass password_hash: "$2b$10$n0L0oe1ZQy7IgM.FvVAzb.aXz43uaZWFiT0wr.05uNoVIDLawmrCG" # password: lockdownpass
discord_id: "0987654321" discord_id: "0987654321"
lockdown: true 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/commandService');
require('./src/services/roverConnectionService'); require('./src/services/roverConnectionService');
require('./src/services/assignmentService'); require('./src/services/assignmentService');
require('./src/services/videoSessions');
require('./src/services/videoAuthService');
require('./src/services/videoSocketService');
require('./src/services/httpServer'); 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> <h3>Sensor Frames</h3>
<div id="currentRover">Active rover: --</div> <div id="currentRover">Active rover: --</div>
<div id="activeDriver">Driver: --</div> <div id="activeDriver">Driver: --</div>
<button id="videoRequest">Get Video URL</button>
<div id="videoLink"></div>
<pre id="sensorOutput"></pre> <pre id="sensorOutput"></pre>
<p>Use WASD keys to drive the selected rover. Shift increases speed.</p> <p>Use WASD keys to drive the selected rover. Shift increases speed.</p>
<div id="alerts"></div> <div id="alerts"></div>
+20
View File
@@ -13,6 +13,8 @@ registerModule('services/roverUI', (require, exports) => {
const modeSelect = document.getElementById('modeSelect'); const modeSelect = document.getElementById('modeSelect');
const activeDriverEl = document.getElementById('activeDriver'); const activeDriverEl = document.getElementById('activeDriver');
const currentRoverEl = document.getElementById('currentRover'); const currentRoverEl = document.getElementById('currentRover');
const videoBtn = document.getElementById('videoRequest');
const videoLinkEl = document.getElementById('videoLink');
let roster = []; let roster = [];
let lastSelection = null; 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', () => { socket.on('connect', () => {
statusEl.textContent = 'Connected'; statusEl.textContent = 'Connected';
}); });
@@ -120,6 +137,9 @@ registerModule('services/roverUI', (require, exports) => {
if (sensorOutput && (clearOutput || roverId !== lastSelection)) { if (sensorOutput && (clearOutput || roverId !== lastSelection)) {
sensorOutput.textContent = roverId ? 'Waiting for sensor data...' : ''; sensorOutput.textContent = roverId ? 'Waiting for sensor data...' : '';
} }
if (clearOutput && videoLinkEl) {
videoLinkEl.textContent = '';
}
lastSelection = roverId; 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 });
}
});
});