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
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
+1 -1
View File
@@ -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-BFbU1lYU.js"></script> <script type="module" crossorigin src="/assets/index-B3VrYCWz.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DwpUkSbG.css"> <link rel="stylesheet" crossorigin href="/assets/index-DwpUkSbG.css">
</head> </head>
<body> <body>
+69 -28
View File
@@ -58,6 +58,7 @@ let blockedTimer = null;
let publisherProcess = null; let publisherProcess = null;
let publisherRestartTimer = null; let publisherRestartTimer = null;
let snapshotTimer = null; let snapshotTimer = null;
let vendorStatePromise = Promise.resolve();
let lastSnapshotState = null; let lastSnapshotState = null;
const snapshotSubscribers = new Map(); const snapshotSubscribers = new Map();
const socketSnapshotSubscriptions = new Map(); const socketSnapshotSubscriptions = new Map();
@@ -194,10 +195,10 @@ function startPublisher() {
const input = addCredentialsToRtsp(state.rtspUri); const input = addCredentialsToRtsp(state.rtspUri);
const output = `srt://127.0.0.1:9000?streamid=publish:${encodeURIComponent(PTZ_STREAM_PATH)}`; const output = `srt://127.0.0.1:9000?streamid=publish:${encodeURIComponent(PTZ_STREAM_PATH)}`;
/* /*
MediaMTX already treats SRT publishers as trusted local media producers. MediaMTX should receive the camera feed exactly as the camera provides it.
Copying the autotrack stream keeps the full-resolution camera feed intact; The WHEP 404 issue is a path mismatch, not a codec problem, so this publisher
if browser H.265 support becomes a problem later, this is the one place to keeps the autotrack stream untouched and lets playback compatibility be
add a transcode without changing PTZ ownership or UI code. handled separately if it actually becomes the blocker.
*/ */
const proc = spawn('ffmpeg', [ const proc = spawn('ffmpeg', [
'-hide_banner', '-hide_banner',
@@ -267,6 +268,18 @@ async function refreshVendorState() {
state.ir = ir?.IrLights || ir || null; state.ir = ir?.IrLights || ir || null;
} }
function serializeVendorState(operation) {
/*
The Reolink HTTP API can return stale light state when reads and writes are
overlapped. Keep spotlight/IR changes in one narrow queue so a button mash
becomes ordered camera operations instead of competing Get/Set requests.
*/
vendorStatePromise = vendorStatePromise
.catch(() => {})
.then(operation);
return vendorStatePromise;
}
async function initialize() { async function initialize() {
if (!enabled || state.initialized || state.initializing) return; if (!enabled || state.initialized || state.initializing) return;
state.initializing = true; state.initializing = true;
@@ -330,15 +343,30 @@ function activateOperator(socket) {
}); });
state.operatorSocketId = socket.id; state.operatorSocketId = socket.id;
state.deadline = Date.now() + getTurnDurationMs(); state.deadline = Date.now() + getTurnDurationMs();
turnTimer = setTimeout(() => { turnTimer = setTimeout(handleTurnDeadline, getTurnDurationMs());
revokeOperator('turn-expired');
advanceQueue('turn-expired');
}, getTurnDurationMs());
socket.emit('ptzCamera:turn', { status: 'active', deadline: state.deadline }); socket.emit('ptzCamera:turn', { status: 'active', deadline: state.deadline });
events.emit('operator', { socketId: socket.id, action: 'active' }); events.emit('operator', { socketId: socket.id, action: 'active' });
emitChange('operator-active'); emitChange('operator-active');
} }
function handleTurnDeadline() {
turnTimer = null;
if (!state.operatorSocketId) return;
if (state.queue.length > 0) {
revokeOperator('turn-expired');
advanceQueue('turn-expired');
return;
}
/*
A turn timer only matters when somebody else is waiting. If the operator is
alone, keep them on the PTZ camera and roll the deadline forward so the UI
stays coherent without kicking out the only active viewer.
*/
state.deadline = Date.now() + getTurnDurationMs();
turnTimer = setTimeout(handleTurnDeadline, getTurnDurationMs());
emitChange('turn-extended-empty-queue');
}
function advanceQueue(reason = 'advance') { function advanceQueue(reason = 'advance') {
clearBlockedTimer(); clearBlockedTimer();
state.blocked = null; state.blocked = null;
@@ -446,30 +474,43 @@ async function getStatus(socket) {
async function setSpotlight(socket, payload = {}) { async function setSpotlight(socket, payload = {}) {
requireOperator(socket); requireOperator(socket);
const client = await ensureReolinkClient(); return serializeVendorState(async () => {
const current = (await client.api('GetWhiteLed', { channel: 0 })).WhiteLed; const client = await ensureReolinkClient();
const next = { const current = (await client.api('GetWhiteLed', { channel: 0 })).WhiteLed || {};
...current, const next = {
channel: 0, ...current,
state: payload.state === undefined ? (current.state ? 0 : 1) : Number(Boolean(payload.state)), channel: 0,
}; state: payload.state === undefined ? (current.state ? 0 : 1) : Number(Boolean(payload.state)),
if (Number.isFinite(Number(payload.bright))) { };
next.bright = Math.max(0, Math.min(100, Number(payload.bright))); if (Number.isFinite(Number(payload.bright))) {
} next.bright = Math.max(0, Math.min(100, Number(payload.bright)));
await client.api('SetWhiteLed', { WhiteLed: next }); }
await refreshVendorState(); /*
emitChange('light'); Update local state optimistically before the refresh. That makes the
return state.light; headlight button feel deterministic while the follow-up read still lets
the camera correct anything it refused or normalized.
*/
state.light = next;
emitChange('light-pending');
await client.api('SetWhiteLed', { WhiteLed: next });
await refreshVendorState();
emitChange('light');
return state.light;
});
} }
async function setIr(socket, payload = {}) { async function setIr(socket, payload = {}) {
requireOperator(socket); requireOperator(socket);
const nextState = String(payload.state || '').toLowerCase() === 'off' ? 'Off' : 'Auto'; return serializeVendorState(async () => {
const client = await ensureReolinkClient(); const nextState = String(payload.state || '').toLowerCase() === 'off' ? 'Off' : 'Auto';
await client.api('SetIrLights', { IrLights: { state: nextState } }); const client = await ensureReolinkClient();
await refreshVendorState(); state.ir = { ...(state.ir || {}), state: nextState };
emitChange('ir'); emitChange('ir-pending');
return state.ir; await client.api('SetIrLights', { IrLights: { state: nextState } });
await refreshVendorState();
emitChange('ir');
return state.ir;
});
} }
function canRequestLiveVideo(socket) { function canRequestLiveVideo(socket) {
@@ -5,6 +5,7 @@ const { loadConfig } = require('../../helpers/configLoader');
const config = loadConfig(); const config = loadConfig();
const mediaConfig = config.media || {}; const mediaConfig = config.media || {};
const PTZ_STREAM_PATH = 'ptz-camera';
function getPathPrefix() { function getPathPrefix() {
const base = mediaConfig.whepBaseUrl; const base = mediaConfig.whepBaseUrl;
@@ -37,6 +38,14 @@ function extractStreamInfo(path) {
const remaining = segments.slice(start, end); const remaining = segments.slice(start, end);
if (remaining.length === 1) { if (remaining.length === 1) {
const rawId = remaining[0] || ''; const rawId = remaining[0] || '';
/*
PTZ is intentionally published as a flat MediaMTX path so WHEP requests
line up with the real stream name. Treat that one reserved path as PTZ
before falling back to the normal one-segment rover parsing rules.
*/
if (rawId === PTZ_STREAM_PATH) {
return { type: 'ptz', id: rawId };
}
if (rawId.endsWith('-fwd')) { if (rawId.endsWith('-fwd')) {
return { type: 'rover', id: rawId, baseId: rawId.slice(0, -4) }; return { type: 'rover', id: rawId, baseId: rawId.slice(0, -4) };
} }
@@ -81,6 +90,10 @@ function extractStreamInfoFromBody(body = {}) {
extractSrtStreamId(body.query); extractSrtStreamId(body.query);
if (!srtId) return null; if (!srtId) return null;
if (srtId === PTZ_STREAM_PATH) {
return { type: 'ptz', id: srtId };
}
if (srtId.endsWith('-fwd')) { if (srtId.endsWith('-fwd')) {
return { type: 'rover', id: srtId, baseId: srtId.slice(0, -4) }; return { type: 'rover', id: srtId, baseId: srtId.slice(0, -4) };
} }
@@ -36,7 +36,14 @@ function buildWhepUrlForSource(source) {
if (source.type === 'room') { if (source.type === 'room') {
segments.push('room', encodeURIComponent(source.id)); segments.push('room', encodeURIComponent(source.id));
} else if (source.type === 'ptz') { } else if (source.type === 'ptz') {
segments.push('ptz', encodeURIComponent(source.id)); /*
MediaMTX exposes WHEP by the exact path name that is being published.
The PTZ ffmpeg publisher registers the single camera as "ptz-camera",
so the browser must request "/video/ptz-camera/whep" instead of a
namespace-like "/video/ptz/ptz-camera/whep" path that MediaMTX has never
seen and correctly returns as 404.
*/
segments.push(encodeURIComponent(source.id));
} else { } else {
segments.push(encodeURIComponent(source.id)); segments.push(encodeURIComponent(source.id));
} }
+37 -58
View File
@@ -80,13 +80,29 @@ function ControlButton({ title, children, onHold, className = '' }) {
function PtzLiveVideo({ enabled }) { function PtzLiveVideo({ enabled }) {
const videoRef = useRef(null); const videoRef = useRef(null);
const playerRef = useRef(null); const playerRef = useRef(null);
const retryTimerRef = useRef(null);
const [status, setStatus] = useState('idle'); const [status, setStatus] = useState('idle');
const [retryVersion, setRetryVersion] = useState(0);
const sources = useVideoRequests( const sources = useVideoRequests(
[{ type: 'ptz', id: PTZ_CAMERA_ID, key: PTZ_CAMERA_ID }], [{ type: 'ptz', id: PTZ_CAMERA_ID, key: PTZ_CAMERA_ID }],
{ enabled }, { enabled, version: retryVersion },
); );
const source = sources[PTZ_CAMERA_ID] || null; 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(() => { useEffect(() => {
if (!enabled || !source?.url || !videoRef.current) return undefined; if (!enabled || !source?.url || !videoRef.current) return undefined;
/* /*
@@ -98,16 +114,33 @@ function PtzLiveVideo({ enabled }) {
url: source.url, url: source.url,
token: source.token, token: source.token,
video: videoRef.current, video: videoRef.current,
onStatus: setStatus, onStatus: (nextStatus) => {
setStatus(nextStatus);
if (['error', 'failed', 'disconnected', 'closed'].includes(String(nextStatus || '').toLowerCase())) {
scheduleRetry();
}
},
receiveAudio: false, receiveAudio: false,
}); });
playerRef.current = player; playerRef.current = player;
player.start().catch((err) => setStatus(err.message || 'error')); player.start().catch((err) => {
setStatus(err.message || 'error');
scheduleRetry();
});
return () => { return () => {
player.stop(); player.stop();
playerRef.current = null; 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 ( return (
<div className="relative h-full w-full bg-black"> <div className="relative h-full w-full bg-black">
@@ -145,60 +178,6 @@ function PtzController({ open, onClose }) {
[sendMove, stopMotion], [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; if (!open) return null;
const toggleSpotlight = async () => { const toggleSpotlight = async () => {
+16 -8
View File
@@ -372,10 +372,18 @@ export function ControlSystemProvider({ children }) {
const setServoAngle = useCallback( const setServoAngle = useCallback(
(value, options = {}) => { (value, options = {}) => {
if (!pipeline.servoConfig) return; if (!pipeline.servoConfig && !pipeline.isPtzOperator) 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;
@@ -387,17 +395,17 @@ export function ControlSystemProvider({ children }) {
const nudgeServo = useCallback( const nudgeServo = useCallback(
(delta = 0) => { (delta = 0) => {
const config = pipeline.servoConfig; const config = pipeline.servoConfig;
if (!config) return; if (!config && !pipeline.isPtzOperator) 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'
? servoAngleRef.current ? servoAngleRef.current
: typeof config.homeAngle === 'number' : typeof config?.homeAngle === 'number'
? config.homeAngle ? config.homeAngle
: 0; : 0;
setServoAngle(baseline + step); setServoAngle(baseline + step);
}, },
[pipeline.servoConfig, setServoAngle], [pipeline.isPtzOperator, pipeline.servoConfig, setServoAngle],
); );
const goServoHome = useCallback(() => { const goServoHome = useCallback(() => {
@@ -484,7 +492,7 @@ export function ControlSystemProvider({ children }) {
const setHeadlight = useCallback( const setHeadlight = useCallback(
(headlightOn) => { (headlightOn) => {
if (!pipeline.headlight) return; if (!pipeline.headlight && !pipeline.isPtzOperator) 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.
@@ -501,7 +509,7 @@ export function ControlSystemProvider({ children }) {
const setLaser = useCallback( const setLaser = useCallback(
(laserOn) => { (laserOn) => {
if (!pipeline.laser) return; if (!pipeline.laser && !pipeline.isPtzOperator) 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.
+96 -6
View File
@@ -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, useMemo } from 'react'; import { useCallback, useEffect, useMemo, useRef } 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,6 +18,9 @@ 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;
@@ -46,6 +49,14 @@ 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) => {
@@ -55,6 +66,20 @@ 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({
@@ -65,7 +90,6 @@ export function useCommandPipeline(options = {}) {
const sendDriveDirect = useCallback( const sendDriveDirect = useCallback(
(speeds) => { (speeds) => {
if (!roverId) return null;
const rawPayload = { const rawPayload = {
left: clampRange(speeds?.left ?? 0, [-500, 500]), left: clampRange(speeds?.left ?? 0, [-500, 500]),
right: clampRange(speeds?.right ?? 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]), 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;
emitCommand({ emitCommand({
type: 'drive', type: 'drive',
data: { driveDirect: payload }, data: { driveDirect: payload },
}); });
return payload; return payload;
}, },
[driveTransform, emitCommand, roverId], [driveTransform, emitCommand, emitPtzCommand, isPtzOperator, roverId],
); );
const sendAuxMotors = useCallback( const sendAuxMotors = useCallback(
@@ -109,6 +150,31 @@ 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',
@@ -116,7 +182,7 @@ export function useCommandPipeline(options = {}) {
}); });
return angle; return angle;
}, },
[emitCommand, roverId, servoConfig], [emitCommand, emitPtzCommand, isPtzOperator, roverId, servoConfig],
); );
const sendOiCommand = useCallback( const sendOiCommand = useCallback(
@@ -175,6 +241,17 @@ 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',
@@ -182,11 +259,22 @@ export function useCommandPipeline(options = {}) {
}); });
return action; return action;
}, },
[emitCommand, headlight, roverId], [emitCommand, emitPtzCommand, headlight, isPtzOperator, ptzCamera?.light?.state, 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',
@@ -194,7 +282,7 @@ export function useCommandPipeline(options = {}) {
}); });
return action; return action;
}, },
[emitCommand, laser, roverId], [emitCommand, emitPtzCommand, isPtzOperator, laser, ptzCamera?.ir?.state, roverId],
); );
const sendHorn = useCallback( const sendHorn = useCallback(
@@ -238,6 +326,7 @@ export function useCommandPipeline(options = {}) {
return useMemo( return useMemo(
() => ({ () => ({
roverId, roverId,
isPtzOperator,
rosterEntry, rosterEntry,
servoConfig, servoConfig,
headlight, headlight,
@@ -259,6 +348,7 @@ export function useCommandPipeline(options = {}) {
}), }),
[ [
roverId, roverId,
isPtzOperator,
rosterEntry, rosterEntry,
servoConfig, servoConfig,
headlight, headlight,