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;
});
@@ -132,11 +132,11 @@ export function ChatIdentity({ message, toolsToggle = null }) {
bot
</span>
) : null}
{message.roverId && (
{(message.roverId || message.operatingModeDisplay) && (
<RoverLabel
roverId={message.roverId}
name={message.roverName}
color={message.roverColor}
name={message.operatingModeDisplay?.name || message.roverName}
color={message.operatingModeDisplay?.color || message.roverColor}
fallback={message.roverId}
className="shrink-0 text-[0.7rem]"
/>
+4 -18
View File
@@ -51,31 +51,17 @@ function resolveTtsSettings(settings) {
function useChatComposerSessionState(allowSpectatorInput) {
const role = useSessionSelector((state) => state.session?.role || null);
const socketId = useSessionSelector((state) => state.session?.socketId || null);
const operatingMode = useSessionSelector((state) => state.session?.operatingMode || 'rover');
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
const users = useSessionSelector((state) => state.session?.users ?? []);
const roster = useSessionSelector((state) => state.session?.roster ?? []);
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
const chatTargetId = useMemo(() => {
/*
PTZ is intentionally not a roster entry, but session users expose the
current chat target for each socket. Prefer the self user entry so the TTS
control follows PTZ queue/operation state instead of only physical rover
assignment state.
*/
const self = users.find((entry) => entry?.socketId === socketId);
if (!self?.roverId && ptz?.id && (ptz?.isOperator || ptz?.queuedPosition)) return ptz.id;
return self?.roverId || currentRoverId || null;
}, [currentRoverId, ptz?.id, ptz?.isOperator, ptz?.queuedPosition, socketId, users]);
const rover = useMemo(
() => roster.find((entry) => String(entry.id) === String(chatTargetId)) || null,
[chatTargetId, roster],
() => roster.find((entry) => String(entry.id) === String(currentRoverId)) || null,
[currentRoverId, roster],
);
const ptzTtsSupported = Boolean(
ptz?.id &&
String(chatTargetId) === String(ptz.id) &&
operatingMode === 'ptz' &&
ptz?.audio?.enabled &&
(ptz?.isOperator || ptz?.queuedPosition),
);
@@ -27,9 +27,8 @@ function detectSafari() {
function HudChatInput({ compact = false, variant = 'legacy', open = true, onOpenChange = null }) {
const role = useSessionSelector((state) => state.session?.role || null);
const socketId = useSessionSelector((state) => state.session?.socketId || null);
const operatingMode = useSessionSelector((state) => state.session?.operatingMode || 'rover');
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
const users = useSessionSelector((state) => state.session?.users || []);
const roverRoster = useSessionSelector((state) => state.session?.roster || []);
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
const { sendMessage, onInputFocus, onInputBlur, blurChat, registerInputRef, setTypingActive } = useChatActions();
@@ -49,24 +48,12 @@ function HudChatInput({ compact = false, variant = 'legacy', open = true, onOpen
const { navigateHistory, resetHistoryNavigation } = useChatMessageHistoryNavigation();
const canChat = role !== 'spectator';
const hideHudChat = role === 'spectator';
const chatTargetId = useMemo(() => {
/*
HUD chat does not render the full composer controls, but it still sends
TTS payloads when the active target supports speech. PTZ appears only in
the session user target, not the physical rover roster, so use the same
self-target resolution as the full chat panel.
*/
const self = users.find((entry) => entry?.socketId === socketId);
if (!self?.roverId && ptz?.id && (ptz?.isOperator || ptz?.queuedPosition)) return ptz.id;
return self?.roverId || currentRoverId || null;
}, [currentRoverId, ptz?.id, ptz?.isOperator, ptz?.queuedPosition, socketId, users]);
const rover = useMemo(
() => roverRoster.find((entry) => String(entry.id) === String(chatTargetId)) || null,
[chatTargetId, roverRoster],
() => roverRoster.find((entry) => String(entry.id) === String(currentRoverId)) || null,
[currentRoverId, roverRoster],
);
const ptzTtsSupported = Boolean(
ptz?.id &&
String(chatTargetId) === String(ptz.id) &&
operatingMode === 'ptz' &&
ptz?.audio?.enabled &&
(ptz?.isOperator || ptz?.queuedPosition),
);
@@ -0,0 +1,22 @@
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { useSessionActions } from '../../context/SessionContext.jsx';
export default function OperatingModeRouteSync() {
const { pathname } = useLocation();
const previousPath = useRef(pathname);
const { setOperatingMode, pushAlert } = useSessionActions();
useEffect(() => {
const leavingPtz = previousPath.current === '/ptz' && pathname !== '/ptz';
previousPath.current = pathname;
if (!leavingPtz) return;
// Observe actual navigation rather than component cleanup, which also runs
// during Strict Mode remounts. Entry remains gated by the PTZ request action.
setOperatingMode('rover').catch((err) => {
pushAlert({ title: 'Operating mode', message: err.message, color: '#f59e0b' });
});
}, [pathname, pushAlert, setOperatingMode]);
return null;
}
+28 -59
View File
@@ -121,6 +121,21 @@ function PtzSnapshotPreview({ feed, label = 'PTZ Camera', className = 'h-full w-
}
function PtzQueueSummary({ ptz, title = 'PTZ queue' }) {
const { ptzClaim, ptzRelease, pushAlert } = useSessionActions();
const [pending, setPending] = useState(false);
const participating = Boolean(ptz?.isOperator || ptz?.queuedPosition);
const changeTurn = async () => {
if (pending) return;
setPending(true);
try {
if (participating) await ptzRelease();
else await ptzClaim();
} catch (err) {
pushAlert({ title: 'PTZ camera', message: err.message, color: '#f59e0b' });
} finally {
setPending(false);
}
};
const selfId = useSessionSelector((state) => state.session?.socketId || null);
const lookupUser = usePtzQueueLookup(ptz);
const { queue, currentId, nextId } = normalizePtzQueue(ptz);
@@ -135,6 +150,9 @@ function PtzQueueSummary({ ptz, title = 'PTZ queue' }) {
selfId={selfId}
lookupUser={lookupUser}
/>
<button type="button" className="button-dark w-full text-xs" disabled={pending || !ptz?.canUse} onClick={changeTurn}>
{pending ? 'Please wait…' : ptz?.isOperator ? 'Release turn' : ptz?.queuedPosition ? 'Leave queue' : 'Request turn'}
</button>
</CardFrame>
);
}
@@ -691,59 +709,25 @@ export function PtzControllerPage({ layout = 'desktop' }) {
const isVerified = useSessionSelector((state) => Boolean(state.session?.isVerified));
const role = useSessionSelector((state) => state.session?.role || null);
const socketId = useSessionSelector((state) => state.session?.socketId || null);
const { ptzClaim, ptzRelease, pushAlert } = useSessionActions();
const { ptzClaim, setOperatingMode, pushAlert } = useSessionActions();
const { stopAllMotion } = useControlActions();
const navigate = useNavigate();
const [releasePending, setReleasePending] = useState(false);
const autoClaimSocketRef = useRef(null);
const routeExitReleaseTimerRef = useRef(null);
const participantRef = useRef(false);
const closingThroughButtonRef = useRef(false);
const { value: pageSettings } = useSettingsNamespace('page', {
backgroundTheme: DEFAULT_PAGE_THEME_KEY,
});
const isMobile = layout !== 'desktop';
const canUse = Boolean(ptz?.canUse || isVerified || role === 'admin' || role === 'lockdown');
const isParticipant = Boolean(ptz?.isOperator || ptz?.queuedPosition);
// PTZ is a separate route but shares the browser's page settings. Applying the catalog class to
// its body surface exposes the theme only through layout padding and card gaps; camera pixels,
// controls, and card interiors retain their purpose-built dark backgrounds.
const pageBackgroundClass = usePageThemeClass(pageSettings?.backgroundTheme);
useEffect(() => {
// Route-exit cleanup runs after the last render, so retain the latest
// server-confirmed membership without making the lifecycle effect resubscribe.
participantRef.current = isParticipant;
}, [isParticipant]);
useEffect(() => {
if (routeExitReleaseTimerRef.current) {
clearTimeout(routeExitReleaseTimerRef.current);
routeExitReleaseTimerRef.current = null;
}
return () => {
if (!participantRef.current || closingThroughButtonRef.current) return;
/*
Browser Back and route navigation unmount the PTZ page without invoking
its Close button. Defer release by one task so React Strict Mode's
development-only cleanup/remount cycle can cancel it in the next setup;
a real route exit has no replacement setup, so membership is released.
This is intentionally membership-gated. An admin release command can
revoke the current operator even when the admin is not that operator,
so an admin merely visiting/leaving a disabled or unjoined page must not
emit a release command.
*/
routeExitReleaseTimerRef.current = setTimeout(() => {
routeExitReleaseTimerRef.current = null;
ptzRelease().catch(() => {});
}, 0);
};
}, [ptzRelease]);
useEffect(() => {
if (!featureEnabled || !ptz || !socketId || !canUse) return undefined;
// Wait for startup to publish readiness before consuming the one entry attempt.
if (!ptz.initialized && !ptz.error) return undefined;
if (ptz.isOperator || ptz.queuedPosition) {
/*
@@ -759,17 +743,8 @@ export function PtzControllerPage({ layout = 'desktop' }) {
autoClaimSocketRef.current = socketId;
let active = true;
/*
A direct /ptz load still receives the ordinary user role first, which can
briefly assign a rover. Claiming through the existing server action is
deliberate: ptzCameraService releases that rover ownership before it
activates or queues this socket, keeping one authoritative transition.
The socket-keyed ref suppresses repeats caused by session updates and
React's development effect replay. The server claim is also idempotent for
an existing operator/queue member, which covers an acknowledgement racing
with a fresh public-state sync.
*/
// Handshake mode prevents rover assignment on direct loads/reconnects;
// this request checks eligibility and joins the camera's turn queue.
ptzClaim().catch((err) => {
if (!active) return;
pushAlert({
@@ -791,26 +766,20 @@ export function PtzControllerPage({ layout = 'desktop' }) {
const releaseAndClose = useCallback(async () => {
if (releasePending) return;
setReleasePending(true);
closingThroughButtonRef.current = true;
try {
/*
Stop first so a held key/pointer cannot leave ONVIF continuous movement
running while the server removes this socket from the PTZ queue.
*/
stopAllMotion?.();
if (ptz?.isOperator || ptz?.queuedPosition) {
await ptzRelease();
}
await setOperatingMode('rover');
navigate('/');
} catch (err) {
// A rejected manual release leaves the route mounted, so route-exit
// cleanup must remain armed for a later Back/navigation attempt.
closingThroughButtonRef.current = false;
throw err;
pushAlert({ title: 'PTZ camera', message: err.message, color: '#f59e0b' });
} finally {
setReleasePending(false);
}
}, [navigate, ptz?.isOperator, ptz?.queuedPosition, ptzRelease, releasePending, stopAllMotion]);
}, [navigate, pushAlert, setOperatingMode, releasePending, stopAllMotion]);
if (!featureEnabled) {
return (
@@ -861,7 +830,7 @@ export default function PtzQueueCard() {
const isVerified = useSessionSelector((state) => Boolean(state.session?.isVerified));
const role = useSessionSelector((state) => state.session?.role || null);
const selfId = useSessionSelector((state) => state.session?.socketId || null);
const { ptzClaim, ptzRelease } = useSessionActions();
const { ptzClaim, setOperatingMode } = useSessionActions();
const navigate = useNavigate();
const lookupUser = usePtzQueueLookup(ptz);
const { queue, currentId, nextId } = normalizePtzQueue(ptz);
@@ -901,7 +870,7 @@ export default function PtzQueueCard() {
if (pending) return;
setPending(true);
try {
await ptzRelease();
await setOperatingMode('rover');
} catch (err) {
alert(err.message || 'Failed to leave PTZ camera.');
} finally {
+3 -2
View File
@@ -141,10 +141,11 @@ export default function UserListPanel({
className={`flex items-center gap-0.5 ${compact ? 'py-0.25 text-[0.8rem]' : 'text-sm'}`}
>
<p className={`font-semibold ${roleColors(user.role)}`}>{formatLabel(user, selfId)}</p>
{user.roverId ? (
{user.roverId || user.operatingModeDisplay ? (
<RoverLabel
roverId={user.roverId}
color={rosterEntry(user.roverId)?.color}
name={user.operatingModeDisplay?.name}
color={user.operatingModeDisplay?.color || rosterEntry(user.roverId)?.color}
fallback={user.roverId}
className="text-[0.7rem]"
/>
@@ -340,7 +340,7 @@ function PtzController({ open, onClose, layout = 'desktop' }) {
(state) => Boolean(state.session?.bandwidthSavings?.nonTurnVideo?.snapshotsActive),
);
const isMobile = layout === 'mobile-portrait' || layout === 'mobile-landscape';
const { ptzRelease } = useSessionActions();
const { setOperatingMode } = useSessionActions();
/*
The VIP fullscreen surface is available to queued PTZ users too. Let queued
users see live video only when the central non-turn video policy allows it;
@@ -356,7 +356,7 @@ function PtzController({ open, onClose, layout = 'desktop' }) {
const releaseAndClose = async () => {
setReleasePending(true);
try {
await ptzRelease();
await setOperatingMode('rover');
onClose();
} finally {
setReleasePending(false);
@@ -481,7 +481,7 @@ export default function VipPtzCameraCard({ onMessage, fullWidth = false, layout
const featureEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'ptzCamera'));
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
const isVerified = useSessionSelector((state) => Boolean(state.session?.isVerified));
const { ptzClaim, ptzRelease } = useSessionActions();
const { ptzClaim, setOperatingMode } = useSessionActions();
const snapshotFeeds = usePtzCameraSnapshots([PTZ_CAMERA_ID], { enabled: Boolean(featureEnabled) });
const snapshot = snapshotFeeds[PTZ_CAMERA_ID] || null;
const [controllerOpen, setControllerOpen] = useState(false);
@@ -527,7 +527,7 @@ export default function VipPtzCameraCard({ onMessage, fullWidth = false, layout
const handleRelease = async () => {
setPending(true);
try {
await ptzRelease();
await setOperatingMode('rover');
onMessage?.('Left PTZ camera.');
} catch (err) {
onMessage?.(err.message || 'Failed to leave PTZ camera.');
+3 -1
View File
@@ -411,8 +411,10 @@ export function SessionProvider({ children }) {
emitWithAck('audioLevels:setPersonalAdjustmentRange', { maxAdjustmentPercent }),
setPrivateSafety: (roverId, safety = {}) =>
emitWithAck('session:privateSafety:set', { roverId, safety }),
ptzClaim: () => emitWithAck('ptzCamera:claim'),
setOperatingMode: (operatingMode) => emitWithAck('session:setOperatingMode', { operatingMode }),
ptzClaim: () => emitWithAck('session:setOperatingMode', { operatingMode: 'ptz' }),
ptzRelease: () => emitWithAck('ptzCamera:release'),
ptzRevokeOperator: () => emitWithAck('ptzCamera:revokeOperator'),
ptzSpotlight: (payload = {}) => emitWithAck('ptzCamera:spotlight', payload),
ptzIr: (payload = {}) => emitWithAck('ptzCamera:ir', payload),
ptzListPresets: () => emitWithAck('ptzCamera:presets:list'),
+1 -1
View File
@@ -33,7 +33,7 @@ export default function useUserIdentitySync({ identitySurface = 'passive' } = {}
const cookieUserId = (identity?.cookieUserId || '').trim();
const nickname = (profile?.nickname || '').trim();
const overseerEnabled = Boolean(overseerPreference?.enabled);
const normalizedIdentitySurface = identitySurface === 'driver' ? 'driver' : 'passive';
const normalizedIdentitySurface = ['driver', 'ptz'].includes(identitySurface) ? identitySurface : 'passive';
const sendIdentify = useCallback(async () => {
if (!ready || !connected || !socket?.id) return;
+2 -2
View File
@@ -20,9 +20,8 @@ function getIdentitySurface() {
*/
return window.location.pathname === '/'
|| window.location.pathname === '/old'
|| window.location.pathname === '/ptz'
? 'driver'
: 'passive';
: window.location.pathname === '/ptz' ? 'ptz' : 'passive';
}
async function buildSocketIdentity() {
@@ -43,6 +42,7 @@ async function buildSocketIdentity() {
audioAdjustments: currentSettings?.audioAdjustments || {},
overseerEnabled: Boolean(currentSettings?.overseerPreference?.enabled),
identitySurface: getIdentitySurface(),
operatingMode: window.location.pathname === '/ptz' ? 'ptz' : 'rover',
};
}
+2
View File
@@ -20,6 +20,7 @@ import { SettingsProvider } from './settings/index.js'
import DeterrenceChaos from './components/DeterrenceChaos/index.jsx'
import AnalyticsReporter from './analytics/AnalyticsReporter.jsx'
import PtzAppRoot from './ptz/PtzAppRoot.jsx'
import OperatingModeRouteSync from './components/OperatingModeRouteSync/index.jsx'
import InitialSessionOverlay from './components/InitialSessionOverlay/index.jsx'
// Reporting and administration carry substantial route-specific libraries.
@@ -53,6 +54,7 @@ createRoot(document.getElementById('root')).render(
<DeterrenceChaos />
<BrowserRouter>
<AnalyticsReporter />
<OperatingModeRouteSync />
<Routes>
<Route path="/" element={<DriverPageRoot />} />
{/* The retired desktop composition remains available for direct comparison
+1 -7
View File
@@ -19,15 +19,9 @@ import useUserIdentitySync from '../hooks/useUserIdentitySync.js';
function PtzRouteContent() {
const layout = useLayoutMode();
/*
Navigating away from the driver route unmounts its identity hooks. The PTZ
route is still an active control surface, so it must keep the same driver
identity heartbeat alive instead of allowing the session to become passive
while someone operates or waits for the camera.
*/
useDefaultNickname();
useIncomingInterInstanceTransfer();
useUserIdentitySync({ identitySurface: 'driver' });
useUserIdentitySync({ identitySurface: 'ptz' });
return (
<ControlSystemProvider>