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
+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,