This commit is contained in:
legop3
2026-07-11 02:22:13 -04:00
parent 3af74870a5
commit 0e5f76fb2f
9 changed files with 434 additions and 269 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
+1 -1
View File
@@ -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/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> <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> <title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-DrIw695l.js"></script> <script type="module" crossorigin src="/assets/index-CnZfvwYB.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DwpUkSbG.css"> <link rel="stylesheet" crossorigin href="/assets/index-DwpUkSbG.css">
</head> </head>
<body> <body>
+26 -3
View File
@@ -88,6 +88,27 @@ function isReplayEnabled() {
return cameraConfig.replayEnabled === undefined ? DEFAULT_REPLAY_ENABLED : Boolean(cameraConfig.replayEnabled); return cameraConfig.replayEnabled === undefined ? DEFAULT_REPLAY_ENABLED : Boolean(cameraConfig.replayEnabled);
} }
function spotlightCameraStateForLogicalOn(logicalOn) {
return Boolean(logicalOn) ? 1 : 0;
}
function isSpotlightOn(light = {}) {
const raw = light?.state;
let rawOn = false;
if (typeof raw === 'string') {
const normalized = raw.trim().toLowerCase();
rawOn = !['', '0', 'off', 'false'].includes(normalized);
} else {
rawOn = Boolean(Number(raw));
}
return rawOn;
}
function normalizeSpotlightState(light) {
if (!light || typeof light !== 'object') return light || null;
return { ...light, on: isSpotlightOn(light) };
}
function passesMode(socket) { function passesMode(socket) {
const mode = getMode(); const mode = getMode();
if (mode === MODES.LOCKDOWN) return isLockdownAdmin(socket); if (mode === MODES.LOCKDOWN) return isLockdownAdmin(socket);
@@ -274,7 +295,7 @@ async function refreshVendorState() {
client.api('GetWhiteLed', { channel: 0 }).catch((err) => ({ error: err.message })), client.api('GetWhiteLed', { channel: 0 }).catch((err) => ({ error: err.message })),
client.api('GetIrLights', { channel: 0 }).catch((err) => ({ error: err.message })), client.api('GetIrLights', { channel: 0 }).catch((err) => ({ error: err.message })),
]); ]);
state.light = white?.WhiteLed || white || null; state.light = normalizeSpotlightState(white?.WhiteLed || white || null);
state.ir = ir?.IrLights || ir || null; state.ir = ir?.IrLights || ir || null;
} }
@@ -486,11 +507,13 @@ async function setSpotlight(socket, payload = {}) {
requireOperator(socket); requireOperator(socket);
return serializeVendorState(async () => { return serializeVendorState(async () => {
const client = await ensureReolinkClient(); const client = await ensureReolinkClient();
const current = (await client.api('GetWhiteLed', { channel: 0 })).WhiteLed || {}; const current = normalizeSpotlightState((await client.api('GetWhiteLed', { channel: 0 })).WhiteLed || {});
const logicalOn = payload.state === undefined ? !current.on : Boolean(payload.state);
const next = { const next = {
...current, ...current,
channel: 0, channel: 0,
state: payload.state === undefined ? (current.state ? 0 : 1) : Number(Boolean(payload.state)), state: spotlightCameraStateForLogicalOn(logicalOn),
on: logicalOn,
}; };
if (Number.isFinite(Number(payload.bright))) { if (Number.isFinite(Number(payload.bright))) {
next.bright = Math.max(0, Math.min(100, Number(payload.bright))); next.bright = Math.max(0, Math.min(100, Number(payload.bright)));
+43 -20
View File
@@ -35,6 +35,7 @@ import {
applyDriveOvercurrentScale, applyDriveOvercurrentScale,
useOvercurrentLimiter, useOvercurrentLimiter,
} from './overcurrentLimiter.js'; } from './overcurrentLimiter.js';
import { usePtzControlAdapter } from './ptzControlAdapter.js';
const ControlSystemContext = createContext(null); const ControlSystemContext = createContext(null);
@@ -179,6 +180,7 @@ export function ControlSystemProvider({ children }) {
[overcurrentLimiter.adminImmune, overcurrentLimiter.scales], [overcurrentLimiter.adminImmune, overcurrentLimiter.scales],
); );
const pipeline = useCommandPipeline({ driveTransform, auxTransform }); const pipeline = useCommandPipeline({ driveTransform, auxTransform });
const ptzControls = usePtzControlAdapter();
const turnOnAllLights = useCallback(() => { const turnOnAllLights = useCallback(() => {
const entities = homeAssistantEntities || []; const entities = homeAssistantEntities || [];
@@ -327,9 +329,10 @@ export function ControlSystemProvider({ children }) {
payload: { ...computed, source: meta.source ?? null }, payload: { ...computed, source: meta.source ?? null },
}); });
recordControlIntent(); recordControlIntent();
if (ptzControls.applyDriveVector(vector, meta)) return;
pipeline.sendDriveDirect(computed.speeds); pipeline.sendDriveDirect(computed.speeds);
}, },
[pipeline, recordControlIntent, state.manualDockAssist?.active], [pipeline, ptzControls, recordControlIntent, state.manualDockAssist?.active],
); );
const setAuxMotors = useCallback( const setAuxMotors = useCallback(
@@ -372,30 +375,37 @@ export function ControlSystemProvider({ children }) {
const setServoAngle = useCallback( const setServoAngle = useCallback(
(value, options = {}) => { (value, options = {}) => {
if (!pipeline.servoConfig && !pipeline.isPtzOperator) return; 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;
}
if (!pipeline.servoConfig) return;
const force = Boolean(options?.force); const force = Boolean(options?.force);
if (state.manualDockAssist?.active && !force) return; if (state.manualDockAssist?.active && !force) return;
/* const clamped = clampServoAngle(pipeline.servoConfig, value);
Rover cameras need hardware min/max clamping from their servo config.
PTZ zoom reuses the same browser camera controls after the rover has
been released, so there may be no rover servo config at all; in that
case the raw numeric target is only used as a direction signal by the
command pipeline and does not represent a physical angle.
*/
const clamped = pipeline.servoConfig ? clampServoAngle(pipeline.servoConfig, value) : Number(value);
if (!Number.isFinite(clamped)) return;
dispatch({ type: 'control/set-camera-angle', payload: clamped }); dispatch({ type: 'control/set-camera-angle', payload: clamped });
pipeline.sendServoAngle(clamped); pipeline.sendServoAngle(clamped);
servoAngleRef.current = clamped; servoAngleRef.current = clamped;
recordControlIntent(); recordControlIntent();
}, },
[pipeline, recordControlIntent, state.manualDockAssist?.active], [pipeline, ptzControls, recordControlIntent, state.manualDockAssist?.active],
); );
const nudgeServo = useCallback( const nudgeServo = useCallback(
(delta = 0) => { (delta = 0) => {
const config = pipeline.servoConfig; const config = pipeline.servoConfig;
if (!config && !pipeline.isPtzOperator) return; if (!config && !ptzControls.isActive) return;
const step = typeof delta === 'number' && delta !== 0 ? delta : config?.nudgeDegrees || 1; const step = typeof delta === 'number' && delta !== 0 ? delta : config?.nudgeDegrees || 1;
const baseline = const baseline =
typeof servoAngleRef.current === 'number' typeof servoAngleRef.current === 'number'
@@ -405,7 +415,7 @@ export function ControlSystemProvider({ children }) {
: 0; : 0;
setServoAngle(baseline + step); setServoAngle(baseline + step);
}, },
[pipeline.isPtzOperator, pipeline.servoConfig, setServoAngle], [pipeline.servoConfig, ptzControls.isActive, setServoAngle],
); );
const goServoHome = useCallback(() => { const goServoHome = useCallback(() => {
@@ -468,9 +478,13 @@ export function ControlSystemProvider({ children }) {
source: 'system-stop', source: 'system-stop',
}, },
}); });
if (ptzControls.isActive) {
ptzControls.stopMotion();
return;
}
pipeline.sendDriveDirect({ left: 0, right: 0 }); pipeline.sendDriveDirect({ left: 0, right: 0 });
pipeline.sendAuxMotors({ main: 0, side: 0, vacuum: 0 }); pipeline.sendAuxMotors({ main: 0, side: 0, vacuum: 0 });
}, [pipeline]); }, [pipeline, ptzControls]);
const sendOiCommand = useCallback( const sendOiCommand = useCallback(
(command) => { (command) => {
@@ -492,7 +506,11 @@ export function ControlSystemProvider({ children }) {
const setHeadlight = useCallback( const setHeadlight = useCallback(
(headlightOn) => { (headlightOn) => {
if (!pipeline.headlight && !pipeline.isPtzOperator) return; if (ptzControls.setSpotlight(headlightOn)) {
recordControlIntent();
return;
}
if (!pipeline.headlight) return;
// Web controls now speak in logical device state. Any electrical // Web controls now speak in logical device state. Any electrical
// inversion needed by the actual GPIO driver is handled by roverd's // inversion needed by the actual GPIO driver is handled by roverd's
// activeLow config, so this command stays readable and direct. // activeLow config, so this command stays readable and direct.
@@ -500,7 +518,7 @@ export function ControlSystemProvider({ children }) {
pipeline.sendHeadlight(action); pipeline.sendHeadlight(action);
recordControlIntent(); recordControlIntent();
}, },
[pipeline, recordControlIntent], [pipeline, ptzControls, recordControlIntent],
); );
const toggleHeadlight = useCallback(() => { const toggleHeadlight = useCallback(() => {
@@ -509,7 +527,11 @@ export function ControlSystemProvider({ children }) {
const setLaser = useCallback( const setLaser = useCallback(
(laserOn) => { (laserOn) => {
if (!pipeline.laser && !pipeline.isPtzOperator) return; if (ptzControls.setIr(laserOn)) {
recordControlIntent();
return;
}
if (!pipeline.laser) return;
if (roomLightsLockedOn && laserOn !== false) return; if (roomLightsLockedOn && laserOn !== false) return;
// The laser shares the same logical toggle contract as the headlight; it // The laser shares the same logical toggle contract as the headlight; it
// is separate only because it has its own GPIO pin, UI control, and keybind. // is separate only because it has its own GPIO pin, UI control, and keybind.
@@ -517,7 +539,7 @@ export function ControlSystemProvider({ children }) {
pipeline.sendLaser(action); pipeline.sendLaser(action);
recordControlIntent(); recordControlIntent();
}, },
[pipeline, recordControlIntent, roomLightsLockedOn], [pipeline, ptzControls, recordControlIntent, roomLightsLockedOn],
); );
const toggleLaser = useCallback(() => { const toggleLaser = useCallback(() => {
@@ -747,10 +769,11 @@ export function ControlSystemProvider({ children }) {
state, state,
dispatch, dispatch,
pipeline, pipeline,
ptzControls,
overcurrentLimiter, overcurrentLimiter,
actions: stableActions, actions: stableActions,
}), }),
[state, pipeline, overcurrentLimiter, stableActions], [state, pipeline, ptzControls, overcurrentLimiter, stableActions],
); );
if (snapshotRef.current == null) { if (snapshotRef.current == null) {
+5 -96
View File
@@ -1,6 +1,6 @@
// Control Command Pipeline // Control Command Pipeline
// Purpose: Converts normalized inputs into command packets sent to the server. Scope: Applies throttling/coalescing/safety filters before socket command emission. // Purpose: Converts normalized inputs into command packets sent to the server. Scope: Applies throttling/coalescing/safety filters before socket command emission.
import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useCallback, useMemo } from 'react';
import { useSocket } from '../context/SocketContext.jsx'; import { useSocket } from '../context/SocketContext.jsx';
import { useSessionSelector } from '../context/SessionContext.jsx'; import { useSessionSelector } from '../context/SessionContext.jsx';
import { import {
@@ -18,9 +18,6 @@ export function useCommandPipeline(options = {}) {
const socket = useSocket(); const socket = useSocket();
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null); const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const roster = useSessionSelector((state) => state.session?.roster ?? []); const roster = useSessionSelector((state) => state.session?.roster ?? []);
const ptzCamera = useSessionSelector((state) => state.session?.ptzCamera ?? null);
const ptzStopTimerRef = useRef(null);
const ptzServoBaselineRef = useRef(null);
const rosterEntry = useMemo(() => { const rosterEntry = useMemo(() => {
if (!roverId || !Array.isArray(roster)) return null; if (!roverId || !Array.isArray(roster)) return null;
@@ -49,15 +46,6 @@ export function useCommandPipeline(options = {}) {
const headlightState = useMemo(() => rosterEntry?.headlight?.state ?? null, [rosterEntry]); const headlightState = useMemo(() => rosterEntry?.headlight?.state ?? null, [rosterEntry]);
const laserState = useMemo(() => rosterEntry?.laser?.state ?? null, [rosterEntry]); const laserState = useMemo(() => rosterEntry?.laser?.state ?? null, [rosterEntry]);
const isPtzOperator = Boolean(ptzCamera?.isOperator);
const emitPtzCommand = useCallback(
(eventName, payload = {}, cb) => {
socket.emit(eventName, payload, cb);
},
[socket],
);
const emitCommand = useCallback( const emitCommand = useCallback(
(payload, cb) => { (payload, cb) => {
if (!roverId) return; if (!roverId) return;
@@ -66,20 +54,6 @@ export function useCommandPipeline(options = {}) {
[socket, roverId], [socket, roverId],
); );
useEffect(() => {
return () => {
/*
PTZ zoom is implemented as short velocity pulses. Clear any pending stop
timer when the pipeline unmounts so old callbacks cannot fire after a
page navigation or React remount.
*/
if (ptzStopTimerRef.current) {
clearTimeout(ptzStopTimerRef.current);
ptzStopTimerRef.current = null;
}
};
}, []);
const enableSensorStream = useCallback(() => { const enableSensorStream = useCallback(() => {
if (!roverId) return; if (!roverId) return;
emitCommand({ emitCommand({
@@ -99,22 +73,6 @@ export function useCommandPipeline(options = {}) {
left: clampRange(transformed?.left ?? 0, [-500, 500]), left: clampRange(transformed?.left ?? 0, [-500, 500]),
right: clampRange(transformed?.right ?? 0, [-500, 500]), right: clampRange(transformed?.right ?? 0, [-500, 500]),
}; };
if (isPtzOperator) {
/*
Convert the final wheel-speed command into PTZ velocity after every
normal rover speed modifier has already run. Differential drive math
gives us a signed turn amount from left-minus-right and a signed
forward amount from their average, which maps cleanly to pan/tilt.
*/
const pan = clampRange((payload.left - payload.right) / 1000, [-1, 1]);
const tilt = clampRange((payload.left + payload.right) / 1000, [-1, 1]);
if (Math.abs(pan) < 0.01 && Math.abs(tilt) < 0.01) {
emitPtzCommand('ptzCamera:stop');
} else {
emitPtzCommand('ptzCamera:move', { pan, tilt });
}
return payload;
}
if (!roverId) return null; if (!roverId) return null;
emitCommand({ emitCommand({
type: 'drive', type: 'drive',
@@ -122,7 +80,7 @@ export function useCommandPipeline(options = {}) {
}); });
return payload; return payload;
}, },
[driveTransform, emitCommand, emitPtzCommand, isPtzOperator, roverId], [driveTransform, emitCommand, roverId],
); );
const sendAuxMotors = useCallback( const sendAuxMotors = useCallback(
@@ -150,31 +108,6 @@ export function useCommandPipeline(options = {}) {
const sendServoAngle = useCallback( const sendServoAngle = useCallback(
(angle) => { (angle) => {
if (isPtzOperator) {
/*
Existing rover camera inputs express intent as an angle target. The
PTZ camera expects zoom velocity, so compare against the previous
target and emit a short zoom pulse in that direction. The delayed stop
keeps keyboard and gamepad nudge behavior responsive without leaving
the ONVIF zoom motor running after input stops.
*/
const numericAngle = Number(angle);
if (!Number.isFinite(numericAngle)) return null;
const previous = typeof ptzServoBaselineRef.current === 'number' ? ptzServoBaselineRef.current : numericAngle;
const delta = numericAngle - previous;
ptzServoBaselineRef.current = numericAngle;
if (Math.abs(delta) >= 0.01) {
emitPtzCommand('ptzCamera:move', { zoom: delta > 0 ? 0.45 : -0.45 });
if (ptzStopTimerRef.current) {
clearTimeout(ptzStopTimerRef.current);
}
ptzStopTimerRef.current = setTimeout(() => {
ptzStopTimerRef.current = null;
emitPtzCommand('ptzCamera:stop');
}, 220);
}
return angle;
}
if (!roverId || !servoConfig) return null; if (!roverId || !servoConfig) return null;
emitCommand({ emitCommand({
type: 'servo', type: 'servo',
@@ -182,7 +115,7 @@ export function useCommandPipeline(options = {}) {
}); });
return angle; return angle;
}, },
[emitCommand, emitPtzCommand, isPtzOperator, roverId, servoConfig], [emitCommand, roverId, servoConfig],
); );
const sendOiCommand = useCallback( const sendOiCommand = useCallback(
@@ -241,17 +174,6 @@ export function useCommandPipeline(options = {}) {
const sendHeadlight = useCallback( const sendHeadlight = useCallback(
(action = 'toggle') => { (action = 'toggle') => {
if (isPtzOperator) {
/*
The rover headlight button is the natural physical control for the
camera spotlight. Resolve toggle client-side from the latest session
state, then let the server serialize and verify the Reolink API call.
*/
const currentOn = Boolean(ptzCamera?.light?.state);
const nextState = action === 'on' ? 1 : action === 'off' ? 0 : currentOn ? 0 : 1;
emitPtzCommand('ptzCamera:spotlight', { state: nextState });
return action;
}
if (!roverId || !headlight) return null; if (!roverId || !headlight) return null;
emitCommand({ emitCommand({
type: 'headlight', type: 'headlight',
@@ -259,22 +181,11 @@ export function useCommandPipeline(options = {}) {
}); });
return action; return action;
}, },
[emitCommand, emitPtzCommand, headlight, isPtzOperator, ptzCamera?.light?.state, roverId], [emitCommand, headlight, roverId],
); );
const sendLaser = useCallback( const sendLaser = useCallback(
(action = 'toggle') => { (action = 'toggle') => {
if (isPtzOperator) {
/*
There is no laser on the PTZ camera, so reuse that secondary light
control for IR mode. Toggle switches between Auto and Off, matching
the simplified UI control used by the PTZ panel.
*/
const currentOff = String(ptzCamera?.ir?.state || '').toLowerCase() === 'off';
const nextState = action === 'on' ? 'Auto' : action === 'off' ? 'Off' : currentOff ? 'Auto' : 'Off';
emitPtzCommand('ptzCamera:ir', { state: nextState });
return action;
}
if (!roverId || !laser) return null; if (!roverId || !laser) return null;
emitCommand({ emitCommand({
type: 'laser', type: 'laser',
@@ -282,7 +193,7 @@ export function useCommandPipeline(options = {}) {
}); });
return action; return action;
}, },
[emitCommand, emitPtzCommand, isPtzOperator, laser, ptzCamera?.ir?.state, roverId], [emitCommand, laser, roverId],
); );
const sendHorn = useCallback( const sendHorn = useCallback(
@@ -326,7 +237,6 @@ export function useCommandPipeline(options = {}) {
return useMemo( return useMemo(
() => ({ () => ({
roverId, roverId,
isPtzOperator,
rosterEntry, rosterEntry,
servoConfig, servoConfig,
headlight, headlight,
@@ -348,7 +258,6 @@ export function useCommandPipeline(options = {}) {
}), }),
[ [
roverId, roverId,
isPtzOperator,
rosterEntry, rosterEntry,
servoConfig, servoConfig,
headlight, headlight,
+210
View File
@@ -0,0 +1,210 @@
// 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 } from 'react';
import { useSocket } from '../context/SocketContext.jsx';
import { useSessionSelector } from '../context/SessionContext.jsx';
const PTZ_STOP = { pan: 0, tilt: 0, zoom: 0 };
const PTZ_SPEEDS = {
slow: 0.22,
medium: 0.45,
fast: 0.72,
};
const ZOOM_PULSE_MS = 220;
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));
}
export function usePtzControlAdapter() {
const socket = useSocket();
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
const isActive = Boolean(ptz?.isOperator);
const lastMotionSignatureRef = useRef(payloadSignature(PTZ_STOP));
const zoomStopTimerRef = useRef(null);
const emitPtz = useCallback(
(eventName, payload = {}) => {
if (!socket || !isActive) return;
socket.emit(eventName, payload);
},
[isActive, socket],
);
const stopMotion = useCallback(() => {
if (zoomStopTimerRef.current) {
clearTimeout(zoomStopTimerRef.current);
zoomStopTimerRef.current = null;
}
const stopSignature = payloadSignature(PTZ_STOP);
if (lastMotionSignatureRef.current === stopSignature) return;
lastMotionSignatureRef.current = stopSignature;
emitPtz('ptzCamera:stop');
}, [emitPtz]);
const sendMotion = useCallback(
(payload) => {
if (!isActive) return false;
const nextPayload = {
pan: clampUnit(payload?.pan),
tilt: clampUnit(payload?.tilt),
zoom: clampUnit(payload?.zoom),
};
const nextSignature = payloadSignature(nextPayload);
if (lastMotionSignatureRef.current === nextSignature) return true;
lastMotionSignatureRef.current = nextSignature;
if (isIdlePayload(nextPayload)) {
emitPtz('ptzCamera:stop');
} else {
emitPtz('ptzCamera:move', nextPayload);
}
return true;
},
[emitPtz, isActive],
);
const applyDriveVector = useCallback(
(vector, meta = {}) => {
if (!isActive) return false;
sendMotion(buildPanTiltPayload(vector, meta));
return true;
},
[isActive, sendMotion],
);
const pulseZoom = useCallback(
(direction) => {
if (!isActive) return false;
const sign = axisSign(direction);
if (!sign) {
stopMotion();
return true;
}
sendMotion({ pan: 0, tilt: 0, zoom: sign * PTZ_SPEEDS.medium });
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;
stopMotion();
}, ZOOM_PULSE_MS);
return true;
},
[isActive, sendMotion, stopMotion],
);
const setSpotlight = useCallback(
(nextOn) => {
if (!isActive) return false;
const desiredOn = typeof nextOn === 'boolean' ? nextOn : !isSpotlightOn(ptz?.light);
emitPtz('ptzCamera:spotlight', { state: desiredOn ? 1 : 0 });
return true;
},
[emitPtz, isActive, ptz?.light],
);
const setIr = useCallback(
(nextOn) => {
if (!isActive) return false;
const currentOff = String(ptz?.ir?.state || '').toLowerCase() === 'off';
const desiredState = typeof nextOn === 'boolean' ? (nextOn ? 'Auto' : 'Off') : currentOff ? 'Auto' : 'Off';
emitPtz('ptzCamera:ir', { state: desiredState });
return true;
},
[emitPtz, isActive, ptz?.ir?.state],
);
useEffect(() => {
if (isActive) return undefined;
lastMotionSignatureRef.current = payloadSignature(PTZ_STOP);
if (zoomStopTimerRef.current) {
clearTimeout(zoomStopTimerRef.current);
zoomStopTimerRef.current = null;
}
return undefined;
}, [isActive]);
useEffect(
() => () => {
if (zoomStopTimerRef.current) clearTimeout(zoomStopTimerRef.current);
},
[],
);
return useMemo(
() => ({
isActive,
state: ptz,
applyDriveVector,
pulseZoom,
setSpotlight,
setIr,
stopMotion,
}),
[applyDriveVector, isActive, ptz, pulseZoom, setIr, setSpotlight, stopMotion],
);
}