mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
slopcurrent
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
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
@@ -78,8 +78,8 @@
|
||||
<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-Da9ufxPv.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BcBTKEa5.css">
|
||||
<script type="module" crossorigin src="/assets/index-DzMZA-qn.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DzqCFmyF.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -9,6 +9,7 @@ const { isDeterred } = require('../verificationService');
|
||||
const logger = require('../../globals/logger').child('commandService');
|
||||
const { isHeadlightBlocked } = require('../../rewards/definitions/darkness');
|
||||
const homeAssistantService = require('../homeAssistantService');
|
||||
const overcurrentProtectionService = require('../overcurrentProtectionService');
|
||||
|
||||
const pendingCommands = new Map(); // id -> { roverId }
|
||||
const lastDriveActivity = new Map(); // roverId -> { ts, socketId, direction, speed, isAdmin }
|
||||
@@ -93,6 +94,31 @@ function issueCommand(roverId, payload) {
|
||||
return id;
|
||||
}
|
||||
|
||||
/*
|
||||
The protection service owns decisions about when a held command must be
|
||||
resent at a lower output. Injecting this raw transport function keeps those
|
||||
resends on the same rover websocket path as every other server command while
|
||||
avoiding a circular dependency from the protection service back into this
|
||||
socket-facing module.
|
||||
*/
|
||||
overcurrentProtectionService.configureCommandIssuer((roverId, payload) => {
|
||||
const blockedUntil = driveCooldowns.get(roverId);
|
||||
const safetyCooldownActive = blockedUntil && Date.now() < blockedUntil;
|
||||
if (safetyCooldownActive && getCommandMotionMagnitude(payload?.type, payload) > 0) {
|
||||
/*
|
||||
Private-rover and dock safety own the existing command cooldown map. A
|
||||
rate-limited protection resend must respect those independent systems;
|
||||
otherwise this new service could restart drive or brushes immediately
|
||||
after an unrelated safety feature deliberately stopped them. Returning
|
||||
false tells the protection service to retry after the cooldown instead of
|
||||
recording an output that never reached the rover.
|
||||
*/
|
||||
return false;
|
||||
}
|
||||
issueCommand(roverId, payload);
|
||||
return true;
|
||||
});
|
||||
|
||||
function handleAck(msg) {
|
||||
const pending = pendingCommands.get(msg.id);
|
||||
if (!pending) return;
|
||||
@@ -226,7 +252,7 @@ io.on('connection', (socket) => {
|
||||
if (type === 'audioLevels') {
|
||||
throw new Error('audioLevels command is service-managed');
|
||||
}
|
||||
const payload = data ? { ...data } : {};
|
||||
let payload = data ? { ...data } : {};
|
||||
if (type === 'headlight' && isHeadlightBlocked()) {
|
||||
logger.info('Ignoring headlight command while darkness lock is active', { socketId: socket.id, roverId });
|
||||
reply({ ignored: true, reason: 'darknessActive' });
|
||||
@@ -291,6 +317,19 @@ io.on('connection', (socket) => {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'drive' || type === 'motors') {
|
||||
/*
|
||||
Role is supplied at the command boundary because telemetry does not
|
||||
identify the operator who produced the active motor intent. Admin and
|
||||
lockdown commands therefore enter the service explicitly bypassed;
|
||||
they are recorded for status visibility but are never scaled, blocked,
|
||||
or countermanded by a later sensor frame.
|
||||
*/
|
||||
payload = overcurrentProtectionService.protectCommand(roverId, type, payload, {
|
||||
bypassed: isAdminSocket,
|
||||
});
|
||||
}
|
||||
const id = issueCommand(roverId, { type, ...payload });
|
||||
logger.info('Queued command', socket.id, roverId, type);
|
||||
if (shouldRecordTurnActivity(type, payload)) {
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
// Overcurrent Protection Service
|
||||
// Purpose: Owns fleet-wide, server-authoritative motor stress calculation and command limiting.
|
||||
// Scope: Keeps overcurrent policy out of rover-manager sensor orchestration while combining command intent with decoded telemetry.
|
||||
|
||||
const logger = require('../../globals/logger').child('overcurrentProtectionService');
|
||||
|
||||
const DEFAULT_CONFIG = Object.freeze({
|
||||
minimumUsefulWheelIntent: 75,
|
||||
stressGrace: 0.2,
|
||||
baseWheelOvercurrentRatePerSec: 0.75,
|
||||
stalledWheelAdditionalRatePerSec: 1.25,
|
||||
wheelRecoveryRatePerSec: 1,
|
||||
brushOvercurrentRatePerSec: 1,
|
||||
brushRecoveryRatePerSec: 0.75,
|
||||
clearBeforeUnlockSec: 0.75,
|
||||
outputRateMs: 250,
|
||||
maxTelemetryDeltaSec: 0.5,
|
||||
});
|
||||
|
||||
const MOTOR_KEYS = Object.freeze(['leftWheel', 'rightWheel', 'mainBrush', 'sideBrush']);
|
||||
|
||||
function clampUnit(value) {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
function finiteNumber(value, fallback = 0) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : fallback;
|
||||
}
|
||||
|
||||
function createMotorState() {
|
||||
return {
|
||||
overcurrent: false,
|
||||
commandedSpeed: 0,
|
||||
measuredSpeed: null,
|
||||
currentMa: null,
|
||||
stallFactor: 0,
|
||||
stress: 0,
|
||||
cap: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function createRoverState() {
|
||||
return {
|
||||
bypassed: false,
|
||||
lastTelemetryAt: 0,
|
||||
driveIntent: { left: 0, right: 0 },
|
||||
auxIntent: { main: 0, side: 0, vacuum: 0 },
|
||||
driveBlocked: false,
|
||||
requiresNeutral: false,
|
||||
neutralSeen: false,
|
||||
driveClearSec: 0,
|
||||
stopReason: null,
|
||||
lastDriveOutputAt: 0,
|
||||
lastAuxOutputAt: 0,
|
||||
lastDriveOutput: { left: 0, right: 0 },
|
||||
lastAuxOutput: { main: 0, side: 0, vacuum: 0 },
|
||||
motors: {
|
||||
leftWheel: createMotorState(),
|
||||
rightWheel: createMotorState(),
|
||||
mainBrush: createMotorState(),
|
||||
sideBrush: createMotorState(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createOvercurrentProtectionService(options = {}) {
|
||||
const config = Object.freeze({ ...DEFAULT_CONFIG, ...(options.config || {}) });
|
||||
const states = new Map();
|
||||
let commandIssuer = typeof options.issueCommand === 'function' ? options.issueCommand : null;
|
||||
|
||||
function getState(roverId) {
|
||||
const key = String(roverId || '');
|
||||
if (!states.has(key)) states.set(key, createRoverState());
|
||||
return states.get(key);
|
||||
}
|
||||
|
||||
function resetProtectionState(state, { bypassed = state.bypassed } = {}) {
|
||||
/*
|
||||
An administrator bypass is a change of authority, not merely a cap of
|
||||
one. Clearing accumulated stress here prevents a previous user's event
|
||||
from unexpectedly affecting the first command after authority changes.
|
||||
Both command intents are cleared before the caller records the new command
|
||||
so telemetry cannot apply a previous operator's held drive or brush value
|
||||
after authority changes.
|
||||
*/
|
||||
state.bypassed = Boolean(bypassed);
|
||||
state.lastTelemetryAt = 0;
|
||||
state.driveBlocked = false;
|
||||
state.requiresNeutral = false;
|
||||
state.neutralSeen = false;
|
||||
state.driveClearSec = 0;
|
||||
state.stopReason = null;
|
||||
state.driveIntent = { left: 0, right: 0 };
|
||||
state.auxIntent = { main: 0, side: 0, vacuum: 0 };
|
||||
state.lastDriveOutput = { left: 0, right: 0 };
|
||||
state.lastAuxOutput = { main: 0, side: 0, vacuum: 0 };
|
||||
state.lastDriveOutputAt = 0;
|
||||
state.lastAuxOutputAt = 0;
|
||||
MOTOR_KEYS.forEach((key) => {
|
||||
state.motors[key] = createMotorState();
|
||||
});
|
||||
}
|
||||
|
||||
function configureCommandIssuer(nextIssuer) {
|
||||
commandIssuer = typeof nextIssuer === 'function' ? nextIssuer : null;
|
||||
}
|
||||
|
||||
function calculateCap(stress) {
|
||||
/*
|
||||
The grace region absorbs short mechanical events such as initial wheel
|
||||
acceleration and direction changes. Above it, the remaining stress range
|
||||
maps linearly to output so the cap reaches exactly zero at hard-stop
|
||||
stress instead of leaving a small command applied to a stalled motor.
|
||||
*/
|
||||
const grace = clampUnit(config.stressGrace);
|
||||
if (stress <= grace) return 1;
|
||||
const usableRange = Math.max(0.0001, 1 - grace);
|
||||
return clampUnit(1 - (stress - grace) / usableRange);
|
||||
}
|
||||
|
||||
function getDriveCap(state) {
|
||||
return Math.min(state.motors.leftWheel.cap, state.motors.rightWheel.cap);
|
||||
}
|
||||
|
||||
function scaleDrive(state, driveDirect = state.driveIntent) {
|
||||
if (state.bypassed) return { ...driveDirect };
|
||||
if (state.driveBlocked) return { left: 0, right: 0 };
|
||||
const cap = getDriveCap(state);
|
||||
return {
|
||||
left: Math.round(finiteNumber(driveDirect?.left) * cap),
|
||||
right: Math.round(finiteNumber(driveDirect?.right) * cap),
|
||||
};
|
||||
}
|
||||
|
||||
function scaleAux(state, motorPwm = state.auxIntent) {
|
||||
if (state.bypassed) return { ...motorPwm };
|
||||
return {
|
||||
main: Math.round(finiteNumber(motorPwm?.main) * state.motors.mainBrush.cap),
|
||||
side: Math.round(finiteNumber(motorPwm?.side) * state.motors.sideBrush.cap),
|
||||
// Create 2/Roomba 600 does not expose a vacuum overcurrent bit, so this
|
||||
// service must not imply that it can measure or limit vacuum motor stress.
|
||||
vacuum: Math.round(finiteNumber(motorPwm?.vacuum)),
|
||||
};
|
||||
}
|
||||
|
||||
function hasDriveIntent(state) {
|
||||
return Boolean(state.driveIntent.left || state.driveIntent.right);
|
||||
}
|
||||
|
||||
function hasAuxIntent(state) {
|
||||
return Boolean(state.auxIntent.main || state.auxIntent.side || state.auxIntent.vacuum);
|
||||
}
|
||||
|
||||
function driveOutputsEqual(left, right) {
|
||||
return left.left === right.left && left.right === right.right;
|
||||
}
|
||||
|
||||
function auxOutputsEqual(left, right) {
|
||||
return left.main === right.main && left.side === right.side && left.vacuum === right.vacuum;
|
||||
}
|
||||
|
||||
function issueAdjustedCommand(roverId, payload) {
|
||||
if (!commandIssuer) return false;
|
||||
try {
|
||||
/*
|
||||
The injected issuer is the raw server-to-roverd transport function.
|
||||
Calling it here intentionally avoids routing a service-generated update
|
||||
through the socket authorization/filter path a second time.
|
||||
*/
|
||||
return commandIssuer(roverId, payload) !== false;
|
||||
} catch (err) {
|
||||
logger.warn('Failed to issue overcurrent protection command', {
|
||||
roverId,
|
||||
type: payload?.type,
|
||||
error: err.message,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function protectCommand(roverId, type, payload = {}, context = {}) {
|
||||
const state = getState(roverId);
|
||||
const bypassed = Boolean(context.bypassed);
|
||||
if (bypassed !== state.bypassed) {
|
||||
resetProtectionState(state, { bypassed });
|
||||
}
|
||||
|
||||
if (type === 'drive' && payload?.driveDirect) {
|
||||
state.driveIntent = {
|
||||
left: finiteNumber(payload.driveDirect.left),
|
||||
right: finiteNumber(payload.driveDirect.right),
|
||||
};
|
||||
|
||||
if (bypassed) {
|
||||
state.lastDriveOutput = { ...state.driveIntent };
|
||||
state.lastDriveOutputAt = Date.now();
|
||||
return { ...payload, driveDirect: { ...state.driveIntent } };
|
||||
}
|
||||
|
||||
const neutral = !state.driveIntent.left && !state.driveIntent.right;
|
||||
if (neutral) {
|
||||
/*
|
||||
A real neutral command proves the operator released their controls.
|
||||
Merely observing zero encoder motion cannot provide that assurance,
|
||||
because a held command against an obstruction also produces no motion.
|
||||
*/
|
||||
state.neutralSeen = true;
|
||||
if (state.driveBlocked && state.driveClearSec >= config.clearBeforeUnlockSec) {
|
||||
state.driveBlocked = false;
|
||||
state.requiresNeutral = false;
|
||||
state.stopReason = null;
|
||||
}
|
||||
}
|
||||
|
||||
const driveDirect = scaleDrive(state);
|
||||
state.lastDriveOutput = { ...driveDirect };
|
||||
state.lastDriveOutputAt = Date.now();
|
||||
return { ...payload, driveDirect };
|
||||
}
|
||||
|
||||
if (type === 'motors' && payload?.motorPwm) {
|
||||
state.auxIntent = {
|
||||
main: finiteNumber(payload.motorPwm.main),
|
||||
side: finiteNumber(payload.motorPwm.side),
|
||||
vacuum: finiteNumber(payload.motorPwm.vacuum),
|
||||
};
|
||||
const motorPwm = scaleAux(state);
|
||||
state.lastAuxOutput = { ...motorPwm };
|
||||
state.lastAuxOutputAt = Date.now();
|
||||
return { ...payload, motorPwm };
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function updateWheelMotor(motor, { overcurrent, command, measured, currentMa, deltaSec }) {
|
||||
const commandMagnitude = Math.abs(finiteNumber(command));
|
||||
const measuredNumber = Number(measured);
|
||||
const measuredMagnitude = Number.isFinite(measuredNumber) ? Math.abs(measuredNumber) : null;
|
||||
const usefulIntent = commandMagnitude >= config.minimumUsefulWheelIntent;
|
||||
const motionRatio = usefulIntent && measuredMagnitude != null
|
||||
? clampUnit(measuredMagnitude / Math.max(commandMagnitude, config.minimumUsefulWheelIntent))
|
||||
: 1;
|
||||
const stallFactor = usefulIntent ? 1 - motionRatio : 0;
|
||||
const riseRate = config.baseWheelOvercurrentRatePerSec
|
||||
+ config.stalledWheelAdditionalRatePerSec * stallFactor;
|
||||
|
||||
motor.overcurrent = Boolean(overcurrent);
|
||||
motor.commandedSpeed = finiteNumber(command);
|
||||
motor.measuredSpeed = measuredMagnitude;
|
||||
motor.currentMa = Number.isFinite(Number(currentMa)) ? Number(currentMa) : null;
|
||||
motor.stallFactor = stallFactor;
|
||||
motor.stress = clampUnit(
|
||||
motor.stress
|
||||
+ (motor.overcurrent ? riseRate * deltaSec : -config.wheelRecoveryRatePerSec * deltaSec),
|
||||
);
|
||||
motor.cap = calculateCap(motor.stress);
|
||||
}
|
||||
|
||||
function updateBrushMotor(motor, { overcurrent, command, currentMa, deltaSec }) {
|
||||
motor.overcurrent = Boolean(overcurrent);
|
||||
motor.commandedSpeed = finiteNumber(command);
|
||||
motor.measuredSpeed = null;
|
||||
motor.currentMa = Number.isFinite(Number(currentMa)) ? Number(currentMa) : null;
|
||||
motor.stallFactor = 0;
|
||||
motor.stress = clampUnit(
|
||||
motor.stress
|
||||
+ (motor.overcurrent
|
||||
? config.brushOvercurrentRatePerSec * deltaSec
|
||||
: -config.brushRecoveryRatePerSec * deltaSec),
|
||||
);
|
||||
motor.cap = calculateCap(motor.stress);
|
||||
}
|
||||
|
||||
function maybeStopDrive(roverId, state) {
|
||||
if (state.bypassed || state.driveBlocked || !hasDriveIntent(state)) return;
|
||||
const stalledWheel = ['leftWheel', 'rightWheel'].find((key) => state.motors[key].stress >= 1);
|
||||
if (!stalledWheel) return;
|
||||
|
||||
state.driveBlocked = true;
|
||||
state.requiresNeutral = true;
|
||||
state.neutralSeen = false;
|
||||
state.driveClearSec = 0;
|
||||
state.stopReason = stalledWheel;
|
||||
state.lastDriveOutputAt = Date.now();
|
||||
state.lastDriveOutput = { left: 0, right: 0 };
|
||||
issueAdjustedCommand(roverId, {
|
||||
type: 'drive',
|
||||
driveDirect: { left: 0, right: 0 },
|
||||
});
|
||||
logger.warn('Stopped rover after persistent wheel overcurrent', {
|
||||
roverId,
|
||||
motor: stalledWheel,
|
||||
});
|
||||
}
|
||||
|
||||
function maybeResendScaledOutputs(roverId, state, now) {
|
||||
if (state.bypassed) return;
|
||||
|
||||
/*
|
||||
A held keyboard/gamepad value may not emit another browser command while
|
||||
telemetry continues changing the cap. Rate-limited resends make each new
|
||||
server calculation effective without requiring the user to move the
|
||||
control again or flooding the Pi websocket at sensor-frame cadence.
|
||||
*/
|
||||
const nextDriveOutput = scaleDrive(state);
|
||||
if (
|
||||
hasDriveIntent(state)
|
||||
&& !state.driveBlocked
|
||||
&& !driveOutputsEqual(nextDriveOutput, state.lastDriveOutput)
|
||||
&& now - state.lastDriveOutputAt >= config.outputRateMs
|
||||
) {
|
||||
const issued = issueAdjustedCommand(roverId, {
|
||||
type: 'drive',
|
||||
driveDirect: nextDriveOutput,
|
||||
});
|
||||
if (issued) {
|
||||
state.lastDriveOutputAt = now;
|
||||
state.lastDriveOutput = { ...nextDriveOutput };
|
||||
}
|
||||
}
|
||||
|
||||
const nextAuxOutput = scaleAux(state);
|
||||
if (
|
||||
hasAuxIntent(state)
|
||||
&& !auxOutputsEqual(nextAuxOutput, state.lastAuxOutput)
|
||||
&& now - state.lastAuxOutputAt >= config.outputRateMs
|
||||
) {
|
||||
const issued = issueAdjustedCommand(roverId, {
|
||||
type: 'motors',
|
||||
motorPwm: nextAuxOutput,
|
||||
});
|
||||
if (issued) {
|
||||
state.lastAuxOutputAt = now;
|
||||
state.lastAuxOutput = { ...nextAuxOutput };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function processTelemetry(roverId, sensors = {}, now = Date.now()) {
|
||||
const state = getState(roverId);
|
||||
const timestamp = finiteNumber(now, Date.now());
|
||||
const previousAt = state.lastTelemetryAt;
|
||||
state.lastTelemetryAt = timestamp;
|
||||
|
||||
if (state.bypassed) {
|
||||
/*
|
||||
Admin bypass means telemetry remains visible but cannot build hidden
|
||||
stress or schedule a delayed stop. Motor observations are still copied
|
||||
into the public snapshot so administrators can see the hardware warning
|
||||
while deliberately retaining full control.
|
||||
*/
|
||||
state.driveBlocked = false;
|
||||
state.requiresNeutral = false;
|
||||
state.neutralSeen = false;
|
||||
state.driveClearSec = 0;
|
||||
state.stopReason = null;
|
||||
}
|
||||
|
||||
const rawDeltaSec = previousAt > 0 ? Math.max(0, (timestamp - previousAt) / 1000) : 0;
|
||||
const deltaSec = state.bypassed
|
||||
? 0
|
||||
: Math.min(config.maxTelemetryDeltaSec, rawDeltaSec);
|
||||
const flags = sensors?.wheelOvercurrents || {};
|
||||
const speeds = sensors?.wheelSpeedsMmPerSecond || {};
|
||||
|
||||
updateWheelMotor(state.motors.leftWheel, {
|
||||
overcurrent: flags.leftWheel,
|
||||
command: state.driveIntent.left,
|
||||
measured: speeds.left,
|
||||
currentMa: sensors?.wheelLeftCurrentMa,
|
||||
deltaSec,
|
||||
});
|
||||
updateWheelMotor(state.motors.rightWheel, {
|
||||
overcurrent: flags.rightWheel,
|
||||
command: state.driveIntent.right,
|
||||
measured: speeds.right,
|
||||
currentMa: sensors?.wheelRightCurrentMa,
|
||||
deltaSec,
|
||||
});
|
||||
updateBrushMotor(state.motors.mainBrush, {
|
||||
overcurrent: flags.mainBrush,
|
||||
command: state.auxIntent.main,
|
||||
currentMa: sensors?.mainBrushCurrentMa,
|
||||
deltaSec,
|
||||
});
|
||||
updateBrushMotor(state.motors.sideBrush, {
|
||||
overcurrent: flags.sideBrush,
|
||||
command: state.auxIntent.side,
|
||||
currentMa: sensors?.sideBrushCurrentMa,
|
||||
deltaSec,
|
||||
});
|
||||
|
||||
const wheelOvercurrent = Boolean(flags.leftWheel || flags.rightWheel);
|
||||
state.driveClearSec = wheelOvercurrent ? 0 : state.driveClearSec + deltaSec;
|
||||
if (
|
||||
state.driveBlocked
|
||||
&& state.neutralSeen
|
||||
&& state.driveClearSec >= config.clearBeforeUnlockSec
|
||||
) {
|
||||
state.driveBlocked = false;
|
||||
state.requiresNeutral = false;
|
||||
state.stopReason = null;
|
||||
}
|
||||
|
||||
maybeStopDrive(roverId, state);
|
||||
maybeResendScaledOutputs(roverId, state, timestamp);
|
||||
return getPublicState(roverId);
|
||||
}
|
||||
|
||||
function getStatus(state) {
|
||||
const anyOvercurrent = MOTOR_KEYS.some((key) => state.motors[key].overcurrent);
|
||||
if (state.bypassed && anyOvercurrent) return 'bypassed';
|
||||
if (state.driveBlocked) return 'stopped';
|
||||
const anyLimited = MOTOR_KEYS.some((key) => state.motors[key].cap < 1);
|
||||
if (anyLimited && anyOvercurrent) return 'limiting';
|
||||
if (anyLimited) return 'recovering';
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
function getPublicState(roverId) {
|
||||
const state = getState(roverId);
|
||||
const motors = MOTOR_KEYS.reduce((result, key) => {
|
||||
result[key] = { ...state.motors[key] };
|
||||
return result;
|
||||
}, {});
|
||||
return {
|
||||
status: getStatus(state),
|
||||
bypassed: state.bypassed,
|
||||
drive: {
|
||||
cap: getDriveCap(state),
|
||||
blocked: state.driveBlocked,
|
||||
requiresNeutral: state.requiresNeutral,
|
||||
clearSec: state.driveClearSec,
|
||||
stopReason: state.stopReason,
|
||||
},
|
||||
motors,
|
||||
config: { ...config },
|
||||
};
|
||||
}
|
||||
|
||||
function cleanupRover(roverId) {
|
||||
states.delete(String(roverId || ''));
|
||||
}
|
||||
|
||||
return {
|
||||
configureCommandIssuer,
|
||||
protectCommand,
|
||||
processTelemetry,
|
||||
getPublicState,
|
||||
cleanupRover,
|
||||
};
|
||||
}
|
||||
|
||||
const service = createOvercurrentProtectionService();
|
||||
|
||||
module.exports = {
|
||||
...service,
|
||||
createOvercurrentProtectionService,
|
||||
DEFAULT_CONFIG,
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
// Overcurrent Protection Service Tests
|
||||
// Purpose: Verifies stress integration, administrator bypass, neutral recovery, and independent brush limiting.
|
||||
// Scope: Exercises the service as a pure state machine with an injected command sink; no rover or socket process is started.
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const { createOvercurrentProtectionService } = require('./index');
|
||||
|
||||
function makeSensors(overrides = {}) {
|
||||
return {
|
||||
wheelOvercurrents: {
|
||||
leftWheel: false,
|
||||
rightWheel: false,
|
||||
mainBrush: false,
|
||||
sideBrush: false,
|
||||
...(overrides.wheelOvercurrents || {}),
|
||||
},
|
||||
wheelSpeedsMmPerSecond: {
|
||||
left: 300,
|
||||
right: 300,
|
||||
...(overrides.wheelSpeedsMmPerSecond || {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createHarness(config = {}) {
|
||||
const issued = [];
|
||||
const service = createOvercurrentProtectionService({
|
||||
config,
|
||||
issueCommand: (roverId, payload) => issued.push({ roverId, payload }),
|
||||
});
|
||||
return { service, issued };
|
||||
}
|
||||
|
||||
test('a short stalled-wheel spike remains inside the grace region', () => {
|
||||
const { service } = createHarness();
|
||||
const start = Date.now();
|
||||
service.protectCommand('rover', 'drive', {
|
||||
driveDirect: { left: 300, right: 300 },
|
||||
});
|
||||
|
||||
service.processTelemetry('rover', makeSensors({
|
||||
wheelOvercurrents: { leftWheel: true },
|
||||
wheelSpeedsMmPerSecond: { left: 0 },
|
||||
}), start);
|
||||
const snapshot = service.processTelemetry('rover', makeSensors({
|
||||
wheelOvercurrents: { leftWheel: true },
|
||||
wheelSpeedsMmPerSecond: { left: 0 },
|
||||
}), start + 100);
|
||||
|
||||
assert.equal(snapshot.motors.leftWheel.stress, 0.2);
|
||||
assert.equal(snapshot.motors.leftWheel.cap, 1);
|
||||
assert.equal(snapshot.status, 'idle');
|
||||
});
|
||||
|
||||
test('persistent stalled-wheel overcurrent scales both wheels and then stops drive', () => {
|
||||
const { service, issued } = createHarness();
|
||||
const start = Date.now();
|
||||
service.protectCommand('rover', 'drive', {
|
||||
driveDirect: { left: 300, right: 200 },
|
||||
});
|
||||
|
||||
for (let step = 0; step <= 5; step += 1) {
|
||||
service.processTelemetry('rover', makeSensors({
|
||||
wheelOvercurrents: { leftWheel: true },
|
||||
wheelSpeedsMmPerSecond: { left: 0, right: 200 },
|
||||
}), start + step * 100);
|
||||
}
|
||||
|
||||
const snapshot = service.getPublicState('rover');
|
||||
assert.equal(snapshot.status, 'stopped');
|
||||
assert.equal(snapshot.drive.blocked, true);
|
||||
assert.equal(snapshot.drive.requiresNeutral, true);
|
||||
assert.equal(snapshot.drive.stopReason, 'leftWheel');
|
||||
assert.deepEqual(issued.at(-1), {
|
||||
roverId: 'rover',
|
||||
payload: { type: 'drive', driveDirect: { left: 0, right: 0 } },
|
||||
});
|
||||
|
||||
/*
|
||||
Before the hard stop, any rate-limited drive update must use one shared cap.
|
||||
This preserves the requested curve instead of driving the healthy wheel at
|
||||
full output around the mechanically obstructed side.
|
||||
*/
|
||||
const scaledDrive = issued.find((entry) => entry.payload.type === 'drive'
|
||||
&& entry.payload.driveDirect.left > 0);
|
||||
assert.ok(scaledDrive);
|
||||
assert.equal(
|
||||
scaledDrive.payload.driveDirect.left / 300,
|
||||
scaledDrive.payload.driveDirect.right / 200,
|
||||
);
|
||||
});
|
||||
|
||||
test('administrator commands and telemetry bypass all enforcement', () => {
|
||||
const { service, issued } = createHarness({ outputRateMs: 0 });
|
||||
const start = Date.now();
|
||||
const command = service.protectCommand('rover', 'drive', {
|
||||
driveDirect: { left: 500, right: -500 },
|
||||
}, { bypassed: true });
|
||||
|
||||
for (let step = 0; step <= 20; step += 1) {
|
||||
service.processTelemetry('rover', makeSensors({
|
||||
wheelOvercurrents: { leftWheel: true, rightWheel: true },
|
||||
wheelSpeedsMmPerSecond: { left: 0, right: 0 },
|
||||
}), start + step * 100);
|
||||
}
|
||||
|
||||
const snapshot = service.getPublicState('rover');
|
||||
assert.deepEqual(command.driveDirect, { left: 500, right: -500 });
|
||||
assert.equal(snapshot.status, 'bypassed');
|
||||
assert.equal(snapshot.bypassed, true);
|
||||
assert.equal(snapshot.motors.leftWheel.stress, 0);
|
||||
assert.equal(snapshot.motors.rightWheel.stress, 0);
|
||||
assert.equal(snapshot.drive.blocked, false);
|
||||
assert.equal(issued.length, 0);
|
||||
});
|
||||
|
||||
test('a stopped drive stays blocked until both clear time and neutral are observed', () => {
|
||||
const { service } = createHarness();
|
||||
const start = Date.now();
|
||||
service.protectCommand('rover', 'drive', {
|
||||
driveDirect: { left: 300, right: 300 },
|
||||
});
|
||||
for (let step = 0; step <= 5; step += 1) {
|
||||
service.processTelemetry('rover', makeSensors({
|
||||
wheelOvercurrents: { leftWheel: true },
|
||||
wheelSpeedsMmPerSecond: { left: 0 },
|
||||
}), start + step * 100);
|
||||
}
|
||||
|
||||
for (let step = 6; step <= 14; step += 1) {
|
||||
service.processTelemetry('rover', makeSensors(), start + step * 100);
|
||||
}
|
||||
assert.equal(service.getPublicState('rover').drive.blocked, true);
|
||||
|
||||
const heldCommand = service.protectCommand('rover', 'drive', {
|
||||
driveDirect: { left: 300, right: 300 },
|
||||
});
|
||||
assert.deepEqual(heldCommand.driveDirect, { left: 0, right: 0 });
|
||||
|
||||
service.protectCommand('rover', 'drive', {
|
||||
driveDirect: { left: 0, right: 0 },
|
||||
});
|
||||
assert.equal(service.getPublicState('rover').drive.blocked, false);
|
||||
|
||||
const resumed = service.protectCommand('rover', 'drive', {
|
||||
driveDirect: { left: 300, right: 300 },
|
||||
});
|
||||
assert.deepEqual(resumed.driveDirect, { left: 300, right: 300 });
|
||||
});
|
||||
|
||||
test('brush stress limits only the brush that reports overcurrent', () => {
|
||||
const { service } = createHarness();
|
||||
const start = Date.now();
|
||||
service.protectCommand('rover', 'motors', {
|
||||
motorPwm: { main: 100, side: 100, vacuum: 100 },
|
||||
});
|
||||
for (let step = 0; step <= 4; step += 1) {
|
||||
service.processTelemetry('rover', makeSensors({
|
||||
wheelOvercurrents: { mainBrush: true },
|
||||
}), start + step * 100);
|
||||
}
|
||||
|
||||
const protectedCommand = service.protectCommand('rover', 'motors', {
|
||||
motorPwm: { main: 100, side: 100, vacuum: 100 },
|
||||
});
|
||||
assert.ok(protectedCommand.motorPwm.main < 100);
|
||||
assert.equal(protectedCommand.motorPwm.side, 100);
|
||||
assert.equal(protectedCommand.motorPwm.vacuum, 100);
|
||||
});
|
||||
@@ -6,6 +6,7 @@ const logger = require('../../globals/logger').child('roverManager');
|
||||
const { sendAlert } = require('../alertService');
|
||||
const { parseSensorFrame } = require('../../helpers/sensorDecoder');
|
||||
const odometerService = require('../odometerService');
|
||||
const overcurrentProtectionService = require('../overcurrentProtectionService');
|
||||
const { MODES, getMode } = require('../modeManager');
|
||||
const { isAdmin, isLockdownAdmin, roleEvents } = require('../roleService');
|
||||
const { publishEvent } = require('../eventBus');
|
||||
@@ -179,6 +180,7 @@ const sensorPipeline = createSensorPipeline({
|
||||
sendAlert,
|
||||
publishEvent,
|
||||
processOdometerFrame: odometerService.processSensorFrame,
|
||||
processOvercurrentTelemetry: overcurrentProtectionService.processTelemetry,
|
||||
isPrivateRecord,
|
||||
isPrivateOpen,
|
||||
getPrivateSafety,
|
||||
@@ -190,6 +192,18 @@ const sensorPipeline = createSensorPipeline({
|
||||
const { handleSensorFrame, applyPrivateDriveSafety } = sensorPipeline;
|
||||
stopDockGuard = sensorPipeline.stopDockGuard;
|
||||
|
||||
managerEvents.on('rover', ({ roverId, action }) => {
|
||||
/*
|
||||
Protection state contains the last motor intent for a specific physical
|
||||
rover connection. Removing it with the roster record prevents a reconnect
|
||||
from inheriting stale stress, an old administrator bypass, or a neutral
|
||||
requirement from the previous connection.
|
||||
*/
|
||||
if (action === 'removed') {
|
||||
overcurrentProtectionService.cleanupRover(roverId);
|
||||
}
|
||||
});
|
||||
|
||||
function removeSocket(socket) {
|
||||
roverLifecycle.removeSocket(socket, disableSpectator);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ function createSensorPipeline(deps) {
|
||||
sendAlert,
|
||||
publishEvent,
|
||||
processOdometerFrame,
|
||||
processOvercurrentTelemetry,
|
||||
isPrivateRecord,
|
||||
isPrivateOpen,
|
||||
getPrivateSafety,
|
||||
@@ -551,6 +552,19 @@ function createSensorPipeline(deps) {
|
||||
};
|
||||
record.lastSensor = { raw: frame, decoded };
|
||||
}
|
||||
/*
|
||||
Rover manager remains responsible only for decoding and routing sensor
|
||||
frames. The dedicated service receives the completed sensor object after
|
||||
odometry has added measured wheel speeds, because requested-versus-actual
|
||||
motion is the evidence that distinguishes a transient current spike from
|
||||
a mechanically stalled wheel.
|
||||
*/
|
||||
// Use server arrival time inside the service rather than the Pi timestamp.
|
||||
// Raspberry Pi clocks can differ across the fleet, while command resend
|
||||
// throttling is also measured on this server and needs one clock domain.
|
||||
const overcurrentProtection = decoded && typeof processOvercurrentTelemetry === 'function'
|
||||
? processOvercurrentTelemetry(roverId, decoded)
|
||||
: null;
|
||||
updateMovement(record, decoded);
|
||||
const hasDockInfo = decoded?.chargingSources != null;
|
||||
if (hasDockInfo) {
|
||||
@@ -563,8 +577,18 @@ function createSensorPipeline(deps) {
|
||||
if (bumps?.bumpLeft || bumps?.bumpRight) record.lastBumpAt = Date.now();
|
||||
handlePrivateButtonHold(record, decoded);
|
||||
evaluatePrivateSafety(record, decoded);
|
||||
io.to(record.room).volatile.emit('sensorFrame', { roverId, frame, sensors: decoded });
|
||||
managerEvents.emit('sensor', { roverId, sensors: decoded, batteryState: record.batteryState });
|
||||
io.to(record.room).volatile.emit('sensorFrame', {
|
||||
roverId,
|
||||
frame,
|
||||
sensors: decoded,
|
||||
overcurrentProtection,
|
||||
});
|
||||
managerEvents.emit('sensor', {
|
||||
roverId,
|
||||
sensors: decoded,
|
||||
batteryState: record.batteryState,
|
||||
overcurrentProtection,
|
||||
});
|
||||
evaluateDockGuard(record, decoded);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,63 +1,72 @@
|
||||
// Overcurrent Overlay
|
||||
// Purpose: Defines the Overcurrent Overlay module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
// Purpose: Shows server-authoritative motor limiting, stop, recovery, and administrator-bypass status.
|
||||
// Scope: Renders protection state only; it never calculates stress or changes motor commands.
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { useSessionSelector } from '../../../context/SessionContext.jsx';
|
||||
import { useVisualTelemetrySelector } from '../../../context/TelemetryContext.jsx';
|
||||
import { overcurrentFlagsEqual, selectOvercurrentFlags } from '../../../context/telemetryViews.js';
|
||||
import { useOvercurrentLimiter } from '../../../controls/index.js';
|
||||
import { OVERCURRENT_LABELS } from './constants.js';
|
||||
|
||||
function OvercurrentOverlay({ roverId = null, sensors, overcurrentLimiter = null, compact = false }) {
|
||||
function OvercurrentOverlay({ roverId = null, overcurrentLimiter = null, compact = false }) {
|
||||
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||
const effectiveRoverId = roverId ?? assignedRoverId;
|
||||
const selectedOvercurrents = useVisualTelemetrySelector(effectiveRoverId, selectOvercurrentFlags, overcurrentFlagsEqual);
|
||||
const internalLimiter = useOvercurrentLimiter(effectiveRoverId);
|
||||
const resolvedOvercurrents = sensors?.wheelOvercurrents ?? selectedOvercurrents;
|
||||
const resolvedOvercurrentLimiter = overcurrentLimiter ?? internalLimiter ?? null;
|
||||
const overcurrentMotors = useMemo(
|
||||
() =>
|
||||
resolvedOvercurrents == null
|
||||
? []
|
||||
: Object.entries(resolvedOvercurrents)
|
||||
.filter(([, active]) => Boolean(active))
|
||||
.map(([key]) => key),
|
||||
[resolvedOvercurrents],
|
||||
const protection = overcurrentLimiter ?? internalLimiter;
|
||||
const status = protection?.status || 'idle';
|
||||
const motors = protection?.motors || {};
|
||||
const activeMotors = useMemo(
|
||||
() => Object.entries(motors)
|
||||
.filter(([, motor]) => Boolean(motor?.overcurrent) || Number(motor?.stress) > 0)
|
||||
.map(([key]) => key),
|
||||
[motors],
|
||||
);
|
||||
const limiterCaps = resolvedOvercurrentLimiter?.caps || null;
|
||||
const limiterFill = useMemo(() => {
|
||||
if (!limiterCaps) return null;
|
||||
const driveCap = Number.isFinite(limiterCaps?.drive?.cap) ? limiterCaps.drive.cap : 1;
|
||||
const auxCap = Number.isFinite(limiterCaps?.aux?.cap) ? limiterCaps.aux.cap : 1;
|
||||
return Math.max(0, Math.min(1, 1 - Math.min(driveCap, auxCap)));
|
||||
}, [limiterCaps]);
|
||||
const limiterActive = Boolean(resolvedOvercurrentLimiter?.isActive);
|
||||
const motors = useMemo(
|
||||
() => (overcurrentMotors.length ? overcurrentMotors : limiterActive ? ['limiter'] : []),
|
||||
[overcurrentMotors, limiterActive],
|
||||
);
|
||||
const fill = limiterFill ?? (overcurrentMotors.length ? 1 : 0);
|
||||
|
||||
if (!motors?.length) return null;
|
||||
const safeLabels = motors.map((name) => OVERCURRENT_LABELS[name] || name);
|
||||
const containerClass = compact ? 'w-[12rem] h-[3.5rem]' : 'w-[20rem] h-[7rem]';
|
||||
const padClass = compact ? 'px-2 py-1' : 'px-4 py-2';
|
||||
const textClass = compact ? 'text-lg' : 'text-4xl';
|
||||
const subTextClass = compact ? 'text-xs' : 'text-xl';
|
||||
const safeFill = Math.max(0, Math.min(1, fill));
|
||||
const fillWidth = `${Math.round(safeFill * 100)}%`;
|
||||
if (status === 'idle') return null;
|
||||
|
||||
const stopReason = protection?.drive?.stopReason;
|
||||
const displayMotors = stopReason ? [stopReason] : activeMotors;
|
||||
const labels = displayMotors.map((name) => OVERCURRENT_LABELS[name] || name);
|
||||
const highestStress = displayMotors.reduce(
|
||||
(highest, name) => Math.max(highest, Number(motors?.[name]?.stress) || 0),
|
||||
0,
|
||||
);
|
||||
const driveCap = Number.isFinite(protection?.drive?.cap) ? protection.drive.cap : 1;
|
||||
const fillWidth = `${Math.round(Math.max(0, Math.min(1, highestStress)) * 100)}%`;
|
||||
const bypassed = status === 'bypassed';
|
||||
const stopped = status === 'stopped';
|
||||
const title = bypassed
|
||||
? 'Overcurrent detected'
|
||||
: stopped
|
||||
? 'Drive stopped'
|
||||
: status === 'recovering'
|
||||
? 'Protection recovering'
|
||||
: 'Overcurrent limiting';
|
||||
const detail = bypassed
|
||||
? 'Admin bypass'
|
||||
: stopped && protection?.drive?.requiresNeutral
|
||||
? `${labels.join(', ') || 'Wheel stall'} · release controls to resume`
|
||||
: status === 'limiting'
|
||||
? `${labels.join(', ')} · output ${Math.round(driveCap * 100)}%`
|
||||
: labels.join(', ');
|
||||
const containerClass = bypassed
|
||||
? 'h-[3.5rem] w-[14rem]'
|
||||
: compact
|
||||
? 'h-[3.5rem] w-[14rem]'
|
||||
: 'h-[7rem] w-[22rem]';
|
||||
const titleClass = compact || bypassed ? 'text-base' : 'text-3xl';
|
||||
const detailClass = compact || bypassed ? 'text-xs' : 'text-base';
|
||||
const backgroundClass = bypassed ? 'bg-amber-950/75' : 'bg-red-950/70';
|
||||
const fillClass = bypassed ? 'bg-amber-700/50' : 'bg-red-700/60';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`pointer-events-none absolute flex items-center justify-center bg-red-900/50 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 ${containerClass}`}
|
||||
className={`pointer-events-none absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center ${backgroundClass} ${containerClass}`}
|
||||
>
|
||||
<div className="relative h-full w-full">
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="h-full bg-red-700/60" style={{ width: fillWidth }} />
|
||||
</div>
|
||||
<div className={`relative z-10 flex h-full w-full flex-col items-center justify-center text-center font-semibold text-white animate-pulse ${textClass} ${padClass}`}>
|
||||
<div>OVERCURRENT</div>
|
||||
<div className={`mt-0 font-medium text-white ${subTextClass}`}>{safeLabels.join(', ')}</div>
|
||||
<div className="relative h-full w-full overflow-hidden">
|
||||
<div className={`absolute inset-y-0 left-0 ${fillClass}`} style={{ width: fillWidth }} />
|
||||
<div className="relative z-10 flex h-full flex-col items-center justify-center px-2 text-center font-semibold text-white">
|
||||
<div className={titleClass}>{title}</div>
|
||||
<div className={`font-medium ${detailClass}`}>{detail}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
// Overcurrent Limiter Panel
|
||||
// Purpose: Defines the Overcurrent Limiter Panel module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import { useMemo } from 'react';
|
||||
// Overcurrent Protection Panel
|
||||
// Purpose: Presents detailed server-calculated motor stress and command-tracking diagnostics.
|
||||
// Scope: Read-only status surface for the assigned rover; protection and recovery remain server-owned.
|
||||
|
||||
import { useControlSelector } from '../../controls/index.js';
|
||||
import { OVERCURRENT_GROUPS } from '../../controls/overcurrentLimiter.js';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
|
||||
const GROUP_LABELS = {
|
||||
drive: 'Drive wheels',
|
||||
aux: 'Aux motors',
|
||||
const MOTOR_LABELS = {
|
||||
leftWheel: 'Left wheel',
|
||||
rightWheel: 'Right wheel',
|
||||
mainBrush: 'Main brush',
|
||||
sideBrush: 'Side brush',
|
||||
};
|
||||
|
||||
function formatPct(value) {
|
||||
@@ -16,8 +17,13 @@ function formatPct(value) {
|
||||
return `${Math.round(value * 100)}%`;
|
||||
}
|
||||
|
||||
function formatSpeed(value) {
|
||||
if (!Number.isFinite(value)) return '--';
|
||||
return `${Math.round(value)} mm/s`;
|
||||
}
|
||||
|
||||
function ProgressBar({ value, color = 'bg-emerald-500' }) {
|
||||
const width = `${Math.round(Math.max(0, Math.min(1, value)) * 100)}%`;
|
||||
const width = `${Math.round(Math.max(0, Math.min(1, Number(value) || 0)) * 100)}%`;
|
||||
return (
|
||||
<div className="h-2 w-full overflow-hidden rounded bg-slate-800">
|
||||
<div className={`h-full ${color}`} style={{ width }} />
|
||||
@@ -25,51 +31,72 @@ function ProgressBar({ value, color = 'bg-emerald-500' }) {
|
||||
);
|
||||
}
|
||||
|
||||
function statusLabel(protection) {
|
||||
if (protection?.adminImmune) return 'Admin bypass';
|
||||
if (protection?.status === 'stopped') return 'Drive stopped';
|
||||
if (protection?.status === 'limiting') return 'Limiting';
|
||||
if (protection?.status === 'recovering') return 'Recovering';
|
||||
return 'Ready';
|
||||
}
|
||||
|
||||
export default function OvercurrentLimiterPanel() {
|
||||
const roverId = useControlSelector((control) => control.state.roverId);
|
||||
const overcurrentLimiter = useControlSelector((control) => control.overcurrentLimiter);
|
||||
const groups = useMemo(() => OVERCURRENT_GROUPS.map((group) => group.key), []);
|
||||
const protection = useControlSelector((control) => control.overcurrentLimiter);
|
||||
const motors = protection?.motors || {};
|
||||
|
||||
return (
|
||||
<CardFrame
|
||||
title="Overcurrent limiter"
|
||||
meta={overcurrentLimiter?.adminImmune ? 'Admin immune' : 'Active'}
|
||||
bodyClassName="space-y-0.5 text-sm"
|
||||
title="Overcurrent protection"
|
||||
meta={statusLabel(protection)}
|
||||
bodyClassName="space-y-1 text-sm"
|
||||
>
|
||||
{!roverId ? (
|
||||
<p className="text-xs text-slate-500">Assign a rover to view limiter status.</p>
|
||||
<p className="text-xs text-slate-500">Assign a rover to view protection status.</p>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{groups.map((key) => {
|
||||
const cap = overcurrentLimiter?.caps?.[key]?.cap ?? 0;
|
||||
const over = overcurrentLimiter?.overcurrent?.groups?.[key] ?? false;
|
||||
const scale = overcurrentLimiter?.scales?.perGroup?.[key] ?? 1;
|
||||
<div className="space-y-1">
|
||||
{Object.entries(MOTOR_LABELS).map(([key, label]) => {
|
||||
const motor = motors[key] || {};
|
||||
const wheel = key === 'leftWheel' || key === 'rightWheel';
|
||||
return (
|
||||
<div key={key} className="space-y-0.5">
|
||||
<div key={key} className="space-y-0.5 border-b border-slate-800 pb-1 last:border-0">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-slate-200">{GROUP_LABELS[key] || key}</span>
|
||||
<span className={over ? 'text-red-300' : 'text-slate-400'}>
|
||||
{over ? 'overcurrent' : 'ok'}
|
||||
<span className="text-slate-200">{label}</span>
|
||||
<span className={motor.overcurrent ? 'text-red-300' : 'text-slate-400'}>
|
||||
{motor.overcurrent ? 'Overcurrent' : 'Clear'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center justify-between text-[0.7rem] text-slate-400">
|
||||
<span>Cap</span>
|
||||
<span>{formatPct(cap)}</span>
|
||||
</div>
|
||||
<ProgressBar value={cap} color="bg-amber-500" />
|
||||
<div className="flex items-center justify-between text-[0.7rem] text-slate-400">
|
||||
<span>Scale</span>
|
||||
<span>{formatPct(scale)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[0.7rem] text-slate-400">
|
||||
<span>Stress {formatPct(motor.stress)}</span>
|
||||
<span>Output {formatPct(motor.cap)}</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={motor.stress}
|
||||
color={motor.overcurrent ? 'bg-red-500' : 'bg-amber-500'}
|
||||
/>
|
||||
{wheel ? (
|
||||
<div className="grid grid-cols-3 gap-1 text-[0.65rem] text-slate-500">
|
||||
<span>{`Command ${formatSpeed(Math.abs(Number(motor.commandedSpeed)))}`}</span>
|
||||
<span>{`Measured ${formatSpeed(motor.measuredSpeed)}`}</span>
|
||||
<span>{`Stall ${formatPct(motor.stallFactor)}`}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{protection?.drive?.blocked ? (
|
||||
<p className="text-xs text-red-300">
|
||||
{protection.drive.requiresNeutral
|
||||
? 'Drive is stopped. Release controls to neutral before resuming.'
|
||||
: 'Drive is stopped while the wheel condition clears.'}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="text-[0.7rem] text-slate-400">
|
||||
<div>{`Down rate ${overcurrentLimiter?.config?.downRatePerSec}/s · Up rate ${overcurrentLimiter?.config?.upRatePerSec}/s`}</div>
|
||||
<div>{`Release delay ${overcurrentLimiter?.config?.releaseDelaySec}s`}</div>
|
||||
<div>{`Output rate ${overcurrentLimiter?.config?.outputRateMs}ms`}</div>
|
||||
<div>{`Drive output ${formatPct(protection?.drive?.cap)}`}</div>
|
||||
<div>
|
||||
{protection?.adminImmune
|
||||
? 'This session bypasses all overcurrent enforcement.'
|
||||
: 'Status and output limits are calculated by the server.'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -231,7 +231,7 @@ export function TelemetryProvider({ children }) {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
function handleSensorFrame({ roverId, sensors = {}, frame = {} }) {
|
||||
function handleSensorFrame({ roverId, sensors = {}, frame = {}, overcurrentProtection = null }) {
|
||||
if (!roverId) return;
|
||||
const previous = framesRef.current[roverId] ?? {};
|
||||
framesRef.current = {
|
||||
@@ -240,6 +240,10 @@ export function TelemetryProvider({ children }) {
|
||||
...previous,
|
||||
roverId,
|
||||
sensors,
|
||||
// Protection is server-calculated policy state, not a native Roomba
|
||||
// sensor. Keeping it beside `sensors` preserves that distinction while
|
||||
// allowing selectors to read one coherent telemetry snapshot.
|
||||
overcurrentProtection,
|
||||
raw: frame?.data || null,
|
||||
receivedAt: Date.now(),
|
||||
},
|
||||
|
||||
@@ -30,11 +30,7 @@ import { canonicalizeKeyInput } from './keymapUtils.js';
|
||||
import { useSettingsNamespace } from '../settings/index.js';
|
||||
import { useSessionActions, useSessionSelector } from '../context/SessionContext.jsx';
|
||||
import { HORN_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
|
||||
import {
|
||||
applyAuxOvercurrentScale,
|
||||
applyDriveOvercurrentScale,
|
||||
useOvercurrentLimiter,
|
||||
} from './overcurrentLimiter.js';
|
||||
import { useOvercurrentLimiter } from './overcurrentLimiter.js';
|
||||
import { usePtzControlAdapter } from './ptzControlAdapter.js';
|
||||
|
||||
const ControlSystemContext = createContext(null);
|
||||
@@ -176,15 +172,13 @@ export function ControlSystemProvider({ children }) {
|
||||
);
|
||||
const { homeAssistantSetState } = useSessionActions();
|
||||
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 });
|
||||
/*
|
||||
Motor commands now remain raw until they reach the server-owned protection
|
||||
service. Applying another transform here would make non-admin commands pass
|
||||
through two independent limiters and would let browser lifecycle determine
|
||||
whether protection exists at all.
|
||||
*/
|
||||
const pipeline = useCommandPipeline();
|
||||
const ptzControls = usePtzControlAdapter();
|
||||
|
||||
const turnOnAllLights = useCallback(() => {
|
||||
@@ -288,39 +282,6 @@ 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]);
|
||||
const limiterDriveSentAtRef = useRef(0);
|
||||
const limiterAuxSentAtRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
driveSpeedsRef.current = state.drive.speeds;
|
||||
}, [state.drive.speeds]);
|
||||
|
||||
useEffect(() => {
|
||||
auxValuesRef.current = state.aux;
|
||||
}, [state.aux]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pipeline.roverId || overcurrentLimiter.adminImmune || !overcurrentLimiter.isActive) return;
|
||||
const outputRateMs = Math.max(0, Number(overcurrentLimiter?.config?.outputRateMs) || 0);
|
||||
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
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 && now - limiterDriveSentAtRef.current >= outputRateMs) {
|
||||
limiterDriveSentAtRef.current = now;
|
||||
pipeline.sendDriveDirect(drive);
|
||||
}
|
||||
if (auxActive && now - limiterAuxSentAtRef.current >= outputRateMs) {
|
||||
limiterAuxSentAtRef.current = now;
|
||||
pipeline.sendAuxMotors(aux);
|
||||
}
|
||||
}, [limiterScaleToken, overcurrentLimiter.adminImmune, overcurrentLimiter.config, overcurrentLimiter.isActive, pipeline]);
|
||||
|
||||
const setDriveVector = useCallback(
|
||||
(vector, meta = {}) => {
|
||||
const speedOptions = { ...(meta.speedOptions || {}) };
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// Overcurrent Limiter Hook/Utility
|
||||
// Purpose: Applies client-side overcurrent guard logic to reduce harmful command spikes. Scope: Tracks limiter state and exposes gated dispatch behavior to controls.
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
// Overcurrent Protection View Hook
|
||||
// Purpose: Adapts server-authoritative protection telemetry for existing control and HUD consumers.
|
||||
// Scope: Contains no protection timers or command scaling; enforcement belongs exclusively to the server service.
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useTelemetrySelector } from '../context/TelemetryContext.jsx';
|
||||
import { overcurrentFlagsEqual, selectOvercurrentFlags } from '../context/telemetryViews.js';
|
||||
import { useSessionSelector } from '../context/SessionContext.jsx';
|
||||
|
||||
export const OVERCURRENT_GROUPS = [
|
||||
@@ -10,189 +11,62 @@ export const OVERCURRENT_GROUPS = [
|
||||
{ key: 'aux', motors: ['mainBrush', 'sideBrush'] },
|
||||
];
|
||||
|
||||
export const DEFAULT_OVERCURRENT_LIMITS = {
|
||||
downRatePerSec: 0.4,
|
||||
upRatePerSec: 0.5,
|
||||
releaseDelaySec: 2.5,
|
||||
outputRateMs: 250,
|
||||
};
|
||||
const EMPTY_PROTECTION = Object.freeze({
|
||||
status: 'idle',
|
||||
bypassed: false,
|
||||
drive: Object.freeze({ cap: 1, blocked: false, requiresNeutral: false, stopReason: null }),
|
||||
motors: Object.freeze({}),
|
||||
config: Object.freeze({}),
|
||||
});
|
||||
|
||||
const RECOVERED_CAP_THRESHOLD = 0.999;
|
||||
|
||||
function createInitialCaps() {
|
||||
return OVERCURRENT_GROUPS.reduce((acc, group) => {
|
||||
acc[group.key] = { cap: 1, clearSec: 0 };
|
||||
return acc;
|
||||
}, {});
|
||||
function selectOvercurrentProtection(frame) {
|
||||
return frame?.overcurrentProtection || EMPTY_PROTECTION;
|
||||
}
|
||||
|
||||
function clampUnit(value) {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
export function useOvercurrentLimiter(roverId, options = {}) {
|
||||
export function useOvercurrentLimiter(roverId) {
|
||||
const role = useSessionSelector((state) => state.session?.role || null);
|
||||
const overcurrentFlags = useTelemetrySelector(roverId, selectOvercurrentFlags, overcurrentFlagsEqual);
|
||||
const config = useMemo(
|
||||
() => ({ ...DEFAULT_OVERCURRENT_LIMITS, ...(options.config || {}) }),
|
||||
[options.config],
|
||||
);
|
||||
const [caps, setCaps] = useState(() => createInitialCaps());
|
||||
const lastTickRef = useRef(0);
|
||||
const flagsRef = useRef(overcurrentFlags);
|
||||
|
||||
useEffect(() => {
|
||||
flagsRef.current = overcurrentFlags || {};
|
||||
}, [overcurrentFlags]);
|
||||
|
||||
useEffect(() => {
|
||||
setCaps(createInitialCaps());
|
||||
lastTickRef.current = 0;
|
||||
}, [roverId]);
|
||||
|
||||
const hasAnyOvercurrent = useMemo(
|
||||
() => OVERCURRENT_GROUPS.some((group) => group.motors.some((motor) => Boolean(overcurrentFlags?.[motor]))),
|
||||
[overcurrentFlags],
|
||||
);
|
||||
const needsRecoveryTick = useMemo(
|
||||
() =>
|
||||
Object.values(caps || {}).some((entry) => {
|
||||
/*
|
||||
Recovery intentionally completes at a tiny tolerance below exactly 1.
|
||||
The limiter advances in timed floating-point steps, so requiring an
|
||||
exact 1 can strand the UI at a visually empty bar while the limiter is
|
||||
still technically active at a value like 0.9992.
|
||||
*/
|
||||
const cap = Number.isFinite(entry?.cap) ? entry.cap : 1;
|
||||
return cap < RECOVERED_CAP_THRESHOLD;
|
||||
}),
|
||||
[caps],
|
||||
);
|
||||
const shouldTick = Boolean(roverId) && (hasAnyOvercurrent || needsRecoveryTick);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldTick) return undefined;
|
||||
lastTickRef.current = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
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 = Math.min(0.25, deltaMs / 1000);
|
||||
if (deltaSec <= 0) return;
|
||||
setCaps((prev) => {
|
||||
let changed = false;
|
||||
const next = {};
|
||||
const downRate = Number.isFinite(config.downRatePerSec) ? Math.max(0, config.downRatePerSec) : 0;
|
||||
const upRate = Number.isFinite(config.upRatePerSec) ? Math.max(0, config.upRatePerSec) : 0;
|
||||
const releaseDelay = Number.isFinite(config.releaseDelaySec) ? Math.max(0, config.releaseDelaySec) : 0;
|
||||
OVERCURRENT_GROUPS.forEach((group) => {
|
||||
const prevEntry = prev[group.key] || { cap: 1, clearSec: 0 };
|
||||
const prevCap = Number.isFinite(prevEntry.cap) ? prevEntry.cap : 1;
|
||||
const prevClear = Number.isFinite(prevEntry.clearSec) ? prevEntry.clearSec : 0;
|
||||
const over = group.motors.some((motor) => Boolean(flagsRef.current?.[motor]));
|
||||
const nextClear = over ? 0 : prevClear + deltaSec;
|
||||
const allowRecover = !over && nextClear >= releaseDelay;
|
||||
const rawNextCap = clampUnit(
|
||||
over ? prevCap - downRate * deltaSec : allowRecover ? prevCap + upRate * deltaSec : prevCap,
|
||||
);
|
||||
/*
|
||||
Once recovery reaches the shared completion threshold, snap the cap
|
||||
to exactly full strength. This keeps the tick loop, command scaling,
|
||||
and HUD visibility from disagreeing over a harmless fractional tail.
|
||||
*/
|
||||
const nextCap = !over && rawNextCap >= RECOVERED_CAP_THRESHOLD ? 1 : rawNextCap;
|
||||
if (Math.abs(nextCap - prevCap) > 0.0001 || Math.abs(nextClear - prevClear) > 0.0001) {
|
||||
changed = true;
|
||||
}
|
||||
next[group.key] = { cap: nextCap, clearSec: nextClear };
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, 100);
|
||||
return () => clearInterval(interval);
|
||||
}, [config.downRatePerSec, config.releaseDelaySec, config.upRatePerSec, roverId, shouldTick]);
|
||||
|
||||
const scales = useMemo(() => {
|
||||
const perGroup = OVERCURRENT_GROUPS.reduce((acc, group) => {
|
||||
const entry = caps?.[group.key];
|
||||
const cap = Number.isFinite(entry?.cap) ? entry.cap : 1;
|
||||
acc[group.key] = clampUnit(cap);
|
||||
return acc;
|
||||
}, {});
|
||||
return {
|
||||
perGroup,
|
||||
drive: {
|
||||
left: perGroup.drive ?? 1,
|
||||
right: perGroup.drive ?? 1,
|
||||
},
|
||||
aux: {
|
||||
main: perGroup.aux ?? 1,
|
||||
side: perGroup.aux ?? 1,
|
||||
vacuum: 1,
|
||||
},
|
||||
};
|
||||
}, [caps]);
|
||||
|
||||
const overcurrent = useMemo(() => {
|
||||
const motors = {};
|
||||
OVERCURRENT_GROUPS.forEach((group) => {
|
||||
group.motors.forEach((motor) => {
|
||||
motors[motor] = Boolean(overcurrentFlags?.[motor]);
|
||||
});
|
||||
});
|
||||
const groups = OVERCURRENT_GROUPS.reduce((acc, group) => {
|
||||
acc[group.key] = group.motors.some((motor) => Boolean(overcurrentFlags?.[motor]));
|
||||
return acc;
|
||||
}, {});
|
||||
return { motors, groups };
|
||||
}, [overcurrentFlags]);
|
||||
|
||||
const protection = useTelemetrySelector(roverId, selectOvercurrentProtection);
|
||||
const adminImmune = role === 'admin' || role === 'lockdown';
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
caps,
|
||||
overcurrent,
|
||||
scales,
|
||||
/*
|
||||
HUD and resend behavior should only remain active while the limiter has
|
||||
meaningful scale left to recover. Using the same threshold as the tick
|
||||
loop prevents an empty overcurrent overlay from staying mounted after
|
||||
recovery has already stopped.
|
||||
*/
|
||||
isActive:
|
||||
(scales?.drive?.left ?? 1) < RECOVERED_CAP_THRESHOLD ||
|
||||
(scales?.drive?.right ?? 1) < RECOVERED_CAP_THRESHOLD ||
|
||||
(scales?.aux?.main ?? 1) < RECOVERED_CAP_THRESHOLD ||
|
||||
(scales?.aux?.side ?? 1) < RECOVERED_CAP_THRESHOLD,
|
||||
config,
|
||||
return useMemo(() => {
|
||||
const motors = protection?.motors || {};
|
||||
const driveCap = Number.isFinite(protection?.drive?.cap) ? protection.drive.cap : 1;
|
||||
const mainCap = Number.isFinite(motors?.mainBrush?.cap) ? motors.mainBrush.cap : 1;
|
||||
const sideCap = Number.isFinite(motors?.sideBrush?.cap) ? motors.sideBrush.cap : 1;
|
||||
const auxCap = Math.min(mainCap, sideCap);
|
||||
const motorFlags = OVERCURRENT_GROUPS.reduce((result, group) => {
|
||||
group.motors.forEach((motor) => {
|
||||
result[motor] = Boolean(motors?.[motor]?.overcurrent);
|
||||
});
|
||||
return result;
|
||||
}, {});
|
||||
const groupFlags = OVERCURRENT_GROUPS.reduce((result, group) => {
|
||||
result[group.key] = group.motors.some((motor) => motorFlags[motor]);
|
||||
return result;
|
||||
}, {});
|
||||
|
||||
/*
|
||||
The compatibility-shaped fields keep existing control-context consumers
|
||||
simple while every value now comes from the same server snapshot. There
|
||||
is deliberately no local recovery loop: a stale or disconnected browser
|
||||
must never invent a safer state than the server actually calculated.
|
||||
*/
|
||||
return {
|
||||
...protection,
|
||||
caps: {
|
||||
drive: { cap: driveCap },
|
||||
aux: { cap: auxCap },
|
||||
},
|
||||
scales: {
|
||||
perGroup: { drive: driveCap, aux: auxCap },
|
||||
drive: { left: driveCap, right: driveCap },
|
||||
aux: { main: mainCap, side: sideCap, vacuum: 1 },
|
||||
},
|
||||
overcurrent: { motors: motorFlags, groups: groupFlags },
|
||||
isActive: protection?.status === 'limiting'
|
||||
|| protection?.status === 'stopped'
|
||||
|| protection?.status === 'recovering',
|
||||
adminImmune,
|
||||
}),
|
||||
[caps, 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;
|
||||
if (leftScale >= 0.999 && rightScale >= 0.999) return speeds;
|
||||
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;
|
||||
if (mainScale >= 0.999 && sideScale >= 0.999 && vacuumScale >= 0.999) return values;
|
||||
return {
|
||||
main: Math.round((values.main ?? 0) * mainScale),
|
||||
side: Math.round((values.side ?? 0) * sideScale),
|
||||
vacuum: Math.round((values.vacuum ?? 0) * vacuumScale),
|
||||
};
|
||||
};
|
||||
}, [adminImmune, protection]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user