switch human alert to button

This commit is contained in:
legop3
2026-04-09 14:02:54 -04:00
parent 5e428b278a
commit 8ed617305d
14 changed files with 382 additions and 1125 deletions
+9 -14
View File
@@ -1460,37 +1460,32 @@ function handleBusEvent(event) {
logger.warn('Replay send failed', err.message);
});
break;
case 'vision.humanDetected': {
case 'humanAlert.buttonPressed': {
const imageBase64 = payload?.imageBase64 ? String(payload.imageBase64) : '';
let attachment = null;
if (imageBase64) {
try {
const imageBuffer = Buffer.from(imageBase64, 'base64');
if (imageBuffer.length > 0) {
attachment = new AttachmentBuilder(imageBuffer, { name: 'human-detected.jpg' });
attachment = new AttachmentBuilder(imageBuffer, { name: 'human-alert-mosaic.jpg' });
}
} catch (err) {
logger.warn('Failed to decode human detection image for Discord', err.message);
logger.warn('Failed to decode human alert image for Discord', err.message);
}
}
const cameraLabel = payload?.cameraId ? `Camera: \`${payload.cameraId}\`` : 'Camera: unknown';
const confidenceLabel =
Number.isFinite(Number(payload?.confidence))
? `Confidence: \`${Number(payload.confidence).toFixed(2)}\``
: 'Confidence: n/a';
const detectedAt = Number(payload?.detectedAt);
const detectedLabel = Number.isFinite(detectedAt)
? `Detected: <t:${Math.floor(detectedAt / 1000)}:F>`
const triggeredAt = Number(payload?.triggeredAt);
const triggeredLabel = Number.isFinite(triggeredAt)
? `Triggered: <t:${Math.floor(triggeredAt / 1000)}:F>`
: null;
const embed = buildEmbed({
title: 'Human Detected',
description: [cameraLabel, confidenceLabel, detectedLabel].filter(Boolean).join('\n'),
title: 'Human Alert Button Pressed',
description: [triggeredLabel].filter(Boolean).join('\n'),
color: 0xe53935,
});
announce({
channelId: channels.humanAlerts,
pingRoleId: roles.humanAlertPing || null,
content: payload?.message || 'Human detected in room.',
content: payload?.message || 'Human alert button pressed.',
embeds: [embed],
files: attachment ? [attachment] : [],
includeSiteUrl: false,
+117
View File
@@ -6,6 +6,7 @@ const logger = require('../globals/logger').child('homeAssistantService');
const { loadConfig } = require('../helpers/configLoader');
const { getMode } = require('./modeManager');
const { isAdmin, isLockdownAdmin } = require('./roleService');
const { publishEvent } = require('./eventBus');
// home-assistant-js-websocket expects a global WebSocket in Node.
if (!global.WebSocket) {
@@ -18,6 +19,9 @@ const haConfig = config.homeAssistant || {};
const events = new EventEmitter();
const entityConfig = new Map(); // entityId -> { id, name, type }
const entityState = new Map(); // entityId -> normalized state
const triggerConfig = []; // [{ runtimeKey, entityId, action, stateEquals, payload, cooldownMs, allowedModes }]
const triggerRuntime = new Map(); // triggerId -> { lastFiredAt, lastState, lastChanged, lastUpdated }
const HA_BUTTON_EVENT_TYPE = 'ha.button.action';
let connection = null;
let unsubscribeEntities = null;
@@ -78,6 +82,49 @@ function loadEntityConfig() {
logger.info('Loaded Home Assistant entities', { count: entityConfig.size });
}
function normalizeTriggerEntry(entry, index) {
if (!entry || typeof entry !== 'object') return null;
const entityId = String(entry.entityId || entry.entity_id || '').trim();
const action = String(entry.action || '').trim();
if (!entityId || !action) return null;
const stateEqualsRaw = entry.stateEquals ?? entry.state_equals;
const stateEquals =
stateEqualsRaw === null || stateEqualsRaw === undefined ? null : String(stateEqualsRaw).trim();
const runtimeKey = `${action}::${entityId}::${stateEquals || '*'}::${index}`;
const cooldownMs = Number.isFinite(Number(entry.cooldownMs)) ? Math.max(0, Number(entry.cooldownMs)) : 0;
const allowedModes = Array.isArray(entry.allowedModes)
? entry.allowedModes.map((mode) => String(mode || '').trim().toLowerCase()).filter(Boolean)
: null;
return {
runtimeKey,
entityId,
action,
stateEquals,
payload: entry.payload && typeof entry.payload === 'object' ? entry.payload : {},
cooldownMs,
allowedModes,
};
}
function loadTriggerConfig() {
triggerConfig.length = 0;
const list = Array.isArray(haConfig?.buttons) ? haConfig.buttons : [];
list.forEach((entry, index) => {
const normalized = normalizeTriggerEntry(entry, index);
if (!normalized) return;
triggerConfig.push(normalized);
if (!triggerRuntime.has(normalized.runtimeKey)) {
triggerRuntime.set(normalized.runtimeKey, {
lastFiredAt: 0,
lastState: null,
lastChanged: null,
lastUpdated: null,
});
}
});
logger.info('Loaded Home Assistant buttons', { count: triggerConfig.length });
}
function buildState(meta, raw) {
if (!meta) return null;
const name = meta.name || raw?.attributes?.friendly_name || meta.id;
@@ -153,6 +200,75 @@ function handleEntitySnapshot(snapshot = {}) {
if (changed) {
emitUpdate();
}
evaluateTriggers(snapshot);
}
function triggerMatches(trigger, raw, runtimeState) {
if (!raw) return false;
const nextState = raw?.state ?? null;
const nextChanged = raw?.last_changed ?? null;
const nextUpdated = raw?.last_updated ?? null;
const changed =
runtimeState.lastState !== nextState ||
runtimeState.lastChanged !== nextChanged ||
runtimeState.lastUpdated !== nextUpdated;
if (!changed) {
return { matched: false, nextState, nextChanged, nextUpdated };
}
if (trigger.stateEquals != null && String(trigger.stateEquals) !== String(nextState)) {
return { matched: false, nextState, nextChanged, nextUpdated };
}
return { matched: true, nextState, nextChanged, nextUpdated };
}
function evaluateTriggers(snapshot = {}) {
if (!triggerConfig.length) return;
const now = Date.now();
const mode = String(getMode() || '').toLowerCase();
triggerConfig.forEach((trigger) => {
const runtimeState = triggerRuntime.get(trigger.runtimeKey) || {
lastFiredAt: 0,
lastState: null,
lastChanged: null,
lastUpdated: null,
};
const raw = snapshot?.[trigger.entityId] || null;
const evalResult = triggerMatches(trigger, raw, runtimeState);
runtimeState.lastState = evalResult.nextState;
runtimeState.lastChanged = evalResult.nextChanged;
runtimeState.lastUpdated = evalResult.nextUpdated;
triggerRuntime.set(trigger.runtimeKey, runtimeState);
if (!evalResult.matched) return;
if (trigger.allowedModes?.length && !trigger.allowedModes.includes(mode)) return;
if (trigger.cooldownMs > 0 && now - runtimeState.lastFiredAt < trigger.cooldownMs) return;
runtimeState.lastFiredAt = now;
triggerRuntime.set(trigger.runtimeKey, runtimeState);
events.emit('trigger', {
buttonId: trigger.action,
entityId: trigger.entityId,
action: trigger.action,
state: raw?.state ?? null,
attributes: raw?.attributes || {},
lastChanged: raw?.last_changed || null,
lastUpdated: raw?.last_updated || null,
firedAt: now,
});
publishEvent({
source: 'homeAssistant',
type: HA_BUTTON_EVENT_TYPE,
payload: {
buttonId: trigger.action,
entityId: trigger.entityId,
action: trigger.action,
state: raw?.state ?? null,
attributes: raw?.attributes || {},
lastChanged: raw?.last_changed || null,
lastUpdated: raw?.last_updated || null,
firedAt: now,
...(trigger.payload || {}),
},
});
});
}
function teardownConnection() {
@@ -270,6 +386,7 @@ function getState() {
}
loadEntityConfig();
loadTriggerConfig();
connect();
io.on('connection', (socket) => {
@@ -0,0 +1,109 @@
const sharp = require('sharp');
const logger = require('../globals/logger').child('humanAlertButton');
const { subscribe, publishEvent } = require('./eventBus');
const { getMode, MODES } = require('./modeManager');
const { getRoomCameras } = require('./roomCameraService');
const { getRoomCameraState } = require('./roomCameraSnapshotService');
const HA_BUTTON_EVENT_TYPE = 'ha.button.action';
const HUMAN_ALERT_ACTION = 'humanAlert';
const HUMAN_ALERT_MESSAGE = 'Human alert button pressed.';
const TILE_WIDTH = 480;
const TILE_HEIGHT = 270;
logger.info('Human alert button service enabled', { action: HUMAN_ALERT_ACTION });
function isModeAllowed() {
const mode = getMode();
return mode !== MODES.ADMIN && mode !== MODES.LOCKDOWN;
}
async function buildTile(camera, state) {
const base = sharp({
create: {
width: TILE_WIDTH,
height: TILE_HEIGHT,
channels: 3,
background: state?.frame ? '#000000' : '#222222',
},
});
if (!state?.frame) {
return base.jpeg({ quality: 75 }).toBuffer();
}
try {
const frame = await sharp(state.frame)
.resize(TILE_WIDTH, TILE_HEIGHT, {
fit: 'cover',
})
.jpeg({ quality: 78 })
.toBuffer();
return frame;
} catch (err) {
logger.warn('Failed to build camera tile from frame', { cameraId: camera.id, error: err.message });
return base.jpeg({ quality: 75 }).toBuffer();
}
}
async function buildHorizontalMosaic() {
const cameras = getRoomCameras();
if (!cameras.length) return null;
const width = TILE_WIDTH * cameras.length;
const height = TILE_HEIGHT;
const layers = [];
for (let idx = 0; idx < cameras.length; idx += 1) {
const cam = cameras[idx];
const state = getRoomCameraState(cam.id);
const input = await buildTile(cam, state);
layers.push({
input,
left: idx * TILE_WIDTH,
top: 0,
});
}
return sharp({
create: {
width,
height,
channels: 3,
background: '#000000',
},
})
.composite(layers)
.jpeg({ quality: 80 })
.toBuffer();
}
async function handleTrigger(event = {}) {
if (String(event?.payload?.action || '') !== HUMAN_ALERT_ACTION) {
return;
}
const now = Date.now();
if (!isModeAllowed()) {
logger.info('Ignoring human alert trigger due to mode gate', { mode: getMode() });
return;
}
let mosaic = null;
try {
mosaic = await buildHorizontalMosaic();
} catch (err) {
logger.warn('Failed to build human alert mosaic', { error: err.message });
}
publishEvent({
source: 'humanAlertButton',
type: 'humanAlert.buttonPressed',
payload: {
message: HUMAN_ALERT_MESSAGE,
triggeredAt: now,
imageBase64: mosaic ? mosaic.toString('base64') : null,
trigger: event?.payload || null,
},
});
}
subscribe(HA_BUTTON_EVENT_TYPE, (event) => {
handleTrigger(event).catch((err) => {
logger.warn('Failed handling human alert trigger', { error: err.message });
});
});
module.exports = {};
@@ -1,507 +0,0 @@
const path = require('path');
const { spawn } = require('child_process');
const readline = require('readline');
const logger = require('../globals/logger').child('roomHumanDetection');
const io = require('../globals/io');
const { loadConfig } = require('../helpers/configLoader');
const { roomCameraStreamEvents } = require('./roomCameraSnapshotService');
const roverManager = require('./roverManager');
const { issueCommand } = require('./commandService');
const { publishEvent } = require('./eventBus');
const { sendAlert } = require('./alertService');
const { getMode, MODES } = require('./modeManager');
const { isAdmin } = require('./roleService');
const config = loadConfig();
const visionConfig = config.vision?.humanDetection || {};
const runtime = {
enabled: Boolean(visionConfig.enabled),
confidenceThreshold: Number.isFinite(Number(visionConfig.confidenceThreshold))
? Number(visionConfig.confidenceThreshold)
: 0.55,
ttsDelayMs: Number.isFinite(Number(visionConfig.ttsDelayMs)) ? Number(visionConfig.ttsDelayMs) : 30000,
discordDelayMs: Number.isFinite(Number(visionConfig.discordDelayMs)) ? Number(visionConfig.discordDelayMs) : 60000,
clearWindowMs: Number.isFinite(Number(visionConfig.clearWindowMs)) ? Number(visionConfig.clearWindowMs) : 3000,
cooldownMs: Number.isFinite(Number(visionConfig.cooldownMs)) ? Number(visionConfig.cooldownMs) : 15 * 60 * 1000,
maxInferenceFpsPerCamera: Number.isFinite(Number(visionConfig.maxInferenceFpsPerCamera))
? Math.max(0.25, Number(visionConfig.maxInferenceFpsPerCamera))
: 3,
};
const TTS_TEXT = 'human detected in room, alerting soon';
const workerScript = path.join(__dirname, '..', '..', 'scripts', 'human_detector_worker.py');
const pythonCandidates = [
process.env.VISION_PYTHON,
'/opt/multiroomba-vision/.venv/bin/python3',
'python3',
].filter(Boolean);
const cameraState = new Map(); // cameraId -> { inflight,lastInferAt,lastResultAt,lastPositiveAt,lastConfidence,error }
const history = []; // up to 80 recent events
let worker = null;
let workerReady = false;
let workerPython = null;
let workerRestartCount = 0;
let reqSeq = 1;
let lastAnyPositiveAt = null;
let episodeStartAt = null;
let ttsSentThisEpisode = false;
let discordSentThisEpisode = false;
let latestPositiveFrame = null; // { cameraId, confidence, ts, buffer }
let cooldownUntil = 0;
let hasClearedSinceLastDiscord = true;
let lastDiscordAlertAt = null;
let lastDiscordAlertMeta = null;
function getInferenceIntervalMs() {
return Math.max(100, Math.round(1000 / Math.max(0.25, Number(runtime.maxInferenceFpsPerCamera) || 3)));
}
function pushHistory(type, detail = {}) {
history.push({ ts: Date.now(), type, detail });
while (history.length > 80) history.shift();
}
function isDetectionActiveMode() {
const mode = getMode();
return mode !== MODES.ADMIN && mode !== MODES.LOCKDOWN;
}
function ensureCameraState(cameraId) {
if (!cameraState.has(cameraId)) {
cameraState.set(cameraId, {
inflight: false,
lastInferAt: 0,
lastResultAt: 0,
lastPositiveAt: 0,
lastConfidence: 0,
error: null,
});
}
return cameraState.get(cameraId);
}
function markCleared(now, reason = 'clear_window') {
if (episodeStartAt == null) return;
episodeStartAt = null;
ttsSentThisEpisode = false;
discordSentThisEpisode = false;
lastAnyPositiveAt = null;
latestPositiveFrame = null;
hasClearedSinceLastDiscord = true;
pushHistory('episode.cleared', { reason });
logger.info('Human detection episode cleared', { now, reason });
}
function sendTtsToNonPrivateRovers() {
const roster = roverManager.getRoster();
let sent = 0;
roster.forEach((entry) => {
if (entry?.private?.enabled) return;
try {
issueCommand(String(entry.id), {
type: 'tts',
tts: {
text: TTS_TEXT,
speak: true,
},
});
sent += 1;
} catch (err) {
logger.warn('Failed to send human-alert TTS', { roverId: entry?.id, error: err.message });
}
});
sendAlert({
color: '#f0b651',
title: 'Human Detection',
message:
sent > 0
? `Person detected; announced on ${sent} rover(s).`
: 'Person detected; no non-private rover available.',
});
publishEvent({
source: 'roomHumanDetection',
type: 'vision.humanTtsSent',
payload: {
text: TTS_TEXT,
roverCount: sent,
ts: Date.now(),
},
});
pushHistory('tts.sent', { roverCount: sent });
}
function sendDiscordDetectionAlert(now, options = {}) {
const payload = {
message: options.message || 'Human detected in room cameras.',
detectedAt: now,
confidence: latestPositiveFrame?.confidence || null,
cameraId: latestPositiveFrame?.cameraId || null,
imageBase64: latestPositiveFrame?.buffer ? latestPositiveFrame.buffer.toString('base64') : null,
};
publishEvent({
source: 'roomHumanDetection',
type: 'vision.humanDetected',
payload,
});
sendAlert({
color: '#e53935',
title: 'Human Detection',
message: 'Human presence persisted; Discord alert sent.',
});
lastDiscordAlertAt = now;
lastDiscordAlertMeta = {
cameraId: payload.cameraId,
confidence: payload.confidence,
detectedAt: payload.detectedAt,
};
pushHistory('discord.sent', {
cameraId: payload.cameraId,
confidence: payload.confidence,
});
}
function buildState(now = Date.now()) {
const mode = getMode();
const modeActive = isDetectionActiveMode();
const humanPresent = episodeStartAt != null;
const elapsed = humanPresent ? Math.max(0, now - episodeStartAt) : 0;
const timeToTtsMs = humanPresent && !ttsSentThisEpisode ? Math.max(0, runtime.ttsDelayMs - elapsed) : 0;
const timeToDiscordMs = humanPresent && !discordSentThisEpisode ? Math.max(0, runtime.discordDelayMs - elapsed) : 0;
const cooldownRemainingMs = Math.max(0, cooldownUntil - now);
const cameras = Array.from(cameraState.entries()).map(([cameraId, state]) => ({
cameraId,
inflight: Boolean(state.inflight),
lastInferAt: state.lastInferAt || null,
lastResultAt: state.lastResultAt || null,
lastPositiveAt: state.lastPositiveAt || null,
lastConfidence: state.lastConfidence || 0,
error: state.error || null,
}));
cameras.sort((a, b) => String(a.cameraId).localeCompare(String(b.cameraId)));
return {
enabled: runtime.enabled,
mode,
modeActive,
workerReady,
workerRunning: Boolean(worker && !worker.killed),
workerPython,
workerScript,
workerRestartCount,
config: { ...runtime },
episode: {
humanPresent,
startAt: episodeStartAt,
lastAnyPositiveAt,
ttsSentThisEpisode,
discordSentThisEpisode,
timeToTtsMs,
timeToDiscordMs,
cooldownUntil,
cooldownRemainingMs,
hasClearedSinceLastDiscord,
},
latestPositive: latestPositiveFrame
? {
cameraId: latestPositiveFrame.cameraId,
confidence: latestPositiveFrame.confidence,
ts: latestPositiveFrame.ts,
}
: null,
lastDiscordAlertAt,
lastDiscordAlertMeta,
cameras,
history: history.slice(),
updatedAt: now,
};
}
function emitStateToAdmins() {
const payload = buildState();
io.sockets.sockets.forEach((socket) => {
if (!isAdmin(socket)) return;
socket.emit('vision:human:state', payload);
});
}
function evaluateEpisode(now = Date.now()) {
if (!runtime.enabled) {
markCleared(now, 'disabled');
return;
}
if (!isDetectionActiveMode()) {
markCleared(now, 'mode_gate');
return;
}
if (episodeStartAt != null && lastAnyPositiveAt != null && now - lastAnyPositiveAt > runtime.clearWindowMs) {
markCleared(now, 'clear_window');
return;
}
if (episodeStartAt == null) return;
const elapsed = Math.max(0, now - episodeStartAt);
if (!ttsSentThisEpisode && elapsed >= runtime.ttsDelayMs) {
sendTtsToNonPrivateRovers();
ttsSentThisEpisode = true;
}
if (discordSentThisEpisode) return;
if (elapsed < runtime.discordDelayMs) return;
if (!hasClearedSinceLastDiscord) return;
if (now < cooldownUntil) return;
sendDiscordDetectionAlert(now);
discordSentThisEpisode = true;
hasClearedSinceLastDiscord = false;
cooldownUntil = now + runtime.cooldownMs;
}
function handleWorkerMessage(line) {
let msg;
try {
msg = JSON.parse(line);
} catch (err) {
logger.warn('Invalid vision worker JSON', { error: err.message });
return;
}
if (msg?.type === 'ready') {
workerReady = true;
pushHistory('worker.ready');
logger.info('Vision worker ready');
emitStateToAdmins();
return;
}
const cameraId = String(msg?.cameraId || '');
if (!cameraId) return;
const state = ensureCameraState(cameraId);
state.inflight = false;
state.lastResultAt = Date.now();
if (!msg.ok) {
state.error = msg.error || 'worker error';
logger.warn('Vision inference failed', { cameraId, error: state.error });
evaluateEpisode(Date.now());
emitStateToAdmins();
return;
}
state.error = null;
state.lastConfidence = Number(msg.bestConfidence || 0);
if (msg.personDetected) {
const now = Number(msg.ts) || Date.now();
state.lastPositiveAt = now;
lastAnyPositiveAt = now;
if (episodeStartAt == null) {
episodeStartAt = now;
ttsSentThisEpisode = false;
discordSentThisEpisode = false;
pushHistory('episode.started', { cameraId });
logger.info('Human detection episode started', { cameraId, now });
}
let buffer = null;
try {
if (msg.annotatedBase64) {
buffer = Buffer.from(String(msg.annotatedBase64), 'base64');
}
} catch (err) {
logger.warn('Failed to decode annotated frame from worker', { cameraId, error: err.message });
}
if (buffer) {
latestPositiveFrame = {
cameraId,
confidence: state.lastConfidence,
ts: now,
buffer,
};
}
}
evaluateEpisode(Number(msg.ts) || Date.now());
emitStateToAdmins();
}
function startWorker() {
for (const pythonBin of pythonCandidates) {
try {
const proc = spawn(pythonBin, [workerScript], {
stdio: ['pipe', 'pipe', 'pipe'],
});
worker = proc;
workerReady = false;
workerPython = pythonBin;
readline.createInterface({ input: proc.stdout }).on('line', handleWorkerMessage);
proc.stderr.on('data', (chunk) => {
const text = String(chunk || '').trim();
if (!text) return;
logger.warn('Vision worker stderr', { text: text.slice(0, 300) });
});
proc.on('exit', (code, signal) => {
logger.warn('Vision worker exited', { code, signal });
worker = null;
workerReady = false;
workerRestartCount += 1;
pushHistory('worker.exit', { code, signal });
emitStateToAdmins();
});
logger.info('Vision worker started', { pythonBin, workerScript });
pushHistory('worker.started', { pythonBin });
return true;
} catch (err) {
logger.warn('Failed to spawn vision worker candidate', { pythonBin, error: err.message });
}
}
return false;
}
function submitFrame(cameraId, buffer, ts = Date.now()) {
if (!runtime.enabled) return;
if (!worker || !workerReady) return;
const state = ensureCameraState(cameraId);
const now = Date.now();
if (state.inflight) return;
if (now - state.lastInferAt < getInferenceIntervalMs()) return;
state.inflight = true;
state.lastInferAt = now;
const payload = {
reqId: `r${reqSeq++}`,
cameraId: String(cameraId),
ts: Number(ts) || now,
confidenceThreshold: runtime.confidenceThreshold,
imageBase64: buffer.toString('base64'),
};
try {
worker.stdin.write(`${JSON.stringify(payload)}\n`);
} catch (err) {
state.inflight = false;
state.error = err.message;
logger.warn('Failed writing frame to vision worker', { cameraId, error: err.message });
}
}
function clampNumber(value, min, max, fallback) {
const num = Number(value);
if (!Number.isFinite(num)) return fallback;
return Math.max(min, Math.min(max, num));
}
function updateRuntimeConfig(patch = {}) {
const prevEnabled = runtime.enabled;
runtime.enabled = typeof patch.enabled === 'boolean' ? patch.enabled : runtime.enabled;
runtime.confidenceThreshold = clampNumber(patch.confidenceThreshold, 0, 1, runtime.confidenceThreshold);
runtime.ttsDelayMs = clampNumber(patch.ttsDelayMs, 1000, 60 * 60 * 1000, runtime.ttsDelayMs);
runtime.discordDelayMs = clampNumber(
patch.discordDelayMs,
runtime.ttsDelayMs,
2 * 60 * 60 * 1000,
runtime.discordDelayMs,
);
runtime.clearWindowMs = clampNumber(patch.clearWindowMs, 250, 60 * 1000, runtime.clearWindowMs);
runtime.cooldownMs = clampNumber(patch.cooldownMs, 0, 12 * 60 * 60 * 1000, runtime.cooldownMs);
runtime.maxInferenceFpsPerCamera = clampNumber(
patch.maxInferenceFpsPerCamera,
0.25,
30,
runtime.maxInferenceFpsPerCamera,
);
if (prevEnabled && !runtime.enabled) {
markCleared(Date.now(), 'disabled');
}
pushHistory('config.updated', { patch });
}
if (!startWorker()) {
logger.error('Room human detection worker failed to start; detection unavailable until restart');
pushHistory('worker.unavailable');
}
roomCameraStreamEvents.on('frame', ({ id, buffer, ts }) => {
if (!runtime.enabled) return;
if (!isDetectionActiveMode()) return;
if (!id || !buffer) return;
submitFrame(String(id), buffer, ts);
});
setInterval(() => {
evaluateEpisode(Date.now());
emitStateToAdmins();
}, 1000);
io.on('connection', (socket) => {
socket.on('vision:human:getState', (_, cb = () => {}) => {
try {
if (!isAdmin(socket)) {
cb({ error: 'Not authorized' });
return;
}
cb({ ok: true, state: buildState() });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('vision:human:updateConfig', ({ config: patch } = {}, cb = () => {}) => {
try {
if (!isAdmin(socket)) {
cb({ error: 'Not authorized' });
return;
}
updateRuntimeConfig(patch || {});
emitStateToAdmins();
cb({ ok: true, state: buildState() });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('vision:human:testTts', (_, cb = () => {}) => {
try {
if (!isAdmin(socket)) {
cb({ error: 'Not authorized' });
return;
}
sendTtsToNonPrivateRovers();
emitStateToAdmins();
cb({ ok: true, state: buildState() });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('vision:human:testDiscord', (_, cb = () => {}) => {
try {
if (!isAdmin(socket)) {
cb({ error: 'Not authorized' });
return;
}
sendDiscordDetectionAlert(Date.now(), { message: 'Human detection test alert.' });
emitStateToAdmins();
cb({ ok: true, state: buildState() });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('vision:human:clear', (_, cb = () => {}) => {
try {
if (!isAdmin(socket)) {
cb({ error: 'Not authorized' });
return;
}
markCleared(Date.now(), 'manual_clear');
emitStateToAdmins();
cb({ ok: true, state: buildState() });
} catch (err) {
cb({ error: err.message });
}
});
if (isAdmin(socket)) {
socket.emit('vision:human:state', buildState());
}
});
logger.info('Room human detection service started', {
...runtime,
workerScript,
});
module.exports = {
getRoomHumanDetectionState: () => buildState(),
};
+1 -3
View File
@@ -1,6 +1,6 @@
const io = require('../globals/io');
const logger = require('../globals/logger').child('sessionService');
const { getRole, isAdmin, roleEvents } = require('./roleService');
const { getRole, roleEvents } = require('./roleService');
const { getMode, modeEvents } = require('./modeManager');
const roverManager = require('./roverManager');
const { managerEvents } = roverManager;
@@ -28,7 +28,6 @@ const { subscribe } = require('./eventBus');
const { getSocketIp, isLocalNetwork } = require('../helpers/ipResolver');
const { getAudioForwardState, audioForwardEvents } = require('./audioForwardService');
const { getAudioLevels, audioLevelsEvents } = require('./audioLevelsService');
const { getRoomHumanDetectionState } = require('./roomHumanDetectionService');
const config = loadConfig();
const discordInvite = config.discord?.invite || null;
@@ -137,7 +136,6 @@ function buildSession(socket) {
isVerified: Boolean(socket?.data?.isVerified),
audioForward: getAudioForwardState(),
audioLevels: getAudioLevels(),
visionHumanDetection: isAdmin(socket) ? getRoomHumanDetectionState() : null,
};
}