mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-14 18:58:49 -05:00
331 lines
12 KiB
JavaScript
331 lines
12 KiB
JavaScript
// PTZ Control Adapter
|
|
// Purpose: Hooks the single PTZ camera into the internal control action layer.
|
|
// Scope: Owns PTZ-specific control mixing and socket commands so keyboard,
|
|
// mobile, desktop, and gamepad inputs do not each learn camera-specific rules.
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useSocket } from '../context/SocketContext.jsx';
|
|
import { useSessionActions, useSessionSelector } from '../context/SessionContext.jsx';
|
|
|
|
const PTZ_STOP = { pan: 0, tilt: 0, zoom: 0 };
|
|
const PTZ_SPEEDS = {
|
|
slow: 0.1,
|
|
medium: 0.5,
|
|
fast: 1,
|
|
};
|
|
// 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;
|
|
return Math.max(-1, Math.min(1, number));
|
|
}
|
|
|
|
function axisSign(value) {
|
|
const number = Number(value) || 0;
|
|
if (number > 0.05) return 1;
|
|
if (number < -0.05) return -1;
|
|
return 0;
|
|
}
|
|
|
|
function pickPanTiltSpeed(vector = {}, meta = {}) {
|
|
/*
|
|
PTZ movement should not inherit rover wheel speeds. Inputs only choose a
|
|
speed tier here: precision/low-magnitude input is slow, normal input is
|
|
medium, and explicit boost is fast. That gives every control surface the
|
|
same camera feel without remixing differential-drive output.
|
|
*/
|
|
if (vector?.boost) return PTZ_SPEEDS.fast;
|
|
const maxAxis = Math.max(Math.abs(Number(vector?.x) || 0), Math.abs(Number(vector?.y) || 0));
|
|
const baseSpeed = Number(meta?.speedOptions?.baseSpeed);
|
|
if (maxAxis > 0 && maxAxis <= 0.45) return PTZ_SPEEDS.slow;
|
|
if (Number.isFinite(baseSpeed) && baseSpeed > 0 && baseSpeed <= 150) return PTZ_SPEEDS.slow;
|
|
return PTZ_SPEEDS.medium;
|
|
}
|
|
|
|
function payloadSignature(payload = PTZ_STOP) {
|
|
return [
|
|
Number(payload.pan || 0).toFixed(3),
|
|
Number(payload.tilt || 0).toFixed(3),
|
|
Number(payload.zoom || 0).toFixed(3),
|
|
].join(':');
|
|
}
|
|
|
|
function isIdlePayload(payload = PTZ_STOP) {
|
|
return !payload.pan && !payload.tilt && !payload.zoom;
|
|
}
|
|
|
|
function buildPanTiltPayload(vector = {}, meta = {}) {
|
|
const panSign = axisSign(vector.x);
|
|
const tiltSign = axisSign(vector.y);
|
|
if (!panSign && !tiltSign) return PTZ_STOP;
|
|
|
|
const speed = pickPanTiltSpeed(vector, meta);
|
|
const diagonalScale = panSign && tiltSign ? Math.SQRT1_2 : 1;
|
|
return {
|
|
pan: clampUnit(panSign * speed * diagonalScale),
|
|
tilt: clampUnit(tiltSign * speed * diagonalScale),
|
|
zoom: 0,
|
|
};
|
|
}
|
|
|
|
function isSpotlightOn(light = {}) {
|
|
if (typeof light?.on === 'boolean') return light.on;
|
|
const raw = light?.state;
|
|
if (typeof raw === 'string') {
|
|
const normalized = raw.trim().toLowerCase();
|
|
return !['', '0', 'off', 'false'].includes(normalized);
|
|
}
|
|
return Boolean(Number(raw));
|
|
}
|
|
|
|
function normalizeIrMode(mode) {
|
|
const normalized = String(mode || '').trim().toLowerCase();
|
|
if (normalized === 'on') return 'On';
|
|
if (normalized === 'off') return 'Off';
|
|
return 'Auto';
|
|
}
|
|
|
|
function nextIrMode(currentMode) {
|
|
/*
|
|
The rover laser button becomes the PTZ camera's IR mode control while the
|
|
PTZ camera is active. Cycle all three modes exposed by this Reolink camera
|
|
instead of reducing the control to a two-state toggle.
|
|
*/
|
|
const current = normalizeIrMode(currentMode);
|
|
if (current === 'Auto') return 'On';
|
|
if (current === 'On') return 'Off';
|
|
return 'Auto';
|
|
}
|
|
|
|
export function usePtzControlAdapter() {
|
|
const socket = useSocket();
|
|
const { ptzSpotlight, ptzIr, pushAlert } = useSessionActions();
|
|
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
|
const isActive = Boolean(ptz?.permissions?.canControl);
|
|
const [lightingPending, setLightingPending] = useState({});
|
|
const lightingPendingRef = useRef({});
|
|
const spotlightOn = isSpotlightOn(ptz?.light);
|
|
const irMode = normalizeIrMode(ptz?.ir?.state);
|
|
|
|
const sendLighting = useCallback((key, send) => {
|
|
// Keyboard repeats and simultaneous touch/key input share one in-flight
|
|
// command per light, so neither can race a pending state change.
|
|
if (lightingPendingRef.current[key]) return;
|
|
lightingPendingRef.current[key] = true;
|
|
setLightingPending((current) => ({ ...current, [key]: true }));
|
|
send().catch((err) => {
|
|
pushAlert({ title: 'PTZ camera', message: err.message, color: '#f59e0b' });
|
|
}).finally(() => {
|
|
lightingPendingRef.current[key] = false;
|
|
setLightingPending((current) => ({ ...current, [key]: false }));
|
|
});
|
|
}, [pushAlert]);
|
|
const lastMotionSignatureRef = useRef(payloadSignature(PTZ_STOP));
|
|
const desiredMotionRef = useRef(PTZ_STOP);
|
|
const panTiltIntentRef = useRef({ pan: 0, tilt: 0 });
|
|
const zoomIntentRef = useRef(0);
|
|
const heartbeatTimerRef = useRef(null);
|
|
|
|
const emitPtz = useCallback(
|
|
(eventName, payload = {}) => {
|
|
if (!socket || !isActive) return;
|
|
socket.emit(eventName, payload);
|
|
},
|
|
[isActive, socket],
|
|
);
|
|
|
|
const clearHeartbeat = useCallback(() => {
|
|
if (!heartbeatTimerRef.current) return;
|
|
clearInterval(heartbeatTimerRef.current);
|
|
heartbeatTimerRef.current = null;
|
|
}, []);
|
|
|
|
const publishMotion = useCallback(
|
|
(payload, options = {}) => {
|
|
if (!isActive) return false;
|
|
const nextPayload = {
|
|
pan: clampUnit(payload?.pan),
|
|
tilt: clampUnit(payload?.tilt),
|
|
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;
|
|
// 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;
|
|
},
|
|
[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(
|
|
(vector, meta = {}) => {
|
|
if (!isActive) return false;
|
|
const panTilt = buildPanTiltPayload(vector, meta);
|
|
panTiltIntentRef.current = {
|
|
pan: panTilt.pan,
|
|
tilt: panTilt.tilt,
|
|
};
|
|
/*
|
|
ONVIF continuous movement accepts pan, tilt, and zoom in one command.
|
|
Preserve the current zoom intent when a direction update arrives so a
|
|
keyboard or touch event on one axis cannot erase another held axis.
|
|
*/
|
|
publishMotion({
|
|
...panTiltIntentRef.current,
|
|
zoom: zoomIntentRef.current,
|
|
});
|
|
return true;
|
|
},
|
|
[isActive, publishMotion],
|
|
);
|
|
|
|
const setZoomIntent = useCallback(
|
|
(direction) => {
|
|
if (!isActive) return false;
|
|
const numeric = clampUnit(direction);
|
|
const sign = axisSign(numeric);
|
|
/*
|
|
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.
|
|
*/
|
|
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,
|
|
});
|
|
return true;
|
|
},
|
|
[isActive, publishMotion],
|
|
);
|
|
|
|
const setSpotlight = useCallback(
|
|
(nextOn) => {
|
|
if (!isActive) return false;
|
|
const desiredOn = typeof nextOn === 'boolean' ? nextOn : !spotlightOn;
|
|
/*
|
|
Lighting keybinds should use the exact acknowledged command action as
|
|
the visible PTZ buttons. Movement remains fire-and-forget because it is
|
|
continuous and high frequency, but a discrete light toggle benefits
|
|
from the existing authorization/error contract and must not maintain a
|
|
second socket-only behavior merely because its source is a keybind.
|
|
*/
|
|
sendLighting('spotlight', () => ptzSpotlight({ state: desiredOn ? 1 : 0 }));
|
|
return true;
|
|
},
|
|
[isActive, spotlightOn, ptzSpotlight, sendLighting],
|
|
);
|
|
|
|
const setIr = useCallback(
|
|
(nextOn) => {
|
|
if (!isActive) return false;
|
|
const desiredState = typeof nextOn === 'boolean'
|
|
? (nextOn ? 'On' : 'Off')
|
|
: nextIrMode(irMode);
|
|
// Match the button path for the same reason as spotlight above. The
|
|
// shared laser key continues to select IR; only its transport is unified.
|
|
sendLighting('ir', () => ptzIr({ state: desiredState }));
|
|
return true;
|
|
},
|
|
[isActive, irMode, ptzIr, sendLighting],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (isActive) return undefined;
|
|
lastMotionSignatureRef.current = payloadSignature(PTZ_STOP);
|
|
desiredMotionRef.current = PTZ_STOP;
|
|
panTiltIntentRef.current = { pan: 0, tilt: 0 };
|
|
zoomIntentRef.current = 0;
|
|
clearHeartbeat();
|
|
return undefined;
|
|
}, [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(
|
|
() => () => {
|
|
clearHeartbeat();
|
|
},
|
|
[clearHeartbeat],
|
|
);
|
|
|
|
return useMemo(
|
|
() => ({
|
|
isActive,
|
|
state: ptz,
|
|
lighting: { spotlightOn, irMode, pending: lightingPending },
|
|
applyDriveVector,
|
|
setZoomIntent,
|
|
setSpotlight,
|
|
setIr,
|
|
stopMotion,
|
|
}),
|
|
[applyDriveVector, isActive, ptz, spotlightOn, irMode, lightingPending, setIr, setSpotlight, setZoomIntent, stopMotion],
|
|
);
|
|
}
|