This commit is contained in:
legop3
2026-07-11 00:38:05 -04:00
parent b3001cf0a3
commit aee1a9d563
11 changed files with 389 additions and 251 deletions
+37 -58
View File
@@ -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 () => {
+16 -8
View File
@@ -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.
+96 -6
View File
@@ -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,