mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
presetses
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -78,8 +78,8 @@
|
||||
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
|
||||
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
|
||||
<title>Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-kly4fuTr.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Dftmy_oH.css">
|
||||
<script type="module" crossorigin src="/assets/index-BdBRYVYh.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DRk-dX1r.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -53,6 +53,8 @@ const state = {
|
||||
status: null,
|
||||
light: null,
|
||||
ir: null,
|
||||
presets: [],
|
||||
presetsError: null,
|
||||
publisher: {
|
||||
running: false,
|
||||
pid: null,
|
||||
@@ -281,6 +283,8 @@ function getPublicState(socket = null) {
|
||||
status: state.status,
|
||||
light: state.light,
|
||||
ir: state.ir,
|
||||
presets: state.presets,
|
||||
presetsError: state.presetsError,
|
||||
publisher: state.publisher,
|
||||
isOperator: Boolean(socketId && state.operatorSocketId === socketId),
|
||||
queuedPosition: socketId ? state.queue.indexOf(socketId) + 1 || null : null,
|
||||
@@ -320,6 +324,81 @@ function callOnvif(method, options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
function normalizePresetName(rawName, token) {
|
||||
/*
|
||||
ONVIF cameras are inconsistent about preset names. Some return a readable
|
||||
Name field, some return name, and some only return the token. The browser
|
||||
needs a stable label for every button, so fall back to the token only after
|
||||
exhausting the human-facing fields the camera may provide.
|
||||
*/
|
||||
const name = String(rawName || '').trim();
|
||||
if (name) return name;
|
||||
const tokenLabel = String(token || '').trim();
|
||||
return tokenLabel ? `Preset ${tokenLabel}` : 'Unnamed preset';
|
||||
}
|
||||
|
||||
function normalizeOnvifPreset(entry, fallbackToken = '') {
|
||||
/*
|
||||
The onvif package returns camera XML converted to plain objects, but exact
|
||||
key casing can vary by device and service response. Normalize once at the
|
||||
service boundary so UI and socket callers never depend on vendor-specific
|
||||
field names.
|
||||
*/
|
||||
if (!entry || typeof entry !== 'object') return null;
|
||||
const token = String(entry.token || entry.$?.token || entry.presetToken || entry.PresetToken || fallbackToken || '').trim();
|
||||
if (!token) return null;
|
||||
return {
|
||||
token,
|
||||
name: normalizePresetName(entry.name || entry.Name, token),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOnvifPresets(raw) {
|
||||
/*
|
||||
getPresets can come back as an array directly, as { presets }, or as nested
|
||||
ONVIF response data depending on the library/device pairing. Keep this
|
||||
intentionally permissive because a missing preset list should degrade to an
|
||||
empty panel, not a broken PTZ session.
|
||||
*/
|
||||
const candidates = Array.isArray(raw)
|
||||
? raw.map((entry) => [null, entry])
|
||||
: Array.isArray(raw?.presets)
|
||||
? raw.presets.map((entry) => [null, entry])
|
||||
: Array.isArray(raw?.Presets)
|
||||
? raw.Presets.map((entry) => [null, entry])
|
||||
: Array.isArray(raw?.GetPresetsResponse?.Preset)
|
||||
? raw.GetPresetsResponse.Preset.map((entry) => [null, entry])
|
||||
: Array.isArray(raw?.Preset)
|
||||
? raw.Preset.map((entry) => [null, entry])
|
||||
: raw && typeof raw === 'object'
|
||||
? Object.entries(raw)
|
||||
: [];
|
||||
return candidates
|
||||
.map(([fallbackToken, entry]) => normalizeOnvifPreset(entry, fallbackToken))
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
|
||||
}
|
||||
|
||||
async function refreshPresets(reason = 'presets') {
|
||||
/*
|
||||
The camera owns preset storage. Reading it back after every create/delete
|
||||
keeps this server stateless and avoids a local JSON store drifting away from
|
||||
what ONVIF will actually accept for gotoPreset.
|
||||
*/
|
||||
await initialize();
|
||||
try {
|
||||
const raw = await callOnvif('getPresets', { profileToken: state.profileToken });
|
||||
state.presets = normalizeOnvifPresets(raw);
|
||||
state.presetsError = null;
|
||||
} catch (err) {
|
||||
state.presets = [];
|
||||
state.presetsError = err.message || String(err);
|
||||
logger.warn('Failed to refresh PTZ presets', { error: state.presetsError });
|
||||
}
|
||||
emitChange(reason);
|
||||
return state.presets;
|
||||
}
|
||||
|
||||
function connectOnvif() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const cam = new Cam({
|
||||
@@ -590,6 +669,13 @@ async function initialize() {
|
||||
onvifCam = await connectOnvif();
|
||||
state.rtspUri = await getStreamUriForProfile(onvifCam);
|
||||
await refreshVendorState();
|
||||
/*
|
||||
Presets are not required for the camera to be usable. Refresh them during
|
||||
startup so connected clients have the list immediately, but keep failures
|
||||
isolated inside refreshPresets() so a camera with broken preset support
|
||||
can still pan, tilt, zoom, and stream normally.
|
||||
*/
|
||||
await refreshPresets('initialize-presets');
|
||||
state.initialized = true;
|
||||
state.error = null;
|
||||
startPublisher();
|
||||
@@ -766,6 +852,61 @@ function requireOperator(socket) {
|
||||
if (!socket || state.operatorSocketId !== socket.id) throw new Error('Not the PTZ operator');
|
||||
}
|
||||
|
||||
function requirePtzUser(socket) {
|
||||
/*
|
||||
Listing presets does not move the camera, but it still reveals operational
|
||||
camera state. Use the same feature gate as queue entry so unverified users
|
||||
cannot query PTZ-only data through raw socket calls.
|
||||
*/
|
||||
if (!enabled) throw new Error('PTZ camera disabled');
|
||||
if (!canUsePtzFeature(socket)) throw new Error('Not authorized for PTZ camera');
|
||||
}
|
||||
|
||||
function requirePresetAdmin(socket) {
|
||||
/*
|
||||
Preset creation and deletion changes shared camera state for everyone. Keep
|
||||
that narrower than normal PTZ operation so regular camera users can only
|
||||
choose from positions an admin has intentionally published.
|
||||
*/
|
||||
if (!enabled) throw new Error('PTZ camera disabled');
|
||||
if (!passesMode(socket)) throw new Error('Not authorized for PTZ camera');
|
||||
if (!isAdmin(socket) && !isLockdownAdmin(socket)) throw new Error('PTZ preset admin required');
|
||||
}
|
||||
|
||||
function normalizePresetToken(rawToken) {
|
||||
const token = String(rawToken || '').trim();
|
||||
if (!token) throw new Error('Preset token is required');
|
||||
if (/[<>&'"]/.test(token)) throw new Error('Preset token contains invalid characters');
|
||||
return token;
|
||||
}
|
||||
|
||||
function escapeOnvifXmlText(value) {
|
||||
/*
|
||||
The installed onvif package writes option values directly into SOAP XML.
|
||||
Escape admin-entered preset names before handing them to the package so a
|
||||
normal label like "Door & window" remains valid XML instead of corrupting
|
||||
the SetPreset request body.
|
||||
*/
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function normalizePresetCreateName(rawName) {
|
||||
/*
|
||||
The ONVIF API stores the preset at the camera's current position. A clear
|
||||
name is the only context future users get in the UI, so require a small
|
||||
non-empty label instead of silently creating "undefined" camera presets.
|
||||
*/
|
||||
const name = String(rawName || '').trim().replace(/\s+/g, ' ');
|
||||
if (!name) throw new Error('Preset name is required');
|
||||
if (name.length > 60) throw new Error('Preset name must be 60 characters or less');
|
||||
return name;
|
||||
}
|
||||
|
||||
async function move(socket, payload = {}) {
|
||||
requireOperator(socket);
|
||||
await initialize();
|
||||
@@ -797,6 +938,76 @@ async function getStatus(socket) {
|
||||
return state.status;
|
||||
}
|
||||
|
||||
async function listPresets(socket) {
|
||||
requirePtzUser(socket);
|
||||
return refreshPresets('presets-list');
|
||||
}
|
||||
|
||||
async function gotoPreset(socket, payload = {}) {
|
||||
requireOperator(socket);
|
||||
await initialize();
|
||||
const presetToken = normalizePresetToken(payload.token || payload.presetToken);
|
||||
/*
|
||||
Stop any continuous move before jumping to a preset. Without this, a held
|
||||
key or touch control can keep sending pan/tilt velocity while the camera is
|
||||
trying to execute the absolute preset move, which makes the final position
|
||||
feel inconsistent.
|
||||
*/
|
||||
await callOnvif('stop', { profileToken: state.profileToken, panTilt: true, zoom: true }).catch(() => {});
|
||||
await callOnvif('gotoPreset', {
|
||||
profileToken: state.profileToken,
|
||||
/*
|
||||
This onvif package names the goto option "preset" even though it writes
|
||||
that value into the ONVIF PresetToken XML element. Keep the local variable
|
||||
named presetToken because that is what the camera and UI are actually
|
||||
handling, but send the package's expected option name here.
|
||||
*/
|
||||
preset: presetToken,
|
||||
});
|
||||
return { ok: true, presetToken };
|
||||
}
|
||||
|
||||
async function createPreset(socket, payload = {}) {
|
||||
requirePresetAdmin(socket);
|
||||
await initialize();
|
||||
const presetName = normalizePresetCreateName(payload.name || payload.presetName);
|
||||
const options = {
|
||||
profileToken: state.profileToken,
|
||||
presetName: escapeOnvifXmlText(presetName),
|
||||
};
|
||||
/*
|
||||
ONVIF setPreset updates an existing token when one is supplied and creates a
|
||||
new preset when it is omitted. Support both so the UI can start simple with
|
||||
"create current position" and later reuse the same server action for rename
|
||||
or overwrite workflows if needed.
|
||||
*/
|
||||
const rawPresetToken = String(payload.token || payload.presetToken || '').trim();
|
||||
const presetToken = rawPresetToken ? normalizePresetToken(rawPresetToken) : '';
|
||||
if (presetToken) options.presetToken = presetToken;
|
||||
const result = await callOnvif('setPreset', options);
|
||||
const presets = await refreshPresets('preset-create');
|
||||
return {
|
||||
ok: true,
|
||||
presetToken: result?.presetToken || result?.PresetToken || presetToken || null,
|
||||
presets,
|
||||
};
|
||||
}
|
||||
|
||||
async function removePreset(socket, payload = {}) {
|
||||
requirePresetAdmin(socket);
|
||||
await initialize();
|
||||
const presetToken = normalizePresetToken(payload.token || payload.presetToken);
|
||||
await callOnvif('removePreset', {
|
||||
profileToken: state.profileToken,
|
||||
presetToken,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
presetToken,
|
||||
presets: await refreshPresets('preset-remove'),
|
||||
};
|
||||
}
|
||||
|
||||
async function setSpotlight(socket, payload = {}) {
|
||||
requireOperator(socket);
|
||||
return serializeVendorState(async () => {
|
||||
@@ -1111,6 +1322,38 @@ function registerSocketHandlers() {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
socket.on('ptzCamera:presets:list', async (firstArg, secondArg) => {
|
||||
const { cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||
try {
|
||||
cb({ ok: true, presets: await listPresets(socket) });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
socket.on('ptzCamera:preset:goto', async (firstArg, secondArg) => {
|
||||
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||
try {
|
||||
cb(await gotoPreset(socket, payload));
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
socket.on('ptzCamera:preset:create', async (firstArg, secondArg) => {
|
||||
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||
try {
|
||||
cb(await createPreset(socket, payload));
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
socket.on('ptzCamera:preset:remove', async (firstArg, secondArg) => {
|
||||
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||
try {
|
||||
cb(await removePreset(socket, payload));
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
socket.on('ptzCamera:snapshotSubscribe', (firstArg, secondArg) => {
|
||||
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||
try {
|
||||
|
||||
@@ -356,6 +356,159 @@ function PtzControlReference() {
|
||||
);
|
||||
}
|
||||
|
||||
function PtzPresetPanel({ ptz }) {
|
||||
const role = useSessionSelector((state) => state.session?.role || null);
|
||||
const {
|
||||
ptzListPresets,
|
||||
ptzGotoPreset,
|
||||
ptzCreatePreset,
|
||||
ptzRemovePreset,
|
||||
} = useSessionActions();
|
||||
const [name, setName] = useState('');
|
||||
const [busy, setBusy] = useState('');
|
||||
const presets = Array.isArray(ptz?.presets) ? ptz.presets : [];
|
||||
const isPresetAdmin = role === 'admin' || role === 'lockdown';
|
||||
const canMoveToPreset = Boolean(ptz?.isOperator);
|
||||
|
||||
const refreshPresets = async () => {
|
||||
if (busy) return;
|
||||
setBusy('refresh');
|
||||
try {
|
||||
/*
|
||||
Presets live on the camera, not in browser state. A manual refresh gives
|
||||
admins a simple recovery path if another admin or the camera's native
|
||||
app changes preset storage while this UI is already open.
|
||||
*/
|
||||
await ptzListPresets();
|
||||
} catch (err) {
|
||||
alert(err.message || 'Failed to refresh PTZ presets.');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
};
|
||||
|
||||
const goToPreset = async (preset) => {
|
||||
if (!canMoveToPreset || busy || !preset?.token) return;
|
||||
setBusy(`goto:${preset.token}`);
|
||||
try {
|
||||
/*
|
||||
Moving to a preset is a physical camera move, so the server still checks
|
||||
that this browser owns the active PTZ turn before accepting the command.
|
||||
*/
|
||||
await ptzGotoPreset({ token: preset.token });
|
||||
} catch (err) {
|
||||
alert(err.message || 'Failed to move to PTZ preset.');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
};
|
||||
|
||||
const createPreset = async (event) => {
|
||||
event.preventDefault();
|
||||
const trimmed = name.trim();
|
||||
if (!isPresetAdmin || busy || !trimmed) return;
|
||||
setBusy('create');
|
||||
try {
|
||||
/*
|
||||
ONVIF setPreset stores the camera's current physical position. The UI
|
||||
only sends the admin's label; the server supplies the active profile
|
||||
token so browser code does not need to know camera profile internals.
|
||||
*/
|
||||
await ptzCreatePreset({ name: trimmed });
|
||||
setName('');
|
||||
} catch (err) {
|
||||
alert(err.message || 'Failed to create PTZ preset.');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
};
|
||||
|
||||
const removePreset = async (preset) => {
|
||||
if (!isPresetAdmin || busy || !preset?.token) return;
|
||||
const confirmed = window.confirm(`Remove preset "${preset.name}"?`);
|
||||
if (!confirmed) return;
|
||||
setBusy(`remove:${preset.token}`);
|
||||
try {
|
||||
/*
|
||||
The token is the camera's durable preset identifier. Names are only UI
|
||||
labels and may not be unique, so deletion always targets the token.
|
||||
*/
|
||||
await ptzRemovePreset({ token: preset.token });
|
||||
} catch (err) {
|
||||
alert(err.message || 'Failed to remove PTZ preset.');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CardFrame
|
||||
title="Position presets"
|
||||
fillHeight
|
||||
actions={(
|
||||
<button type="button" className="button-dark text-xs" disabled={Boolean(busy)} onClick={refreshPresets}>
|
||||
Refresh
|
||||
</button>
|
||||
)}
|
||||
bodyClassName="flex min-h-0 flex-col gap-1 p-1 text-xs"
|
||||
>
|
||||
{ptz?.presetsError ? (
|
||||
<div className="rounded border border-amber-500/50 bg-amber-950/40 p-1 text-amber-100">
|
||||
{ptz.presetsError}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="min-h-0 flex-1 space-y-0.5 overflow-y-auto">
|
||||
{presets.length ? presets.map((preset) => {
|
||||
const gotoBusy = busy === `goto:${preset.token}`;
|
||||
const removeBusy = busy === `remove:${preset.token}`;
|
||||
return (
|
||||
<div key={preset.token} className="surface grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="button-dark min-w-0 truncate text-left text-xs disabled:opacity-50"
|
||||
disabled={!canMoveToPreset || Boolean(busy)}
|
||||
onClick={() => goToPreset(preset)}
|
||||
title={canMoveToPreset ? `Move to ${preset.name}` : 'Your PTZ turn must be active'}
|
||||
>
|
||||
{gotoBusy ? 'Moving...' : preset.name}
|
||||
</button>
|
||||
{isPresetAdmin ? (
|
||||
<button
|
||||
type="button"
|
||||
className="button-dark text-xs text-rose-200 disabled:opacity-50"
|
||||
disabled={Boolean(busy)}
|
||||
onClick={() => removePreset(preset)}
|
||||
>
|
||||
{removeBusy ? 'Removing...' : 'Remove'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}) : (
|
||||
<div className="rounded border border-slate-700 bg-black/30 p-2 text-center text-slate-400">
|
||||
No presets saved.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isPresetAdmin ? (
|
||||
<form className="grid grid-cols-[minmax(0,1fr)_auto] gap-1" onSubmit={createPreset}>
|
||||
<input
|
||||
className="min-w-0 rounded border border-slate-700 bg-black px-2 py-1 text-xs text-slate-100 outline-none focus:border-cyan-300"
|
||||
value={name}
|
||||
maxLength={60}
|
||||
disabled={Boolean(busy)}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="Preset name"
|
||||
/>
|
||||
<button type="submit" className="button-dark text-xs" disabled={Boolean(busy) || !name.trim()}>
|
||||
{busy === 'create' ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function buildPtzTurnModel(ptz, selfId) {
|
||||
const { queue, currentId, nextId } = normalizePtzQueue(ptz);
|
||||
const isActive = Boolean(ptz?.isOperator);
|
||||
@@ -427,9 +580,7 @@ function PtzDesktopFullscreen({ ptz, releasePending }) {
|
||||
</div>
|
||||
<div className="grid min-h-0 grid-cols-[minmax(0,1.6fr)_minmax(16rem,0.7fr)] gap-0.5 overflow-hidden">
|
||||
<ChatPanel fillHeight title="Chat" allowSpectatorInput inputTarget="overlay" />
|
||||
<CardFrame title="Position presets" fillHeight bodyClassName="p-1 text-xs text-slate-500">
|
||||
Presets will live here.
|
||||
</CardFrame>
|
||||
<PtzPresetPanel ptz={ptz} />
|
||||
</div>
|
||||
{releasePending ? (
|
||||
<div className="pointer-events-none absolute bottom-1 right-1 rounded bg-black/80 px-2 py-1 text-xs text-slate-200">
|
||||
@@ -469,6 +620,7 @@ function PtzMobileFullscreen({ ptz, layout, onClose, releasePending = false }) {
|
||||
<ChatPanel title="Chat" allowSpectatorInput inputTarget="overlay" />
|
||||
<div className="space-y-0.5">
|
||||
<PtzQueueSummary ptz={ptz} />
|
||||
<PtzPresetPanel ptz={ptz} />
|
||||
<PtzStatePanel ptz={ptz} compact />
|
||||
<ReplaySourcesPanel panelId="ptz-controller-replay-mobile" />
|
||||
</div>
|
||||
|
||||
@@ -426,6 +426,10 @@ export function SessionProvider({ children }) {
|
||||
ptzStop: () => emitWithAck('ptzCamera:stop'),
|
||||
ptzSpotlight: (payload = {}) => emitWithAck('ptzCamera:spotlight', payload),
|
||||
ptzIr: (payload = {}) => emitWithAck('ptzCamera:ir', payload),
|
||||
ptzListPresets: () => emitWithAck('ptzCamera:presets:list'),
|
||||
ptzGotoPreset: (payload = {}) => emitWithAck('ptzCamera:preset:goto', payload),
|
||||
ptzCreatePreset: (payload = {}) => emitWithAck('ptzCamera:preset:create', payload),
|
||||
ptzRemovePreset: (payload = {}) => emitWithAck('ptzCamera:preset:remove', payload),
|
||||
llmControl: (action, controls = {}) =>
|
||||
emitWithAck('llm:control', { controls: { action, ...controls } }),
|
||||
overseerControl: (action, controls = {}) =>
|
||||
|
||||
Reference in New Issue
Block a user