mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
overcurrent limiter testing
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user