mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-14 18:58:49 -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;
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user