mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-17 10:00:46 -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/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>
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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