mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
overcurrent limiter testing
This commit is contained in:
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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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