From 846c9dae55cfefd636fa2ae8394e267afe79b734 Mon Sep 17 00:00:00 2001 From: legop3 Date: Tue, 22 Sep 2026 14:42:21 -0400 Subject: [PATCH] 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. --- .../src/services/assignmentService/index.js | 14 +- .../services/chatService/contextBuilders.js | 49 +----- .../src/services/chatService/notifications.js | 12 +- .../integrations/chatBridge.js | 6 - .../discordBotService/integrations/helpers.js | 10 +- .../llmCommentaryService/snapshotEngine.js | 7 +- .../services/operatingModeService/index.js | 65 ++++++++ .../services/overseerControlService/index.js | 5 - server/src/services/ptzCameraService/index.js | 156 +++++------------- .../services/roverManager/privateAccess.js | 2 + server/src/services/sessionService/index.js | 45 +++-- .../src/services/verificationService/index.js | 10 +- webui/src/components/ChatMessageRow/index.jsx | 6 +- webui/src/components/ChatPanel/index.jsx | 22 +-- .../HudOverlays/HudChatInput/index.jsx | 21 +-- .../OperatingModeRouteSync/index.jsx | 22 +++ webui/src/components/PtzCamera/index.jsx | 87 ++++------ webui/src/components/UserListPanel/index.jsx | 5 +- webui/src/components/vip/VipPtzCameraCard.jsx | 8 +- webui/src/context/SessionContext.jsx | 4 +- webui/src/hooks/useUserIdentitySync.js | 2 +- webui/src/lib/socket.js | 4 +- webui/src/main.jsx | 2 + webui/src/ptz/PtzAppRoot.jsx | 8 +- 24 files changed, 236 insertions(+), 336 deletions(-) create mode 100644 server/src/services/operatingModeService/index.js create mode 100644 webui/src/components/OperatingModeRouteSync/index.jsx diff --git a/server/src/services/assignmentService/index.js b/server/src/services/assignmentService/index.js index b760f449..15319f4d 100644 --- a/server/src/services/assignmentService/index.js +++ b/server/src/services/assignmentService/index.js @@ -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); diff --git a/server/src/services/chatService/contextBuilders.js b/server/src/services/chatService/contextBuilders.js index b400dbcf..6f3a1074 100644 --- a/server/src/services/chatService/contextBuilders.js +++ b/server/src/services/chatService/contextBuilders.js @@ -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, diff --git a/server/src/services/chatService/notifications.js b/server/src/services/chatService/notifications.js index 059cebf4..61f4b9db 100644 --- a/server/src/services/chatService/notifications.js +++ b/server/src/services/chatService/notifications.js @@ -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; diff --git a/server/src/services/discordBotService/integrations/chatBridge.js b/server/src/services/discordBotService/integrations/chatBridge.js index 75fa7d51..4b109c3b 100644 --- a/server/src/services/discordBotService/integrations/chatBridge.js +++ b/server/src/services/discordBotService/integrations/chatBridge.js @@ -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; diff --git a/server/src/services/discordBotService/integrations/helpers.js b/server/src/services/discordBotService/integrations/helpers.js index 5a137454..0a1f935f 100644 --- a/server/src/services/discordBotService/integrations/helpers.js +++ b/server/src/services/discordBotService/integrations/helpers.js @@ -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}`; } diff --git a/server/src/services/llmCommentaryService/snapshotEngine.js b/server/src/services/llmCommentaryService/snapshotEngine.js index 4f3f17be..4d08f193 100644 --- a/server/src/services/llmCommentaryService/snapshotEngine.js +++ b/server/src/services/llmCommentaryService/snapshotEngine.js @@ -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 }, }; diff --git a/server/src/services/operatingModeService/index.js b/server/src/services/operatingModeService/index.js new file mode 100644 index 00000000..039e5d56 --- /dev/null +++ b/server/src/services/operatingModeService/index.js @@ -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 }; diff --git a/server/src/services/overseerControlService/index.js b/server/src/services/overseerControlService/index.js index 92b0eb5c..34d88d3e 100644 --- a/server/src/services/overseerControlService/index.js +++ b/server/src/services/overseerControlService/index.js @@ -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)); diff --git a/server/src/services/ptzCameraService/index.js b/server/src/services/ptzCameraService/index.js index 32ce7fc4..3a84833f 100644 --- a/server/src/services/ptzCameraService/index.js +++ b/server/src/services/ptzCameraService/index.js @@ -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, diff --git a/server/src/services/roverManager/privateAccess.js b/server/src/services/roverManager/privateAccess.js index 129324de..f98c8309 100644 --- a/server/src/services/roverManager/privateAccess.js +++ b/server/src/services/roverManager/privateAccess.js @@ -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'; diff --git a/server/src/services/sessionService/index.js b/server/src/services/sessionService/index.js index 1956f3f4..8ace0229 100644 --- a/server/src/services/sessionService/index.js +++ b/server/src/services/sessionService/index.js @@ -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); diff --git a/server/src/services/verificationService/index.js b/server/src/services/verificationService/index.js index fbac33d7..d799008e 100644 --- a/server/src/services/verificationService/index.js +++ b/server/src/services/verificationService/index.js @@ -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; }); diff --git a/webui/src/components/ChatMessageRow/index.jsx b/webui/src/components/ChatMessageRow/index.jsx index 1ee128e0..8303518a 100644 --- a/webui/src/components/ChatMessageRow/index.jsx +++ b/webui/src/components/ChatMessageRow/index.jsx @@ -132,11 +132,11 @@ export function ChatIdentity({ message, toolsToggle = null }) { bot ) : null} - {message.roverId && ( + {(message.roverId || message.operatingModeDisplay) && ( diff --git a/webui/src/components/ChatPanel/index.jsx b/webui/src/components/ChatPanel/index.jsx index e96bce29..971e7ae0 100644 --- a/webui/src/components/ChatPanel/index.jsx +++ b/webui/src/components/ChatPanel/index.jsx @@ -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), ); diff --git a/webui/src/components/HudOverlays/HudChatInput/index.jsx b/webui/src/components/HudOverlays/HudChatInput/index.jsx index 7aa79552..2197ee59 100644 --- a/webui/src/components/HudOverlays/HudChatInput/index.jsx +++ b/webui/src/components/HudOverlays/HudChatInput/index.jsx @@ -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), ); diff --git a/webui/src/components/OperatingModeRouteSync/index.jsx b/webui/src/components/OperatingModeRouteSync/index.jsx new file mode 100644 index 00000000..6220f364 --- /dev/null +++ b/webui/src/components/OperatingModeRouteSync/index.jsx @@ -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; +} diff --git a/webui/src/components/PtzCamera/index.jsx b/webui/src/components/PtzCamera/index.jsx index 512b840c..19b625fb 100644 --- a/webui/src/components/PtzCamera/index.jsx +++ b/webui/src/components/PtzCamera/index.jsx @@ -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} /> + ); } @@ -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 { diff --git a/webui/src/components/UserListPanel/index.jsx b/webui/src/components/UserListPanel/index.jsx index 0339c7e8..55de3103 100644 --- a/webui/src/components/UserListPanel/index.jsx +++ b/webui/src/components/UserListPanel/index.jsx @@ -141,10 +141,11 @@ export default function UserListPanel({ className={`flex items-center gap-0.5 ${compact ? 'py-0.25 text-[0.8rem]' : 'text-sm'}`} >

{formatLabel(user, selfId)}

- {user.roverId ? ( + {user.roverId || user.operatingModeDisplay ? ( diff --git a/webui/src/components/vip/VipPtzCameraCard.jsx b/webui/src/components/vip/VipPtzCameraCard.jsx index abfc1a76..4c83f26e 100644 --- a/webui/src/components/vip/VipPtzCameraCard.jsx +++ b/webui/src/components/vip/VipPtzCameraCard.jsx @@ -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.'); diff --git a/webui/src/context/SessionContext.jsx b/webui/src/context/SessionContext.jsx index 43f875b3..766879ba 100644 --- a/webui/src/context/SessionContext.jsx +++ b/webui/src/context/SessionContext.jsx @@ -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'), diff --git a/webui/src/hooks/useUserIdentitySync.js b/webui/src/hooks/useUserIdentitySync.js index 8ea516fc..cbdedfef 100644 --- a/webui/src/hooks/useUserIdentitySync.js +++ b/webui/src/hooks/useUserIdentitySync.js @@ -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; diff --git a/webui/src/lib/socket.js b/webui/src/lib/socket.js index ad550ff2..14396fc7 100644 --- a/webui/src/lib/socket.js +++ b/webui/src/lib/socket.js @@ -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', }; } diff --git a/webui/src/main.jsx b/webui/src/main.jsx index 464d594f..0ee5c8f0 100644 --- a/webui/src/main.jsx +++ b/webui/src/main.jsx @@ -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( + } /> {/* The retired desktop composition remains available for direct comparison diff --git a/webui/src/ptz/PtzAppRoot.jsx b/webui/src/ptz/PtzAppRoot.jsx index 49a08a3d..08ee080f 100644 --- a/webui/src/ptz/PtzAppRoot.jsx +++ b/webui/src/ptz/PtzAppRoot.jsx @@ -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 (