new internal concept: operating mode. will allow for more UI and separate pages to be made to operate things other than rovers. Converted PTZ to new system.

This commit is contained in:
legop3
2026-09-22 14:42:21 -04:00
parent e750712a39
commit 846c9dae55
24 changed files with 236 additions and 336 deletions
+12 -2
View File
@@ -7,6 +7,7 @@ const logger = require('../../globals/logger').child('assignment');
const { MODES, getMode, modeEvents } = require('../modeManager');
const { roleEvents, getRole, isAdmin, isLockdownAdmin } = require('../roleService');
const roverManager = require('../roverManager');
const { getOperatingMode } = require('../operatingModeService');
const { compareRoversForAssignment } = require('./roverRanking');
const socketRefs = new Map(); // socketId -> socket
@@ -131,7 +132,7 @@ roverManager.managerEvents.on('switch', ({ socketId, roverId }) => {
});
function assignSocket(socket, options = {}) {
if (!socket || isAdmin(socket) || getRole(socket) !== 'user') {
if (!socket || getOperatingMode(socket) !== 'rover' || isAdmin(socket) || getRole(socket) !== 'user') {
return;
}
// avoid double assignment
@@ -311,13 +312,22 @@ function describeAssignment(socketId) {
module.exports = {
assignmentEvents,
resumeAssignment: assignSocket,
releaseForOperatingMode: (socket) => {
// Include direct admin control as well as automatic assignment ownership.
const roverIds = new Set(roverManager.getRoversForSocket(socket.id));
const assigned = assignments.get(socket.id);
if (assigned) roverIds.add(assigned);
waiting.delete(socket.id);
roverIds.forEach((roverId) => forceRelease(roverId, socket.id));
},
describeAssignment,
forceRelease,
forceReleaseWithNotice,
rerollAssignments,
getAssignedRover: (socketId) => assignments.get(socketId) || null,
moveAssignment: (socket, roverId, { releasePrevious = true } = {}) => {
if (!socket || !roverId) return;
if (!socket || !roverId || getOperatingMode(socket) !== 'rover') return;
const previous = assignments.get(socket.id);
if (previous && previous !== roverId && releasePrevious) {
roverManager.releaseControl(previous, socket);
@@ -2,20 +2,14 @@
// Purpose: Builds normalized chat message/typing payloads and rover context snapshots.
// Scope: Encapsulates chat DTO construction and rover/driver metadata extraction.
const { v4: uuidv4 } = require('uuid');
const io = require('../../globals/io');
const roverManager = require('../roverManager');
const { getRole } = require('../roleService');
const { getOperatingMode } = require('../operatingModeService');
const { describeAssignment } = require('../assignmentService');
const { getNickname } = require('../nicknameService');
const ptzCameraService = require('../ptzCameraService');
function resolvePtzChatTarget(socketId) {
return ptzCameraService.getChatTargetForSocket(socketId) || null;
}
function resolveRoverId(socketId) {
const ptzTarget = resolvePtzChatTarget(socketId);
if (ptzTarget?.roverId) return ptzTarget.roverId;
const primary = roverManager.getPrimaryRoverForSocket(socketId);
if (primary) return primary;
const assignment = describeAssignment(socketId);
@@ -24,54 +18,22 @@ function resolveRoverId(socketId) {
function resolveRoverColor(roverId) {
if (!roverId) return null;
if (String(roverId) === ptzCameraService.PTZ_CAMERA_ID) {
return ptzCameraService.getPublicState()?.color || null;
}
const record = roverManager.rovers.get(String(roverId));
return record?.meta?.color || null;
}
function resolveRoverName(roverId) {
if (!roverId) return null;
if (String(roverId) === ptzCameraService.PTZ_CAMERA_ID) {
return ptzCameraService.getPublicState()?.name || null;
}
const record = roverManager.rovers.get(String(roverId));
return record?.meta?.name || null;
}
function isPtzChatTargetId(roverId) {
/*
PTZ is intentionally treated as a virtual rover for chat identity only. It
does not live in roverManager.rovers because movement, video authorization,
and queue ownership are PTZ-service concerns, but chat needs one stable
"rover-like" id so the existing web UI, Discord bridge, and AI transcript
code can all render the same badge without learning PTZ internals.
*/
return Boolean(roverId) && String(roverId) === ptzCameraService.PTZ_CAMERA_ID;
}
function isPublicChatTargetId(roverId, socket = null) {
if (!roverId) return false;
/*
Normal rovers remain governed by the existing replay visibility rule, which
is also the rule chat historically used to avoid exposing closed private
rover activity. PTZ gets an explicit allow-list entry here because it is a
public chat target that deliberately pretends to be a rover, even though it
is not a roverManager record.
*/
if (isPtzChatTargetId(roverId)) return true;
return roverManager.canReplayRoverId(roverId, socket) === true;
return Boolean(roverId) && roverManager.canReplayRoverId(roverId, socket) === true;
}
function isPrivateClosedRoverId(roverId) {
if (!roverId) return false;
/*
PTZ uses the existing rover badge fields so chat rows can reuse RoverLabel,
but it is not a private rover. Let PTZ-badged messages broadcast normally
instead of falling into the closed-private rover path for unknown ids.
*/
return !isPublicChatTargetId(roverId);
return Boolean(roverId) && !isPublicChatTargetId(roverId);
}
function normalizeProfileImageUrl(value) {
@@ -170,6 +132,8 @@ function buildMessage(socket, text, meta = {}) {
socketId: socket?.id || null,
nickname: meta.nickname || getNickname(socket) || null,
role: meta.role || getRole(socket),
operatingMode: socket ? getOperatingMode(socket) : null,
operatingModeDisplay: socket ? ptzCameraService.getOperatingModeDisplay(socket.id) : null,
roverId,
roverName,
roverColor,
@@ -215,6 +179,8 @@ function buildTypingPayload(socket, meta = {}) {
socketId,
nickname: meta.nickname || getNickname(socket) || null,
role: meta.role || getRole(socket),
operatingMode: socket ? getOperatingMode(socket) : null,
operatingModeDisplay: socket ? ptzCameraService.getOperatingModeDisplay(socket.id) : null,
roverId,
roverName,
roverColor,
@@ -231,7 +197,6 @@ function buildTypingPayload(socket, meta = {}) {
module.exports = {
resolveRoverId,
isPtzChatTargetId,
isPublicChatTargetId,
isPrivateClosedRoverId,
buildRoverCtxSnapshot,
@@ -19,13 +19,6 @@ const { getLastAccessNoticeAt, setLastAccessNoticeAt } = require('./state');
function playTypingNote(roverId, note, socketId) {
if (!roverId) return;
/*
PTZ borrows the roverId field for chat badges, but it has no rover command
channel. Skipping the song command here keeps PTZ chat from producing noisy
"unknown rover" command attempts while still allowing the message itself to
behave like rover chat everywhere else.
*/
if (String(roverId) === ptzCameraService.PTZ_CAMERA_ID) return;
try {
issueCommand(roverId, {
type: 'song',
@@ -90,8 +83,8 @@ function maybeSendAccessNotice(message, sendSystemMessage) {
}
function maybeSpeak(socket, message, ttsOptions) {
if (!ttsOptions || !message?.roverId) return;
if (String(message.roverId) === ptzCameraService.PTZ_CAMERA_ID) {
if (!ttsOptions || !message) return;
if (message.operatingMode === 'ptz') {
/*
PTZ has no rover websocket, but it does have a real speaker behind the
Reolink/neolink path. Keep PTZ routing here so chat remains the single
@@ -107,6 +100,7 @@ function maybeSpeak(socket, message, ttsOptions) {
});
return;
}
if (!message.roverId) return;
const record = roverManager.rovers.get(message.roverId);
const ttsEnabled = Boolean(record?.meta?.audio?.ttsEnabled);
if (!ttsEnabled) return;
@@ -59,12 +59,6 @@ function createChatBridgeHandlers(deps) {
function handleChatBridgeOutbound(event) {
const payload = event?.payload;
if (!payload) return;
/*
Outbound bridge filtering must use chat visibility, not rover replay
visibility. PTZ deliberately uses roverId: "ptz-camera" so the existing
chat badge path can be reused, but that id is not a roverManager rover and
would be dropped by canReplayRoverId().
*/
if (payload?.roverId && !isPublicChatTargetId(payload.roverId)) return;
const guildConfigs = listGuildConfigs();
if (!guildConfigs.length) return;
@@ -24,14 +24,8 @@ function formatWebhookUsername(payload) {
const origin = payload.discordGuildName ? ` (From: ${payload.discordGuildName})` : '';
return `${name}${origin}${botTag}${spectatorTag}${adminTag}`;
}
/*
The chat payload already carries the resolved display name for rover-like
targets. Prefer that name so PTZ, which is intentionally pretending to be a
rover in chat, shows up as "PTZ Camera" instead of the internal id
"ptz-camera"; fall back to the id for older payloads or missing metadata.
*/
const roverTagLabel = payload.roverName || payload.roverId;
const roverTag = payload.roverId ? ` [${roverTagLabel}]` : '';
const tagLabel = payload.operatingModeDisplay?.name || payload.roverName || payload.roverId;
const roverTag = tagLabel ? ` [${tagLabel}]` : '';
return `${name}${botTag}${spectatorTag}${adminTag}${roverTag}`;
}
@@ -370,11 +370,6 @@ function createSnapshotEngine(deps) {
.filter((entry) => {
const roverId = entry?.roverId ? String(entry.roverId) : null;
if (!roverId) return true;
/*
This is a chat transcript filter, not a physical-rover filter. PTZ
chat intentionally carries a rover-like id so transcript consumers can
render it consistently, even though roverManager cannot replay that id.
*/
return isPublicChatTargetId(roverId);
});
const chatRecent = allRecentMessages
@@ -430,6 +425,8 @@ function createSnapshotEngine(deps) {
type: 'chat',
nickname: entry.nickname || entry.socketId?.slice(0, 6) || 'unknown',
text: entry.text || '',
operating_mode: entry.operatingMode || null,
operating_mode_name: entry.operatingModeDisplay?.name || null,
rover_id: roverId,
rover_ctx: { ...baseCtx, ...storedCtx },
};
@@ -0,0 +1,65 @@
const EventEmitter = require('events');
const operatingModeEvents = new EventEmitter();
const transitions = new WeakMap();
function getOperatingMode(socket) {
// Handshake intent reserves the mode before role-driven assignment runs.
// It grants no camera permissions; entry still checks access and readiness.
return socket?.data?.operatingMode
|| (socket?.handshake?.auth?.operatingMode === 'ptz' ? 'ptz' : 'rover');
}
async function transition(socket, operatingMode) {
// Resolve services at the action boundary to avoid a dependency cycle with
// assignment and camera policy, which both read operating mode.
const assignment = require('../assignmentService');
const roverManager = require('../roverManager');
const ptz = require('../ptzCameraService');
if (operatingMode === 'rover' && getOperatingMode(socket) === 'rover') {
return { operatingMode, state: ptz.getPublicState(socket) };
}
if (operatingMode === 'ptz') {
await ptz.prepareParticipation(socket);
if (socket.disconnected) throw new Error('Session disconnected');
ptz.checkParticipationAccess(socket);
const leave = roverManager.canLeaveCurrentRover(socket);
if (!leave.ok) throw new Error(leave.message);
// Block new rover ownership before release events notify other services.
socket.data.operatingMode = 'ptz';
assignment.releaseForOperatingMode(socket);
ptz.claimTurn(socket);
} else {
ptz.releaseTurn(socket);
socket.data.operatingMode = 'rover';
assignment.resumeAssignment(socket);
}
operatingModeEvents.emit('change', { socket, operatingMode });
return { operatingMode, state: ptz.getPublicState(socket) };
}
function enqueueTransition(socket, action) {
// Camera initialization can yield. Serialize switches and turn release per
// socket so a late entry cannot undo a subsequent exit or release request.
const previous = transitions.get(socket) || Promise.resolve();
const next = previous.catch(() => {}).then(() => {
if (socket.disconnected) throw new Error('Session disconnected');
return action();
});
transitions.set(socket, next);
return next;
}
function setOperatingMode(socket, operatingMode) {
if (!['rover', 'ptz'].includes(operatingMode)) {
return Promise.reject(new Error('Invalid operating mode'));
}
return enqueueTransition(socket, () => transition(socket, operatingMode));
}
function releasePtzTurn(socket) {
return enqueueTransition(socket, () => require('../ptzCameraService').releaseTurn(socket));
}
module.exports = { getOperatingMode, setOperatingMode, releasePtzTurn, operatingModeEvents };
@@ -396,11 +396,6 @@ async function runDecision(triggerReason) {
.filter((entry) => Number(entry?.ts || 0) >= runtime.contextResetAt)
.filter((entry) => {
if (!entry?.roverId) return true;
/*
Chat context should preserve every public chat target, including the
PTZ virtual rover. Rover replay visibility alone would drop PTZ because
it is owned by ptzCameraService instead of roverManager.
*/
return isPublicChatTargetId(entry.roverId);
})
.slice(-(MAX_CHAT_CONTEXT + MAX_BOT_CONTEXT));
+42 -114
View File
@@ -20,7 +20,7 @@ const { isAdmin, isLockdownAdmin, getRole } = require('../roleService');
const { isVerified } = require('../verificationService');
const { getSocketIp, isLocalNetwork } = require('../../helpers/ipResolver');
const roverManager = require('../roverManager');
const assignmentService = require('../assignmentService');
const { getOperatingMode, releasePtzTurn } = require('../operatingModeService');
const videoSessions = require('../videoSessions');
const { createPtzAudioPlayback } = require('./audioPlayback');
@@ -337,36 +337,20 @@ function getPublicState(socket = null) {
};
}
function getChatTargetForSocket(socketId) {
/*
Chat badges are rendered through the same rover badge component on the
browser, so PTZ presents itself as a rover-like chat target while a user is
actively operating or waiting for the camera. Keeping this mapping in the
PTZ service avoids making chat infer camera queue details from public state.
*/
if (!enabled || !socketId) return null;
const normalized = String(socketId);
const waiting = state.queue.includes(normalized);
const operating = state.operatorSocketId === normalized;
if (!waiting && !operating) return null;
function getOperatingModeDisplay(socketId) {
const socket = io.sockets.sockets.get(socketId);
if (!socket || getOperatingMode(socket) !== 'ptz') return null;
return {
roverId: PTZ_CAMERA_ID,
roverName: cameraConfig.name || 'PTZ Camera',
roverColor: cameraConfig.color || DEFAULT_PTZ_COLOR,
name: cameraConfig.name || 'PTZ Camera',
color: cameraConfig.color || DEFAULT_PTZ_COLOR,
};
}
function getParticipantSocketIds() {
/*
PTZ has no roverManager record, so services that need a global "how many
controllable users are online" count need a tiny PTZ-owned participant list.
The operator and queue are the only users attached to this controllable
camera target; spectators merely viewing snapshots/live video are excluded.
*/
return Array.from(new Set([
state.operatorSocketId,
...state.queue,
].filter(Boolean)));
// A released/expired camera turn does not end participation in PTZ mode.
return Array.from(io.sockets.sockets.values())
.filter((socket) => getOperatingMode(socket) === 'ptz' && canUsePtzFeature(socket))
.map((socket) => socket.id);
}
function countControllableUsers() {
@@ -386,13 +370,13 @@ function countControllableUsers() {
function canSpeakThroughPtz(socket) {
/*
PTZ chat uses roverId for identity, but the camera has its own queue rather
PTZ chat carries its own operating-mode identity and camera queue rather
than a roverManager driver record. Match the rover TTS rule closely: the
current operator may speak, and queued users may prepare/use TTS while they
are in the camera queue. canUsePtzFeature keeps the normal VIP/admin/mode
access gates in front of both cases.
*/
if (!canUsePtzFeature(socket)) return false;
if (getOperatingMode(socket) !== 'ptz' || !canUsePtzFeature(socket)) return false;
const socketId = socket?.id ? String(socket.id) : '';
if (!socketId) return false;
return state.operatorSocketId === socketId || state.queue.includes(socketId);
@@ -898,39 +882,6 @@ function removeFromQueue(socketId) {
state.queue = state.queue.filter((id) => id !== socketId);
}
function buildDockRequiredPayload(socket, leave) {
/*
PTZ must not become an escape hatch for abandoning the last undocked rover.
Keep the payload shape shared between immediate claim rejection and stale
queue cleanup so the browser gets one consistent dock-required event.
*/
return {
socketId: socket.id,
label: getSocketLabel(socket.id),
roverId: leave.currentId || null,
message: leave.message,
until: null,
};
}
function releaseRoverOwnershipForPtz(socket) {
/*
PTZ operation must remove the browser from every rover-control state, not
only the roverManager driver set. The normal assignment UI is backed by
assignmentService, while low-level control membership is tracked inside
roverManager. In healthy flows those two agree, but reconnects, admin paths,
or earlier cleanup can leave only one side populated. Releasing the union
keeps PTZ from looking like a second simultaneous rover assignment.
*/
if (!socket?.id) return;
const roverIds = new Set(roverManager.getRoversForSocket(socket.id));
const assignedRoverId = assignmentService.getAssignedRover?.(socket.id);
if (assignedRoverId) roverIds.add(assignedRoverId);
roverIds.forEach((roverId) => {
assignmentService.forceRelease(roverId, socket.id);
});
}
function revokeOperator(reason = 'release') {
if (!state.operatorSocketId) return;
const previous = state.operatorSocketId;
@@ -981,64 +932,38 @@ function advanceQueue(reason = 'advance') {
}
const nextId = state.queue[0];
const socket = io.sockets.sockets.get(nextId);
if (!socket || !canUsePtzFeature(socket)) {
if (!socket || getOperatingMode(socket) !== 'ptz' || !canUsePtzFeature(socket)) {
removeFromQueue(nextId);
advanceQueue('drop-invalid');
return;
}
const leave = roverManager.canLeaveCurrentRover(socket);
if (!leave.ok) {
/*
claim() blocks this before queue entry, but this defensive check handles
stale state: a user can dock, join the queue, then undock again before
their PTZ turn arrives. In that case they are removed instead of holding a
PTZ queue slot while still responsible for an undocked rover.
*/
removeFromQueue(socket.id);
socket.emit('ptzCamera:dockRequired', buildDockRequiredPayload(socket, leave));
emitChange('drop-dock-required');
advanceQueue('drop-dock-required');
return;
}
activateOperator(socket);
}
async function claim(socket) {
function checkParticipationAccess(socket) {
if (!canUsePtzFeature(socket)) throw new Error('Not authorized for PTZ camera');
await initialize();
if (!state.initialized) throw new Error(state.error || 'PTZ camera is not ready');
if (state.operatorSocketId === socket.id) return getPublicState(socket);
const leave = roverManager.canLeaveCurrentRover(socket);
if (!leave.ok) {
const payload = buildDockRequiredPayload(socket, leave);
socket.emit('ptzCamera:dockRequired', payload);
throw new Error(leave.message);
}
/*
Joining PTZ is the point where rover ownership must end, even when another
user is currently operating the camera and this socket only enters the
waiting queue. PTZ queue membership is still a camera session: the user is
waiting for a camera turn, not continuing as a rover driver until their turn
arrives. Releasing here also keeps chat badges, assignment UI, and command
routing from presenting a "rover plus camera queue" hybrid state.
*/
releaseRoverOwnershipForPtz(socket);
if (state.operatorSocketId) {
if (!state.queue.includes(socket.id)) state.queue.push(socket.id);
emitChange('queue-join');
return getPublicState(socket);
}
if (!state.queue.includes(socket.id)) state.queue.unshift(socket.id);
advanceQueue('claim');
return getPublicState(socket);
}
async function release(socket) {
if (state.operatorSocketId === socket.id || isAdmin(socket)) {
async function prepareParticipation(socket) {
if (!canUsePtzFeature(socket)) throw new Error('Not authorized for PTZ camera');
await initialize();
}
function claimTurn(socket) {
checkParticipationAccess(socket);
if (getOperatingMode(socket) !== 'ptz') throw new Error('PTZ operating mode required');
if (state.operatorSocketId === socket.id || state.queue.includes(socket.id)) return;
state.queue.push(socket.id);
advanceQueue('claim');
}
function releaseTurn(socket) {
removeFromQueue(socket.id);
if (state.operatorSocketId === socket.id) {
revokeOperator('manual-release');
advanceQueue('manual-release');
} else {
removeFromQueue(socket.id);
emitChange('queue-leave');
}
return getPublicState(socket);
@@ -1047,7 +972,7 @@ async function release(socket) {
function requireOperator(socket) {
if (!enabled) throw new Error('PTZ camera disabled');
if (!passesMode(socket)) throw new Error('Not authorized for PTZ camera');
if (!socket || state.operatorSocketId !== socket.id) throw new Error('Not the PTZ operator');
if (!socket || getOperatingMode(socket) !== 'ptz' || state.operatorSocketId !== socket.id) throw new Error('Not the PTZ operator');
}
function requirePtzUser(socket) {
@@ -1686,18 +1611,17 @@ events.on('snapshot:status', ({ error }) => {
function registerSocketHandlers() {
io.on('connection', (socket) => {
socket.on('ptzCamera:claim', async (firstArg, secondArg) => {
socket.on('ptzCamera:revokeOperator', (firstArg, secondArg) => {
const { cb } = normalizeSocketArgs(firstArg, secondArg);
try {
cb({ ok: true, state: await claim(socket) });
} catch (err) {
cb({ error: err.message });
}
if (!isAdmin(socket)) return cb({ error: 'Admin required' });
revokeOperator('admin-revoke');
advanceQueue('admin-revoke');
cb({ ok: true });
});
socket.on('ptzCamera:release', async (firstArg, secondArg) => {
const { cb } = normalizeSocketArgs(firstArg, secondArg);
try {
cb({ ok: true, state: await release(socket) });
cb({ ok: true, state: await releasePtzTurn(socket) });
} catch (err) {
cb({ error: err.message });
}
@@ -1870,7 +1794,11 @@ module.exports = {
PTZ_STREAM_PATH,
ptzCameraEvents: events,
getPublicState,
getChatTargetForSocket,
getOperatingModeDisplay,
prepareParticipation,
checkParticipationAccess,
claimTurn,
releaseTurn,
getParticipantSocketIds,
canSpeakThroughPtz,
speakText,
@@ -1,3 +1,4 @@
const { getOperatingMode } = require('../operatingModeService');
// rover Manager private access
// Purpose: Encapsulates private-rover visibility and control-authorization policy checks.
// Scope: Keeps runtime behavior unchanged while isolating policy decisions from orchestration wiring.
@@ -80,6 +81,7 @@ function createPrivateAccessPolicy(deps) {
allowClosedPrivateGrantInLockdown = false,
allowClosedPrivateCurrentDriver = false,
} = options;
if (getOperatingMode(socket) !== 'rover') return 'Leave PTZ before requesting rover control';
if (!record) return 'Unknown rover';
if (!allowUser && !isAdmin(socket)) return 'Only admins can request control';
if (record.locked && !isAdmin(socket)) return 'Rover locked';
+19 -26
View File
@@ -5,6 +5,7 @@ const io = require('../../globals/io');
const logger = require('../../globals/logger').child('sessionService');
const { getFeatureFlags, configurationEvents } = require('../../configuration');
const { getRole, isAdmin, roleEvents } = require('../roleService');
const { getOperatingMode, setOperatingMode, operatingModeEvents } = require('../operatingModeService');
const { getMode, modeEvents } = require('../modeManager');
const roverManager = require('../roverManager');
const { managerEvents } = roverManager;
@@ -13,8 +14,8 @@ const { getActiveDrivers, getTurnQueues, turnEvents } = require('../turnService'
const { getRoomCameras, roomCameraEvents } = require('../roomCameraService');
const {
getPublicState: getPtzCameraState,
getChatTargetForSocket: getPtzChatTargetForSocket,
PTZ_CAMERA_ID,
getOperatingModeDisplay,
getParticipantSocketIds,
ptzCameraEvents,
} = require('../ptzCameraService');
const { getState: getHomeAssistantState, homeAssistantEvents } = require('../homeAssistantService');
@@ -111,18 +112,13 @@ function buildBandwidthSavingsSessionState(socket, controllableUserCount = 0) {
function countControllableUsers(userEntries = []) {
const ids = new Set();
const ptzParticipants = new Set(getParticipantSocketIds());
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);
if (socketId && (roverId || ptzParticipants.has(socketId))) ids.add(socketId);
});
return ids.size;
}
@@ -146,19 +142,14 @@ function buildUserEntry(socket) {
&& 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,
operatingMode: getOperatingMode(socket),
operatingModeDisplay: getOperatingModeDisplay(socket.id),
roverId: verifiedPrimaryRover || verifiedAssignmentRover || null,
};
}
@@ -171,15 +162,7 @@ function buildSession(socket) {
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),
roverId: filterVisibleRoverId(socket, entry.roverId),
}));
const roster = roverManager.getRosterForSocket(socket);
const assignment = assignmentService.describeAssignment(socket?.id || '');
@@ -201,6 +184,7 @@ function buildSession(socket) {
return {
socketId: socket?.id || null,
role: getRole(socket),
operatingMode: getOperatingMode(socket),
mode: getMode(),
isLocalNetwork: isLocalNetwork(getSocketIp(socket)),
bandwidthSavings: buildBandwidthSavingsSessionState(socket, controllableUserCount),
@@ -280,10 +264,19 @@ function syncAll() {
}
io.on('connection', (socket) => {
socket.on('session:setOperatingMode', async ({ operatingMode } = {}, cb = () => {}) => {
try {
cb({ ok: true, ...await setOperatingMode(socket, operatingMode) });
} catch (err) {
cb({ error: err.message });
}
});
logger.info('New socket connected', socket.id);
syncSocket(socket);
});
operatingModeEvents.on('change', () => syncAll());
roleEvents.on('change', ({ socket }) => {
if (!socket) return;
logger.info('Role changed; syncing session', socket.id);
@@ -157,11 +157,11 @@ function identifySocket(socket, payload = {}) {
}
/*
Spectator-style pages carry identity too, but only the driver surface
participates in duplicate-driver enforcement. This flag remains on the
Spectator-style pages carry identity too, but only the driver and PTZ surfaces
participate in duplicate-driver enforcement. This flag remains on the
socket because it is connection-specific, not person-specific.
*/
socket.data.identitySurface = payload.identitySurface === 'driver' ? 'driver' : 'passive';
socket.data.identitySurface = ['driver', 'ptz'].includes(payload.identitySurface) ? payload.identitySurface : 'passive';
const result = identifyCanonicalSocket(socket, {
...payload,
@@ -207,7 +207,7 @@ function enforceSingleDriverSocketPerIdentity(currentSocket) {
if (
!currentSocket?.id ||
!currentUserId ||
currentSocket.data?.identitySurface !== 'driver' ||
!['driver', 'ptz'].includes(currentSocket.data?.identitySurface) ||
!enforceForCurrentSocket
) {
return;
@@ -215,7 +215,7 @@ function enforceSingleDriverSocketPerIdentity(currentSocket) {
const duplicates = Array.from(io.sockets.sockets.values()).filter((candidate) => {
if (!candidate?.id || candidate.id === currentSocket.id || candidate.disconnected) return false;
if (candidate?.data?.identitySurface !== 'driver') return false;
if (!['driver', 'ptz'].includes(candidate?.data?.identitySurface)) return false;
return getUserIdForSocket(candidate) === currentUserId;
});