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
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
+2 -2
View File
@@ -11,8 +11,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-D58yshkA.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cbwue-lt.css">
<script type="module" crossorigin src="/assets/index-S4n9Io2g.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BBEWYgmH.css">
</head>
<body>
<div id="root"></div>
@@ -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),
};
}