diff --git a/README.md b/README.md index b021411f..36e8d0df 100644 --- a/README.md +++ b/README.md @@ -59,3 +59,23 @@ sudo ./pi/install_roverd.sh --mediamtx Then point each rover's `/etc/roverd.yaml` at `ws://: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. diff --git a/mediamtx_server_integration.md b/mediamtx_server_integration.md index 0045aa91..8be0f70d 100644 --- a/mediamtx_server_integration.md +++ b/mediamtx_server_integration.md @@ -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/` and serves WHEP playback at `/whep/`. -- 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=&roverId=` before letting a client access `/whep/?session=`. Lockdown mode simply stops minting sessions for non-lockdown admins. ## Driver / spectator UIs diff --git a/server/config.example.yaml b/server/config.example.yaml index 1b707ef5..0b4ed61b 100644 --- a/server/config.example.yaml +++ b/server/config.example.yaml @@ -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" diff --git a/server/index.js b/server/index.js index b32f11c5..dee223b0 100644 --- a/server/index.js +++ b/server/index.js @@ -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'); diff --git a/server/install_server.sh b/server/install_server.sh new file mode 100755 index 00000000..847dc571 --- /dev/null +++ b/server/install_server.sh @@ -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" < "$MULTIROVER_SERVICE" <Sensor Frames
Active rover: --
Driver: --
+ +

         

Use WASD keys to drive the selected rover. Shift increases speed.

diff --git a/server/public/src/services/roverUI.js b/server/public/src/services/roverUI.js index f326c5f5..5ce67cbd 100644 --- a/server/public/src/services/roverUI.js +++ b/server/public/src/services/roverUI.js @@ -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: ${resp.url}`; + }); + }); + 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; } }); diff --git a/server/src/services/videoAuthService.js b/server/src/services/videoAuthService.js new file mode 100644 index 00000000..c4183610 --- /dev/null +++ b/server/src/services/videoAuthService.js @@ -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(); +}); diff --git a/server/src/services/videoSessions.js b/server/src/services/videoSessions.js new file mode 100644 index 00000000..6fda0db7 --- /dev/null +++ b/server/src/services/videoSessions.js @@ -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, +}; diff --git a/server/src/services/videoSocketService.js b/server/src/services/videoSocketService.js new file mode 100644 index 00000000..8e3472dd --- /dev/null +++ b/server/src/services/videoSocketService.js @@ -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 }); + } + }); +});