// session Service // Purpose: Defines the session Service module and the helpers/state used by this service unit. // Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary. const io = require('../../globals/io'); const logger = require('../../globals/logger').child('sessionService'); const { getRole, isAdmin, roleEvents } = require('../roleService'); const { getMode, modeEvents } = require('../modeManager'); const roverManager = require('../roverManager'); const { managerEvents } = roverManager; const assignmentService = require('../assignmentService'); const { getActiveDrivers, getTurnQueues, turnEvents } = require('../turnService'); const { getRoomCameras, roomCameraEvents } = require('../roomCameraService'); const { getPublicState: getPtzCameraState, getChatTargetForSocket: getPtzChatTargetForSocket, PTZ_CAMERA_ID, ptzCameraEvents, } = require('../ptzCameraService'); const { getState: getHomeAssistantState, homeAssistantEvents } = require('../homeAssistantService'); const { isEnabled: isGreenModeEnabled, greenModeEvents } = require('../greenModeService'); const { getState: getNeatoState, neatoEvents } = require('../neatoService'); const { getState: getLiftState, liftEvents } = require('../liftService'); const { getState: getKinectState, kinectEvents } = require('../kinectService'); const { getState: getBalanceBoardState, balanceBoardEvents, } = require('../balanceBoardService'); const { getVoteStatus: getOverseerVoteStatus } = require('../overseerControlService'); const { getNickname, nicknameEvents } = require('../nicknameService'); const { getVerificationStateForSocket, getModerationStateForSocket, getIdentitySummary, verificationEvents, } = require('../verificationService'); const { getStateForSocket: getPrivateRoverAccessStateForSocket, requestEvents: privateRoverAccessRequestEvents, } = require('../privateRoverAccessRequestService'); const { getReplayState, replayEvents, getReplaySources } = require('../replayEngineV2'); const { getHealthSnapshot } = require('../healthService'); const { getGlobalObjective } = require('../globalObjectiveService'); const { getAdminReason } = require('../adminReasonService'); const { subscribe } = require('../eventBus'); const { getSocketIp, isLocalNetwork } = require('../../helpers/ipResolver'); const { getFeatureFlags } = require('../../helpers/features'); const { canUseExternalSpectatorAccess, getBandwidthSavingsPolicy, shouldUseSnapshotsForNonTurnVideo, } = require('../../helpers/bandwidthSavings'); const { getFeatureState, getUserIdForSocket, } = require('../identityService'); const { getAudioForwardState, audioForwardEvents } = require('../audioForwardService'); const { getAudioLevels, getAudioAdjustmentStateForSocket, audioLevelsEvents } = require('../audioLevelsService'); const { getButtonBoxState } = require('../buttonBoxService'); const { getState: getInterInstanceState, interInstanceEvents } = require('../interInstanceService'); const { discordInvite, kofiLink, serverTimezone, configuredSocials, driverAd, ACTIVITY_SYNC_COOLDOWN_MS, GPIO_TOGGLE_SYNC_COOLDOWN_MS, PERIODIC_SYNC_MS, } = require('./constants'); const { getState, setState } = require('./state'); const { filterVisibleRoverId, filterActiveDriversForSocket, filterTurnQueuesForSocket, } = require('./filters'); logger.info('Discord invite loaded:', discordInvite ? 'present' : 'not configured'); logger.info('Ko-fi link loaded:', kofiLink ? 'present' : 'not configured'); logger.info('Socials config loaded:', configuredSocials?.length ? `${configuredSocials.length} entries` : 'not configured'); const SPECTATOR_ACCESS_NAMESPACE = 'spectatorAccess'; function hasExternalSpectatorGrant(socket) { const userId = getUserIdForSocket(socket); if (!userId) return false; const state = getFeatureState(userId, SPECTATOR_ACCESS_NAMESPACE, {}); return Boolean(state?.external); } function buildBandwidthSavingsSessionState(socket, controllableUserCount = 0) { const policy = getBandwidthSavingsPolicy(); const local = isLocalNetwork(getSocketIp(socket)); const granted = hasExternalSpectatorGrant(socket); return { ...policy, nonTurnVideo: { ...policy.nonTurnVideo, controllableUserCount, snapshotsActive: shouldUseSnapshotsForNonTurnVideo({ controllableUserCount }), }, /* These derived fields let browser routes make clear UI choices without re-implementing IP/admin/grant logic. The server still enforces the same decisions in auth and video services, so the UI remains advisory only. */ externalSpectatorGranted: granted, canUseExternalSpectatorAccess: canUseExternalSpectatorAccess({ isLocal: local, isAdmin: isAdmin(socket), isVerified: Boolean(socket?.data?.isVerified), hasGrant: granted, }), }; } function countControllableUsers(userEntries = []) { const ids = new Set(); userEntries.forEach((entry) => { const role = String(entry?.role || ''); if (role === 'spectator') return; const socketId = String(entry?.socketId || '').trim(); const roverId = String(entry?.roverId || '').trim(); /* buildUserEntry already maps PTZ queued/operators to the PTZ pseudo-rover id and normal drivers to their physical rover. Counting entries after that normalization gives the browser the same conceptual "controllable users" count it shows in the user/queue panels without duplicating PTZ UI logic. */ if (socketId && roverId) ids.add(socketId); }); return ids.size; } function buildUserEntry(socket) { if (!socket) return null; const role = getRole(socket); const assignment = assignmentService.describeAssignment(socket.id); const primaryRover = roverManager.getPrimaryRoverForSocket(socket.id); /* assignmentService owns automatic placement policy, while roverManager owns actual control membership. Validate both candidate indexes before exposing presence because neither cached direction is authoritative without the physical rover record agreeing that this socket is one of its drivers. */ const verifiedPrimaryRover = primaryRover && roverManager.isDriver(primaryRover, socket) ? primaryRover : null; const verifiedAssignmentRover = assignment?.roverId && roverManager.isDriver(assignment.roverId, socket) ? assignment.roverId : null; const ptzChatTarget = getPtzChatTargetForSocket(socket.id); return { socketId: socket.id, userId: socket?.data?.userId || null, nickname: getNickname(socket) || null, role, /* PTZ is not inserted into the physical rover roster, but for chat and user presence it should read like the user moved to a rover-like target. Prefer the PTZ chat target while the socket is queued or operating so presence, queue lookup, and chat identity all agree. */ roverId: ptzChatTarget?.roverId || verifiedPrimaryRover || verifiedAssignmentRover || null, }; } function buildSession(socket) { const overseerVote = getOverseerVoteStatus(); const features = getFeatureFlags(); const userEntries = Array.from(io.sockets.sockets.values()) .map((sock) => buildUserEntry(sock)) .filter(Boolean); const controllableUserCount = countControllableUsers(userEntries); const users = userEntries.map((entry) => ({ ...entry, /* PTZ is intentionally not a roverManager record, so the normal physical rover visibility filter would erase the user's PTZ chat target. Preserve it here because getPtzChatTargetForSocket already applied the PTZ access and queue/operator rules before buildUserEntry returned it. */ roverId: entry.roverId === PTZ_CAMERA_ID ? entry.roverId : filterVisibleRoverId(socket, entry.roverId), })); const roster = roverManager.getRosterForSocket(socket); const assignment = assignmentService.describeAssignment(socket?.id || ''); /* Visibility alone is insufficient here: a reconnected rover can be visible before a stale assignment map has recreated actual driver membership. The session contract consumed by every UI surface must require both visibility and roverManager's authoritative membership check. */ const verifiedAssignmentRover = assignment?.roverId && roverManager.isDriver(assignment.roverId, socket) ? assignment.roverId : null; const assignmentRoverId = filterVisibleRoverId(socket, verifiedAssignmentRover); const activeDrivers = filterActiveDriversForSocket(getActiveDrivers(), socket); const turnQueues = filterTurnQueuesForSocket(getTurnQueues(), socket); const socials = features.socials && configuredSocials?.length ? configuredSocials : []; return { socketId: socket?.id || null, role: getRole(socket), mode: getMode(), isLocalNetwork: isLocalNetwork(getSocketIp(socket)), bandwidthSavings: buildBandwidthSavingsSessionState(socket, controllableUserCount), /* Features is the single UI contract for optional server capabilities. A disabled feature should be absent from navigation/layout decisions even though the service module may still be loaded on the Node side. */ features, roster, odometers: roverManager.getOdometersForSocket(socket), assignment: { ...assignment, roverId: assignmentRoverId, status: assignmentRoverId ? assignment.status : assignment.status === 'waiting' ? 'waiting' : null, }, activeDrivers, turnQueues, roomCameras: getRoomCameras(), ptzCamera: getPtzCameraState(socket), homeAssistant: getHomeAssistantState(), // Green mode is a server-wide visual feature. It stays separate from Home // Assistant state because HA only supplies the generic light operations. greenMode: isGreenModeEnabled(), neato: getNeatoState(), lift: getLiftState(), kinect: getKinectState(), balanceBoard: getBalanceBoardState(), replay: getReplayState(), replaySources: getReplaySources(socket), health: getHealthSnapshot(), globalObjective: getGlobalObjective(), adminReason: getAdminReason(), users, socials, /* This is intentionally raw, operator-authored HTML. The browser only mounts it on the desktop driver page, but keeping it in the ordinary session payload makes the server configuration the single source of truth and avoids a separate endpoint for one small optional card. */ driverAd, discord: { invite: discordInvite, }, timezone: serverTimezone, kofi: { link: kofiLink, }, identity: getIdentitySummary(socket), verification: getVerificationStateForSocket(socket), moderation: getModerationStateForSocket(socket), privateRoverAccess: getPrivateRoverAccessStateForSocket(socket), isVerified: Boolean(socket?.data?.isVerified), audioForward: getAudioForwardState(), audioLevels: getAudioLevels(), audioAdjustments: getAudioAdjustmentStateForSocket(socket), buttonBox: getButtonBoxState(), /* Inter-instance state is a read-only directory snapshot. It is included in session sync because the UI already treats session payloads as the source of truth for rovers, queues, and public feature availability. */ interInstances: getInterInstanceState(), overseerVote: { ...overseerVote, preference: typeof socket?.data?.overseerEnabled === 'boolean' ? socket.data.overseerEnabled : true, }, }; } function syncSocket(socket) { if (!socket) return; const payload = buildSession(socket); logger.info('Syncing session', socket.id, payload.role, payload.assignment); socket.emit('session:sync', payload); } function syncAll() { logger.info('Broadcasting session sync to all sockets'); io.sockets.sockets.forEach((socket) => syncSocket(socket)); } io.on('connection', (socket) => { logger.info('New socket connected', socket.id); syncSocket(socket); }); roleEvents.on('change', ({ socket }) => { if (!socket) return; logger.info('Role changed; syncing session', socket.id); syncSocket(socket); }); assignmentService.assignmentEvents.on('update', (socketId) => { const socket = io.sockets.sockets.get(socketId); if (socket) { logger.info('Assignment update; syncing session', socketId); syncSocket(socket); } }); modeEvents.on('change', () => { logger.info('Mode change detected; syncing all clients'); syncAll(); }); managerEvents.on('rover', (event = {}) => { const state = getState(); if (event.action === 'headlight' || event.action === 'laser') { const now = Date.now(); const elapsed = now - state.lastGPIOToggleSync; if (elapsed >= GPIO_TOGGLE_SYNC_COOLDOWN_MS) { setState({ lastGPIOToggleSync: now }); logger.info('GPIO toggle update; syncing all clients (immediate)'); syncAll(); return; } if (!state.pendingGPIOToggleSync) { const delay = GPIO_TOGGLE_SYNC_COOLDOWN_MS - elapsed; const timer = setTimeout(() => { setState({ lastGPIOToggleSync: Date.now(), pendingGPIOToggleSync: null }); logger.info('GPIO toggle update; syncing all clients (delayed)'); syncAll(); }, delay); setState({ pendingGPIOToggleSync: timer }); } return; } if (state.pendingGPIOToggleSync) { clearTimeout(state.pendingGPIOToggleSync); setState({ pendingGPIOToggleSync: null }); } logger.info('Rover roster change; syncing all clients'); syncAll(); }); managerEvents.on('lock', ({ roverId, locked }) => { logger.info('Rover lock change', roverId, locked); syncAll(); }); managerEvents.on('private', ({ roverId, open }) => { logger.info('Private rover visibility change', roverId, open); syncAll(); }); managerEvents.on('privateSafety', ({ roverId }) => { logger.info('Private rover safety config changed', roverId); syncAll(); }); managerEvents.on('help', ({ roverId, needsHelp }) => { // HELP is roster state used by several passive UI routes, so every connected // socket must receive the transition rather than only the rover's drivers. logger.info('Rover help state changed', roverId, needsHelp); syncAll(); }); privateRoverAccessRequestEvents.on('change', (event = {}) => { logger.info('Private rover access request state changed', event.reason || 'unknown'); syncAll(); }); managerEvents.on('driver', ({ socketId }) => { if (!socketId) return; const socket = io.sockets.sockets.get(socketId); if (socket) { logger.info('Driver assignment change; syncing session', socketId); syncSocket(socket); } }); turnEvents.on('activeDriver', () => { logger.info('Active driver change; syncing all clients'); syncAll(); }); turnEvents.on('queue', (event = {}) => { const state = getState(); const { reason } = event; if (reason === 'activity') { const now = Date.now(); const elapsed = now - state.lastActivitySync; if (elapsed >= ACTIVITY_SYNC_COOLDOWN_MS) { setState({ lastActivitySync: now }); logger.info('Turn activity; syncing all clients (immediate)'); syncAll(); return; } if (!state.pendingActivitySync) { const delay = ACTIVITY_SYNC_COOLDOWN_MS - elapsed; const timer = setTimeout(() => { setState({ lastActivitySync: Date.now(), pendingActivitySync: null }); logger.info('Turn activity; syncing all clients (delayed)'); syncAll(); }, delay); setState({ pendingActivitySync: timer }); } return; } if (state.pendingActivitySync) { clearTimeout(state.pendingActivitySync); setState({ pendingActivitySync: null }); } logger.info('Turn queue change; syncing all clients'); syncAll(); }); roomCameraEvents.on('update', () => { logger.info('Room camera change detected; syncing all clients'); syncAll(); }); ptzCameraEvents.on('change', () => { logger.info('PTZ camera state change; syncing all clients'); syncAll(); }); homeAssistantEvents.on('update', () => { logger.info('Home Assistant state change; syncing all clients'); syncAll(); }); greenModeEvents.on('change', () => { logger.info('Green mode change detected; syncing all clients'); syncAll(); }); homeAssistantEvents.on('status', () => { logger.info('Home Assistant status change; syncing all clients'); syncAll(); }); neatoEvents.on('update', () => { logger.info('Neato state change; syncing all clients'); syncAll(); }); liftEvents.on('update', () => { logger.info('Lift state change; syncing all clients'); syncAll(); }); kinectEvents.on('change', () => { logger.info('Kinect state change; syncing all clients'); syncAll(); }); balanceBoardEvents.on('change', () => { // Live weight frames use their own Socket.IO room because they change much // faster than the full session. Only connection/status changes reach this // listener, keeping session sync inexpensive while the panel stays current. logger.info('Balance Board state change; syncing all clients'); syncAll(); }); replayEvents.on('update', () => { logger.info('Replay cooldown updated; syncing all clients'); syncAll(); }); nicknameEvents.on('change', ({ socketId }) => { const socket = socketId ? io.sockets.sockets.get(socketId) : null; if (socket) { logger.info('Nickname change; syncing session', socketId); syncSocket(socket); } else { syncAll(); } }); verificationEvents.on('change', ({ socketId } = {}) => { if (socketId) { const socket = io.sockets.sockets.get(socketId); if (socket) { syncSocket(socket); return; } } syncAll(); }); subscribe('globalObjective.updated', () => { logger.info('Global objective updated; syncing all clients'); syncAll(); }); subscribe('adminReason.updated', () => { logger.info('Admin reason updated; syncing all clients'); syncAll(); }); subscribe('buttonBox.updated', () => { syncAll(); }); audioForwardEvents.on('change', () => { syncAll(); }); audioLevelsEvents.on('change', ({ scope, socketId } = {}) => { /* A browser dragging its own volume slider changes only that connection's payload. Base-level and allowed-range changes still affect everyone and use the full broadcast path. */ if (scope === 'socket' && socketId) { syncSocket(io.sockets.sockets.get(socketId)); return; } syncAll(); }); interInstanceEvents.on('change', () => { syncAll(); }); // sync all sockets 20 seconds // setInterval(() => { // logger.info('Periodic session sync for all clients'); // syncAll(); // }, PERIODIC_SYNC_MS); module.exports = { buildSession, syncSocket, syncAll, };