mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
218 lines
6.8 KiB
JavaScript
218 lines
6.8 KiB
JavaScript
// Kinect Socket Gateway
|
|
// Purpose: Registers browser-facing Kinect snapshot controls and broadcasts shared Kinect frames over Socket.IO.
|
|
// Scope: Owns authorization, global capture cooldown, request serialization, cached-frame replay, and session-status events.
|
|
const EventEmitter = require('events');
|
|
const io = require('../../globals/io');
|
|
const logger = require('../../globals/logger').child('kinectService');
|
|
const { getMode, MODES } = require('../modeManager');
|
|
const { isAdmin, isLockdownAdmin, getRole } = require('../roleService');
|
|
|
|
const DEFAULT_CAPTURE_COOLDOWN_MS = 10000;
|
|
|
|
const kinectEvents = new EventEmitter();
|
|
|
|
function passesMode(socket) {
|
|
const mode = getMode();
|
|
if (mode === MODES.LOCKDOWN) return isLockdownAdmin(socket);
|
|
if (mode === MODES.ADMIN) {
|
|
const role = getRole(socket);
|
|
return role === 'spectator' || isAdmin(socket);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function normalizeKinectConfig(config = {}) {
|
|
const raw = config.kinect || {};
|
|
return {
|
|
enabled: Boolean(raw.enabled),
|
|
captureCooldownMs:
|
|
Number.isFinite(Number(raw.captureCooldownMs)) && Number(raw.captureCooldownMs) >= 0
|
|
? Number(raw.captureCooldownMs)
|
|
: DEFAULT_CAPTURE_COOLDOWN_MS,
|
|
};
|
|
}
|
|
|
|
function registerKinectSocketGateway({ config, hardware }) {
|
|
const settings = normalizeKinectConfig(config);
|
|
let captureCooldownUntil = 0;
|
|
let busy = false;
|
|
let lastAction = null;
|
|
let lastError = null;
|
|
let lastPointCloud = null;
|
|
let lastColorImage = null;
|
|
|
|
function buildStatus(extra = {}) {
|
|
return {
|
|
enabled: settings.enabled,
|
|
// Availability starts optimistic when enabled. A real worker/capture
|
|
// failure changes lastError, and session sync then makes the UI show that
|
|
// the camera path needs attention.
|
|
available: settings.enabled && !lastError,
|
|
busy,
|
|
captureCooldownUntil,
|
|
lastAction,
|
|
lastError,
|
|
hasPointCloud: Boolean(lastPointCloud?.buffer),
|
|
hasColorImage: Boolean(lastColorImage?.buffer),
|
|
lastPointCloudTs: lastPointCloud?.meta?.ts || null,
|
|
lastColorImageTs: lastColorImage?.meta?.ts || null,
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
function emitStatusChange(extra = {}) {
|
|
// The browser already receives session-wide status through sessionService.
|
|
// Emitting a local service event keeps Kinect-specific code from importing
|
|
// sessionService directly and creating a require cycle.
|
|
kinectEvents.emit('change', buildStatus(extra));
|
|
}
|
|
|
|
function sendCachedFrames(socket) {
|
|
if (!passesMode(socket)) {
|
|
return;
|
|
}
|
|
// Cached-frame replay gives newly opened tabs the latest room snapshot
|
|
// without starting a new Kinect capture or spending upload continuously.
|
|
if (lastPointCloud?.buffer) {
|
|
socket.emit('kinect:pointCloudFrame', lastPointCloud.meta, lastPointCloud.buffer);
|
|
}
|
|
if (lastColorImage?.buffer) {
|
|
socket.emit('kinect:colorFrame', lastColorImage.meta, lastColorImage.buffer);
|
|
}
|
|
}
|
|
|
|
function broadcastFrame(eventName, meta, buffer) {
|
|
// Kinect frames are room-privacy-sensitive, especially in lockdown mode.
|
|
// Do not use io.emit here: every frame must be checked against the current
|
|
// mode because lockdown is explicitly a privacy mode where only lockdown
|
|
// admins should receive camera-like data.
|
|
io.sockets.sockets.forEach((socket) => {
|
|
if (!passesMode(socket)) {
|
|
return;
|
|
}
|
|
socket.emit(eventName, meta, buffer);
|
|
});
|
|
}
|
|
|
|
function rejectDisabled() {
|
|
if (!settings.enabled) {
|
|
return { error: 'kinect service is disabled' };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function rejectUnauthorized(socket) {
|
|
if (!passesMode(socket)) {
|
|
return { error: 'not authorized for kinect controls' };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function rejectCaptureCooldown() {
|
|
const now = Date.now();
|
|
if (captureCooldownUntil > now) {
|
|
return {
|
|
error: 'kinect capture cooldown active',
|
|
retryAfterMs: captureCooldownUntil - now,
|
|
captureCooldownUntil,
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function handleCapture(socket, kind, cb) {
|
|
const disabled = rejectDisabled();
|
|
const unauthorized = rejectUnauthorized(socket);
|
|
const cooldown = rejectCaptureCooldown();
|
|
if (disabled || unauthorized || cooldown) {
|
|
const response = disabled || unauthorized || cooldown;
|
|
cb(response);
|
|
emitStatusChange();
|
|
return;
|
|
}
|
|
|
|
if (busy) {
|
|
cb({ error: 'kinect capture already running' });
|
|
emitStatusChange();
|
|
return;
|
|
}
|
|
|
|
busy = true;
|
|
lastAction = kind;
|
|
lastError = null;
|
|
// The cooldown starts when the server accepts the request. That makes every
|
|
// connected browser disable capture controls immediately, instead of waiting
|
|
// for the worker to finish serializing a multi-megabyte point cloud.
|
|
captureCooldownUntil = Date.now() + settings.captureCooldownMs;
|
|
cb({ ok: true, captureCooldownUntil });
|
|
emitStatusChange();
|
|
|
|
try {
|
|
const capture =
|
|
kind === 'pointCloud'
|
|
? await hardware.capturePointCloud()
|
|
: await hardware.captureColorImage();
|
|
const meta = {
|
|
...capture.meta,
|
|
ts: Date.now(),
|
|
requestedBy: socket.id,
|
|
};
|
|
if (kind === 'pointCloud') {
|
|
lastPointCloud = { meta, buffer: capture.buffer };
|
|
broadcastFrame('kinect:pointCloudFrame', meta, capture.buffer);
|
|
} else {
|
|
lastColorImage = { meta, buffer: capture.buffer };
|
|
broadcastFrame('kinect:colorFrame', meta, capture.buffer);
|
|
}
|
|
logger.info('Kinect capture broadcast', {
|
|
kind,
|
|
bytes: capture.buffer?.length || capture.buffer?.byteLength || 0,
|
|
socketId: socket.id,
|
|
});
|
|
} catch (err) {
|
|
lastError = err.message || 'kinect capture failed';
|
|
logger.warn('Kinect capture failed', { kind, socketId: socket.id, err: lastError });
|
|
} finally {
|
|
busy = false;
|
|
emitStatusChange();
|
|
}
|
|
}
|
|
|
|
io.on('connection', (socket) => {
|
|
sendCachedFrames(socket);
|
|
|
|
socket.on('kinect:requestCachedFrames', (_payload = {}, cb = () => {}) => {
|
|
sendCachedFrames(socket);
|
|
cb({ ok: true });
|
|
});
|
|
|
|
socket.on('kinect:requestPointCloud', (_payload = {}, cb = () => {}) => {
|
|
handleCapture(socket, 'pointCloud', cb);
|
|
});
|
|
|
|
socket.on('kinect:requestColorImage', (_payload = {}, cb = () => {}) => {
|
|
handleCapture(socket, 'colorImage', cb);
|
|
});
|
|
});
|
|
|
|
if (settings.enabled) {
|
|
try {
|
|
// Starting the worker at service startup gives the callback path time to
|
|
// warm up, while clients still control when bytes are uploaded to them.
|
|
hardware.startWorker();
|
|
} catch (err) {
|
|
lastError = err.message || 'kinect worker failed to start';
|
|
logger.warn('Kinect worker startup failed', { err: lastError });
|
|
}
|
|
}
|
|
|
|
return {
|
|
getState: buildStatus,
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
registerKinectSocketGateway,
|
|
kinectEvents,
|
|
};
|