mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-14 10:42:45 -05:00
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:
@@ -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));
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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]"
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user