presetses

This commit is contained in:
legop3
2026-07-12 13:19:36 -04:00
parent 4360e9ca03
commit 7286c5b36c
9 changed files with 554 additions and 155 deletions
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
+2 -2
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
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 {
+155 -3
View File
@@ -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>
+4
View File
@@ -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 = {}) =>