lets see . . .

This commit is contained in:
legop3
2026-01-13 17:21:32 -05:00
parent 4ab6bad1b8
commit f3e13f6ab7
25 changed files with 841 additions and 175 deletions
+7 -7
View File
@@ -1,14 +1,11 @@
## raspberry pi
- need to throttle sensor sending, maybe only send one in every 5 packets.
<!-- - need to throttle sensor sending, maybe only send one in every 5 packets. -->
## 3d models for camera:
- https://www.thingiverse.com/thing:2873677
- https://www.printables.com/model/356894-raspberry-camera-module-with-automatic-ir-cut-swit
- https://www.thingiverse.com/thing:4514531
## spectator page
- completely remake it from scratch
## todo
<!-- 1. battery manager -->
<!-- 2. pi-side sensor throttle (1/5th) -->
@@ -22,6 +19,9 @@
<!-- 9. online user list -->
<!-- 10. chat -->
<!-- 9. discord invite button -->
10. redo both mobile layouts
11. redo spectator view (last)
21. finally.. set the favicon and title
<!-- 10. redo both mobile layouts -->
<!-- 11. redo spectator view (last) -->
<!-- 21. finally.. set the favicon and title -->
22. rover snapshots freezing and never coming back
23. rover snapshot -> video switching needs to be smoother, no black flash. connect and play before showing.
24. rover snapshots delayed (not just because of framerate)
+90 -7
View File
@@ -2,13 +2,15 @@
set -euo pipefail
# Configurable via environment
DEVICE="${DEVICE:-/dev/video0}"
RESOLUTION="${RESOLUTION:-640x480}"
QUALITY="${QUALITY:-10}" # ffmpeg MJPEG quality (lower is better)
PORT="${PORT:-8088}"
WORKDIR="${WORKDIR:-/run/roomcam}"
export DEVICE="${DEVICE:-/dev/video0}"
export RESOLUTION="${RESOLUTION:-640x480}"
export QUALITY="${QUALITY:-10}" # ffmpeg MJPEG quality (lower is better)
export PORT="${PORT:-8088}"
export WORKDIR="${WORKDIR:-/run/roomcam}"
# Optional: set INPUT_FORMAT=bayer_grbg8 to transcode raw Bayer cams (e.g., OV534) to JPEG.
INPUT_FORMAT="${INPUT_FORMAT:-mjpeg}"
export INPUT_FORMAT="${INPUT_FORMAT:-mjpeg}"
export MJPEG_FPS="${MJPEG_FPS:-15}"
export MJPEG_QUALITY="${MJPEG_QUALITY:-8}"
mkdir -p "${WORKDIR}"
SNAPSHOT_PATH="${WORKDIR}/snapshot.jpg"
@@ -36,7 +38,88 @@ fi
-f image2 -update 1 "${SNAPSHOT_PATH}" &
FFMPEG_PID=$!
/usr/bin/python3 -u -m http.server "${PORT}" --directory "${WORKDIR}" --bind 0.0.0.0 &
cat > "${WORKDIR}/mjpeg_server.py" <<'PY'
import os
import subprocess
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
DEVICE = os.environ.get("DEVICE", "/dev/video0")
RESOLUTION = os.environ.get("RESOLUTION", "640x480")
INPUT_FORMAT = os.environ.get("INPUT_FORMAT", "mjpeg")
MJPEG_FPS = os.environ.get("MJPEG_FPS", "15")
MJPEG_QUALITY = os.environ.get("MJPEG_QUALITY", "8")
WORKDIR = os.environ.get("WORKDIR", "/run/roomcam")
SNAPSHOT_PATH = os.path.join(WORKDIR, "snapshot.jpg")
FFMPEG_INPUT_ARGS = [
"-f", "v4l2",
"-input_format", INPUT_FORMAT,
"-video_size", RESOLUTION,
"-i", DEVICE,
]
FFMPEG_FILTERS = []
if INPUT_FORMAT.startswith("bayer_"):
FFMPEG_FILTERS = ["-pix_fmt", "yuv420p"]
def spawn_mjpeg():
cmd = [
"/usr/bin/ffmpeg",
"-loglevel", "warning", "-nostats",
*FFMPEG_INPUT_ARGS,
*FFMPEG_FILTERS,
"-r", str(MJPEG_FPS),
"-q:v", str(MJPEG_QUALITY),
"-f", "mpjpeg",
"-",
]
return subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/" or self.path == "/snapshot.jpg":
try:
with open(SNAPSHOT_PATH, "rb") as fh:
data = fh.read()
self.send_response(200)
self.send_header("Content-Type", "image/jpeg")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
except FileNotFoundError:
self.send_error(404, "snapshot missing")
return
if self.path == "/stream.mjpg":
self.send_response(200)
self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=ffmpeg")
self.end_headers()
proc = spawn_mjpeg()
try:
while True:
chunk = proc.stdout.read(8192)
if not chunk:
break
self.wfile.write(chunk)
except BrokenPipeError:
pass
finally:
proc.kill()
return
self.send_error(404, "not found")
def log_message(self, format, *args):
return
def main():
addr = ("0.0.0.0", int(os.environ.get("PORT", "8088")))
ThreadingHTTPServer(addr, Handler).serve_forever()
if __name__ == "__main__":
main()
PY
/usr/bin/python3 -u "${WORKDIR}/mjpeg_server.py" &
HTTP_PID=$!
wait -n "${FFMPEG_PID}" "${HTTP_PID}"
+2
View File
@@ -28,10 +28,12 @@ roomCameras:
name: "Lobby Camera"
description: "Wide shot of the staging area."
url: "http://192.168.0.50/snapshot.jpg"
streamUrl: "http://192.168.0.50/stream.mjpg"
- id: "workshop"
name: "Workshop Bench"
description: "Shows the workbench and charging docks."
url: "http://192.168.0.51/snapshot.jpg"
streamUrl: "http://192.168.0.51/stream.mjpg"
discord:
token: "DISCORD_BOT_TOKEN"
+1
View File
@@ -28,5 +28,6 @@ require('./src/services/homeAssistantService');
require('./src/services/sessionService');
require('./src/services/batteryManager');
require('./src/services/replaySocketService');
require('./src/services/replaySegmentManager');
require('./src/services/discordBotService');
require('./src/services/httpServer');
+4
View File
@@ -10,6 +10,7 @@ MEDIAMTX_SNAPSHOT_SCRIPT="$MEDIAMTX_CONF_DIR/rover-snapshot.sh"
MEDIAMTX_SERVICE="/etc/systemd/system/mediamtx.service"
MULTIROVER_SERVICE="/etc/systemd/system/multirover.service"
SNAPSHOT_DIR="/var/lib/rover-snapshots"
REPLAY_SEGMENT_DIR="/var/lib/replay-segments"
if [[ $EUID -ne 0 ]]; then
echo "This installer must be run with sudo/root." >&2
@@ -86,6 +87,8 @@ chown -R "$TARGET_USER":"$TARGET_USER" "$MEDIAMTX_CONF_DIR"
echo "[4/6] Writing systemd units..."
mkdir -p "$SNAPSHOT_DIR"
chown "$TARGET_USER":"$TARGET_USER" "$SNAPSHOT_DIR"
mkdir -p "$REPLAY_SEGMENT_DIR"
chown "$TARGET_USER":"$TARGET_USER" "$REPLAY_SEGMENT_DIR"
cat > "$MEDIAMTX_SERVICE" <<EOF
[Unit]
Description=mediaMTX WebRTC Server
@@ -118,6 +121,7 @@ WorkingDirectory=$SERVER_DIR
Environment=NODE_ENV=production
Environment=SERVER_CONFIG=$CONFIG_PATH
Environment=ROVER_SNAPSHOT_DIR=$SNAPSHOT_DIR
Environment=REPLAY_SEGMENT_DIR=$REPLAY_SEGMENT_DIR
ExecStart=$NODE_BIN $SERVER_DIR/index.js
Restart=on-failure
RestartSec=2
+1 -1
View File
@@ -12,7 +12,7 @@ if [[ "${STREAM_ID}" == *-audio ]]; then
fi
OUTPUT_DIR="${ROVER_SNAPSHOT_DIR:-/var/lib/rover-snapshots}"
FPS="${ROVER_SNAPSHOT_FPS:-1}"
FPS="${ROVER_SNAPSHOT_FPS:-3}"
WIDTH="${ROVER_SNAPSHOT_WIDTH:-640}"
QUALITY="${ROVER_SNAPSHOT_QUALITY:-8}"
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -11,8 +11,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-ClyuL_YR.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-COnIgwcF.css">
<script type="module" crossorigin src="/assets/index-FaKr6Sxz.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DAAnNTU0.css">
</head>
<body>
<div id="root"></div>
+44 -46
View File
@@ -15,8 +15,8 @@ const { subscribe } = require('./eventBus');
const { getRoster, lockRover, rovers } = require('./roverManager');
const { MODES, getMode, setMode } = require('./modeManager');
const { sendExternalMessage } = require('./chatService');
const { getRoomCameras, getRoomCamera } = require('./roomCameraService');
const { buildRoomCameraReplayVideo } = require('./roomCameraReplayService');
const { buildReplayVideo } = require('./replayBuildService');
const { getReplaySources, getDefaultDiscordSources, validateSources } = require('./replaySourceService');
const { getActiveDrivers } = require('./turnService');
const { getNickname } = require('./nicknameService');
const { tryTriggerReplay } = require('./replayService');
@@ -207,7 +207,7 @@ function formatHelp() {
'**Rover Bot Commands**',
'`rs help` — show this help',
'`rs status [id]` — show rover status (all or one)',
'`rs replay [camera]` — send room camera instant replay',
'`rs replay [sources]` — send instant replay (room/rover)',
'`rs bridge` — show chat bridge status for this server',
'`rs bridge here <global|private>` — set chat bridge to this channel',
'`rs bridge mode <global|private>` — change chat bridge mode',
@@ -263,57 +263,59 @@ function buildDriverCaption() {
return `Drivers: ${entries.join(', ')}`;
}
function normalizeCameraQuery(input) {
function normalizeReplayQuery(input) {
return String(input || '').trim().toLowerCase();
}
function resolveReplayCamera(query) {
const cleaned = normalizeCameraQuery(query);
if (!cleaned || cleaned === 'all' || cleaned === '*') return { camera: null };
const cameras = getRoomCameras();
const direct = cameras.find(
(camera) =>
String(camera.id).toLowerCase() === cleaned ||
String(camera.name || '').toLowerCase() === cleaned,
);
if (direct) return { camera: direct };
const starts = cameras.filter(
(camera) =>
String(camera.id).toLowerCase().startsWith(cleaned) ||
String(camera.name || '').toLowerCase().startsWith(cleaned),
);
if (starts.length === 1) return { camera: starts[0] };
if (starts.length > 1) return { error: 'Ambiguous camera name', matches: starts };
const includes = cameras.filter(
(camera) =>
String(camera.id).toLowerCase().includes(cleaned) ||
String(camera.name || '').toLowerCase().includes(cleaned),
);
if (includes.length === 1) return { camera: includes[0] };
if (includes.length > 1) return { error: 'Ambiguous camera name', matches: includes };
return { error: 'Camera not found', matches: [] };
function resolveReplaySources(query) {
const cleaned = normalizeReplayQuery(query);
if (!cleaned || cleaned === 'all' || cleaned === '*') {
return { sources: getDefaultDiscordSources() };
}
const tokens = cleaned.split(',').map((token) => token.trim()).filter(Boolean);
const all = getReplaySources();
const matches = [];
tokens.forEach((token) => {
const [prefix, rest] = token.includes(':') ? token.split(':', 2) : [null, token];
const candidates = all.filter((entry) => {
const matchId = String(entry.id).toLowerCase() === rest;
const matchLabel = String(entry.label || '').toLowerCase() === rest;
if (!matchId && !matchLabel) return false;
if (!prefix) return true;
return entry.type === prefix;
});
if (candidates.length === 1) {
matches.push({ type: candidates[0].type, id: candidates[0].id, label: candidates[0].label });
}
});
const sources = validateSources(matches);
if (!sources.length) {
return { error: 'No matching sources found', matches: [] };
}
return { sources };
}
function buildReplayCaption(requester, camera) {
function buildReplayCaption(requester, sources = []) {
const requesterLabel = requester || 'unknown';
const cameraLabel = camera ? `Camera: ${camera.name || camera.id}.` : null;
const sourceLabel = sources.length
? `Sources: ${sources.map((source) => source.label || `${source.type}:${source.id}`).join(', ')}.`
: 'No sources.';
return [
`Replay requested by ${requesterLabel}.`,
cameraLabel,
sourceLabel,
buildDriverCaption(),
]
.filter(Boolean)
.join(' ');
}
async function sendReplayToChannel(channelId, requester, cameraId = null) {
async function sendReplayToChannel(channelId, requester, sources = []) {
if (!channelId) {
throw new Error('Replay channel not configured');
}
const buffer = await buildRoomCameraReplayVideo({ cameraId });
const buffer = await buildReplayVideo({ sources });
const attachment = new AttachmentBuilder(buffer, { name: 'replay.mp4' });
const camera = cameraId ? getRoomCamera(cameraId) : null;
const caption = buildReplayCaption(requester, camera);
const caption = buildReplayCaption(requester, sources);
await sendToChannel(channelId, caption, { files: [attachment] }, { parse: [] });
}
@@ -337,25 +339,21 @@ async function handleReplayCommand(message, query) {
});
return;
}
const resolved = resolveReplayCamera(query);
const resolved = resolveReplaySources(query);
if (resolved?.error) {
const matches = resolved.matches || [];
const list = matches.length
? `Matches: ${matches.map((cam) => cam.name || cam.id).join(', ')}`
: 'No matching cameras found.';
await message.reply({
content: sanitizeMentions(`${resolved.error}. ${list}`),
content: sanitizeMentions(resolved.error),
allowedMentions: { parse: [], repliedUser: false },
});
return;
}
const cameraId = resolved.camera?.id || null;
const sources = resolved.sources || [];
const requester =
message.member?.nickname || message.author?.globalName || message.author?.username || 'Discord';
try {
const buffer = await buildRoomCameraReplayVideo({ cameraId });
const buffer = await buildReplayVideo({ sources });
const attachment = new AttachmentBuilder(buffer, { name: 'replay.mp4' });
const caption = buildReplayCaption(requester, resolved.camera || null);
const caption = buildReplayCaption(requester, sources);
await message.reply({
content: sanitizeMentions(caption),
files: [attachment],
@@ -1009,7 +1007,7 @@ function handleBusEvent(event) {
updatePresence();
break;
case 'replay.requested':
sendReplayToChannel(payload?.channelId, payload?.requester, payload?.cameraId || null).catch((err) => {
sendReplayToChannel(payload?.channelId, payload?.requester, payload?.sources || []).catch((err) => {
logger.warn('Replay send failed', err.message);
});
break;
+196
View File
@@ -0,0 +1,196 @@
const { execFile } = require('child_process');
const fsp = require('fs/promises');
const os = require('os');
const path = require('path');
const { promisify } = require('util');
const logger = require('../globals/logger').child('replayBuild');
const { replaySegmentsDir, segmentSeconds } = require('./replaySegmentManager');
const execFileAsync = promisify(execFile);
const REPLAY_DURATION_MS = 20000;
const REPLAY_FPS = 15;
const MAX_REPLAY_WIDTH = 1280;
const MAX_REPLAY_HEIGHT = 720;
const REPLAY_MAX_BYTES = Math.floor(9.5 * 1024 * 1024);
function buildGridLayout(count) {
const cols = Math.ceil(Math.sqrt(count));
const rows = Math.ceil(count / cols);
return { cols, rows };
}
function clampEven(value) {
return Math.max(2, Math.floor(value / 2) * 2);
}
function buildScalePadFilter(tileWidth, tileHeight) {
return `scale=${tileWidth}:${tileHeight}:force_original_aspect_ratio=decrease:flags=lanczos,pad=${tileWidth}:${tileHeight}:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1`;
}
async function probeMaxFrameSize(paths) {
let maxWidth = 0;
let maxHeight = 0;
for (const filePath of paths) {
try {
const { stdout } = await execFileAsync('ffprobe', [
'-v',
'error',
'-select_streams',
'v:0',
'-show_entries',
'stream=width,height',
'-of',
'csv=p=0',
filePath,
]);
const [widthRaw, heightRaw] = stdout.trim().split(',');
const width = Number(widthRaw);
const height = Number(heightRaw);
if (Number.isFinite(width) && Number.isFinite(height)) {
maxWidth = Math.max(maxWidth, width);
maxHeight = Math.max(maxHeight, height);
}
} catch (err) {
logger.warn('Failed to probe replay clip size', err.message);
}
}
return { maxWidth, maxHeight };
}
async function listLatestSegments(sourceKey, neededCount) {
const dir = path.join(replaySegmentsDir, sourceKey);
const entries = await fsp.readdir(dir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.mp4')) continue;
const filePath = path.join(dir, entry.name);
const stat = await fsp.stat(filePath);
files.push({ filePath, mtimeMs: stat.mtimeMs });
}
files.sort((a, b) => a.mtimeMs - b.mtimeMs);
return files.slice(-neededCount).map((file) => file.filePath);
}
async function buildReplayVideo({ sources = [] } = {}) {
if (!sources.length) {
throw new Error('No replay sources selected');
}
const segmentCount = Math.max(1, Math.ceil(REPLAY_DURATION_MS / (segmentSeconds * 1000)));
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'rover-replay-'));
try {
const clipPaths = [];
for (let i = 0; i < sources.length; i += 1) {
const source = sources[i];
const key = `${source.type}__${source.id}`;
let segmentPaths;
try {
segmentPaths = await listLatestSegments(key, segmentCount);
} catch {
throw new Error(`Missing replay segments for ${source.type}:${source.id}`);
}
if (segmentPaths.length < segmentCount) {
throw new Error(`Not enough replay segments for ${source.type}:${source.id}`);
}
const concatPath = path.join(tmpDir, `concat-${i}.txt`);
const concatBody = segmentPaths.map((file) => `file '${file}'`).join('\n');
await fsp.writeFile(concatPath, concatBody);
const clipPath = path.join(tmpDir, `clip-${i}.mp4`);
await execFileAsync('ffmpeg', [
'-hide_banner',
'-loglevel',
'error',
'-f',
'concat',
'-safe',
'0',
'-i',
concatPath,
'-c',
'copy',
clipPath,
]);
clipPaths.push(clipPath);
}
const { maxWidth, maxHeight } = await probeMaxFrameSize(clipPaths);
const layout = buildGridLayout(clipPaths.length);
let tileWidth = maxWidth || 640;
let tileHeight = maxHeight || 360;
let outputWidth = tileWidth * layout.cols;
let outputHeight = tileHeight * layout.rows;
if (outputWidth > MAX_REPLAY_WIDTH || outputHeight > MAX_REPLAY_HEIGHT) {
const scale = Math.min(MAX_REPLAY_WIDTH / outputWidth, MAX_REPLAY_HEIGHT / outputHeight);
tileWidth *= scale;
tileHeight *= scale;
outputWidth = tileWidth * layout.cols;
outputHeight = tileHeight * layout.rows;
}
tileWidth = clampEven(tileWidth);
tileHeight = clampEven(tileHeight);
outputWidth = clampEven(tileWidth * layout.cols);
outputHeight = clampEven(tileHeight * layout.rows);
const inputArgs = [];
const filterParts = [];
const layoutParts = [];
for (let i = 0; i < clipPaths.length; i += 1) {
inputArgs.push('-i', clipPaths[i]);
filterParts.push(`[${i}:v]${buildScalePadFilter(tileWidth, tileHeight)}[v${i}]`);
const x = (i % layout.cols) * tileWidth;
const y = Math.floor(i / layout.cols) * tileHeight;
layoutParts.push(`${x}_${y}`);
}
if (clipPaths.length === 1) {
filterParts.push('[v0]null[v]');
} else {
filterParts.push(
`${clipPaths.map((_, i) => `[v${i}]`).join('')}` +
`xstack=inputs=${clipPaths.length}:layout=${layoutParts.join('|')}:fill=black[v]`,
);
}
const durationSec = Math.max(1, REPLAY_DURATION_MS / 1000);
const targetBitrateKbps = Math.max(300, Math.floor((REPLAY_MAX_BYTES * 8) / durationSec / 1000));
const maxrateKbps = Math.floor(targetBitrateKbps * 1.1);
const bufsizeKbps = Math.floor(targetBitrateKbps * 2);
const outPath = path.join(tmpDir, 'replay.mp4');
await execFileAsync('ffmpeg', [
'-y',
'-hide_banner',
'-loglevel',
'error',
...inputArgs,
'-filter_complex',
filterParts.join(';'),
'-map',
'[v]',
'-r',
String(REPLAY_FPS),
'-c:v',
'libx264',
'-b:v',
`${targetBitrateKbps}k`,
'-maxrate',
`${maxrateKbps}k`,
'-bufsize',
`${bufsizeKbps}k`,
'-pix_fmt',
'yuv420p',
outPath,
]);
const buffer = await fsp.readFile(outPath);
return buffer;
} finally {
try {
await fsp.rm(tmpDir, { recursive: true, force: true });
} catch (err) {
logger.warn('Failed to cleanup replay temp dir', err.message);
}
}
}
module.exports = {
buildReplayVideo,
};
+231
View File
@@ -0,0 +1,231 @@
const { spawn } = require('child_process');
const fsp = require('fs/promises');
const path = require('path');
const logger = require('../globals/logger').child('replaySegments');
const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
const roverManager = require('./roverManager');
const SEGMENT_DIR = process.env.REPLAY_SEGMENT_DIR || '/var/lib/replay-segments';
const SEGMENT_SECONDS = 2;
const BUFFER_SECONDS = 40;
const CLEANUP_INTERVAL_MS = 20000;
const FPS = 15;
const SCALE_WIDTH = 640;
const MAX_BYTES = Number.parseInt(process.env.REPLAY_SEGMENT_MAX_BYTES || '0', 10);
const FFMPEG_BIN = process.env.FFMPEG_BIN || 'ffmpeg';
const recorders = new Map(); // key -> { proc, source }
let cleanupTimer = null;
function sourceKey(source) {
return `${source.type}__${source.id}`;
}
function getRoomCameraStream(camera) {
if (camera.streamUrl) return camera.streamUrl;
const url = String(camera.url || '');
if (url.includes('.mjpg') || url.includes('mjpeg') || url.includes('stream')) {
return url;
}
return null;
}
function listSources() {
const rooms = getRoomCameras().map((camera) => ({
type: 'room',
id: String(camera.id),
label: camera.name || camera.id,
streamUrl: getRoomCameraStream(camera),
}));
const rovers = roverManager.getRoster().map((rover) => ({
type: 'rover',
id: String(rover.id),
label: rover.name || rover.id,
}));
return [...rooms, ...rovers];
}
function buildInputUrl(source) {
if (source.type === 'room') {
return source.streamUrl || null;
}
return `srt://127.0.0.1:9000?streamid=read:${encodeURIComponent(source.id)}`;
}
async function ensureDir(dir) {
await fsp.mkdir(dir, { recursive: true });
}
function buildOutputPattern(key) {
return path.join(SEGMENT_DIR, key, '%Y%m%d-%H%M%S.mp4');
}
function spawnRecorder(source) {
const key = sourceKey(source);
const inputUrl = buildInputUrl(source);
if (!inputUrl) {
logger.warn('Replay recorder missing input URL', { key, source });
return;
}
const outPattern = buildOutputPattern(key);
ensureDir(path.dirname(outPattern))
.then(() => {
const args = [
'-hide_banner',
'-loglevel',
'warning',
'-fflags',
'nobuffer',
'-flags',
'low_delay',
'-i',
inputUrl,
'-r',
String(FPS),
'-vf',
`fps=${FPS},scale=${SCALE_WIDTH}:-1`,
'-an',
'-c:v',
'libx264',
'-preset',
'veryfast',
'-tune',
'zerolatency',
'-g',
String(FPS),
'-keyint_min',
String(FPS),
'-sc_threshold',
'0',
'-f',
'segment',
'-segment_time',
String(SEGMENT_SECONDS),
'-segment_format',
'mp4',
'-reset_timestamps',
'1',
'-strftime',
'1',
outPattern,
];
const proc = spawn(FFMPEG_BIN, args, { stdio: 'ignore' });
recorders.set(key, { proc, source });
proc.on('exit', (code, signal) => {
recorders.delete(key);
if (!shouldRecord(source)) {
return;
}
const delay = 2000;
logger.warn('Replay recorder exited; restarting', { key, code, signal });
setTimeout(() => {
if (!recorders.has(key) && shouldRecord(source)) {
spawnRecorder(source);
}
}, delay);
});
})
.catch((err) => {
logger.warn('Replay recorder setup failed', { key, err: err.message });
});
}
function stopRecorder(key) {
const entry = recorders.get(key);
if (!entry) return;
entry.proc.kill('SIGTERM');
recorders.delete(key);
}
function shouldRecord(source) {
if (source.type === 'room') {
return Boolean(source.streamUrl);
}
return true;
}
function syncRecorders() {
const sources = listSources();
const desiredKeys = new Set();
sources.forEach((source) => {
if (!shouldRecord(source)) return;
const key = sourceKey(source);
desiredKeys.add(key);
if (!recorders.has(key)) {
spawnRecorder(source);
}
});
Array.from(recorders.keys()).forEach((key) => {
if (!desiredKeys.has(key)) {
stopRecorder(key);
}
});
}
async function cleanupSegments() {
try {
await ensureDir(SEGMENT_DIR);
const cutoff = Date.now() - BUFFER_SECONDS * 1000;
const entries = await fsp.readdir(SEGMENT_DIR, { withFileTypes: true });
let totalBytes = 0;
const files = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const dirPath = path.join(SEGMENT_DIR, entry.name);
const inner = await fsp.readdir(dirPath, { withFileTypes: true });
for (const file of inner) {
if (!file.isFile() || !file.name.endsWith('.mp4')) continue;
const filePath = path.join(dirPath, file.name);
const stats = await fsp.stat(filePath);
totalBytes += stats.size;
files.push({ filePath, mtimeMs: stats.mtimeMs, size: stats.size });
if (stats.mtimeMs < cutoff) {
await fsp.unlink(filePath);
}
}
const remaining = await fsp.readdir(dirPath);
if (!remaining.length) {
await fsp.rmdir(dirPath);
}
}
if (MAX_BYTES > 0 && totalBytes > MAX_BYTES) {
const overBy = totalBytes - MAX_BYTES;
let freed = 0;
files.sort((a, b) => a.mtimeMs - b.mtimeMs);
for (const file of files) {
if (freed >= overBy) break;
try {
await fsp.unlink(file.filePath);
freed += file.size;
} catch {
// ignore
}
}
}
} catch (err) {
logger.warn('Replay cleanup failed', err.message);
}
}
function start() {
syncRecorders();
cleanupSegments();
if (cleanupTimer) clearInterval(cleanupTimer);
cleanupTimer = setInterval(cleanupSegments, CLEANUP_INTERVAL_MS);
}
roomCameraEvents.on('update', () => {
syncRecorders();
});
roverManager.managerEvents.on('rover', () => {
syncRecorders();
});
start();
module.exports = {
replaySegmentsDir: SEGMENT_DIR,
segmentSeconds: SEGMENT_SECONDS,
bufferSeconds: BUFFER_SECONDS,
};
+11 -5
View File
@@ -3,9 +3,10 @@ const logger = require('../globals/logger').child('replaySocket');
const { getMode, MODES } = require('./modeManager');
const { publishEvent } = require('./eventBus');
const { tryTriggerReplay } = require('./replayService');
const { validateSources, getDefaultWebSources } = require('./replaySourceService');
const assignmentService = require('./assignmentService');
const { getNickname } = require('./nicknameService');
const { loadConfig } = require('../helpers/configLoader');
const { getRoomCamera } = require('./roomCameraService');
const config = loadConfig();
const discordConfig = config.discord || {};
@@ -25,9 +26,14 @@ io.on('connection', (socket) => {
cb({ error: 'Replay channel not configured', state: null });
return;
}
const requestedCameraId = payload?.cameraId ? String(payload.cameraId) : null;
if (requestedCameraId && !getRoomCamera(requestedCameraId)) {
cb({ error: 'Unknown camera', state: null });
const requestedSources = Array.isArray(payload?.sources) ? payload.sources : null;
let sources = requestedSources ? validateSources(requestedSources) : [];
if (!sources.length) {
const assignment = assignmentService.describeAssignment(socket.id);
sources = getDefaultWebSources(assignment);
}
if (!sources.length) {
cb({ error: 'No replay sources selected', state: null });
return;
}
const requester = buildRequesterLabel(socket);
@@ -42,7 +48,7 @@ io.on('connection', (socket) => {
payload: {
channelId,
requester,
cameraId: requestedCameraId,
sources,
requestedBy: { socketId: socket.id },
},
});
@@ -0,0 +1,72 @@
const roverManager = require('./roverManager');
const { getRoomCameras } = require('./roomCameraService');
function getReplaySources() {
const roverSources = roverManager.getRoster().map((rover) => ({
type: 'rover',
id: String(rover.id),
label: rover.name || rover.id,
}));
const roomSources = getRoomCameras().map((camera) => ({
type: 'room',
id: String(camera.id),
label: camera.name || camera.id,
}));
return [...roverSources, ...roomSources];
}
function normalizeSource(entry) {
if (!entry) return null;
if (typeof entry === 'string') {
const [type, id] = entry.split(':');
if (!type || !id) return null;
return { type, id: String(id) };
}
if (typeof entry === 'object') {
if (entry.type && entry.id) {
return { type: entry.type, id: String(entry.id) };
}
}
return null;
}
function validateSources(list = []) {
const allowed = new Map();
getReplaySources().forEach((source) => {
allowed.set(`${source.type}:${source.id}`, source);
});
const unique = new Map();
(Array.isArray(list) ? list : []).forEach((entry) => {
const normalized = normalizeSource(entry);
if (!normalized) return;
const key = `${normalized.type}:${normalized.id}`;
const source = allowed.get(key);
if (!source) return;
unique.set(key, { type: source.type, id: source.id, label: source.label });
});
return Array.from(unique.values());
}
function getDefaultWebSources(assignment = {}) {
if (assignment?.roverId) {
const id = String(assignment.roverId);
const match = getReplaySources().find((entry) => entry.type === 'rover' && entry.id === id);
return [{ type: 'rover', id, label: match?.label || id }];
}
return [];
}
function getDefaultDiscordSources() {
return getRoomCameras().map((camera) => ({
type: 'room',
id: String(camera.id),
label: camera.name || camera.id,
}));
}
module.exports = {
getReplaySources,
validateSources,
getDefaultWebSources,
getDefaultDiscordSources,
};
+1
View File
@@ -23,6 +23,7 @@ function normalizeCamera(camera) {
name: camera.name || camera.id || String(id),
description: camera.description || null,
url: camera.url,
streamUrl: camera.streamUrl || camera.mjpegUrl || null,
};
}
@@ -1,10 +1,6 @@
const EventEmitter = require('events');
const logger = require('../globals/logger').child('roomCameraSnapshot');
const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
const {
recordRoomCameraFrame,
clearRoomCameraReplayFrames,
} = require('./roomCameraReplayService');
const POLL_INTERVAL_MS = 67;
const FETCH_TIMEOUT_MS = 2000;
@@ -36,7 +32,6 @@ async function fetchSnapshot(camera) {
const buffer = Buffer.from(arrayBuffer);
const ts = Date.now();
markState(id, { frame: buffer, ts, error: null, failures: 0 });
recordRoomCameraFrame(id, buffer, ts);
events.emit('frame', { id, buffer, ts });
} catch (err) {
const failures = (state?.failures || 0) + 1;
@@ -55,7 +50,6 @@ function stopAll() {
pollTimer = null;
}
cameraState.clear();
clearRoomCameraReplayFrames();
}
function startAll() {
+1 -1
View File
@@ -5,7 +5,7 @@ const logger = require('../globals/logger').child('roverSnapshot');
const roverManager = require('./roverManager');
const SNAPSHOT_DIR = process.env.ROVER_SNAPSHOT_DIR || '/var/lib/rover-snapshots';
const POLL_INTERVAL_MS = 500;
const POLL_INTERVAL_MS = 300;
const roverState = new Map(); // id -> { frame, ts, error, failures, fetching, mtimeMs }
const events = new EventEmitter(); // frame, status
@@ -7,7 +7,7 @@ const { roverSnapshotEvents, getRoverSnapshotState } = require('./roverSnapshotS
const SUBSCRIBE_LIMIT = 50;
const SUBSCRIBE_WINDOW_MS = 10000;
const STREAM_INTERVAL_MS = 800;
const STREAM_INTERVAL_MS = 333;
function passesMode(socket) {
const mode = getMode();
+2
View File
@@ -10,6 +10,7 @@ const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
const { getState: getHomeAssistantState, homeAssistantEvents } = require('./homeAssistantService');
const { getNickname, nicknameEvents } = require('./nicknameService');
const { getReplayState, replayEvents } = require('./replayService');
const { getReplaySources } = require('./replaySourceService');
const { loadConfig } = require('../helpers/configLoader');
const discordInvite = loadConfig().discord?.invite || null;
@@ -49,6 +50,7 @@ function buildSession(socket) {
roomCameras: getRoomCameras(),
homeAssistant: getHomeAssistantState(),
replay: getReplayState(),
replaySources: getReplaySources(),
users,
discord: {
invite: discordInvite,
+9 -1
View File
@@ -5,6 +5,7 @@ import { useControlSystem } from '../controls/index.js';
import { formatKeyLabel } from '../controls/keymapUtils.js';
import TopDownMap from './TopDownMap.jsx';
import RoverRoster from './RoverRoster.jsx';
import ReplaySourcesPanel from './ReplaySourcesPanel.jsx';
import DriveDockAction, { useDriveDockState } from './DriveDockAction.jsx';
export function RoverRosterPanel({ title = 'Rovers' }) {
@@ -69,7 +70,14 @@ export default function ControlSummary({ showRoster = true }) {
{!hideInlineControls ? <InlineCameraTilt keymap={keymap} /> : null}
</div>
</div>
{showRoster ? <RoverRosterPanel /> : null}
{showRoster ? (
<div className="grid gap-0.5 md:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
<div className="aspect-square">
<ReplaySourcesPanel />
</div>
<RoverRosterPanel />
</div>
) : null}
</section>
);
}
+150
View File
@@ -0,0 +1,150 @@
import { useEffect, useMemo, useState } from 'react';
import { useSession } from '../context/SessionContext.jsx';
import { useSettingsNamespace } from '../settings/index.js';
function normalizeSources(list = []) {
return list
.map((entry) => {
if (!entry?.type || !entry?.id) return null;
return {
type: entry.type,
id: String(entry.id),
label: entry.label || String(entry.id),
key: `${entry.type}:${entry.id}`,
};
})
.filter(Boolean);
}
export default function ReplaySourcesPanel({ panelId = 'replay-sources' }) {
const { session, triggerReplay } = useSession();
const sources = normalizeSources(session?.replaySources || []);
const { value: settings, save: saveSettings } = useSettingsNamespace('replaySources', {});
const [selected, setSelected] = useState([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
const replayState = session?.replay || null;
const [remainingMs, setRemainingMs] = useState(0);
const defaults = useMemo(() => {
const roverId = session?.assignment?.roverId;
if (roverId) {
return [`rover:${roverId}`];
}
return [];
}, [session?.assignment?.roverId]);
useEffect(() => {
const saved = settings?.[panelId];
if (Array.isArray(saved) && saved.length) {
setSelected(saved);
return;
}
setSelected(defaults);
}, [settings?.[panelId], defaults, panelId]);
useEffect(() => {
const allowed = new Set(sources.map((source) => source.key));
setSelected((prev) => prev.filter((key) => allowed.has(key)));
}, [sources]);
const grouped = useMemo(() => {
return {
rovers: sources.filter((source) => source.type === 'rover'),
rooms: sources.filter((source) => source.type === 'room'),
};
}, [sources]);
useEffect(() => {
if (!replayState?.lastTriggeredAt || !replayState?.cooldownMs) {
setRemainingMs(0);
return undefined;
}
const update = () => {
const next = replayState.lastTriggeredAt + replayState.cooldownMs - Date.now();
setRemainingMs(Math.max(0, next));
};
update();
const interval = setInterval(update, 250);
return () => clearInterval(interval);
}, [replayState?.lastTriggeredAt, replayState?.cooldownMs, replayState?.remainingMs]);
const replayDisabled = busy || session?.mode === 'lockdown' || remainingMs > 0 || !selected.length;
const toggleKey = (key) => {
setSelected((prev) => {
const next = prev.includes(key) ? prev.filter((value) => value !== key) : [...prev, key];
saveSettings((current) => ({ ...(current || {}), [panelId]: next }));
return next;
});
};
const handleReplay = async () => {
if (replayDisabled) return;
setBusy(true);
setError(null);
try {
const payload = selected.map((key) => {
const [type, id] = key.split(':');
return { type, id };
});
await triggerReplay(payload);
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
return (
<section className="panel h-full w-full">
<div className="flex h-full flex-col gap-0.5 p-0.75 text-sm">
<header className="flex items-center justify-between text-slate-300">
<span className="text-xs uppercase tracking-wide text-slate-400">Replay Sources</span>
<span className="text-xs text-slate-500">{sources.length}</span>
</header>
<div className="flex-1 overflow-auto pr-0.5">
<GroupList title="Rovers" items={grouped.rovers} selected={selected} onToggle={toggleKey} />
<GroupList title="Room Cams" items={grouped.rooms} selected={selected} onToggle={toggleKey} />
</div>
<div className="space-y-0.25">
<button
type="button"
className={`w-full rounded border px-1 py-0.5 text-xs ${
replayDisabled
? 'border-slate-700 text-slate-500'
: 'border-slate-500 text-slate-200 hover:border-slate-300 hover:text-white'
}`}
onClick={handleReplay}
disabled={replayDisabled}
>
{remainingMs > 0 ? `Replay (${Math.ceil(remainingMs / 1000)}s)` : busy ? 'Replay…' : 'Replay'}
</button>
{error ? <div className="text-xs text-amber-400">{error}</div> : null}
</div>
</div>
</section>
);
}
function GroupList({ title, items, selected, onToggle }) {
if (!items.length) return null;
return (
<div className="mb-0.5 space-y-0.25">
<div className="text-xs uppercase text-slate-500">{title}</div>
<div className="space-y-0.25">
{items.map((item) => (
<label key={item.key} className="flex items-center gap-0.5 text-xs text-slate-200">
<input
type="checkbox"
checked={selected.includes(item.key)}
onChange={() => onToggle(item.key)}
className="accent-emerald-400"
/>
<span className="truncate">{item.label}</span>
</label>
))}
</div>
</div>
);
}
+2 -84
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import { useSession } from '../context/SessionContext.jsx';
import { useSettingsNamespace } from '../settings/index.js';
import { useRoomCameraSnapshots } from '../hooks/useRoomCameraSnapshots.js';
@@ -29,21 +29,16 @@ export default function RoomCameraPanel({
hideHeader = false,
panelId = null,
}) {
const { session, triggerReplay } = useSession();
const { session } = useSession();
const cameras = session?.roomCameras || [];
const feedMap = useRoomCameraSnapshots(cameras.map((camera) => ({ id: camera.id })));
const { value: orientationSettings, save: saveOrientationSettings } = useSettingsNamespace('roomCameraPanels', {});
const { value: replaySettings, save: saveReplaySettings } = useSettingsNamespace('roomCameraReplay', {});
const [orientation, setOrientation] = useState(() =>
normalizeOrientation(
panelId ? orientationSettings?.[panelId] : defaultOrientation,
'horizontal',
),
);
const [selectedReplayCamera, setSelectedReplayCamera] = useState(() => {
if (!panelId) return 'all';
return replaySettings?.[panelId] || 'all';
});
useEffect(() => {
if (!panelId) return;
@@ -52,62 +47,12 @@ export default function RoomCameraPanel({
setOrientation(normalizeOrientation(stored, 'horizontal'));
// only respond to changes for this panel id
}, [panelId, orientationSettings?.[panelId]]);
useEffect(() => {
if (!panelId) return;
const stored = replaySettings?.[panelId];
if (!stored) return;
setSelectedReplayCamera(stored);
}, [panelId, replaySettings?.[panelId]]);
const effectiveOrientation = forcedOrientation
? normalizeOrientation(forcedOrientation, 'horizontal')
: orientation;
const containerClass =
effectiveOrientation === 'vertical' ? 'flex flex-col gap-0.5' : 'grid gap-0.5 md:grid-cols-2';
const showLayoutToggle = !hideLayoutToggle && !forcedOrientation && cameras.length > 0;
const [replayBusy, setReplayBusy] = useState(false);
const [replayError, setReplayError] = useState(null);
const replayState = session?.replay || null;
const replayRemainingMs = useMemo(() => {
if (!replayState?.lastTriggeredAt || !replayState?.cooldownMs) return 0;
const remaining = replayState.lastTriggeredAt + replayState.cooldownMs - Date.now();
return Math.max(0, remaining);
}, [replayState?.lastTriggeredAt, replayState?.cooldownMs, replayState?.remainingMs]);
const [remainingMs, setRemainingMs] = useState(replayRemainingMs);
const replayDisabled =
replayBusy || session?.mode === 'lockdown' || !cameras.length || remainingMs > 0;
useEffect(() => {
setRemainingMs(replayRemainingMs);
if (!replayState?.lastTriggeredAt || !replayState?.cooldownMs) {
return undefined;
}
const interval = setInterval(() => {
const next = replayState.lastTriggeredAt + replayState.cooldownMs - Date.now();
setRemainingMs(Math.max(0, next));
}, 250);
return () => clearInterval(interval);
}, [replayState?.lastTriggeredAt, replayState?.cooldownMs, replayRemainingMs]);
const handleReplay = async () => {
if (replayDisabled) return;
setReplayError(null);
setReplayBusy(true);
try {
const cameraId = selectedReplayCamera === 'all' ? null : selectedReplayCamera;
await triggerReplay(cameraId);
} catch (err) {
setReplayError(err.message);
} finally {
setReplayBusy(false);
}
};
const handleReplayCameraChange = (event) => {
const value = event.target.value;
setSelectedReplayCamera(value);
if (panelId) {
saveReplaySettings((current) => ({ ...(current || {}), [panelId]: value }));
}
};
const applyOrientation = (next) => {
setOrientation(next);
if (panelId) {
@@ -128,33 +73,6 @@ export default function RoomCameraPanel({
<span className="text-xs text-slate-500">{cameras.length}</span>
</div>
<div className="flex flex-wrap items-center gap-1 text-xs">
<select
className="rounded border border-slate-700 bg-black/40 px-1 py-0.5 text-slate-200"
value={selectedReplayCamera}
onChange={handleReplayCameraChange}
aria-label="Replay camera"
>
<option value="all">All cameras</option>
{cameras.map((camera) => (
<option key={camera.id} value={camera.id}>
{camera.name || camera.id}
</option>
))}
</select>
<button
type="button"
className={`rounded border px-1.5 py-0.5 ${
replayDisabled
? 'border-slate-700 text-slate-500'
: 'border-slate-500 text-slate-200 hover:border-slate-300 hover:text-white'
}`}
onClick={handleReplay}
disabled={replayDisabled}
title={session?.mode === 'lockdown' ? 'Replay disabled in lockdown' : 'Send replay to Discord'}
>
{remainingMs > 0 ? `Replay (${Math.ceil(remainingMs / 1000)}s)` : replayBusy ? 'Replay…' : 'Replay'}
</button>
{replayError && <span className="text-amber-400">{replayError}</span>}
{showLayoutToggle && (
<div className="flex items-center gap-0.5">
<span className="text-slate-500">Layout:</span>
+1 -1
View File
@@ -97,7 +97,7 @@ export function SessionProvider({ children }) {
homeAssistantSetState: (entityId, state) =>
emitWithAck('homeAssistant:setState', { entityId, state }),
setNickname: (nickname) => emitWithAck('nickname:set', { nickname }),
triggerReplay: (cameraId = null) => emitWithAck('replay:trigger', { cameraId }),
triggerReplay: (sources = []) => emitWithAck('replay:trigger', { sources }),
pushAlert: (alert) =>
setAlerts((prev) => [
...prev.slice(-49),