overcurrent limiter testing

This commit is contained in:
legop3
2026-01-21 02:12:54 -05:00
parent b9a007bd48
commit 59c19f1da9
10 changed files with 430 additions and 128 deletions
@@ -0,0 +1,82 @@
import { useMemo } from 'react';
import { useControlSystem } from '../controls/index.js';
const MOTOR_LABELS = {
leftWheel: 'Left wheel',
rightWheel: 'Right wheel',
mainBrush: 'Main brush',
sideBrush: 'Side brush',
};
function formatPct(value) {
if (!Number.isFinite(value)) return '--';
return `${Math.round(value * 100)}%`;
}
function ProgressBar({ value, color = 'bg-emerald-500' }) {
const width = `${Math.round(Math.max(0, Math.min(1, value)) * 100)}%`;
return (
<div className="h-2 w-full overflow-hidden rounded bg-slate-800">
<div className={`h-full ${color}`} style={{ width }} />
</div>
);
}
export default function OvercurrentLimiterPanel() {
const {
state: { roverId },
overcurrentLimiter,
} = useControlSystem();
const motors = useMemo(() => Object.keys(MOTOR_LABELS), []);
return (
<section className="panel-section space-y-0.5 text-sm">
<div className="flex items-center justify-between text-xs text-slate-400">
<span>Overcurrent limiter</span>
<span>{overcurrentLimiter?.adminImmune ? 'Admin immune' : 'Active'}</span>
</div>
{!roverId ? (
<p className="text-xs text-slate-500">Assign a rover to view limiter status.</p>
) : (
<div className="space-y-0.5">
{motors.map((key) => {
const meterA = overcurrentLimiter?.meters?.[key]?.a ?? 0;
const meterB = overcurrentLimiter?.meters?.[key]?.b ?? 0;
const over = overcurrentLimiter?.overcurrent?.[key] ?? false;
const scale = overcurrentLimiter?.scales?.perMotor?.[key] ?? 1;
return (
<div key={key} className="surface space-y-0.5">
<div className="flex items-center justify-between text-xs">
<span className="text-slate-200">{MOTOR_LABELS[key] || key}</span>
<span className={over ? 'text-red-300' : 'text-slate-400'}>
{over ? 'overcurrent' : 'ok'}
</span>
</div>
<div className="space-y-0.5">
<div className="flex items-center justify-between text-[0.7rem] text-slate-400">
<span>Meter A</span>
<span>{formatPct(meterA)}</span>
</div>
<ProgressBar value={meterA} color="bg-amber-500" />
<div className="flex items-center justify-between text-[0.7rem] text-slate-400">
<span>Meter B</span>
<span>{formatPct(meterB)}</span>
</div>
<ProgressBar value={meterB} color="bg-red-500" />
<div className="flex items-center justify-between text-[0.7rem] text-slate-400">
<span>Scale</span>
<span>{formatPct(scale)}</span>
</div>
</div>
</div>
);
})}
<div className="surface text-[0.7rem] text-slate-400">
<div>{`A charge ${overcurrentLimiter?.config?.meterAChargeSec}s · A decay ${overcurrentLimiter?.config?.meterADecaySec}s`}</div>
<div>{`B charge ${overcurrentLimiter?.config?.meterBChargeSec}s · B decay ${overcurrentLimiter?.config?.meterBDecaySec}s`}</div>
</div>
</div>
)}
</section>
);
}
+2
View File
@@ -4,6 +4,7 @@ import AuthPanel from './AuthPanel.jsx';
import AdminPanel from './AdminPanel.jsx';
import KeymapSettings from './KeymapSettings.jsx';
import GamepadMappingSettings from './GamepadMappingSettings.jsx';
import OvercurrentLimiterPanel from './OvercurrentLimiterPanel.jsx';
import Tabs, { Tab, TabList, TabPanel, TabPanels } from './Tabs.jsx';
import SessionSnapshot from './SessionSnapshot.jsx';
import SocketLogPanel from './SocketLogPanel.jsx';
@@ -141,6 +142,7 @@ export default function SettingsPanel() {
{!canControl && <p className="text-xs text-slate-500">Assign a rover to toggle streams.</p>}
</section>
<AuthPanel />
<OvercurrentLimiterPanel />
<AdminPanel />
<SessionSnapshot />
<SocketLogPanel />
+45 -1
View File
@@ -6,6 +6,11 @@ import { DEFAULT_KEYMAP, DEFAULT_MACROS, SONG_DEFAULT_NOTE } from './constants.j
import { canonicalizeKeyInput } from './keymapUtils.js';
import { useSettingsNamespace } from '../settings/index.js';
import { useSession } from '../context/SessionContext.jsx';
import {
applyAuxOvercurrentScale,
applyDriveOvercurrentScale,
useOvercurrentLimiter,
} from './overcurrentLimiter.js';
const ControlSystemContext = createContext(null);
@@ -23,7 +28,6 @@ function clampServoAngle(config, value) {
}
export function ControlSystemProvider({ children }) {
const pipeline = useCommandPipeline();
const [state, dispatch] = useReducer(controlReducer, initialControlState);
const prevModeRef = useRef(null);
const pendingLightsRef = useRef(false);
@@ -33,6 +37,17 @@ export function ControlSystemProvider({ children }) {
save: saveControlSettings,
} = useSettingsNamespace('controls', { keymap: DEFAULT_KEYMAP, macros: DEFAULT_MACROS });
const { session, homeAssistantSetState } = useSession();
const roverId = session?.assignment?.roverId ?? null;
const overcurrentLimiter = useOvercurrentLimiter(roverId);
const driveTransform = useCallback(
(speeds) => applyDriveOvercurrentScale(speeds, overcurrentLimiter.scales, overcurrentLimiter.adminImmune),
[overcurrentLimiter.adminImmune, overcurrentLimiter.scales],
);
const auxTransform = useCallback(
(values) => applyAuxOvercurrentScale(values, overcurrentLimiter.scales, overcurrentLimiter.adminImmune),
[overcurrentLimiter.adminImmune, overcurrentLimiter.scales],
);
const pipeline = useCommandPipeline({ driveTransform, auxTransform });
const turnOnAllLights = useCallback(() => {
const entities = session?.homeAssistant?.entities || [];
@@ -124,6 +139,33 @@ export function ControlSystemProvider({ children }) {
dispatch({ type: 'control/record-intent' });
}, []);
const driveSpeedsRef = useRef(state.drive.speeds);
const auxValuesRef = useRef(state.aux);
const limiterScaleToken = useMemo(() => JSON.stringify(overcurrentLimiter.scales), [overcurrentLimiter.scales]);
useEffect(() => {
driveSpeedsRef.current = state.drive.speeds;
}, [state.drive.speeds]);
useEffect(() => {
auxValuesRef.current = state.aux;
}, [state.aux]);
useEffect(() => {
if (!pipeline.roverId || overcurrentLimiter.adminImmune) return;
const drive = driveSpeedsRef.current || { left: 0, right: 0 };
const aux = auxValuesRef.current || { main: 0, side: 0, vacuum: 0 };
const driveActive = Boolean(drive.left || drive.right);
const auxActive = Boolean(aux.main || aux.side || aux.vacuum);
if (!driveActive && !auxActive) return;
if (driveActive) {
pipeline.sendDriveDirect(drive);
}
if (auxActive) {
pipeline.sendAuxMotors(aux);
}
}, [limiterScaleToken, overcurrentLimiter.adminImmune, pipeline]);
const setDriveVector = useCallback(
(vector, meta = {}) => {
const computed = computeDifferentialSpeeds(vector, meta.speedOptions);
@@ -294,6 +336,7 @@ export function ControlSystemProvider({ children }) {
state,
dispatch,
pipeline,
overcurrentLimiter,
actions: {
setMode,
setDriveVector,
@@ -316,6 +359,7 @@ export function ControlSystemProvider({ children }) {
[
state,
pipeline,
overcurrentLimiter,
setMode,
setDriveVector,
setAuxMotors,
+17 -5
View File
@@ -11,7 +11,8 @@ import {
} from './constants.js';
import { bytesToBase64, clampRange, sleep } from './controlMath.js';
export function useCommandPipeline() {
export function useCommandPipeline(options = {}) {
const { driveTransform, auxTransform } = options;
const socket = useSocket();
const { session } = useSession();
const roverId = session?.assignment?.roverId;
@@ -50,34 +51,45 @@ export function useCommandPipeline() {
const sendDriveDirect = useCallback(
(speeds) => {
if (!roverId) return null;
const payload = {
const rawPayload = {
left: clampRange(speeds?.left ?? 0, [-500, 500]),
right: clampRange(speeds?.right ?? 0, [-500, 500]),
};
const transformed = driveTransform ? driveTransform(rawPayload) : rawPayload;
const payload = {
left: clampRange(transformed?.left ?? 0, [-500, 500]),
right: clampRange(transformed?.right ?? 0, [-500, 500]),
};
emitCommand({
type: 'drive',
data: { driveDirect: payload },
});
return payload;
},
[emitCommand, roverId],
[driveTransform, emitCommand, roverId],
);
const sendAuxMotors = useCallback(
({ main = 0, side = 0, vacuum = 0 } = {}) => {
if (!roverId) return null;
const payload = {
const rawPayload = {
main: clampRange(main, AUX_LIMITS.main),
side: clampRange(side, AUX_LIMITS.side),
vacuum: clampRange(vacuum, AUX_LIMITS.vacuum),
};
const transformed = auxTransform ? auxTransform(rawPayload) : rawPayload;
const payload = {
main: clampRange(transformed?.main ?? 0, AUX_LIMITS.main),
side: clampRange(transformed?.side ?? 0, AUX_LIMITS.side),
vacuum: clampRange(transformed?.vacuum ?? 0, AUX_LIMITS.vacuum),
};
emitCommand({
type: 'motors',
data: { motorPwm: payload },
});
return payload;
},
[emitCommand, roverId],
[auxTransform, emitCommand, roverId],
);
const sendServoAngle = useCallback(
+1
View File
@@ -1,3 +1,4 @@
export { ControlSystemProvider, useControlSystem } from './ControlContext.jsx';
export { default as KeyboardInputManager } from './inputs/KeyboardInputManager.jsx';
export { default as GamepadInputManager } from './inputs/GamepadInputManager.jsx';
export { useOvercurrentLimiter } from './overcurrentLimiter.js';
+161
View File
@@ -0,0 +1,161 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
import { useSession } from '../context/SessionContext.jsx';
export const OVERCURRENT_MOTORS = ['leftWheel', 'rightWheel', 'mainBrush', 'sideBrush'];
export const DEFAULT_OVERCURRENT_LIMITS = {
meterAChargeSec: 9,
meterADecaySec: 9,
meterBChargeSec: 5,
meterBDecaySec: 5,
};
function createInitialMeters() {
return OVERCURRENT_MOTORS.reduce((acc, key) => {
acc[key] = { a: 0, b: 0 };
return acc;
}, {});
}
function clampUnit(value) {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.min(1, value));
}
function stepValue(current, delta, seconds, direction) {
if (!Number.isFinite(seconds) || seconds <= 0) {
return direction === 'up' ? 1 : 0;
}
const amount = delta / seconds;
return direction === 'up' ? current + amount : current - amount;
}
export function useOvercurrentLimiter(roverId, options = {}) {
const { session } = useSession();
const frame = useTelemetryFrame(roverId);
const sensors = frame?.sensors || {};
const overcurrentFlags = sensors?.wheelOvercurrents || {};
const config = useMemo(
() => ({ ...DEFAULT_OVERCURRENT_LIMITS, ...(options.config || {}) }),
[options.config],
);
const [meters, setMeters] = useState(() => createInitialMeters());
const lastTickRef = useRef(typeof performance !== 'undefined' ? performance.now() : Date.now());
const flagsRef = useRef(overcurrentFlags);
useEffect(() => {
flagsRef.current = overcurrentFlags || {};
}, [overcurrentFlags]);
useEffect(() => {
setMeters(createInitialMeters());
}, [roverId]);
useEffect(() => {
const interval = setInterval(() => {
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
const deltaMs = Math.max(0, now - lastTickRef.current);
lastTickRef.current = now;
const deltaSec = deltaMs / 1000;
if (deltaSec <= 0) return;
setMeters((prev) => {
let changed = false;
const next = {};
OVERCURRENT_MOTORS.forEach((key) => {
const prevEntry = prev[key] || { a: 0, b: 0 };
const over = Boolean(flagsRef.current?.[key]);
const nextA = clampUnit(
stepValue(
prevEntry.a,
deltaSec,
over ? config.meterAChargeSec : config.meterADecaySec,
over ? 'up' : 'down',
),
);
const shouldFillB = over && nextA >= 1;
const nextB = clampUnit(
stepValue(
prevEntry.b,
deltaSec,
shouldFillB ? config.meterBChargeSec : config.meterBDecaySec,
shouldFillB ? 'up' : 'down',
),
);
if (Math.abs(nextA - prevEntry.a) > 0.0001 || Math.abs(nextB - prevEntry.b) > 0.0001) {
changed = true;
}
next[key] = { a: nextA, b: nextB };
});
return changed ? next : prev;
});
}, 100);
return () => clearInterval(interval);
}, [config.meterAChargeSec, config.meterADecaySec, config.meterBChargeSec, config.meterBDecaySec]);
const scales = useMemo(() => {
const perMotor = OVERCURRENT_MOTORS.reduce((acc, key) => {
acc[key] = clampUnit(1 - (meters?.[key]?.b ?? 0));
return acc;
}, {});
return {
perMotor,
drive: {
left: perMotor.leftWheel ?? 1,
right: perMotor.rightWheel ?? 1,
},
aux: {
main: perMotor.mainBrush ?? 1,
side: perMotor.sideBrush ?? 1,
vacuum: 1,
},
};
}, [meters]);
const overcurrent = useMemo(
() =>
OVERCURRENT_MOTORS.reduce((acc, key) => {
acc[key] = Boolean(overcurrentFlags?.[key]);
return acc;
}, {}),
[overcurrentFlags],
);
const adminImmune =
session?.role === 'admin' ||
session?.role === 'lockdown' ||
session?.role === 'lockdown-admin';
return useMemo(
() => ({
meters,
overcurrent,
scales,
config,
adminImmune,
}),
[meters, overcurrent, scales, config, adminImmune],
);
}
export function applyDriveOvercurrentScale(speeds = {}, scales, adminImmune = false) {
if (adminImmune || !scales?.drive) return speeds;
const leftScale = typeof scales.drive.left === 'number' ? scales.drive.left : 1;
const rightScale = typeof scales.drive.right === 'number' ? scales.drive.right : 1;
return {
left: Math.round((speeds.left ?? 0) * leftScale),
right: Math.round((speeds.right ?? 0) * rightScale),
};
}
export function applyAuxOvercurrentScale(values = {}, scales, adminImmune = false) {
if (adminImmune || !scales?.aux) return values;
const mainScale = typeof scales.aux.main === 'number' ? scales.aux.main : 1;
const sideScale = typeof scales.aux.side === 'number' ? scales.aux.side : 1;
const vacuumScale = typeof scales.aux.vacuum === 'number' ? scales.aux.vacuum : 1;
return {
main: Math.round((values.main ?? 0) * mainScale),
side: Math.round((values.side ?? 0) * sideScale),
vacuum: Math.round((values.vacuum ?? 0) * vacuumScale),
};
}