mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
awa
This commit is contained in:
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
@@ -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/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>
|
||||
<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">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -88,6 +88,27 @@ function isReplayEnabled() {
|
||||
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) {
|
||||
const mode = getMode();
|
||||
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('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;
|
||||
}
|
||||
|
||||
@@ -486,11 +507,13 @@ async function setSpotlight(socket, payload = {}) {
|
||||
requireOperator(socket);
|
||||
return serializeVendorState(async () => {
|
||||
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 = {
|
||||
...current,
|
||||
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))) {
|
||||
next.bright = Math.max(0, Math.min(100, Number(payload.bright)));
|
||||
|
||||
@@ -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