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/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-BFbU1lYU.js"></script>
<script type="module" crossorigin src="/assets/index-B3VrYCWz.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DwpUkSbG.css">
</head>
<body>
+69 -28
View File
@@ -58,6 +58,7 @@ let blockedTimer = null;
let publisherProcess = null;
let publisherRestartTimer = null;
let snapshotTimer = null;
let vendorStatePromise = Promise.resolve();
let lastSnapshotState = null;
const snapshotSubscribers = new Map();
const socketSnapshotSubscriptions = new Map();
@@ -194,10 +195,10 @@ function startPublisher() {
const input = addCredentialsToRtsp(state.rtspUri);
const output = `srt://127.0.0.1:9000?streamid=publish:${encodeURIComponent(PTZ_STREAM_PATH)}`;
/*
MediaMTX already treats SRT publishers as trusted local media producers.
Copying the autotrack stream keeps the full-resolution camera feed intact;
if browser H.265 support becomes a problem later, this is the one place to
add a transcode without changing PTZ ownership or UI code.
MediaMTX should receive the camera feed exactly as the camera provides it.
The WHEP 404 issue is a path mismatch, not a codec problem, so this publisher
keeps the autotrack stream untouched and lets playback compatibility be
handled separately if it actually becomes the blocker.
*/
const proc = spawn('ffmpeg', [
'-hide_banner',
@@ -267,6 +268,18 @@ async function refreshVendorState() {
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() {
if (!enabled || state.initialized || state.initializing) return;
state.initializing = true;
@@ -330,15 +343,30 @@ function activateOperator(socket) {
});
state.operatorSocketId = socket.id;
state.deadline = Date.now() + getTurnDurationMs();
turnTimer = setTimeout(() => {
revokeOperator('turn-expired');
advanceQueue('turn-expired');
}, getTurnDurationMs());
turnTimer = setTimeout(handleTurnDeadline, getTurnDurationMs());
socket.emit('ptzCamera:turn', { status: 'active', deadline: state.deadline });
events.emit('operator', { socketId: socket.id, action: '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') {
clearBlockedTimer();
state.blocked = null;
@@ -446,30 +474,43 @@ async function getStatus(socket) {
async function setSpotlight(socket, payload = {}) {
requireOperator(socket);
const client = await ensureReolinkClient();
const current = (await client.api('GetWhiteLed', { channel: 0 })).WhiteLed;
const next = {
...current,
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)));
}
await client.api('SetWhiteLed', { WhiteLed: next });
await refreshVendorState();
emitChange('light');
return state.light;
return serializeVendorState(async () => {
const client = await ensureReolinkClient();
const current = (await client.api('GetWhiteLed', { channel: 0 })).WhiteLed || {};
const next = {
...current,
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)));
}
/*
Update local state optimistically before the refresh. That makes the
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 = {}) {
requireOperator(socket);
const nextState = String(payload.state || '').toLowerCase() === 'off' ? 'Off' : 'Auto';
const client = await ensureReolinkClient();
await client.api('SetIrLights', { IrLights: { state: nextState } });
await refreshVendorState();
emitChange('ir');
return state.ir;
return serializeVendorState(async () => {
const nextState = String(payload.state || '').toLowerCase() === 'off' ? 'Off' : 'Auto';
const client = await ensureReolinkClient();
state.ir = { ...(state.ir || {}), state: nextState };
emitChange('ir-pending');
await client.api('SetIrLights', { IrLights: { state: nextState } });
await refreshVendorState();
emitChange('ir');
return state.ir;
});
}
function canRequestLiveVideo(socket) {
@@ -5,6 +5,7 @@ const { loadConfig } = require('../../helpers/configLoader');
const config = loadConfig();
const mediaConfig = config.media || {};
const PTZ_STREAM_PATH = 'ptz-camera';
function getPathPrefix() {
const base = mediaConfig.whepBaseUrl;
@@ -37,6 +38,14 @@ function extractStreamInfo(path) {
const remaining = segments.slice(start, end);
if (remaining.length === 1) {
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')) {
return { type: 'rover', id: rawId, baseId: rawId.slice(0, -4) };
}
@@ -81,6 +90,10 @@ function extractStreamInfoFromBody(body = {}) {
extractSrtStreamId(body.query);
if (!srtId) return null;
if (srtId === PTZ_STREAM_PATH) {
return { type: 'ptz', id: srtId };
}
if (srtId.endsWith('-fwd')) {
return { type: 'rover', id: srtId, baseId: srtId.slice(0, -4) };
}
@@ -36,7 +36,14 @@ function buildWhepUrlForSource(source) {
if (source.type === 'room') {
segments.push('room', encodeURIComponent(source.id));
} 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 {
segments.push(encodeURIComponent(source.id));
}
+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,