mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-19 02:50:46 -04:00
ptz control updates
This commit is contained in:
@@ -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