mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
ptz control updates
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
@@ -78,7 +78,7 @@
|
||||
<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-DC2T_UQE.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-CVzYrQ9e.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BHMHSpML.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -29,6 +29,13 @@ const PTZ_STREAM_PATH = 'ptz-camera';
|
||||
const DEFAULT_ONVIF_PORT = 8000;
|
||||
const DEFAULT_PROFILE_TOKEN = '003';
|
||||
const DEFAULT_TURN_DURATION_MS = 5 * 60 * 1000;
|
||||
// The TrackMix reports PT1S as its minimum supported continuous-move timeout.
|
||||
// Browser heartbeats arrive every 250 ms, so 650 ms allows ordinary LAN jitter
|
||||
// while still issuing an explicit stop well before the camera's own one-second
|
||||
// timeout becomes the final safety backstop.
|
||||
const MOTION_WATCHDOG_MS = 650;
|
||||
const ONVIF_MOTION_TIMEOUT_MS = 1000;
|
||||
const STOP_MOTION = Object.freeze({ pan: 0, tilt: 0, zoom: 0 });
|
||||
// PTZ is a normal replay source now, so capture should be on unless the feature
|
||||
// explicitly disables replay for the camera.
|
||||
const DEFAULT_REPLAY_ENABLED = true;
|
||||
@@ -92,6 +99,11 @@ let publisherStderrSyncTimer = null;
|
||||
let snapshotTimer = null;
|
||||
let spotlightVerifyTimer = null;
|
||||
let vendorStatePromise = Promise.resolve();
|
||||
let motionWatchdogTimer = null;
|
||||
let desiredMotion = STOP_MOTION;
|
||||
let desiredMotionVersion = 0;
|
||||
let appliedMotionVersion = 0;
|
||||
let motionCommandPromise = null;
|
||||
let lastSnapshotState = null;
|
||||
const snapshotSubscribers = new Map();
|
||||
const socketSnapshotSubscriptions = new Map();
|
||||
@@ -924,7 +936,10 @@ function revokeOperator(reason = 'release') {
|
||||
state.deadline = null;
|
||||
clearTurnTimer();
|
||||
videoSessions.revokeWhere((info) => info.socketId === previous && info.sourceType === 'ptz');
|
||||
callOnvif('stop', { profileToken: state.profileToken, panTilt: true, zoom: true }).catch(() => {});
|
||||
// Operator handoff/disconnect must enter the same serialized stream as
|
||||
// movement. A raw concurrent Stop could otherwise finish before an older
|
||||
// ContinuousMove and allow that stale move to restart the camera afterward.
|
||||
forceMotionStop(`operator-${reason}`);
|
||||
events.emit('operator', { socketId: previous, action: 'release', reason });
|
||||
}
|
||||
|
||||
@@ -1089,26 +1104,126 @@ function normalizePresetCreateName(rawName) {
|
||||
return name;
|
||||
}
|
||||
|
||||
async function move(socket, payload = {}) {
|
||||
requireOperator(socket);
|
||||
await initialize();
|
||||
const x = clampUnit(payload.pan ?? payload.x);
|
||||
const y = clampUnit(payload.tilt ?? payload.y);
|
||||
const zoom = clampUnit(payload.zoom);
|
||||
await callOnvif('continuousMove', {
|
||||
profileToken: state.profileToken,
|
||||
x,
|
||||
y,
|
||||
zoom,
|
||||
timeout: 1000,
|
||||
});
|
||||
return { ok: true };
|
||||
function normalizeMotionIntent(payload = {}) {
|
||||
return {
|
||||
pan: clampUnit(payload.pan ?? payload.x),
|
||||
tilt: clampUnit(payload.tilt ?? payload.y),
|
||||
zoom: clampUnit(payload.zoom),
|
||||
};
|
||||
}
|
||||
|
||||
async function stop(socket) {
|
||||
function isMotionIdle(motion = STOP_MOTION) {
|
||||
return !motion.pan && !motion.tilt && !motion.zoom;
|
||||
}
|
||||
|
||||
function clearMotionWatchdog() {
|
||||
if (!motionWatchdogTimer) return;
|
||||
clearTimeout(motionWatchdogTimer);
|
||||
motionWatchdogTimer = null;
|
||||
}
|
||||
|
||||
function runMotionCommandPump() {
|
||||
if (motionCommandPromise) return motionCommandPromise;
|
||||
|
||||
/*
|
||||
ONVIF requests are asynchronous HTTP/SOAP operations. Starting one request
|
||||
per Socket.IO event allowed a quick move/stop/move sequence to overlap at
|
||||
the camera, where response order is not a safe proxy for execution order.
|
||||
This single pump permits exactly one camera operation at a time. If browser
|
||||
heartbeats arrive while it is busy, only the newest complete desired state
|
||||
survives the loop, so intermediate input noise is coalesced rather than
|
||||
replayed after the operator has already released the controls.
|
||||
*/
|
||||
motionCommandPromise = (async () => {
|
||||
while (appliedMotionVersion < desiredMotionVersion) {
|
||||
const commandVersion = desiredMotionVersion;
|
||||
const commandMotion = desiredMotion;
|
||||
try {
|
||||
await initialize();
|
||||
if (isMotionIdle(commandMotion)) {
|
||||
await callOnvif('stop', {
|
||||
profileToken: state.profileToken,
|
||||
panTilt: true,
|
||||
zoom: true,
|
||||
});
|
||||
} else {
|
||||
/*
|
||||
Send the complete vector even when only one axis changed. The live
|
||||
TrackMix accepts combined pan/tilt/zoom despite failing to advertise
|
||||
its continuous zoom space, and zero on an axis is how the newest
|
||||
intent releases that axis without disturbing a non-zero sibling.
|
||||
*/
|
||||
await callOnvif('continuousMove', {
|
||||
profileToken: state.profileToken,
|
||||
x: commandMotion.pan,
|
||||
y: commandMotion.tilt,
|
||||
zoom: commandMotion.zoom,
|
||||
timeout: ONVIF_MOTION_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
/*
|
||||
Mark this version consumed below instead of spinning on a failing
|
||||
camera. A held control supplies another heartbeat and therefore a
|
||||
bounded retry; a failed stop still has the camera's one-second ONVIF
|
||||
timeout as its independent final safety mechanism.
|
||||
*/
|
||||
logger.warn('PTZ motion command failed', {
|
||||
error: getErrorMessage(err),
|
||||
idle: isMotionIdle(commandMotion),
|
||||
version: commandVersion,
|
||||
});
|
||||
}
|
||||
appliedMotionVersion = commandVersion;
|
||||
}
|
||||
})().finally(() => {
|
||||
motionCommandPromise = null;
|
||||
// An intent can arrive after the loop condition but before this promise's
|
||||
// finally callback. Recheck the versions so that narrow timing window does
|
||||
// not strand the newest state without a command pump.
|
||||
if (appliedMotionVersion < desiredMotionVersion) runMotionCommandPump();
|
||||
});
|
||||
|
||||
return motionCommandPromise;
|
||||
}
|
||||
|
||||
function queueMotionIntent(motion, reason = 'input') {
|
||||
desiredMotion = normalizeMotionIntent(motion);
|
||||
desiredMotionVersion += 1;
|
||||
clearMotionWatchdog();
|
||||
|
||||
if (!isMotionIdle(desiredMotion)) {
|
||||
/*
|
||||
Socket disconnect normally arrives quickly, but it is not a suitable motor
|
||||
safety boundary. Every non-zero browser heartbeat replaces this timer; if
|
||||
releases or subsequent heartbeats disappear, the server injects a zero
|
||||
intent into the same serialized stream as ordinary control changes.
|
||||
*/
|
||||
motionWatchdogTimer = setTimeout(() => {
|
||||
motionWatchdogTimer = null;
|
||||
queueMotionIntent(STOP_MOTION, 'watchdog');
|
||||
}, MOTION_WATCHDOG_MS);
|
||||
}
|
||||
|
||||
const pending = runMotionCommandPump();
|
||||
pending.catch(() => {});
|
||||
return { ok: true, motion: desiredMotion, reason };
|
||||
}
|
||||
|
||||
function acceptMotionIntent(socket, payload = {}) {
|
||||
requireOperator(socket);
|
||||
await callOnvif('stop', { profileToken: state.profileToken, panTilt: true, zoom: true });
|
||||
return { ok: true };
|
||||
return queueMotionIntent(payload, 'operator-input');
|
||||
}
|
||||
|
||||
function forceMotionStop(reason = 'safety-stop') {
|
||||
/*
|
||||
Lifecycle stops intentionally increment the version even when local state is
|
||||
already zero. The browser may have lost its final packet, or the camera may
|
||||
have accepted a command whose response has not returned, so deduplicating a
|
||||
safety stop would trust precisely the state we are trying to recover from.
|
||||
*/
|
||||
queueMotionIntent(STOP_MOTION, reason);
|
||||
return motionCommandPromise || Promise.resolve();
|
||||
}
|
||||
|
||||
async function getStatus(socket) {
|
||||
@@ -1133,9 +1248,10 @@ async function gotoPreset(socket, payload = {}) {
|
||||
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.
|
||||
feel inconsistent. Await the serialized safety stop instead of issuing a
|
||||
raw concurrent ONVIF request that could itself race an older movement.
|
||||
*/
|
||||
await callOnvif('stop', { profileToken: state.profileToken, panTilt: true, zoom: true }).catch(() => {});
|
||||
await forceMotionStop('preset').catch(() => {});
|
||||
await callOnvif('gotoPreset', {
|
||||
profileToken: state.profileToken,
|
||||
/*
|
||||
@@ -1475,18 +1591,16 @@ function registerSocketHandlers() {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
socket.on('ptzCamera:move', async (firstArg, secondArg) => {
|
||||
socket.on('ptzCamera:motion', (firstArg, secondArg) => {
|
||||
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||
try {
|
||||
cb(await move(socket, payload));
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
socket.on('ptzCamera:stop', async (firstArg, secondArg) => {
|
||||
const { cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||
try {
|
||||
cb(await stop(socket));
|
||||
/*
|
||||
Acknowledge acceptance of the newest desired state immediately. The
|
||||
serialized ONVIF pump deliberately runs independently of Socket.IO
|
||||
request latency so browser heartbeats cannot accumulate while waiting
|
||||
for a camera SOAP response.
|
||||
*/
|
||||
cb(acceptMotionIntent(socket, payload));
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
|
||||
@@ -148,13 +148,25 @@ export default function ControlPadPanel({ compact = false, disabled = false }) {
|
||||
return () => {
|
||||
clearRepeatTimer();
|
||||
/*
|
||||
Mobile controls can unmount when layouts change or the driver leaves the
|
||||
control surface. Clear the shared flag so a stale mobile precision choice
|
||||
cannot leave desktop/keyboard camera tilt in fine-step mode.
|
||||
Mobile controls can unmount during an orientation/layout change while a
|
||||
pointer is still captured by the disappearing element. Publish a neutral
|
||||
vector directly during cleanup so neither rover drive nor PTZ pan/tilt
|
||||
can retain the last cell merely because pointerup had nowhere to land.
|
||||
*/
|
||||
activeCellRef.current = null;
|
||||
setDriveVector({ x: 0, y: 0, boost: false }, { source: SOURCE });
|
||||
registerInputState(SOURCE, {
|
||||
keys: [],
|
||||
vector: { x: 0, y: 0, boost: false },
|
||||
activeCell: 'stop',
|
||||
speedMode: speedModeRef.current,
|
||||
lastEvent: 'unmount',
|
||||
});
|
||||
// Clear the shared flag too, so a stale mobile precision choice cannot
|
||||
// leave desktop/keyboard camera tilt in fine-step mode.
|
||||
setCameraPrecisionMode(false);
|
||||
};
|
||||
}, [clearRepeatTimer, setCameraPrecisionMode]);
|
||||
}, [clearRepeatTimer, registerInputState, setCameraPrecisionMode, setDriveVector]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!disabled) return;
|
||||
|
||||
@@ -188,46 +188,30 @@ function PtzLightingControls({ ptz, disabled = false }) {
|
||||
}
|
||||
|
||||
function PtzMobileZoomButtons({ disabled = false }) {
|
||||
const { nudgeServo } = useControlActions();
|
||||
const repeatTimerRef = useRef(null);
|
||||
const { setCameraAxisIntent } = useControlActions();
|
||||
|
||||
const stopZoom = useCallback(() => {
|
||||
/*
|
||||
Mobile zoom is intentionally routed through the normal camera-up/down
|
||||
control action instead of emitting PTZ socket commands directly. That
|
||||
keeps the zoom buttons on the same path as keyboard/gamepad camera tilt,
|
||||
and the PTZ adapter remains the one place that translates "camera nudge"
|
||||
into Reolink zoom pulses.
|
||||
Zero only releases the zoom axis. The PTZ adapter combines it with any
|
||||
pan/tilt direction still held on the movement pad, so lifting one finger
|
||||
cannot erase the other finger's intent.
|
||||
*/
|
||||
if (repeatTimerRef.current) {
|
||||
clearInterval(repeatTimerRef.current);
|
||||
repeatTimerRef.current = null;
|
||||
}
|
||||
/*
|
||||
Zero is a zoom-only release signal in the PTZ adapter. Using the global
|
||||
stop action here previously erased a simultaneously held pan/tilt vector,
|
||||
making mixed touch controls unexpectedly stop the camera.
|
||||
*/
|
||||
nudgeServo(0);
|
||||
}, [nudgeServo]);
|
||||
setCameraAxisIntent(0);
|
||||
}, [setCameraAxisIntent]);
|
||||
|
||||
const startZoom = useCallback(
|
||||
(direction) => (event) => {
|
||||
/*
|
||||
Send an immediate nudge and then repeat while held. The adapter turns
|
||||
each nudge into a short zoom pulse, so repeating the standard action is
|
||||
the simplest way to get continuous hold-to-zoom without adding another
|
||||
PTZ-specific command loop.
|
||||
Publish held state once. The adapter owns the single motion heartbeat,
|
||||
so this button no longer creates a second interval whose queued callback
|
||||
could run after pointerup and restart zoom.
|
||||
*/
|
||||
event.preventDefault();
|
||||
if (disabled) return;
|
||||
stopZoom();
|
||||
nudgeServo(direction);
|
||||
repeatTimerRef.current = setInterval(() => {
|
||||
nudgeServo(direction);
|
||||
}, 120);
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
setCameraAxisIntent(direction);
|
||||
},
|
||||
[disabled, nudgeServo, stopZoom],
|
||||
[disabled, setCameraAxisIntent],
|
||||
);
|
||||
const stopFromPointer = useCallback(
|
||||
(event) => {
|
||||
@@ -242,16 +226,12 @@ function PtzMobileZoomButtons({ disabled = false }) {
|
||||
() => () => {
|
||||
/*
|
||||
A touch surface can unmount during orientation changes or fullscreen
|
||||
close while a pointer is still down. Clear the repeat timer here so a
|
||||
held zoom button cannot keep firing camera-up/down actions after the
|
||||
mobile controls have disappeared.
|
||||
close while a pointer is still down. Explicitly clear zoom here because
|
||||
an unmounted DOM node cannot deliver its pointerup/pointercancel event.
|
||||
*/
|
||||
if (repeatTimerRef.current) {
|
||||
clearInterval(repeatTimerRef.current);
|
||||
repeatTimerRef.current = null;
|
||||
}
|
||||
setCameraAxisIntent(0);
|
||||
},
|
||||
[],
|
||||
[setCameraAxisIntent],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -263,7 +243,7 @@ function PtzMobileZoomButtons({ disabled = false }) {
|
||||
onPointerDown={startZoom(-1)}
|
||||
onPointerUp={stopFromPointer}
|
||||
onPointerCancel={stopFromPointer}
|
||||
onPointerLeave={stopFromPointer}
|
||||
onLostPointerCapture={stopFromPointer}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
Zoom out
|
||||
@@ -275,7 +255,7 @@ function PtzMobileZoomButtons({ disabled = false }) {
|
||||
onPointerDown={startZoom(1)}
|
||||
onPointerUp={stopFromPointer}
|
||||
onPointerCancel={stopFromPointer}
|
||||
onPointerLeave={stopFromPointer}
|
||||
onLostPointerCapture={stopFromPointer}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
Zoom in
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Vip PTZ Camera Card
|
||||
// Purpose: Provides the verified-user entry point and fullscreen controller for the single Reolink PTZ camera.
|
||||
// Scope: Owns PTZ UI state only; server-side PTZ ownership, rover handoff, and command authorization remain authoritative.
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import ChatPanel from '../ChatPanel/index.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
@@ -11,13 +11,12 @@ import PtzLiveVideo from '../PtzLiveVideo/index.jsx';
|
||||
import ReplaySourcesPanel from '../ReplaySourcesPanel/index.jsx';
|
||||
import KeyPill from './VipAudioUploadCard/KeyPill.jsx';
|
||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useControlSelector } from '../../controls/index.js';
|
||||
import { useControlActions, useControlSelector } from '../../controls/index.js';
|
||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||
import { usePtzCameraSnapshots } from '../../hooks/usePtzCameraSnapshot.js';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
|
||||
const PTZ_CAMERA_ID = 'ptz-camera';
|
||||
const PTZ_ZOOM_SPEED = 0.55;
|
||||
|
||||
function formatRemaining(deadline) {
|
||||
const remaining = Math.max(0, Math.ceil((Number(deadline || 0) - Date.now()) / 1000));
|
||||
@@ -218,22 +217,25 @@ function PtzLightingControls({ ptz, disabled = false }) {
|
||||
}
|
||||
|
||||
function PtzMobileZoomButtons({ disabled = false }) {
|
||||
const { ptzMove, ptzStop } = useSessionActions();
|
||||
const { setCameraAxisIntent } = useControlActions();
|
||||
const stopZoom = useCallback(() => {
|
||||
ptzStop().catch(() => {});
|
||||
}, [ptzStop]);
|
||||
// This is a zoom-only release; the shared adapter retains any simultaneous
|
||||
// pan/tilt intent from the movement pad in its next combined motion state.
|
||||
setCameraAxisIntent(0);
|
||||
}, [setCameraAxisIntent]);
|
||||
const startZoom = useCallback(
|
||||
(direction) => (event) => {
|
||||
/*
|
||||
Mobile needs explicit zoom targets because the regular mobile drive pad
|
||||
is already used for pan/tilt. Desktop does not render these buttons; it
|
||||
uses the mapped camera up/down controls shown in the reference panel.
|
||||
The adapter owns renewal for held PTZ state. Publishing the direction
|
||||
once avoids a component-local repeat timer and keeps this legacy card on
|
||||
the exact same motion path as the dedicated PTZ route.
|
||||
*/
|
||||
event.preventDefault();
|
||||
if (disabled) return;
|
||||
ptzMove({ pan: 0, tilt: 0, zoom: direction * PTZ_ZOOM_SPEED }).catch(() => {});
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
setCameraAxisIntent(direction);
|
||||
},
|
||||
[disabled, ptzMove],
|
||||
[disabled, setCameraAxisIntent],
|
||||
);
|
||||
const stopFromPointer = useCallback(
|
||||
(event) => {
|
||||
@@ -244,6 +246,15 @@ function PtzMobileZoomButtons({ disabled = false }) {
|
||||
[disabled, stopZoom],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
// Orientation changes can unmount the button before pointerup. Clear the
|
||||
// held zoom state explicitly instead of waiting for the server watchdog.
|
||||
setCameraAxisIntent(0);
|
||||
},
|
||||
[setCameraAxisIntent],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mobile-touch-control grid grid-cols-2 gap-0.5 text-sm">
|
||||
<button
|
||||
@@ -253,7 +264,7 @@ function PtzMobileZoomButtons({ disabled = false }) {
|
||||
onPointerDown={startZoom(-1)}
|
||||
onPointerUp={stopFromPointer}
|
||||
onPointerCancel={stopFromPointer}
|
||||
onPointerLeave={stopFromPointer}
|
||||
onLostPointerCapture={stopFromPointer}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
Zoom out
|
||||
@@ -265,7 +276,7 @@ function PtzMobileZoomButtons({ disabled = false }) {
|
||||
onPointerDown={startZoom(1)}
|
||||
onPointerUp={stopFromPointer}
|
||||
onPointerCancel={stopFromPointer}
|
||||
onPointerLeave={stopFromPointer}
|
||||
onLostPointerCapture={stopFromPointer}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
Zoom in
|
||||
|
||||
@@ -426,8 +426,6 @@ export function SessionProvider({ children }) {
|
||||
emitWithAck('session:privateSafety:set', { roverId, safety }),
|
||||
ptzClaim: () => emitWithAck('ptzCamera:claim'),
|
||||
ptzRelease: () => emitWithAck('ptzCamera:release'),
|
||||
ptzMove: (payload = {}) => emitWithAck('ptzCamera:move', payload),
|
||||
ptzStop: () => emitWithAck('ptzCamera:stop'),
|
||||
ptzSpotlight: (payload = {}) => emitWithAck('ptzCamera:spotlight', payload),
|
||||
ptzIr: (payload = {}) => emitWithAck('ptzCamera:ir', payload),
|
||||
ptzListPresets: () => emitWithAck('ptzCamera:presets:list'),
|
||||
|
||||
@@ -45,6 +45,7 @@ const CONTROL_ACTION_NAMES = [
|
||||
'setAuxMotors',
|
||||
'setServoAngle',
|
||||
'nudgeServo',
|
||||
'setCameraAxisIntent',
|
||||
'goServoHome',
|
||||
'setCameraPrecisionMode',
|
||||
'runMacro',
|
||||
@@ -352,21 +353,12 @@ export function ControlSystemProvider({ children }) {
|
||||
|
||||
const setServoAngle = useCallback(
|
||||
(value, options = {}) => {
|
||||
if (ptzControls.isActive) {
|
||||
/*
|
||||
Servo-capable rover controls converge here from keyboard, gamepad,
|
||||
desktop, and mobile. When the active control target is the PTZ camera,
|
||||
route the intent through the PTZ adapter instead of making the rover
|
||||
command pipeline understand camera zoom semantics.
|
||||
*/
|
||||
const baseline = typeof servoAngleRef.current === 'number' ? servoAngleRef.current : 0;
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return;
|
||||
ptzControls.pulseZoom(numeric - baseline);
|
||||
servoAngleRef.current = numeric;
|
||||
recordControlIntent();
|
||||
return;
|
||||
}
|
||||
/*
|
||||
Absolute servo positions belong only to rover hardware. PTZ zoom now
|
||||
enters through setCameraAxisIntent as a signed held velocity, so this
|
||||
function must not infer zoom direction by comparing unrelated absolute
|
||||
angle values from gamepad/manual-dock callers.
|
||||
*/
|
||||
if (!pipeline.servoConfig) return;
|
||||
const force = Boolean(options?.force);
|
||||
if (state.manualDockAssist?.active && !force) return;
|
||||
@@ -376,13 +368,24 @@ export function ControlSystemProvider({ children }) {
|
||||
servoAngleRef.current = clamped;
|
||||
recordControlIntent();
|
||||
},
|
||||
[pipeline, ptzControls, recordControlIntent, state.manualDockAssist?.active],
|
||||
[pipeline, recordControlIntent, state.manualDockAssist?.active],
|
||||
);
|
||||
|
||||
const nudgeServo = useCallback(
|
||||
(delta = 0) => {
|
||||
if (ptzControls.isActive) {
|
||||
/*
|
||||
A PTZ camera has no absolute browser-side servo angle. Treat a nudge
|
||||
as held zoom direction and, importantly, preserve zero as an explicit
|
||||
release. The previous fallback converted nudgeServo(0) into a positive
|
||||
default step, so releasing the mobile zoom button could zoom in again.
|
||||
*/
|
||||
ptzControls.setZoomIntent(delta);
|
||||
recordControlIntent();
|
||||
return;
|
||||
}
|
||||
const config = pipeline.servoConfig;
|
||||
if (!config && !ptzControls.isActive) return;
|
||||
if (!config) return;
|
||||
const step = typeof delta === 'number' && delta !== 0 ? delta : config?.nudgeDegrees || 1;
|
||||
const baseline =
|
||||
typeof servoAngleRef.current === 'number'
|
||||
@@ -392,7 +395,28 @@ export function ControlSystemProvider({ children }) {
|
||||
: 0;
|
||||
setServoAngle(baseline + step);
|
||||
},
|
||||
[pipeline.servoConfig, ptzControls.isActive, setServoAngle],
|
||||
[pipeline.servoConfig, ptzControls, recordControlIntent, setServoAngle],
|
||||
);
|
||||
|
||||
const setCameraAxisIntent = useCallback(
|
||||
(direction = 0) => {
|
||||
/*
|
||||
Keyboard, touch, and gamepad all need an explicit way to say that a
|
||||
camera axis returned to neutral. Rover servos remain position/nudge
|
||||
based, so returning false tells those callers to continue through their
|
||||
existing rover implementation without introducing PTZ rules there.
|
||||
*/
|
||||
if (!ptzControls.isActive) return false;
|
||||
ptzControls.setZoomIntent(direction);
|
||||
/*
|
||||
Do not dispatch recordControlIntent here. Gamepads publish their neutral
|
||||
and held axes every animation frame; the PTZ adapter deduplicates state
|
||||
and owns its 250 ms heartbeat, so a React reducer update per frame would
|
||||
add churn without representing a new user action.
|
||||
*/
|
||||
return true;
|
||||
},
|
||||
[ptzControls],
|
||||
);
|
||||
|
||||
const goServoHome = useCallback(() => {
|
||||
@@ -691,6 +715,7 @@ export function ControlSystemProvider({ children }) {
|
||||
setAuxMotors,
|
||||
setServoAngle,
|
||||
nudgeServo,
|
||||
setCameraAxisIntent,
|
||||
goServoHome,
|
||||
setCameraPrecisionMode,
|
||||
runMacro,
|
||||
@@ -718,6 +743,7 @@ export function ControlSystemProvider({ children }) {
|
||||
setAuxMotors,
|
||||
setServoAngle,
|
||||
nudgeServo,
|
||||
setCameraAxisIntent,
|
||||
goServoHome,
|
||||
setCameraPrecisionMode,
|
||||
runMacro,
|
||||
|
||||
@@ -53,6 +53,7 @@ export default function GamepadInputManager() {
|
||||
setDriveVector,
|
||||
setAuxMotors,
|
||||
setServoAngle,
|
||||
setCameraAxisIntent,
|
||||
runMacro,
|
||||
toggleHeadlight,
|
||||
toggleLaser,
|
||||
@@ -173,6 +174,7 @@ export default function GamepadInputManager() {
|
||||
runMacro,
|
||||
saveGamepadSettings,
|
||||
setAuxMotors,
|
||||
setCameraAxisIntent,
|
||||
setDriveVector,
|
||||
setMode,
|
||||
setServoAngle,
|
||||
@@ -187,6 +189,9 @@ export default function GamepadInputManager() {
|
||||
if (!latest) return;
|
||||
const activePad = pickActivePad(hubState.pads, latest.activeSignature);
|
||||
if (!activePad) {
|
||||
// A disconnected controller cannot deliver a final neutral axis sample.
|
||||
// Publish it here so PTZ zoom never depends on the browser doing so.
|
||||
latest.setCameraAxisIntent(0);
|
||||
if (!areVectorsEqual(lastVectorRef.current, ZERO_VECTOR)) {
|
||||
lastVectorRef.current = ZERO_VECTOR;
|
||||
latest.setDriveVector(ZERO_VECTOR, { source: SOURCE });
|
||||
@@ -204,6 +209,9 @@ export default function GamepadInputManager() {
|
||||
}
|
||||
|
||||
if (isTextEntryActive()) {
|
||||
// Entering text blocks gamepad control immediately, including a held
|
||||
// camera axis that otherwise would keep its last PTZ zoom direction.
|
||||
latest.setCameraAxisIntent(0);
|
||||
if (!areVectorsEqual(lastVectorRef.current, ZERO_VECTOR)) {
|
||||
lastVectorRef.current = ZERO_VECTOR;
|
||||
latest.setDriveVector(ZERO_VECTOR, { source: SOURCE });
|
||||
@@ -302,7 +310,14 @@ export default function GamepadInputManager() {
|
||||
handleButtonEdge('laserToggle', false);
|
||||
}
|
||||
|
||||
if (Math.abs(outputs.cameraAxis) > 0.001) {
|
||||
/*
|
||||
PTZ zoom consumes the live signed gamepad axis, including its zero
|
||||
position, so releasing the stick is an explicit stop instead of merely
|
||||
ending calls to the old servo updater. Rover camera servos return false
|
||||
here and continue through their established absolute/velocity mapping.
|
||||
*/
|
||||
const handledAsPtzZoom = latest.setCameraAxisIntent(outputs.cameraAxis);
|
||||
if (!handledAsPtzZoom && Math.abs(outputs.cameraAxis) > 0.001) {
|
||||
handleCameraAxis(outputs.cameraAxis, profile.calibration);
|
||||
}
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ export default function KeyboardInputManager() {
|
||||
setDriveVector,
|
||||
setAuxMotors,
|
||||
nudgeServo,
|
||||
setCameraAxisIntent,
|
||||
runMacro,
|
||||
stopAllMotion,
|
||||
registerInputState,
|
||||
@@ -215,6 +216,13 @@ export default function KeyboardInputManager() {
|
||||
const ensureServoLoop = useCallback(() => {
|
||||
const direction = computeServoDirection();
|
||||
if (direction === 0) {
|
||||
/*
|
||||
Keyup must publish a real neutral PTZ zoom intent before the repeat loop
|
||||
disappears. Rover servos ignore this generic axis release and retain
|
||||
their existing nudge behavior because setCameraAxisIntent returns false
|
||||
whenever PTZ is not the active target.
|
||||
*/
|
||||
latestRef.current?.setCameraAxisIntent(0);
|
||||
stopServoLoop();
|
||||
return;
|
||||
}
|
||||
@@ -235,7 +243,9 @@ export default function KeyboardInputManager() {
|
||||
const tokensSnapshot = new Set(activeTokensRef.current);
|
||||
const precisionActive = isPrecisionDriveActive(tokensSnapshot, latest.keymap);
|
||||
const servoStep = precisionActive ? PRECISION_SERVO_NUDGE_DEGREES : latest.servoStep;
|
||||
latest.nudgeServo(nextDirection * servoStep);
|
||||
if (!latest.setCameraAxisIntent(nextDirection)) {
|
||||
latest.nudgeServo(nextDirection * servoStep);
|
||||
}
|
||||
servoIntervalRef.current = setTimeout(tick, latest.servoRepeatMs);
|
||||
};
|
||||
servoIntervalRef.current = setTimeout(tick, 0);
|
||||
@@ -394,6 +404,7 @@ export default function KeyboardInputManager() {
|
||||
servoRepeatMs,
|
||||
servoStep,
|
||||
setAuxMotors,
|
||||
setCameraAxisIntent,
|
||||
setCameraPrecisionMode,
|
||||
setDriveVector,
|
||||
setMicPttActive,
|
||||
|
||||
@@ -12,7 +12,11 @@ const PTZ_SPEEDS = {
|
||||
medium: 0.5,
|
||||
fast: 1,
|
||||
};
|
||||
const ZOOM_PULSE_MS = 220;
|
||||
// The TrackMix advertises a minimum ONVIF movement timeout of one second. A
|
||||
// quarter-second browser heartbeat gives the server several opportunities to
|
||||
// renew a genuinely held intent while still letting its watchdog distinguish a
|
||||
// live control from a browser that disappeared without delivering a release.
|
||||
const MOTION_HEARTBEAT_MS = 250;
|
||||
|
||||
function clampUnit(value) {
|
||||
const number = Number(value) || 0;
|
||||
@@ -102,9 +106,10 @@ export function usePtzControlAdapter() {
|
||||
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
||||
const isActive = Boolean(ptz?.isOperator);
|
||||
const lastMotionSignatureRef = useRef(payloadSignature(PTZ_STOP));
|
||||
const desiredMotionRef = useRef(PTZ_STOP);
|
||||
const panTiltIntentRef = useRef({ pan: 0, tilt: 0 });
|
||||
const zoomIntentRef = useRef(0);
|
||||
const zoomStopTimerRef = useRef(null);
|
||||
const heartbeatTimerRef = useRef(null);
|
||||
|
||||
const emitPtz = useCallback(
|
||||
(eventName, payload = {}) => {
|
||||
@@ -114,22 +119,13 @@ export function usePtzControlAdapter() {
|
||||
[isActive, socket],
|
||||
);
|
||||
|
||||
const stopMotion = useCallback(() => {
|
||||
if (zoomStopTimerRef.current) {
|
||||
clearTimeout(zoomStopTimerRef.current);
|
||||
zoomStopTimerRef.current = null;
|
||||
}
|
||||
// A true global stop is used for blur, route close, and control release, so
|
||||
// it deliberately clears every independently tracked PTZ axis intent.
|
||||
panTiltIntentRef.current = { pan: 0, tilt: 0 };
|
||||
zoomIntentRef.current = 0;
|
||||
const stopSignature = payloadSignature(PTZ_STOP);
|
||||
if (lastMotionSignatureRef.current === stopSignature) return;
|
||||
lastMotionSignatureRef.current = stopSignature;
|
||||
emitPtz('ptzCamera:stop');
|
||||
}, [emitPtz]);
|
||||
const clearHeartbeat = useCallback(() => {
|
||||
if (!heartbeatTimerRef.current) return;
|
||||
clearInterval(heartbeatTimerRef.current);
|
||||
heartbeatTimerRef.current = null;
|
||||
}, []);
|
||||
|
||||
const sendMotion = useCallback(
|
||||
const publishMotion = useCallback(
|
||||
(payload, options = {}) => {
|
||||
if (!isActive) return false;
|
||||
const nextPayload = {
|
||||
@@ -138,16 +134,49 @@ export function usePtzControlAdapter() {
|
||||
zoom: clampUnit(payload?.zoom),
|
||||
};
|
||||
const nextSignature = payloadSignature(nextPayload);
|
||||
desiredMotionRef.current = nextPayload;
|
||||
|
||||
if (isIdlePayload(nextPayload)) {
|
||||
clearHeartbeat();
|
||||
} else if (!heartbeatTimerRef.current) {
|
||||
/*
|
||||
Movement is renewed from the complete desired vector, not from the
|
||||
individual input event that happened to start it. This is what makes
|
||||
pan/tilt and zoom independent: every heartbeat describes all axes as
|
||||
they should be now, and no delayed zoom pulse can resurrect an older
|
||||
direction after a release.
|
||||
*/
|
||||
heartbeatTimerRef.current = setInterval(() => {
|
||||
const current = desiredMotionRef.current;
|
||||
if (isIdlePayload(current)) {
|
||||
clearHeartbeat();
|
||||
return;
|
||||
}
|
||||
emitPtz('ptzCamera:motion', current);
|
||||
}, MOTION_HEARTBEAT_MS);
|
||||
}
|
||||
|
||||
if (!options.force && lastMotionSignatureRef.current === nextSignature) return true;
|
||||
lastMotionSignatureRef.current = nextSignature;
|
||||
if (isIdlePayload(nextPayload)) {
|
||||
emitPtz('ptzCamera:stop');
|
||||
} else {
|
||||
emitPtz('ptzCamera:move', nextPayload);
|
||||
}
|
||||
// Zero is a first-class desired state. The server translates the complete
|
||||
// idle vector into ONVIF Stop inside the same serialized command stream as
|
||||
// movement, which prevents separate move/stop handlers from racing.
|
||||
emitPtz('ptzCamera:motion', nextPayload);
|
||||
return true;
|
||||
},
|
||||
[emitPtz, isActive],
|
||||
[clearHeartbeat, emitPtz, isActive],
|
||||
);
|
||||
|
||||
const stopMotion = useCallback(
|
||||
(options = {}) => {
|
||||
// A global stop deliberately clears every axis. Safety/lifecycle callers
|
||||
// use force so the server receives a fresh stop even when the browser's
|
||||
// local signature already says it is idle after a dropped connection.
|
||||
panTiltIntentRef.current = { pan: 0, tilt: 0 };
|
||||
zoomIntentRef.current = 0;
|
||||
return publishMotion(PTZ_STOP, { force: Boolean(options.force) });
|
||||
},
|
||||
[publishMotion],
|
||||
);
|
||||
|
||||
const applyDriveVector = useCallback(
|
||||
@@ -163,60 +192,36 @@ export function usePtzControlAdapter() {
|
||||
Preserve the current zoom intent when a direction update arrives so a
|
||||
keyboard or touch event on one axis cannot erase another held axis.
|
||||
*/
|
||||
sendMotion({
|
||||
publishMotion({
|
||||
...panTiltIntentRef.current,
|
||||
zoom: zoomIntentRef.current,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
[isActive, sendMotion],
|
||||
[isActive, publishMotion],
|
||||
);
|
||||
|
||||
const pulseZoom = useCallback(
|
||||
const setZoomIntent = useCallback(
|
||||
(direction) => {
|
||||
if (!isActive) return false;
|
||||
const sign = axisSign(direction);
|
||||
if (!sign) {
|
||||
if (zoomStopTimerRef.current) {
|
||||
clearTimeout(zoomStopTimerRef.current);
|
||||
zoomStopTimerRef.current = null;
|
||||
}
|
||||
zoomIntentRef.current = 0;
|
||||
/*
|
||||
Releasing zoom must not call the global PTZ stop. Re-emit the retained
|
||||
pan/tilt intent with zoom cleared so a held direction continues
|
||||
immediately instead of waiting for another directional key event.
|
||||
*/
|
||||
sendMotion({ ...panTiltIntentRef.current, zoom: 0 }, { force: true });
|
||||
return true;
|
||||
}
|
||||
const numeric = clampUnit(direction);
|
||||
const sign = axisSign(numeric);
|
||||
/*
|
||||
Zoom is different from pan/tilt because it is driven by repeated nudge
|
||||
events from existing camera controls. Force each pulse through even when
|
||||
the payload is identical, otherwise holding "camera up" only sends the
|
||||
first zoom command and every later nudge is de-duped away.
|
||||
PTZ zoom is a held velocity, not a rover servo nudge. Convert the input
|
||||
magnitude to the same precision/normal tiers used for pan and tilt, then
|
||||
retain it until the input surface explicitly publishes zero. The shared
|
||||
heartbeat renews that state; there are no per-button repeat or delayed
|
||||
stop timers left to race with pointer/key release.
|
||||
*/
|
||||
zoomIntentRef.current = sign * PTZ_SPEEDS.medium;
|
||||
sendMotion({
|
||||
const speed = !sign ? 0 : Math.abs(numeric) <= 0.45 ? PTZ_SPEEDS.slow : PTZ_SPEEDS.medium;
|
||||
zoomIntentRef.current = sign * speed;
|
||||
publishMotion({
|
||||
...panTiltIntentRef.current,
|
||||
zoom: zoomIntentRef.current,
|
||||
}, { force: true });
|
||||
if (zoomStopTimerRef.current) clearTimeout(zoomStopTimerRef.current);
|
||||
/*
|
||||
Existing rover camera controls are nudge/slider based, not hold-based.
|
||||
Treat each nudge as a short PTZ zoom pulse, then stop from the adapter
|
||||
so delayed stop behavior is owned by the camera layer only.
|
||||
*/
|
||||
zoomStopTimerRef.current = setTimeout(() => {
|
||||
zoomStopTimerRef.current = null;
|
||||
zoomIntentRef.current = 0;
|
||||
// A zoom pulse ending restores, rather than stops, any direction that
|
||||
// is still held in the independent pan/tilt intent.
|
||||
sendMotion({ ...panTiltIntentRef.current, zoom: 0 }, { force: true });
|
||||
}, ZOOM_PULSE_MS);
|
||||
});
|
||||
return true;
|
||||
},
|
||||
[isActive, sendMotion],
|
||||
[isActive, publishMotion],
|
||||
);
|
||||
|
||||
const setSpotlight = useCallback(
|
||||
@@ -253,20 +258,42 @@ export function usePtzControlAdapter() {
|
||||
useEffect(() => {
|
||||
if (isActive) return undefined;
|
||||
lastMotionSignatureRef.current = payloadSignature(PTZ_STOP);
|
||||
desiredMotionRef.current = PTZ_STOP;
|
||||
panTiltIntentRef.current = { pan: 0, tilt: 0 };
|
||||
zoomIntentRef.current = 0;
|
||||
if (zoomStopTimerRef.current) {
|
||||
clearTimeout(zoomStopTimerRef.current);
|
||||
zoomStopTimerRef.current = null;
|
||||
}
|
||||
clearHeartbeat();
|
||||
return undefined;
|
||||
}, [isActive]);
|
||||
}, [clearHeartbeat, isActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return undefined;
|
||||
|
||||
const forceSafetyStop = () => stopMotion({ force: true });
|
||||
const handleVisibility = () => {
|
||||
if (document.visibilityState === 'hidden') forceSafetyStop();
|
||||
};
|
||||
|
||||
/*
|
||||
Input components handle ordinary pointer/key releases, but the adapter is
|
||||
the only layer guaranteed to see every PTZ control surface. Centralizing
|
||||
browser lifecycle stops here covers touch, keyboard, and gamepad equally
|
||||
when a tab hides, a window blurs, or mobile navigation fires pagehide.
|
||||
*/
|
||||
window.addEventListener('blur', forceSafetyStop);
|
||||
window.addEventListener('pagehide', forceSafetyStop);
|
||||
document.addEventListener('visibilitychange', handleVisibility);
|
||||
return () => {
|
||||
window.removeEventListener('blur', forceSafetyStop);
|
||||
window.removeEventListener('pagehide', forceSafetyStop);
|
||||
document.removeEventListener('visibilitychange', handleVisibility);
|
||||
};
|
||||
}, [isActive, stopMotion]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (zoomStopTimerRef.current) clearTimeout(zoomStopTimerRef.current);
|
||||
clearHeartbeat();
|
||||
},
|
||||
[],
|
||||
[clearHeartbeat],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
@@ -274,11 +301,11 @@ export function usePtzControlAdapter() {
|
||||
isActive,
|
||||
state: ptz,
|
||||
applyDriveVector,
|
||||
pulseZoom,
|
||||
setZoomIntent,
|
||||
setSpotlight,
|
||||
setIr,
|
||||
stopMotion,
|
||||
}),
|
||||
[applyDriveVector, isActive, ptz, pulseZoom, setIr, setSpotlight, stopMotion],
|
||||
[applyDriveVector, isActive, ptz, setIr, setSpotlight, setZoomIntent, stopMotion],
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user