mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
pete
This commit is contained in:
@@ -80,13 +80,29 @@ function ControlButton({ title, children, onHold, className = '' }) {
|
||||
function PtzLiveVideo({ enabled }) {
|
||||
const videoRef = useRef(null);
|
||||
const playerRef = useRef(null);
|
||||
const retryTimerRef = useRef(null);
|
||||
const [status, setStatus] = useState('idle');
|
||||
const [retryVersion, setRetryVersion] = useState(0);
|
||||
const sources = useVideoRequests(
|
||||
[{ type: 'ptz', id: PTZ_CAMERA_ID, key: PTZ_CAMERA_ID }],
|
||||
{ enabled },
|
||||
{ enabled, version: retryVersion },
|
||||
);
|
||||
const source = sources[PTZ_CAMERA_ID] || null;
|
||||
|
||||
const scheduleRetry = useCallback(() => {
|
||||
if (!enabled || retryTimerRef.current) return;
|
||||
/*
|
||||
A failed WHEP POST consumes the short-lived video token and leaves the
|
||||
PeerConnection in a terminal state. Requesting a fresh server session is
|
||||
the simplest reliable retry path, and it matches how rover playback gets
|
||||
a new authorization token after reconnects.
|
||||
*/
|
||||
retryTimerRef.current = setTimeout(() => {
|
||||
retryTimerRef.current = null;
|
||||
setRetryVersion((value) => value + 1);
|
||||
}, 1500);
|
||||
}, [enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !source?.url || !videoRef.current) return undefined;
|
||||
/*
|
||||
@@ -98,16 +114,33 @@ function PtzLiveVideo({ enabled }) {
|
||||
url: source.url,
|
||||
token: source.token,
|
||||
video: videoRef.current,
|
||||
onStatus: setStatus,
|
||||
onStatus: (nextStatus) => {
|
||||
setStatus(nextStatus);
|
||||
if (['error', 'failed', 'disconnected', 'closed'].includes(String(nextStatus || '').toLowerCase())) {
|
||||
scheduleRetry();
|
||||
}
|
||||
},
|
||||
receiveAudio: false,
|
||||
});
|
||||
playerRef.current = player;
|
||||
player.start().catch((err) => setStatus(err.message || 'error'));
|
||||
player.start().catch((err) => {
|
||||
setStatus(err.message || 'error');
|
||||
scheduleRetry();
|
||||
});
|
||||
return () => {
|
||||
player.stop();
|
||||
playerRef.current = null;
|
||||
};
|
||||
}, [enabled, source?.token, source?.url]);
|
||||
}, [enabled, scheduleRetry, source?.token, source?.url]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (retryTimerRef.current) {
|
||||
clearTimeout(retryTimerRef.current);
|
||||
retryTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full bg-black">
|
||||
@@ -145,60 +178,6 @@ function PtzController({ open, onClose }) {
|
||||
[sendMove, stopMotion],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !isOperator) return undefined;
|
||||
/*
|
||||
Keyboard control is intentionally active only while the fullscreen PTZ
|
||||
controller is open. Releasing, blurring, or losing operator status sends a
|
||||
stop command so continuous ONVIF movement cannot be left running.
|
||||
*/
|
||||
const pressed = new Set();
|
||||
const recompute = () => {
|
||||
let pan = 0;
|
||||
let tilt = 0;
|
||||
let zoom = 0;
|
||||
if (pressed.has('ArrowLeft') || pressed.has('KeyA')) pan -= 0.55;
|
||||
if (pressed.has('ArrowRight') || pressed.has('KeyD')) pan += 0.55;
|
||||
if (pressed.has('ArrowUp') || pressed.has('KeyW')) tilt += 0.55;
|
||||
if (pressed.has('ArrowDown') || pressed.has('KeyS')) tilt -= 0.55;
|
||||
if (pressed.has('KeyQ')) zoom += 0.55;
|
||||
if (pressed.has('KeyE')) zoom -= 0.55;
|
||||
if (pan || tilt || zoom) sendMove({ pan, tilt, zoom });
|
||||
else stopMotion();
|
||||
};
|
||||
const onKeyDown = (event) => {
|
||||
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'KeyA', 'KeyD', 'KeyW', 'KeyS', 'KeyQ', 'KeyE', 'Space'].includes(event.code)) return;
|
||||
event.preventDefault();
|
||||
if (event.code === 'Space') {
|
||||
pressed.clear();
|
||||
stopMotion();
|
||||
return;
|
||||
}
|
||||
if (!pressed.has(event.code)) {
|
||||
pressed.add(event.code);
|
||||
recompute();
|
||||
}
|
||||
};
|
||||
const onKeyUp = (event) => {
|
||||
if (!pressed.delete(event.code)) return;
|
||||
event.preventDefault();
|
||||
recompute();
|
||||
};
|
||||
const onBlur = () => {
|
||||
pressed.clear();
|
||||
stopMotion();
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
window.addEventListener('keyup', onKeyUp);
|
||||
window.addEventListener('blur', onBlur);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
window.removeEventListener('keyup', onKeyUp);
|
||||
window.removeEventListener('blur', onBlur);
|
||||
stopMotion();
|
||||
};
|
||||
}, [isOperator, open, sendMove, stopMotion]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const toggleSpotlight = async () => {
|
||||
|
||||
@@ -372,10 +372,18 @@ export function ControlSystemProvider({ children }) {
|
||||
|
||||
const setServoAngle = useCallback(
|
||||
(value, options = {}) => {
|
||||
if (!pipeline.servoConfig) return;
|
||||
if (!pipeline.servoConfig && !pipeline.isPtzOperator) return;
|
||||
const force = Boolean(options?.force);
|
||||
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 });
|
||||
pipeline.sendServoAngle(clamped);
|
||||
servoAngleRef.current = clamped;
|
||||
@@ -387,17 +395,17 @@ export function ControlSystemProvider({ children }) {
|
||||
const nudgeServo = useCallback(
|
||||
(delta = 0) => {
|
||||
const config = pipeline.servoConfig;
|
||||
if (!config) return;
|
||||
const step = typeof delta === 'number' && delta !== 0 ? delta : config.nudgeDegrees || 1;
|
||||
if (!config && !pipeline.isPtzOperator) return;
|
||||
const step = typeof delta === 'number' && delta !== 0 ? delta : config?.nudgeDegrees || 1;
|
||||
const baseline =
|
||||
typeof servoAngleRef.current === 'number'
|
||||
? servoAngleRef.current
|
||||
: typeof config.homeAngle === 'number'
|
||||
: typeof config?.homeAngle === 'number'
|
||||
? config.homeAngle
|
||||
: 0;
|
||||
setServoAngle(baseline + step);
|
||||
},
|
||||
[pipeline.servoConfig, setServoAngle],
|
||||
[pipeline.isPtzOperator, pipeline.servoConfig, setServoAngle],
|
||||
);
|
||||
|
||||
const goServoHome = useCallback(() => {
|
||||
@@ -484,7 +492,7 @@ export function ControlSystemProvider({ children }) {
|
||||
|
||||
const setHeadlight = useCallback(
|
||||
(headlightOn) => {
|
||||
if (!pipeline.headlight) return;
|
||||
if (!pipeline.headlight && !pipeline.isPtzOperator) 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.
|
||||
@@ -501,7 +509,7 @@ export function ControlSystemProvider({ children }) {
|
||||
|
||||
const setLaser = useCallback(
|
||||
(laserOn) => {
|
||||
if (!pipeline.laser) return;
|
||||
if (!pipeline.laser && !pipeline.isPtzOperator) 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.
|
||||
|
||||
@@ -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, useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useSocket } from '../context/SocketContext.jsx';
|
||||
import { useSessionSelector } from '../context/SessionContext.jsx';
|
||||
import {
|
||||
@@ -18,6 +18,9 @@ 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;
|
||||
@@ -46,6 +49,14 @@ 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) => {
|
||||
@@ -55,6 +66,20 @@ 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({
|
||||
@@ -65,7 +90,6 @@ export function useCommandPipeline(options = {}) {
|
||||
|
||||
const sendDriveDirect = useCallback(
|
||||
(speeds) => {
|
||||
if (!roverId) return null;
|
||||
const rawPayload = {
|
||||
left: clampRange(speeds?.left ?? 0, [-500, 500]),
|
||||
right: clampRange(speeds?.right ?? 0, [-500, 500]),
|
||||
@@ -75,13 +99,30 @@ 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',
|
||||
data: { driveDirect: payload },
|
||||
});
|
||||
return payload;
|
||||
},
|
||||
[driveTransform, emitCommand, roverId],
|
||||
[driveTransform, emitCommand, emitPtzCommand, isPtzOperator, roverId],
|
||||
);
|
||||
|
||||
const sendAuxMotors = useCallback(
|
||||
@@ -109,6 +150,31 @@ 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',
|
||||
@@ -116,7 +182,7 @@ export function useCommandPipeline(options = {}) {
|
||||
});
|
||||
return angle;
|
||||
},
|
||||
[emitCommand, roverId, servoConfig],
|
||||
[emitCommand, emitPtzCommand, isPtzOperator, roverId, servoConfig],
|
||||
);
|
||||
|
||||
const sendOiCommand = useCallback(
|
||||
@@ -175,6 +241,17 @@ 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',
|
||||
@@ -182,11 +259,22 @@ export function useCommandPipeline(options = {}) {
|
||||
});
|
||||
return action;
|
||||
},
|
||||
[emitCommand, headlight, roverId],
|
||||
[emitCommand, emitPtzCommand, headlight, isPtzOperator, ptzCamera?.light?.state, 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',
|
||||
@@ -194,7 +282,7 @@ export function useCommandPipeline(options = {}) {
|
||||
});
|
||||
return action;
|
||||
},
|
||||
[emitCommand, laser, roverId],
|
||||
[emitCommand, emitPtzCommand, isPtzOperator, laser, ptzCamera?.ir?.state, roverId],
|
||||
);
|
||||
|
||||
const sendHorn = useCallback(
|
||||
@@ -238,6 +326,7 @@ export function useCommandPipeline(options = {}) {
|
||||
return useMemo(
|
||||
() => ({
|
||||
roverId,
|
||||
isPtzOperator,
|
||||
rosterEntry,
|
||||
servoConfig,
|
||||
headlight,
|
||||
@@ -259,6 +348,7 @@ export function useCommandPipeline(options = {}) {
|
||||
}),
|
||||
[
|
||||
roverId,
|
||||
isPtzOperator,
|
||||
rosterEntry,
|
||||
servoConfig,
|
||||
headlight,
|
||||
|
||||
Reference in New Issue
Block a user