mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
awa
This commit is contained in:
@@ -35,6 +35,7 @@ import {
|
||||
applyDriveOvercurrentScale,
|
||||
useOvercurrentLimiter,
|
||||
} from './overcurrentLimiter.js';
|
||||
import { usePtzControlAdapter } from './ptzControlAdapter.js';
|
||||
|
||||
const ControlSystemContext = createContext(null);
|
||||
|
||||
@@ -179,6 +180,7 @@ export function ControlSystemProvider({ children }) {
|
||||
[overcurrentLimiter.adminImmune, overcurrentLimiter.scales],
|
||||
);
|
||||
const pipeline = useCommandPipeline({ driveTransform, auxTransform });
|
||||
const ptzControls = usePtzControlAdapter();
|
||||
|
||||
const turnOnAllLights = useCallback(() => {
|
||||
const entities = homeAssistantEntities || [];
|
||||
@@ -327,9 +329,10 @@ export function ControlSystemProvider({ children }) {
|
||||
payload: { ...computed, source: meta.source ?? null },
|
||||
});
|
||||
recordControlIntent();
|
||||
if (ptzControls.applyDriveVector(vector, meta)) return;
|
||||
pipeline.sendDriveDirect(computed.speeds);
|
||||
},
|
||||
[pipeline, recordControlIntent, state.manualDockAssist?.active],
|
||||
[pipeline, ptzControls, recordControlIntent, state.manualDockAssist?.active],
|
||||
);
|
||||
|
||||
const setAuxMotors = useCallback(
|
||||
@@ -372,30 +375,37 @@ export function ControlSystemProvider({ children }) {
|
||||
|
||||
const setServoAngle = useCallback(
|
||||
(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);
|
||||
if (state.manualDockAssist?.active && !force) return;
|
||||
/*
|
||||
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;
|
||||
const clamped = clampServoAngle(pipeline.servoConfig, value);
|
||||
dispatch({ type: 'control/set-camera-angle', payload: clamped });
|
||||
pipeline.sendServoAngle(clamped);
|
||||
servoAngleRef.current = clamped;
|
||||
recordControlIntent();
|
||||
},
|
||||
[pipeline, recordControlIntent, state.manualDockAssist?.active],
|
||||
[pipeline, ptzControls, recordControlIntent, state.manualDockAssist?.active],
|
||||
);
|
||||
|
||||
const nudgeServo = useCallback(
|
||||
(delta = 0) => {
|
||||
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 baseline =
|
||||
typeof servoAngleRef.current === 'number'
|
||||
@@ -405,7 +415,7 @@ export function ControlSystemProvider({ children }) {
|
||||
: 0;
|
||||
setServoAngle(baseline + step);
|
||||
},
|
||||
[pipeline.isPtzOperator, pipeline.servoConfig, setServoAngle],
|
||||
[pipeline.servoConfig, ptzControls.isActive, setServoAngle],
|
||||
);
|
||||
|
||||
const goServoHome = useCallback(() => {
|
||||
@@ -468,9 +478,13 @@ export function ControlSystemProvider({ children }) {
|
||||
source: 'system-stop',
|
||||
},
|
||||
});
|
||||
if (ptzControls.isActive) {
|
||||
ptzControls.stopMotion();
|
||||
return;
|
||||
}
|
||||
pipeline.sendDriveDirect({ left: 0, right: 0 });
|
||||
pipeline.sendAuxMotors({ main: 0, side: 0, vacuum: 0 });
|
||||
}, [pipeline]);
|
||||
}, [pipeline, ptzControls]);
|
||||
|
||||
const sendOiCommand = useCallback(
|
||||
(command) => {
|
||||
@@ -492,7 +506,11 @@ export function ControlSystemProvider({ children }) {
|
||||
|
||||
const setHeadlight = useCallback(
|
||||
(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
|
||||
// inversion needed by the actual GPIO driver is handled by roverd's
|
||||
// activeLow config, so this command stays readable and direct.
|
||||
@@ -500,7 +518,7 @@ export function ControlSystemProvider({ children }) {
|
||||
pipeline.sendHeadlight(action);
|
||||
recordControlIntent();
|
||||
},
|
||||
[pipeline, recordControlIntent],
|
||||
[pipeline, ptzControls, recordControlIntent],
|
||||
);
|
||||
|
||||
const toggleHeadlight = useCallback(() => {
|
||||
@@ -509,7 +527,11 @@ export function ControlSystemProvider({ children }) {
|
||||
|
||||
const setLaser = useCallback(
|
||||
(laserOn) => {
|
||||
if (!pipeline.laser && !pipeline.isPtzOperator) return;
|
||||
if (ptzControls.setIr(laserOn)) {
|
||||
recordControlIntent();
|
||||
return;
|
||||
}
|
||||
if (!pipeline.laser) return;
|
||||
if (roomLightsLockedOn && laserOn !== false) return;
|
||||
// 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.
|
||||
@@ -517,7 +539,7 @@ export function ControlSystemProvider({ children }) {
|
||||
pipeline.sendLaser(action);
|
||||
recordControlIntent();
|
||||
},
|
||||
[pipeline, recordControlIntent, roomLightsLockedOn],
|
||||
[pipeline, ptzControls, recordControlIntent, roomLightsLockedOn],
|
||||
);
|
||||
|
||||
const toggleLaser = useCallback(() => {
|
||||
@@ -747,10 +769,11 @@ export function ControlSystemProvider({ children }) {
|
||||
state,
|
||||
dispatch,
|
||||
pipeline,
|
||||
ptzControls,
|
||||
overcurrentLimiter,
|
||||
actions: stableActions,
|
||||
}),
|
||||
[state, pipeline, overcurrentLimiter, stableActions],
|
||||
[state, pipeline, ptzControls, overcurrentLimiter, stableActions],
|
||||
);
|
||||
|
||||
if (snapshotRef.current == null) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Control Command Pipeline
|
||||
// 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 { useSessionSelector } from '../context/SessionContext.jsx';
|
||||
import {
|
||||
@@ -18,9 +18,6 @@ export function useCommandPipeline(options = {}) {
|
||||
const socket = useSocket();
|
||||
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||
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(() => {
|
||||
if (!roverId || !Array.isArray(roster)) return null;
|
||||
@@ -49,15 +46,6 @@ export function useCommandPipeline(options = {}) {
|
||||
|
||||
const headlightState = useMemo(() => rosterEntry?.headlight?.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(
|
||||
(payload, cb) => {
|
||||
if (!roverId) return;
|
||||
@@ -66,20 +54,6 @@ export function useCommandPipeline(options = {}) {
|
||||
[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(() => {
|
||||
if (!roverId) return;
|
||||
emitCommand({
|
||||
@@ -99,22 +73,6 @@ export function useCommandPipeline(options = {}) {
|
||||
left: clampRange(transformed?.left ?? 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;
|
||||
emitCommand({
|
||||
type: 'drive',
|
||||
@@ -122,7 +80,7 @@ export function useCommandPipeline(options = {}) {
|
||||
});
|
||||
return payload;
|
||||
},
|
||||
[driveTransform, emitCommand, emitPtzCommand, isPtzOperator, roverId],
|
||||
[driveTransform, emitCommand, roverId],
|
||||
);
|
||||
|
||||
const sendAuxMotors = useCallback(
|
||||
@@ -150,31 +108,6 @@ export function useCommandPipeline(options = {}) {
|
||||
|
||||
const sendServoAngle = useCallback(
|
||||
(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;
|
||||
emitCommand({
|
||||
type: 'servo',
|
||||
@@ -182,7 +115,7 @@ export function useCommandPipeline(options = {}) {
|
||||
});
|
||||
return angle;
|
||||
},
|
||||
[emitCommand, emitPtzCommand, isPtzOperator, roverId, servoConfig],
|
||||
[emitCommand, roverId, servoConfig],
|
||||
);
|
||||
|
||||
const sendOiCommand = useCallback(
|
||||
@@ -241,17 +174,6 @@ export function useCommandPipeline(options = {}) {
|
||||
|
||||
const sendHeadlight = useCallback(
|
||||
(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;
|
||||
emitCommand({
|
||||
type: 'headlight',
|
||||
@@ -259,22 +181,11 @@ export function useCommandPipeline(options = {}) {
|
||||
});
|
||||
return action;
|
||||
},
|
||||
[emitCommand, emitPtzCommand, headlight, isPtzOperator, ptzCamera?.light?.state, roverId],
|
||||
[emitCommand, headlight, roverId],
|
||||
);
|
||||
|
||||
const sendLaser = useCallback(
|
||||
(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;
|
||||
emitCommand({
|
||||
type: 'laser',
|
||||
@@ -282,7 +193,7 @@ export function useCommandPipeline(options = {}) {
|
||||
});
|
||||
return action;
|
||||
},
|
||||
[emitCommand, emitPtzCommand, isPtzOperator, laser, ptzCamera?.ir?.state, roverId],
|
||||
[emitCommand, laser, roverId],
|
||||
);
|
||||
|
||||
const sendHorn = useCallback(
|
||||
@@ -326,7 +237,6 @@ export function useCommandPipeline(options = {}) {
|
||||
return useMemo(
|
||||
() => ({
|
||||
roverId,
|
||||
isPtzOperator,
|
||||
rosterEntry,
|
||||
servoConfig,
|
||||
headlight,
|
||||
@@ -348,7 +258,6 @@ export function useCommandPipeline(options = {}) {
|
||||
}),
|
||||
[
|
||||
roverId,
|
||||
isPtzOperator,
|
||||
rosterEntry,
|
||||
servoConfig,
|
||||
headlight,
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user