mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
replay capture ?
This commit is contained in:
@@ -4,13 +4,29 @@ const {
|
|||||||
Partials,
|
Partials,
|
||||||
ActivityType,
|
ActivityType,
|
||||||
EmbedBuilder,
|
EmbedBuilder,
|
||||||
|
AttachmentBuilder,
|
||||||
} = require('discord.js');
|
} = require('discord.js');
|
||||||
|
const fsp = require('fs/promises');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const { promisify } = require('util');
|
||||||
|
const { execFile } = require('child_process');
|
||||||
const logger = require('../globals/logger').child('discordBot');
|
const logger = require('../globals/logger').child('discordBot');
|
||||||
|
const io = require('../globals/io');
|
||||||
const { loadConfig } = require('../helpers/configLoader');
|
const { loadConfig } = require('../helpers/configLoader');
|
||||||
const { subscribe } = require('./eventBus');
|
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 } = require('./roomCameraService');
|
||||||
|
const {
|
||||||
|
getRoomCameraFrames,
|
||||||
|
getRoomCameraReplayDelayMs,
|
||||||
|
getRoomCameraReplayFrameCount,
|
||||||
|
} = require('./roomCameraSnapshotService');
|
||||||
|
const { getActiveDrivers } = require('./turnService');
|
||||||
|
const { getNickname } = require('./nicknameService');
|
||||||
|
const { tryTriggerReplay } = require('./replayService');
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const discordConfig = config.discord || {};
|
const discordConfig = config.discord || {};
|
||||||
@@ -37,6 +53,7 @@ const client = new Client({
|
|||||||
|
|
||||||
const channelCache = new Map();
|
const channelCache = new Map();
|
||||||
let skippedFirstModeAnnouncement = false;
|
let skippedFirstModeAnnouncement = false;
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
function sanitizeMentions(text) {
|
function sanitizeMentions(text) {
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
@@ -182,6 +199,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` — send room camera instant replay',
|
||||||
'`rs lock <id>` — lock a rover',
|
'`rs lock <id>` — lock a rover',
|
||||||
'`rs unlock <id>` — unlock a rover',
|
'`rs unlock <id>` — unlock a rover',
|
||||||
'`rs mode <open|turns|admin|lockdown>` — change server mode',
|
'`rs mode <open|turns|admin|lockdown>` — change server mode',
|
||||||
@@ -216,6 +234,115 @@ async function handleStatusCommand(message, roverId) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildDriverCaption() {
|
||||||
|
const activeDrivers = getActiveDrivers();
|
||||||
|
const roster = Array.from(rovers.values());
|
||||||
|
if (!roster.length) return 'Drivers: no rovers online.';
|
||||||
|
const entries = roster.map((record) => {
|
||||||
|
const driverId = activeDrivers[record.id];
|
||||||
|
if (!driverId) {
|
||||||
|
return `${record.meta?.name || record.id}: none`;
|
||||||
|
}
|
||||||
|
const socket = io.sockets.sockets.get(driverId);
|
||||||
|
const nickname = getNickname(socket) || socket?.data?.user?.username || driverId;
|
||||||
|
return `${record.meta?.name || record.id}: ${nickname}`;
|
||||||
|
});
|
||||||
|
return `Drivers: ${entries.join(', ')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildReplayGif() {
|
||||||
|
const cameras = getRoomCameras();
|
||||||
|
if (!cameras.length) {
|
||||||
|
throw new Error('No room cameras configured');
|
||||||
|
}
|
||||||
|
const frames = [];
|
||||||
|
cameras.forEach((camera) => {
|
||||||
|
const history = getRoomCameraFrames(camera.id, getRoomCameraReplayFrameCount());
|
||||||
|
history.forEach((entry) => {
|
||||||
|
frames.push({ camera, buffer: entry.buffer });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (!frames.length) {
|
||||||
|
throw new Error('No camera frames available yet');
|
||||||
|
}
|
||||||
|
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'rover-replay-'));
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < frames.length; i += 1) {
|
||||||
|
const filename = `frame-${String(i + 1).padStart(4, '0')}.jpg`;
|
||||||
|
await fsp.writeFile(path.join(tmpDir, filename), frames[i].buffer);
|
||||||
|
}
|
||||||
|
const outPath = path.join(tmpDir, 'replay.gif');
|
||||||
|
const delayMs = getRoomCameraReplayDelayMs();
|
||||||
|
const fps = (1000 / delayMs).toFixed(3);
|
||||||
|
await execFileAsync('ffmpeg', [
|
||||||
|
'-y',
|
||||||
|
'-hide_banner',
|
||||||
|
'-loglevel',
|
||||||
|
'error',
|
||||||
|
'-framerate',
|
||||||
|
fps,
|
||||||
|
'-i',
|
||||||
|
'frame-%04d.jpg',
|
||||||
|
'-vf',
|
||||||
|
'scale=640:-1:flags=lanczos',
|
||||||
|
'-loop',
|
||||||
|
'0',
|
||||||
|
outPath,
|
||||||
|
], { cwd: tmpDir });
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReplayCommand(message) {
|
||||||
|
if (getMode() === MODES.LOCKDOWN) {
|
||||||
|
await message.reply({
|
||||||
|
content: 'Replay is disabled while the server is in lockdown.',
|
||||||
|
allowedMentions: { parse: [], repliedUser: false },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const attempt = tryTriggerReplay({
|
||||||
|
by: message.author?.id || null,
|
||||||
|
source: 'discord',
|
||||||
|
});
|
||||||
|
if (!attempt.ok) {
|
||||||
|
const remaining = Math.ceil(attempt.remainingMs / 1000);
|
||||||
|
await message.reply({
|
||||||
|
content: `Replay cooldown active. Try again in ${remaining}s.`,
|
||||||
|
allowedMentions: { parse: [], repliedUser: false },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const requester =
|
||||||
|
message.member?.nickname || message.author?.globalName || message.author?.username || 'Discord';
|
||||||
|
try {
|
||||||
|
const buffer = await buildReplayGif();
|
||||||
|
const attachment = new AttachmentBuilder(buffer, { name: 'replay.gif' });
|
||||||
|
const caption = [
|
||||||
|
`Replay requested by ${requester}.`,
|
||||||
|
buildDriverCaption(),
|
||||||
|
].join(' ');
|
||||||
|
await message.reply({
|
||||||
|
content: sanitizeMentions(caption),
|
||||||
|
files: [attachment],
|
||||||
|
allowedMentions: { parse: [], repliedUser: false },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Replay capture failed', err.message);
|
||||||
|
await message.reply({
|
||||||
|
content: sanitizeMentions(`Replay failed: ${err.message}`),
|
||||||
|
allowedMentions: { parse: [], repliedUser: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleLockCommand(message, roverId, locked) {
|
async function handleLockCommand(message, roverId, locked) {
|
||||||
if (!roverId) {
|
if (!roverId) {
|
||||||
await message.reply({
|
await message.reply({
|
||||||
@@ -271,7 +398,7 @@ async function handleCommand(message) {
|
|||||||
const action = (tokens.shift() || '').toLowerCase();
|
const action = (tokens.shift() || '').toLowerCase();
|
||||||
const isAdmin = isAdminUser(message.author.id);
|
const isAdmin = isAdminUser(message.author.id);
|
||||||
|
|
||||||
if (!isAdmin && action !== '' && action !== 'status' && action !== 'help') {
|
if (!isAdmin && action !== '' && action !== 'status' && action !== 'help' && action !== 'replay') {
|
||||||
return; // ignore non-admins for privileged commands
|
return; // ignore non-admins for privileged commands
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,6 +412,9 @@ async function handleCommand(message) {
|
|||||||
case 'status':
|
case 'status':
|
||||||
await handleStatusCommand(message, tokens[0]);
|
await handleStatusCommand(message, tokens[0]);
|
||||||
break;
|
break;
|
||||||
|
case 'replay':
|
||||||
|
await handleReplayCommand(message);
|
||||||
|
break;
|
||||||
case 'lock':
|
case 'lock':
|
||||||
await handleLockCommand(message, tokens[0], true);
|
await handleLockCommand(message, tokens[0], true);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
const EventEmitter = require('events');
|
||||||
|
|
||||||
|
const COOLDOWN_MS = 10 * 1000;
|
||||||
|
|
||||||
|
const events = new EventEmitter();
|
||||||
|
let lastTriggeredAt = null;
|
||||||
|
let lastTriggeredBy = null;
|
||||||
|
|
||||||
|
function getRemainingMs(now = Date.now()) {
|
||||||
|
if (!lastTriggeredAt) return 0;
|
||||||
|
const elapsed = now - lastTriggeredAt;
|
||||||
|
return Math.max(0, COOLDOWN_MS - elapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getState() {
|
||||||
|
const remainingMs = getRemainingMs();
|
||||||
|
return {
|
||||||
|
cooldownMs: COOLDOWN_MS,
|
||||||
|
lastTriggeredAt,
|
||||||
|
lastTriggeredBy,
|
||||||
|
remainingMs,
|
||||||
|
available: remainingMs === 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryTrigger(by = null) {
|
||||||
|
const remainingMs = getRemainingMs();
|
||||||
|
if (remainingMs > 0) {
|
||||||
|
return { ok: false, remainingMs, state: getState() };
|
||||||
|
}
|
||||||
|
lastTriggeredAt = Date.now();
|
||||||
|
lastTriggeredBy = by || null;
|
||||||
|
const state = getState();
|
||||||
|
events.emit('update', { state, by: lastTriggeredBy });
|
||||||
|
return { ok: true, state };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
tryTriggerReplay: tryTrigger,
|
||||||
|
getReplayState: getState,
|
||||||
|
replayEvents: events,
|
||||||
|
};
|
||||||
@@ -3,9 +3,11 @@ const logger = require('../globals/logger').child('roomCameraSnapshot');
|
|||||||
const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
|
const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 800;
|
const POLL_INTERVAL_MS = 800;
|
||||||
|
const REPLAY_FRAME_COUNT = 15;
|
||||||
const FETCH_TIMEOUT_MS = 2000;
|
const FETCH_TIMEOUT_MS = 2000;
|
||||||
|
|
||||||
const cameraState = new Map(); // id -> {frame, ts, error, failures, fetching}
|
const cameraState = new Map(); // id -> {frame, ts, error, failures, fetching}
|
||||||
|
const frameHistory = new Map(); // id -> [{buffer, ts}]
|
||||||
const events = new EventEmitter(); // frame, status
|
const events = new EventEmitter(); // frame, status
|
||||||
let pollTimer = null;
|
let pollTimer = null;
|
||||||
|
|
||||||
@@ -16,6 +18,15 @@ function markState(id, updates = {}) {
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function recordFrame(id, buffer, ts) {
|
||||||
|
const history = frameHistory.get(id) || [];
|
||||||
|
history.push({ buffer, ts });
|
||||||
|
if (history.length > REPLAY_FRAME_COUNT) {
|
||||||
|
history.splice(0, history.length - REPLAY_FRAME_COUNT);
|
||||||
|
}
|
||||||
|
frameHistory.set(id, history);
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchSnapshot(camera) {
|
async function fetchSnapshot(camera) {
|
||||||
const { id, url } = camera;
|
const { id, url } = camera;
|
||||||
const state = cameraState.get(id);
|
const state = cameraState.get(id);
|
||||||
@@ -32,6 +43,7 @@ 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 });
|
||||||
|
recordFrame(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;
|
||||||
@@ -50,6 +62,7 @@ function stopAll() {
|
|||||||
pollTimer = null;
|
pollTimer = null;
|
||||||
}
|
}
|
||||||
cameraState.clear();
|
cameraState.clear();
|
||||||
|
frameHistory.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
function startAll() {
|
function startAll() {
|
||||||
@@ -71,6 +84,20 @@ function getState(id) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getReplayFrames(id, count = REPLAY_FRAME_COUNT) {
|
||||||
|
const history = frameHistory.get(id) || [];
|
||||||
|
if (!history.length) return [];
|
||||||
|
return history.slice(Math.max(0, history.length - count));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReplayFrameDelayMs() {
|
||||||
|
return POLL_INTERVAL_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReplayFrameCount() {
|
||||||
|
return REPLAY_FRAME_COUNT;
|
||||||
|
}
|
||||||
|
|
||||||
roomCameraEvents.on('update', () => {
|
roomCameraEvents.on('update', () => {
|
||||||
logger.info('Room cameras changed; restarting snapshot pollers');
|
logger.info('Room cameras changed; restarting snapshot pollers');
|
||||||
startAll();
|
startAll();
|
||||||
@@ -81,4 +108,7 @@ startAll();
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
roomCameraStreamEvents: events,
|
roomCameraStreamEvents: events,
|
||||||
getRoomCameraState: getState,
|
getRoomCameraState: getState,
|
||||||
|
getRoomCameraFrames: getReplayFrames,
|
||||||
|
getRoomCameraReplayDelayMs: getReplayFrameDelayMs,
|
||||||
|
getRoomCameraReplayFrameCount: getReplayFrameCount,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const { getActiveDrivers, getTurnQueues, turnEvents } = require('./turnService')
|
|||||||
const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
|
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 { loadConfig } = require('../helpers/configLoader');
|
const { loadConfig } = require('../helpers/configLoader');
|
||||||
|
|
||||||
const discordInvite = loadConfig().discord?.invite || null;
|
const discordInvite = loadConfig().discord?.invite || null;
|
||||||
@@ -45,6 +46,7 @@ function buildSession(socket) {
|
|||||||
turnQueues: getTurnQueues(),
|
turnQueues: getTurnQueues(),
|
||||||
roomCameras: getRoomCameras(),
|
roomCameras: getRoomCameras(),
|
||||||
homeAssistant: getHomeAssistantState(),
|
homeAssistant: getHomeAssistantState(),
|
||||||
|
replay: getReplayState(),
|
||||||
users,
|
users,
|
||||||
discord: {
|
discord: {
|
||||||
invite: discordInvite,
|
invite: discordInvite,
|
||||||
@@ -156,6 +158,11 @@ homeAssistantEvents.on('status', () => {
|
|||||||
syncAll();
|
syncAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
replayEvents.on('update', () => {
|
||||||
|
logger.info('Replay cooldown updated; syncing all clients');
|
||||||
|
syncAll();
|
||||||
|
});
|
||||||
|
|
||||||
nicknameEvents.on('change', ({ socketId }) => {
|
nicknameEvents.on('change', ({ socketId }) => {
|
||||||
const socket = socketId ? io.sockets.sockets.get(socketId) : null;
|
const socket = socketId ? io.sockets.sockets.get(socketId) : null;
|
||||||
if (socket) {
|
if (socket) {
|
||||||
|
|||||||
Reference in New Issue
Block a user