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) && (
{formatLabel(user, selfId)}
- {user.roverId ? ( + {user.roverId || user.operatingModeDisplay ? (