ptz control updates

This commit is contained in:
legop3
2026-07-17 23:06:12 -04:00
parent 4e6e9e3021
commit afe8ffc62e
14 changed files with 522 additions and 328 deletions
+44 -18
View File
@@ -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,
+98 -71
View File
@@ -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],
);
}