This commit is contained in:
legop3
2026-07-11 02:22:13 -04:00
parent 3af74870a5
commit 0e5f76fb2f
9 changed files with 434 additions and 269 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-DrIw695l.js"></script>
<script type="module" crossorigin src="/assets/index-CnZfvwYB.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DwpUkSbG.css">
</head>
<body>
+26 -3
View File
@@ -88,6 +88,27 @@ function isReplayEnabled() {
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) {
const mode = getMode();
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('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;
}
@@ -486,11 +507,13 @@ async function setSpotlight(socket, payload = {}) {
requireOperator(socket);
return serializeVendorState(async () => {
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 = {
...current,
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))) {
next.bright = Math.max(0, Math.min(100, Number(payload.bright)));
+43 -20
View File
@@ -35,6 +35,7 @@ import {
applyDriveOvercurrentScale,
useOvercurrentLimiter,
} from './overcurrentLimiter.js';
import { usePtzControlAdapter } from './ptzControlAdapter.js';
const ControlSystemContext = createContext(null);
@@ -179,6 +180,7 @@ export function ControlSystemProvider({ children }) {
[overcurrentLimiter.adminImmune, overcurrentLimiter.scales],
);
const pipeline = useCommandPipeline({ driveTransform, auxTransform });
const ptzControls = usePtzControlAdapter();
const turnOnAllLights = useCallback(() => {
const entities = homeAssistantEntities || [];
@@ -327,9 +329,10 @@ export function ControlSystemProvider({ children }) {
payload: { ...computed, source: meta.source ?? null },
});
recordControlIntent();
if (ptzControls.applyDriveVector(vector, meta)) return;
pipeline.sendDriveDirect(computed.speeds);
},
[pipeline, recordControlIntent, state.manualDockAssist?.active],
[pipeline, ptzControls, recordControlIntent, state.manualDockAssist?.active],
);
const setAuxMotors = useCallback(
@@ -372,30 +375,37 @@ export function ControlSystemProvider({ children }) {
const setServoAngle = useCallback(
(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);
if (state.manualDockAssist?.active && !force) return;
/*
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;
const clamped = clampServoAngle(pipeline.servoConfig, value);
dispatch({ type: 'control/set-camera-angle', payload: clamped });
pipeline.sendServoAngle(clamped);
servoAngleRef.current = clamped;
recordControlIntent();
},
[pipeline, recordControlIntent, state.manualDockAssist?.active],
[pipeline, ptzControls, recordControlIntent, state.manualDockAssist?.active],
);
const nudgeServo = useCallback(
(delta = 0) => {
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 baseline =
typeof servoAngleRef.current === 'number'
@@ -405,7 +415,7 @@ export function ControlSystemProvider({ children }) {
: 0;
setServoAngle(baseline + step);
},
[pipeline.isPtzOperator, pipeline.servoConfig, setServoAngle],
[pipeline.servoConfig, ptzControls.isActive, setServoAngle],
);
const goServoHome = useCallback(() => {
@@ -468,9 +478,13 @@ export function ControlSystemProvider({ children }) {
source: 'system-stop',
},
});
if (ptzControls.isActive) {
ptzControls.stopMotion();
return;
}
pipeline.sendDriveDirect({ left: 0, right: 0 });
pipeline.sendAuxMotors({ main: 0, side: 0, vacuum: 0 });
}, [pipeline]);
}, [pipeline, ptzControls]);
const sendOiCommand = useCallback(
(command) => {
@@ -492,7 +506,11 @@ export function ControlSystemProvider({ children }) {
const setHeadlight = useCallback(
(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
// inversion needed by the actual GPIO driver is handled by roverd's
// activeLow config, so this command stays readable and direct.
@@ -500,7 +518,7 @@ export function ControlSystemProvider({ children }) {
pipeline.sendHeadlight(action);
recordControlIntent();
},
[pipeline, recordControlIntent],
[pipeline, ptzControls, recordControlIntent],
);
const toggleHeadlight = useCallback(() => {
@@ -509,7 +527,11 @@ export function ControlSystemProvider({ children }) {
const setLaser = useCallback(
(laserOn) => {
if (!pipeline.laser && !pipeline.isPtzOperator) return;
if (ptzControls.setIr(laserOn)) {
recordControlIntent();
return;
}
if (!pipeline.laser) 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.
@@ -517,7 +539,7 @@ export function ControlSystemProvider({ children }) {
pipeline.sendLaser(action);
recordControlIntent();
},
[pipeline, recordControlIntent, roomLightsLockedOn],
[pipeline, ptzControls, recordControlIntent, roomLightsLockedOn],
);
const toggleLaser = useCallback(() => {
@@ -747,10 +769,11 @@ export function ControlSystemProvider({ children }) {
state,
dispatch,
pipeline,
ptzControls,
overcurrentLimiter,
actions: stableActions,
}),
[state, pipeline, overcurrentLimiter, stableActions],
[state, pipeline, ptzControls, overcurrentLimiter, stableActions],
);
if (snapshotRef.current == null) {
+5 -96
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, useEffect, useMemo, useRef } from 'react';
import { useCallback, useMemo } from 'react';
import { useSocket } from '../context/SocketContext.jsx';
import { useSessionSelector } from '../context/SessionContext.jsx';
import {
@@ -18,9 +18,6 @@ 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;
@@ -49,15 +46,6 @@ 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) => {
if (!roverId) return;
@@ -66,20 +54,6 @@ 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({
@@ -99,22 +73,6 @@ 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',
@@ -122,7 +80,7 @@ export function useCommandPipeline(options = {}) {
});
return payload;
},
[driveTransform, emitCommand, emitPtzCommand, isPtzOperator, roverId],
[driveTransform, emitCommand, roverId],
);
const sendAuxMotors = useCallback(
@@ -150,31 +108,6 @@ 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',
@@ -182,7 +115,7 @@ export function useCommandPipeline(options = {}) {
});
return angle;
},
[emitCommand, emitPtzCommand, isPtzOperator, roverId, servoConfig],
[emitCommand, roverId, servoConfig],
);
const sendOiCommand = useCallback(
@@ -241,17 +174,6 @@ 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',
@@ -259,22 +181,11 @@ export function useCommandPipeline(options = {}) {
});
return action;
},
[emitCommand, emitPtzCommand, headlight, isPtzOperator, ptzCamera?.light?.state, roverId],
[emitCommand, headlight, 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',
@@ -282,7 +193,7 @@ export function useCommandPipeline(options = {}) {
});
return action;
},
[emitCommand, emitPtzCommand, isPtzOperator, laser, ptzCamera?.ir?.state, roverId],
[emitCommand, laser, roverId],
);
const sendHorn = useCallback(
@@ -326,7 +237,6 @@ export function useCommandPipeline(options = {}) {
return useMemo(
() => ({
roverId,
isPtzOperator,
rosterEntry,
servoConfig,
headlight,
@@ -348,7 +258,6 @@ export function useCommandPipeline(options = {}) {
}),
[
roverId,
isPtzOperator,
rosterEntry,
servoConfig,
headlight,
+210
View File
@@ -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],
);
}