mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
lets see . . .
This commit is contained in:
+7
-7
@@ -1,14 +1,11 @@
|
|||||||
## raspberry pi
|
## 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:
|
## 3d models for camera:
|
||||||
- https://www.thingiverse.com/thing:2873677
|
- https://www.thingiverse.com/thing:2873677
|
||||||
- https://www.printables.com/model/356894-raspberry-camera-module-with-automatic-ir-cut-swit
|
- https://www.printables.com/model/356894-raspberry-camera-module-with-automatic-ir-cut-swit
|
||||||
- https://www.thingiverse.com/thing:4514531
|
- https://www.thingiverse.com/thing:4514531
|
||||||
|
|
||||||
## spectator page
|
|
||||||
- completely remake it from scratch
|
|
||||||
|
|
||||||
## todo
|
## todo
|
||||||
<!-- 1. battery manager -->
|
<!-- 1. battery manager -->
|
||||||
<!-- 2. pi-side sensor throttle (1/5th) -->
|
<!-- 2. pi-side sensor throttle (1/5th) -->
|
||||||
@@ -22,6 +19,9 @@
|
|||||||
<!-- 9. online user list -->
|
<!-- 9. online user list -->
|
||||||
<!-- 10. chat -->
|
<!-- 10. chat -->
|
||||||
<!-- 9. discord invite button -->
|
<!-- 9. discord invite button -->
|
||||||
10. redo both mobile layouts
|
<!-- 10. redo both mobile layouts -->
|
||||||
11. redo spectator view (last)
|
<!-- 11. redo spectator view (last) -->
|
||||||
21. finally.. set the favicon and title
|
<!-- 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)
|
||||||
@@ -2,13 +2,15 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Configurable via environment
|
# Configurable via environment
|
||||||
DEVICE="${DEVICE:-/dev/video0}"
|
export DEVICE="${DEVICE:-/dev/video0}"
|
||||||
RESOLUTION="${RESOLUTION:-640x480}"
|
export RESOLUTION="${RESOLUTION:-640x480}"
|
||||||
QUALITY="${QUALITY:-10}" # ffmpeg MJPEG quality (lower is better)
|
export QUALITY="${QUALITY:-10}" # ffmpeg MJPEG quality (lower is better)
|
||||||
PORT="${PORT:-8088}"
|
export PORT="${PORT:-8088}"
|
||||||
WORKDIR="${WORKDIR:-/run/roomcam}"
|
export WORKDIR="${WORKDIR:-/run/roomcam}"
|
||||||
# Optional: set INPUT_FORMAT=bayer_grbg8 to transcode raw Bayer cams (e.g., OV534) to JPEG.
|
# 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}"
|
mkdir -p "${WORKDIR}"
|
||||||
SNAPSHOT_PATH="${WORKDIR}/snapshot.jpg"
|
SNAPSHOT_PATH="${WORKDIR}/snapshot.jpg"
|
||||||
@@ -36,7 +38,88 @@ fi
|
|||||||
-f image2 -update 1 "${SNAPSHOT_PATH}" &
|
-f image2 -update 1 "${SNAPSHOT_PATH}" &
|
||||||
FFMPEG_PID=$!
|
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=$!
|
HTTP_PID=$!
|
||||||
|
|
||||||
wait -n "${FFMPEG_PID}" "${HTTP_PID}"
|
wait -n "${FFMPEG_PID}" "${HTTP_PID}"
|
||||||
|
|||||||
@@ -28,10 +28,12 @@ roomCameras:
|
|||||||
name: "Lobby Camera"
|
name: "Lobby Camera"
|
||||||
description: "Wide shot of the staging area."
|
description: "Wide shot of the staging area."
|
||||||
url: "http://192.168.0.50/snapshot.jpg"
|
url: "http://192.168.0.50/snapshot.jpg"
|
||||||
|
streamUrl: "http://192.168.0.50/stream.mjpg"
|
||||||
- id: "workshop"
|
- id: "workshop"
|
||||||
name: "Workshop Bench"
|
name: "Workshop Bench"
|
||||||
description: "Shows the workbench and charging docks."
|
description: "Shows the workbench and charging docks."
|
||||||
url: "http://192.168.0.51/snapshot.jpg"
|
url: "http://192.168.0.51/snapshot.jpg"
|
||||||
|
streamUrl: "http://192.168.0.51/stream.mjpg"
|
||||||
|
|
||||||
discord:
|
discord:
|
||||||
token: "DISCORD_BOT_TOKEN"
|
token: "DISCORD_BOT_TOKEN"
|
||||||
|
|||||||
@@ -28,5 +28,6 @@ require('./src/services/homeAssistantService');
|
|||||||
require('./src/services/sessionService');
|
require('./src/services/sessionService');
|
||||||
require('./src/services/batteryManager');
|
require('./src/services/batteryManager');
|
||||||
require('./src/services/replaySocketService');
|
require('./src/services/replaySocketService');
|
||||||
|
require('./src/services/replaySegmentManager');
|
||||||
require('./src/services/discordBotService');
|
require('./src/services/discordBotService');
|
||||||
require('./src/services/httpServer');
|
require('./src/services/httpServer');
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ MEDIAMTX_SNAPSHOT_SCRIPT="$MEDIAMTX_CONF_DIR/rover-snapshot.sh"
|
|||||||
MEDIAMTX_SERVICE="/etc/systemd/system/mediamtx.service"
|
MEDIAMTX_SERVICE="/etc/systemd/system/mediamtx.service"
|
||||||
MULTIROVER_SERVICE="/etc/systemd/system/multirover.service"
|
MULTIROVER_SERVICE="/etc/systemd/system/multirover.service"
|
||||||
SNAPSHOT_DIR="/var/lib/rover-snapshots"
|
SNAPSHOT_DIR="/var/lib/rover-snapshots"
|
||||||
|
REPLAY_SEGMENT_DIR="/var/lib/replay-segments"
|
||||||
|
|
||||||
if [[ $EUID -ne 0 ]]; then
|
if [[ $EUID -ne 0 ]]; then
|
||||||
echo "This installer must be run with sudo/root." >&2
|
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..."
|
echo "[4/6] Writing systemd units..."
|
||||||
mkdir -p "$SNAPSHOT_DIR"
|
mkdir -p "$SNAPSHOT_DIR"
|
||||||
chown "$TARGET_USER":"$TARGET_USER" "$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
|
cat > "$MEDIAMTX_SERVICE" <<EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=mediaMTX WebRTC Server
|
Description=mediaMTX WebRTC Server
|
||||||
@@ -118,6 +121,7 @@ WorkingDirectory=$SERVER_DIR
|
|||||||
Environment=NODE_ENV=production
|
Environment=NODE_ENV=production
|
||||||
Environment=SERVER_CONFIG=$CONFIG_PATH
|
Environment=SERVER_CONFIG=$CONFIG_PATH
|
||||||
Environment=ROVER_SNAPSHOT_DIR=$SNAPSHOT_DIR
|
Environment=ROVER_SNAPSHOT_DIR=$SNAPSHOT_DIR
|
||||||
|
Environment=REPLAY_SEGMENT_DIR=$REPLAY_SEGMENT_DIR
|
||||||
ExecStart=$NODE_BIN $SERVER_DIR/index.js
|
ExecStart=$NODE_BIN $SERVER_DIR/index.js
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=2
|
RestartSec=2
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ if [[ "${STREAM_ID}" == *-audio ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
OUTPUT_DIR="${ROVER_SNAPSHOT_DIR:-/var/lib/rover-snapshots}"
|
OUTPUT_DIR="${ROVER_SNAPSHOT_DIR:-/var/lib/rover-snapshots}"
|
||||||
FPS="${ROVER_SNAPSHOT_FPS:-1}"
|
FPS="${ROVER_SNAPSHOT_FPS:-3}"
|
||||||
WIDTH="${ROVER_SNAPSHOT_WIDTH:-640}"
|
WIDTH="${ROVER_SNAPSHOT_WIDTH:-640}"
|
||||||
QUALITY="${ROVER_SNAPSHOT_QUALITY:-8}"
|
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
@@ -11,8 +11,8 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||||
<title>Multi Roomba Rover</title>
|
<title>Multi Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-ClyuL_YR.js"></script>
|
<script type="module" crossorigin src="/assets/index-FaKr6Sxz.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-COnIgwcF.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-DAAnNTU0.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ const { subscribe } = require('./eventBus');
|
|||||||
const { getRoster, lockRover, rovers } = require('./roverManager');
|
const { getRoster, lockRover, rovers } = require('./roverManager');
|
||||||
const { MODES, getMode, setMode } = require('./modeManager');
|
const { MODES, getMode, setMode } = require('./modeManager');
|
||||||
const { sendExternalMessage } = require('./chatService');
|
const { sendExternalMessage } = require('./chatService');
|
||||||
const { getRoomCameras, getRoomCamera } = require('./roomCameraService');
|
const { buildReplayVideo } = require('./replayBuildService');
|
||||||
const { buildRoomCameraReplayVideo } = require('./roomCameraReplayService');
|
const { getReplaySources, getDefaultDiscordSources, validateSources } = require('./replaySourceService');
|
||||||
const { getActiveDrivers } = require('./turnService');
|
const { getActiveDrivers } = require('./turnService');
|
||||||
const { getNickname } = require('./nicknameService');
|
const { getNickname } = require('./nicknameService');
|
||||||
const { tryTriggerReplay } = require('./replayService');
|
const { tryTriggerReplay } = require('./replayService');
|
||||||
@@ -207,7 +207,7 @@ function formatHelp() {
|
|||||||
'**Rover Bot Commands**',
|
'**Rover Bot Commands**',
|
||||||
'`rs help` — show this help',
|
'`rs help` — show this help',
|
||||||
'`rs status [id]` — show rover status (all or one)',
|
'`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` — show chat bridge status for this server',
|
||||||
'`rs bridge here <global|private>` — set chat bridge to this channel',
|
'`rs bridge here <global|private>` — set chat bridge to this channel',
|
||||||
'`rs bridge mode <global|private>` — change chat bridge mode',
|
'`rs bridge mode <global|private>` — change chat bridge mode',
|
||||||
@@ -263,57 +263,59 @@ function buildDriverCaption() {
|
|||||||
return `Drivers: ${entries.join(', ')}`;
|
return `Drivers: ${entries.join(', ')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeCameraQuery(input) {
|
function normalizeReplayQuery(input) {
|
||||||
return String(input || '').trim().toLowerCase();
|
return String(input || '').trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveReplayCamera(query) {
|
function resolveReplaySources(query) {
|
||||||
const cleaned = normalizeCameraQuery(query);
|
const cleaned = normalizeReplayQuery(query);
|
||||||
if (!cleaned || cleaned === 'all' || cleaned === '*') return { camera: null };
|
if (!cleaned || cleaned === 'all' || cleaned === '*') {
|
||||||
const cameras = getRoomCameras();
|
return { sources: getDefaultDiscordSources() };
|
||||||
const direct = cameras.find(
|
}
|
||||||
(camera) =>
|
const tokens = cleaned.split(',').map((token) => token.trim()).filter(Boolean);
|
||||||
String(camera.id).toLowerCase() === cleaned ||
|
const all = getReplaySources();
|
||||||
String(camera.name || '').toLowerCase() === cleaned,
|
const matches = [];
|
||||||
);
|
tokens.forEach((token) => {
|
||||||
if (direct) return { camera: direct };
|
const [prefix, rest] = token.includes(':') ? token.split(':', 2) : [null, token];
|
||||||
const starts = cameras.filter(
|
const candidates = all.filter((entry) => {
|
||||||
(camera) =>
|
const matchId = String(entry.id).toLowerCase() === rest;
|
||||||
String(camera.id).toLowerCase().startsWith(cleaned) ||
|
const matchLabel = String(entry.label || '').toLowerCase() === rest;
|
||||||
String(camera.name || '').toLowerCase().startsWith(cleaned),
|
if (!matchId && !matchLabel) return false;
|
||||||
);
|
if (!prefix) return true;
|
||||||
if (starts.length === 1) return { camera: starts[0] };
|
return entry.type === prefix;
|
||||||
if (starts.length > 1) return { error: 'Ambiguous camera name', matches: starts };
|
});
|
||||||
const includes = cameras.filter(
|
if (candidates.length === 1) {
|
||||||
(camera) =>
|
matches.push({ type: candidates[0].type, id: candidates[0].id, label: candidates[0].label });
|
||||||
String(camera.id).toLowerCase().includes(cleaned) ||
|
}
|
||||||
String(camera.name || '').toLowerCase().includes(cleaned),
|
});
|
||||||
);
|
const sources = validateSources(matches);
|
||||||
if (includes.length === 1) return { camera: includes[0] };
|
if (!sources.length) {
|
||||||
if (includes.length > 1) return { error: 'Ambiguous camera name', matches: includes };
|
return { error: 'No matching sources found', matches: [] };
|
||||||
return { error: 'Camera not found', matches: [] };
|
}
|
||||||
|
return { sources };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildReplayCaption(requester, camera) {
|
function buildReplayCaption(requester, sources = []) {
|
||||||
const requesterLabel = requester || 'unknown';
|
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 [
|
return [
|
||||||
`Replay requested by ${requesterLabel}.`,
|
`Replay requested by ${requesterLabel}.`,
|
||||||
cameraLabel,
|
sourceLabel,
|
||||||
buildDriverCaption(),
|
buildDriverCaption(),
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ');
|
.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendReplayToChannel(channelId, requester, cameraId = null) {
|
async function sendReplayToChannel(channelId, requester, sources = []) {
|
||||||
if (!channelId) {
|
if (!channelId) {
|
||||||
throw new Error('Replay channel not configured');
|
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 attachment = new AttachmentBuilder(buffer, { name: 'replay.mp4' });
|
||||||
const camera = cameraId ? getRoomCamera(cameraId) : null;
|
const caption = buildReplayCaption(requester, sources);
|
||||||
const caption = buildReplayCaption(requester, camera);
|
|
||||||
await sendToChannel(channelId, caption, { files: [attachment] }, { parse: [] });
|
await sendToChannel(channelId, caption, { files: [attachment] }, { parse: [] });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,25 +339,21 @@ async function handleReplayCommand(message, query) {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const resolved = resolveReplayCamera(query);
|
const resolved = resolveReplaySources(query);
|
||||||
if (resolved?.error) {
|
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({
|
await message.reply({
|
||||||
content: sanitizeMentions(`${resolved.error}. ${list}`),
|
content: sanitizeMentions(resolved.error),
|
||||||
allowedMentions: { parse: [], repliedUser: false },
|
allowedMentions: { parse: [], repliedUser: false },
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const cameraId = resolved.camera?.id || null;
|
const sources = resolved.sources || [];
|
||||||
const requester =
|
const requester =
|
||||||
message.member?.nickname || message.author?.globalName || message.author?.username || 'Discord';
|
message.member?.nickname || message.author?.globalName || message.author?.username || 'Discord';
|
||||||
try {
|
try {
|
||||||
const buffer = await buildRoomCameraReplayVideo({ cameraId });
|
const buffer = await buildReplayVideo({ sources });
|
||||||
const attachment = new AttachmentBuilder(buffer, { name: 'replay.mp4' });
|
const attachment = new AttachmentBuilder(buffer, { name: 'replay.mp4' });
|
||||||
const caption = buildReplayCaption(requester, resolved.camera || null);
|
const caption = buildReplayCaption(requester, sources);
|
||||||
await message.reply({
|
await message.reply({
|
||||||
content: sanitizeMentions(caption),
|
content: sanitizeMentions(caption),
|
||||||
files: [attachment],
|
files: [attachment],
|
||||||
@@ -1009,7 +1007,7 @@ function handleBusEvent(event) {
|
|||||||
updatePresence();
|
updatePresence();
|
||||||
break;
|
break;
|
||||||
case 'replay.requested':
|
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);
|
logger.warn('Replay send failed', err.message);
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -3,9 +3,10 @@ const logger = require('../globals/logger').child('replaySocket');
|
|||||||
const { getMode, MODES } = require('./modeManager');
|
const { getMode, MODES } = require('./modeManager');
|
||||||
const { publishEvent } = require('./eventBus');
|
const { publishEvent } = require('./eventBus');
|
||||||
const { tryTriggerReplay } = require('./replayService');
|
const { tryTriggerReplay } = require('./replayService');
|
||||||
|
const { validateSources, getDefaultWebSources } = require('./replaySourceService');
|
||||||
|
const assignmentService = require('./assignmentService');
|
||||||
const { getNickname } = require('./nicknameService');
|
const { getNickname } = require('./nicknameService');
|
||||||
const { loadConfig } = require('../helpers/configLoader');
|
const { loadConfig } = require('../helpers/configLoader');
|
||||||
const { getRoomCamera } = require('./roomCameraService');
|
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const discordConfig = config.discord || {};
|
const discordConfig = config.discord || {};
|
||||||
@@ -25,9 +26,14 @@ io.on('connection', (socket) => {
|
|||||||
cb({ error: 'Replay channel not configured', state: null });
|
cb({ error: 'Replay channel not configured', state: null });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const requestedCameraId = payload?.cameraId ? String(payload.cameraId) : null;
|
const requestedSources = Array.isArray(payload?.sources) ? payload.sources : null;
|
||||||
if (requestedCameraId && !getRoomCamera(requestedCameraId)) {
|
let sources = requestedSources ? validateSources(requestedSources) : [];
|
||||||
cb({ error: 'Unknown camera', state: null });
|
if (!sources.length) {
|
||||||
|
const assignment = assignmentService.describeAssignment(socket.id);
|
||||||
|
sources = getDefaultWebSources(assignment);
|
||||||
|
}
|
||||||
|
if (!sources.length) {
|
||||||
|
cb({ error: 'No replay sources selected', state: null });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const requester = buildRequesterLabel(socket);
|
const requester = buildRequesterLabel(socket);
|
||||||
@@ -42,7 +48,7 @@ io.on('connection', (socket) => {
|
|||||||
payload: {
|
payload: {
|
||||||
channelId,
|
channelId,
|
||||||
requester,
|
requester,
|
||||||
cameraId: requestedCameraId,
|
sources,
|
||||||
requestedBy: { socketId: socket.id },
|
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,
|
||||||
|
};
|
||||||
@@ -23,6 +23,7 @@ function normalizeCamera(camera) {
|
|||||||
name: camera.name || camera.id || String(id),
|
name: camera.name || camera.id || String(id),
|
||||||
description: camera.description || null,
|
description: camera.description || null,
|
||||||
url: camera.url,
|
url: camera.url,
|
||||||
|
streamUrl: camera.streamUrl || camera.mjpegUrl || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
const EventEmitter = require('events');
|
const EventEmitter = require('events');
|
||||||
const logger = require('../globals/logger').child('roomCameraSnapshot');
|
const logger = require('../globals/logger').child('roomCameraSnapshot');
|
||||||
const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
|
const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
|
||||||
const {
|
|
||||||
recordRoomCameraFrame,
|
|
||||||
clearRoomCameraReplayFrames,
|
|
||||||
} = require('./roomCameraReplayService');
|
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 67;
|
const POLL_INTERVAL_MS = 67;
|
||||||
const FETCH_TIMEOUT_MS = 2000;
|
const FETCH_TIMEOUT_MS = 2000;
|
||||||
@@ -36,7 +32,6 @@ async function fetchSnapshot(camera) {
|
|||||||
const buffer = Buffer.from(arrayBuffer);
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
const ts = Date.now();
|
const ts = Date.now();
|
||||||
markState(id, { frame: buffer, ts, error: null, failures: 0 });
|
markState(id, { frame: buffer, ts, error: null, failures: 0 });
|
||||||
recordRoomCameraFrame(id, buffer, ts);
|
|
||||||
events.emit('frame', { id, buffer, ts });
|
events.emit('frame', { id, buffer, ts });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const failures = (state?.failures || 0) + 1;
|
const failures = (state?.failures || 0) + 1;
|
||||||
@@ -55,7 +50,6 @@ function stopAll() {
|
|||||||
pollTimer = null;
|
pollTimer = null;
|
||||||
}
|
}
|
||||||
cameraState.clear();
|
cameraState.clear();
|
||||||
clearRoomCameraReplayFrames();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function startAll() {
|
function startAll() {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const logger = require('../globals/logger').child('roverSnapshot');
|
|||||||
const roverManager = require('./roverManager');
|
const roverManager = require('./roverManager');
|
||||||
|
|
||||||
const SNAPSHOT_DIR = process.env.ROVER_SNAPSHOT_DIR || '/var/lib/rover-snapshots';
|
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 roverState = new Map(); // id -> { frame, ts, error, failures, fetching, mtimeMs }
|
||||||
const events = new EventEmitter(); // frame, status
|
const events = new EventEmitter(); // frame, status
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const { roverSnapshotEvents, getRoverSnapshotState } = require('./roverSnapshotS
|
|||||||
|
|
||||||
const SUBSCRIBE_LIMIT = 50;
|
const SUBSCRIBE_LIMIT = 50;
|
||||||
const SUBSCRIBE_WINDOW_MS = 10000;
|
const SUBSCRIBE_WINDOW_MS = 10000;
|
||||||
const STREAM_INTERVAL_MS = 800;
|
const STREAM_INTERVAL_MS = 333;
|
||||||
|
|
||||||
function passesMode(socket) {
|
function passesMode(socket) {
|
||||||
const mode = getMode();
|
const mode = getMode();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
|
|||||||
const { getState: getHomeAssistantState, homeAssistantEvents } = require('./homeAssistantService');
|
const { getState: getHomeAssistantState, homeAssistantEvents } = require('./homeAssistantService');
|
||||||
const { getNickname, nicknameEvents } = require('./nicknameService');
|
const { getNickname, nicknameEvents } = require('./nicknameService');
|
||||||
const { getReplayState, replayEvents } = require('./replayService');
|
const { getReplayState, replayEvents } = require('./replayService');
|
||||||
|
const { getReplaySources } = require('./replaySourceService');
|
||||||
const { loadConfig } = require('../helpers/configLoader');
|
const { loadConfig } = require('../helpers/configLoader');
|
||||||
|
|
||||||
const discordInvite = loadConfig().discord?.invite || null;
|
const discordInvite = loadConfig().discord?.invite || null;
|
||||||
@@ -49,6 +50,7 @@ function buildSession(socket) {
|
|||||||
roomCameras: getRoomCameras(),
|
roomCameras: getRoomCameras(),
|
||||||
homeAssistant: getHomeAssistantState(),
|
homeAssistant: getHomeAssistantState(),
|
||||||
replay: getReplayState(),
|
replay: getReplayState(),
|
||||||
|
replaySources: getReplaySources(),
|
||||||
users,
|
users,
|
||||||
discord: {
|
discord: {
|
||||||
invite: discordInvite,
|
invite: discordInvite,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useControlSystem } from '../controls/index.js';
|
|||||||
import { formatKeyLabel } from '../controls/keymapUtils.js';
|
import { formatKeyLabel } from '../controls/keymapUtils.js';
|
||||||
import TopDownMap from './TopDownMap.jsx';
|
import TopDownMap from './TopDownMap.jsx';
|
||||||
import RoverRoster from './RoverRoster.jsx';
|
import RoverRoster from './RoverRoster.jsx';
|
||||||
|
import ReplaySourcesPanel from './ReplaySourcesPanel.jsx';
|
||||||
import DriveDockAction, { useDriveDockState } from './DriveDockAction.jsx';
|
import DriveDockAction, { useDriveDockState } from './DriveDockAction.jsx';
|
||||||
|
|
||||||
export function RoverRosterPanel({ title = 'Rovers' }) {
|
export function RoverRosterPanel({ title = 'Rovers' }) {
|
||||||
@@ -69,7 +70,14 @@ export default function ControlSummary({ showRoster = true }) {
|
|||||||
{!hideInlineControls ? <InlineCameraTilt keymap={keymap} /> : null}
|
{!hideInlineControls ? <InlineCameraTilt keymap={keymap} /> : null}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useSession } from '../context/SessionContext.jsx';
|
import { useSession } from '../context/SessionContext.jsx';
|
||||||
import { useSettingsNamespace } from '../settings/index.js';
|
import { useSettingsNamespace } from '../settings/index.js';
|
||||||
import { useRoomCameraSnapshots } from '../hooks/useRoomCameraSnapshots.js';
|
import { useRoomCameraSnapshots } from '../hooks/useRoomCameraSnapshots.js';
|
||||||
@@ -29,21 +29,16 @@ export default function RoomCameraPanel({
|
|||||||
hideHeader = false,
|
hideHeader = false,
|
||||||
panelId = null,
|
panelId = null,
|
||||||
}) {
|
}) {
|
||||||
const { session, triggerReplay } = useSession();
|
const { session } = useSession();
|
||||||
const cameras = session?.roomCameras || [];
|
const cameras = session?.roomCameras || [];
|
||||||
const feedMap = useRoomCameraSnapshots(cameras.map((camera) => ({ id: camera.id })));
|
const feedMap = useRoomCameraSnapshots(cameras.map((camera) => ({ id: camera.id })));
|
||||||
const { value: orientationSettings, save: saveOrientationSettings } = useSettingsNamespace('roomCameraPanels', {});
|
const { value: orientationSettings, save: saveOrientationSettings } = useSettingsNamespace('roomCameraPanels', {});
|
||||||
const { value: replaySettings, save: saveReplaySettings } = useSettingsNamespace('roomCameraReplay', {});
|
|
||||||
const [orientation, setOrientation] = useState(() =>
|
const [orientation, setOrientation] = useState(() =>
|
||||||
normalizeOrientation(
|
normalizeOrientation(
|
||||||
panelId ? orientationSettings?.[panelId] : defaultOrientation,
|
panelId ? orientationSettings?.[panelId] : defaultOrientation,
|
||||||
'horizontal',
|
'horizontal',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const [selectedReplayCamera, setSelectedReplayCamera] = useState(() => {
|
|
||||||
if (!panelId) return 'all';
|
|
||||||
return replaySettings?.[panelId] || 'all';
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!panelId) return;
|
if (!panelId) return;
|
||||||
@@ -52,62 +47,12 @@ export default function RoomCameraPanel({
|
|||||||
setOrientation(normalizeOrientation(stored, 'horizontal'));
|
setOrientation(normalizeOrientation(stored, 'horizontal'));
|
||||||
// only respond to changes for this panel id
|
// only respond to changes for this panel id
|
||||||
}, [panelId, orientationSettings?.[panelId]]);
|
}, [panelId, orientationSettings?.[panelId]]);
|
||||||
useEffect(() => {
|
|
||||||
if (!panelId) return;
|
|
||||||
const stored = replaySettings?.[panelId];
|
|
||||||
if (!stored) return;
|
|
||||||
setSelectedReplayCamera(stored);
|
|
||||||
}, [panelId, replaySettings?.[panelId]]);
|
|
||||||
const effectiveOrientation = forcedOrientation
|
const effectiveOrientation = forcedOrientation
|
||||||
? normalizeOrientation(forcedOrientation, 'horizontal')
|
? normalizeOrientation(forcedOrientation, 'horizontal')
|
||||||
: orientation;
|
: orientation;
|
||||||
const containerClass =
|
const containerClass =
|
||||||
effectiveOrientation === 'vertical' ? 'flex flex-col gap-0.5' : 'grid gap-0.5 md:grid-cols-2';
|
effectiveOrientation === 'vertical' ? 'flex flex-col gap-0.5' : 'grid gap-0.5 md:grid-cols-2';
|
||||||
const showLayoutToggle = !hideLayoutToggle && !forcedOrientation && cameras.length > 0;
|
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) => {
|
const applyOrientation = (next) => {
|
||||||
setOrientation(next);
|
setOrientation(next);
|
||||||
if (panelId) {
|
if (panelId) {
|
||||||
@@ -128,33 +73,6 @@ export default function RoomCameraPanel({
|
|||||||
<span className="text-xs text-slate-500">{cameras.length}</span>
|
<span className="text-xs text-slate-500">{cameras.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-1 text-xs">
|
<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 && (
|
{showLayoutToggle && (
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
<span className="text-slate-500">Layout:</span>
|
<span className="text-slate-500">Layout:</span>
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export function SessionProvider({ children }) {
|
|||||||
homeAssistantSetState: (entityId, state) =>
|
homeAssistantSetState: (entityId, state) =>
|
||||||
emitWithAck('homeAssistant:setState', { entityId, state }),
|
emitWithAck('homeAssistant:setState', { entityId, state }),
|
||||||
setNickname: (nickname) => emitWithAck('nickname:set', { nickname }),
|
setNickname: (nickname) => emitWithAck('nickname:set', { nickname }),
|
||||||
triggerReplay: (cameraId = null) => emitWithAck('replay:trigger', { cameraId }),
|
triggerReplay: (sources = []) => emitWithAck('replay:trigger', { sources }),
|
||||||
pushAlert: (alert) =>
|
pushAlert: (alert) =>
|
||||||
setAlerts((prev) => [
|
setAlerts((prev) => [
|
||||||
...prev.slice(-49),
|
...prev.slice(-49),
|
||||||
|
|||||||
Reference in New Issue
Block a user