mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
roverdometer
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
@@ -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-BEB1f3bD.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-THYGwIt0.css">
|
||||
<script type="module" crossorigin src="/assets/index-DXjwvKfx.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BcP4x_Qk.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -54,8 +54,8 @@ const GROUP100_LAYOUT = [
|
||||
{ id: 40, key: 'requestedRadius', bytes: 2, parser: parseInt },
|
||||
{ id: 41, key: 'requestedRightVelocity', bytes: 2, parser: parseInt },
|
||||
{ id: 42, key: 'requestedLeftVelocity', bytes: 2, parser: parseInt },
|
||||
{ id: 43, key: 'encoderCountsLeft', bytes: 2, parser: parseUInt },
|
||||
{ id: 44, key: 'encoderCountsRight', bytes: 2, parser: parseUInt },
|
||||
{ id: 43, key: 'encoderCountsLeft', bytes: 2, parser: parseInt },
|
||||
{ id: 44, key: 'encoderCountsRight', bytes: 2, parser: parseInt },
|
||||
{ id: 45, key: 'lightBumper', bytes: 1, parser: parseLightBumper },
|
||||
{ id: 46, key: 'lightBumpLeftSignal', bytes: 2, parser: parseUInt },
|
||||
{ id: 47, key: 'lightBumpFrontLeftSignal', bytes: 2, parser: parseUInt },
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
// odometer Service
|
||||
// Purpose: Converts Roomba wheel encoder samples into persistent rover distance totals.
|
||||
// Scope: Owns encoder rollover handling, sanity filtering, per-rover odometer state, and disk persistence.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const EventEmitter = require('events');
|
||||
const { resolveDataPath } = require('../../helpers/dataPaths');
|
||||
const logger = require('../../globals/logger').child('odometerService');
|
||||
|
||||
const STORE_PATH = resolveDataPath('rover-odometers.json');
|
||||
const ENCODER_MODULUS = 65536;
|
||||
const ENCODER_HALF_RANGE = ENCODER_MODULUS / 2;
|
||||
const DEFAULT_WHEEL_DIAMETER_MM = 72.0;
|
||||
const DEFAULT_COUNTS_PER_REVOLUTION = 508.8;
|
||||
const DEFAULT_MM_PER_COUNT = (Math.PI * DEFAULT_WHEEL_DIAMETER_MM) / DEFAULT_COUNTS_PER_REVOLUTION;
|
||||
const DEFAULT_CALIBRATION_MULTIPLIER = 1;
|
||||
const MAX_REASONABLE_SPEED_MM_PER_SECOND = 1200;
|
||||
const MAX_REASONABLE_DELTA_FLOOR_MM = 250;
|
||||
const SAVE_DEBOUNCE_MS = 2500;
|
||||
const MIN_SAVE_INTERVAL_MS = 10000;
|
||||
const EMIT_THROTTLE_MS = 500;
|
||||
|
||||
const odometerEvents = new EventEmitter();
|
||||
const states = new Map();
|
||||
let saveTimer = null;
|
||||
let lastSaveAt = 0;
|
||||
let loaded = false;
|
||||
|
||||
function nowMs() {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function safeNumber(value, fallback = 0) {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : fallback;
|
||||
}
|
||||
|
||||
function createInitialComparison() {
|
||||
return {
|
||||
sampleCount: 0,
|
||||
missingDistanceSamples: 0,
|
||||
ignoredSamples: 0,
|
||||
encoderSessionMm: 0,
|
||||
distancePacketSessionMm: 0,
|
||||
signedEncoderSessionMm: 0,
|
||||
signedDistancePacketSessionMm: 0,
|
||||
last: null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeStoredEntry(entry = {}) {
|
||||
const totalMm = Math.max(0, safeNumber(entry.totalMm, 0));
|
||||
const calibrationMultiplier = Math.max(0.01, safeNumber(entry.calibrationMultiplier, DEFAULT_CALIBRATION_MULTIPLIER));
|
||||
return {
|
||||
totalMm,
|
||||
calibrationMultiplier,
|
||||
updatedAt: safeNumber(entry.updatedAt, null),
|
||||
};
|
||||
}
|
||||
|
||||
function ensureLoaded() {
|
||||
if (loaded) return;
|
||||
loaded = true;
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(STORE_PATH, 'utf8'));
|
||||
const rovers = raw && typeof raw === 'object' && raw.rovers && typeof raw.rovers === 'object'
|
||||
? raw.rovers
|
||||
: raw;
|
||||
Object.entries(rovers || {}).forEach(([roverId, entry]) => {
|
||||
const normalized = normalizeStoredEntry(entry);
|
||||
states.set(String(roverId), {
|
||||
roverId: String(roverId),
|
||||
totalMm: normalized.totalMm,
|
||||
sessionMm: 0,
|
||||
calibrationMultiplier: normalized.calibrationMultiplier,
|
||||
lastLeftCount: null,
|
||||
lastRightCount: null,
|
||||
lastSampleAt: null,
|
||||
lastIntegratedAt: null,
|
||||
lastDelta: null,
|
||||
rolloverEvents: 0,
|
||||
ignoredSamples: 0,
|
||||
status: 'waiting',
|
||||
statusReason: 'waiting for encoder sample',
|
||||
comparison: createInitialComparison(),
|
||||
lastEmittedAt: 0,
|
||||
lastEmittedStatus: null,
|
||||
updatedAt: normalized.updatedAt,
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
logger.warn('Failed to load rover odometers', { path: STORE_PATH, error: err.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureState(roverId) {
|
||||
ensureLoaded();
|
||||
const id = String(roverId || '').trim();
|
||||
if (!id) return null;
|
||||
if (!states.has(id)) {
|
||||
states.set(id, {
|
||||
roverId: id,
|
||||
totalMm: 0,
|
||||
sessionMm: 0,
|
||||
calibrationMultiplier: DEFAULT_CALIBRATION_MULTIPLIER,
|
||||
lastLeftCount: null,
|
||||
lastRightCount: null,
|
||||
lastSampleAt: null,
|
||||
lastIntegratedAt: null,
|
||||
lastDelta: null,
|
||||
rolloverEvents: 0,
|
||||
ignoredSamples: 0,
|
||||
status: 'waiting',
|
||||
statusReason: 'waiting for encoder sample',
|
||||
comparison: createInitialComparison(),
|
||||
lastEmittedAt: 0,
|
||||
lastEmittedStatus: null,
|
||||
updatedAt: null,
|
||||
});
|
||||
}
|
||||
return states.get(id);
|
||||
}
|
||||
|
||||
function signedEncoderDelta(previous, current) {
|
||||
let delta = current - previous;
|
||||
if (delta > ENCODER_HALF_RANGE) {
|
||||
delta -= ENCODER_MODULUS;
|
||||
} else if (delta < -ENCODER_HALF_RANGE) {
|
||||
delta += ENCODER_MODULUS;
|
||||
}
|
||||
return delta;
|
||||
}
|
||||
|
||||
function crossedRollover(previous, current, delta) {
|
||||
// The unwrapped delta is intentionally compared with the raw subtraction.
|
||||
// If they differ, the encoder crossed the signed 16-bit boundary between
|
||||
// samples and the modular correction above was required.
|
||||
return current - previous !== delta;
|
||||
}
|
||||
|
||||
function maxReasonableDeltaMm(elapsedMs) {
|
||||
const elapsedSeconds = Math.max(0.05, safeNumber(elapsedMs, 0) / 1000);
|
||||
return Math.max(MAX_REASONABLE_DELTA_FLOOR_MM, elapsedSeconds * MAX_REASONABLE_SPEED_MM_PER_SECOND);
|
||||
}
|
||||
|
||||
function persistSoon() {
|
||||
const elapsed = nowMs() - lastSaveAt;
|
||||
if (elapsed >= MIN_SAVE_INTERVAL_MS) {
|
||||
saveNow();
|
||||
return;
|
||||
}
|
||||
if (saveTimer) return;
|
||||
saveTimer = setTimeout(() => {
|
||||
saveTimer = null;
|
||||
saveNow();
|
||||
}, Math.max(SAVE_DEBOUNCE_MS, MIN_SAVE_INTERVAL_MS - elapsed));
|
||||
}
|
||||
|
||||
function saveNow() {
|
||||
ensureLoaded();
|
||||
const rovers = {};
|
||||
for (const [roverId, state] of states.entries()) {
|
||||
rovers[roverId] = {
|
||||
totalMm: Math.round(state.totalMm * 1000) / 1000,
|
||||
calibrationMultiplier: state.calibrationMultiplier,
|
||||
updatedAt: state.updatedAt || null,
|
||||
};
|
||||
}
|
||||
const payload = {
|
||||
version: 1,
|
||||
unit: 'millimeters',
|
||||
source: 'roomba wheel encoders',
|
||||
mmPerCount: DEFAULT_MM_PER_COUNT,
|
||||
rovers,
|
||||
};
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(STORE_PATH), { recursive: true });
|
||||
const tempPath = `${STORE_PATH}.tmp`;
|
||||
fs.writeFileSync(tempPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
||||
fs.renameSync(tempPath, STORE_PATH);
|
||||
lastSaveAt = nowMs();
|
||||
} catch (err) {
|
||||
logger.warn('Failed to save rover odometers', { path: STORE_PATH, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
function percentDifference(differenceMm, referenceMm) {
|
||||
const reference = Math.abs(safeNumber(referenceMm, 0));
|
||||
if (reference < 1) return null;
|
||||
return Math.round((Math.abs(differenceMm) / reference) * 1000) / 10;
|
||||
}
|
||||
|
||||
function snapshotComparison(comparison = createInitialComparison()) {
|
||||
const sessionDifferenceMm = comparison.distancePacketSessionMm - comparison.encoderSessionMm;
|
||||
return {
|
||||
sampleCount: comparison.sampleCount,
|
||||
missingDistanceSamples: comparison.missingDistanceSamples,
|
||||
ignoredSamples: comparison.ignoredSamples,
|
||||
encoderSessionMm: Math.round(comparison.encoderSessionMm),
|
||||
distancePacketSessionMm: Math.round(comparison.distancePacketSessionMm),
|
||||
signedEncoderSessionMm: Math.round(comparison.signedEncoderSessionMm),
|
||||
signedDistancePacketSessionMm: Math.round(comparison.signedDistancePacketSessionMm),
|
||||
sessionDifferenceMm: Math.round(sessionDifferenceMm),
|
||||
sessionDifferencePct: percentDifference(sessionDifferenceMm, comparison.encoderSessionMm),
|
||||
last: comparison.last,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshotState(state) {
|
||||
if (!state) return null;
|
||||
return {
|
||||
roverId: state.roverId,
|
||||
totalMm: Math.round(state.totalMm),
|
||||
sessionMm: Math.round(state.sessionMm),
|
||||
calibrationMultiplier: state.calibrationMultiplier,
|
||||
mmPerCount: DEFAULT_MM_PER_COUNT * state.calibrationMultiplier,
|
||||
rawMmPerCount: DEFAULT_MM_PER_COUNT,
|
||||
countsPerRevolution: DEFAULT_COUNTS_PER_REVOLUTION,
|
||||
wheelDiameterMm: DEFAULT_WHEEL_DIAMETER_MM,
|
||||
lastLeftCount: state.lastLeftCount,
|
||||
lastRightCount: state.lastRightCount,
|
||||
lastSampleAt: state.lastSampleAt,
|
||||
lastIntegratedAt: state.lastIntegratedAt,
|
||||
lastDelta: state.lastDelta,
|
||||
rolloverEvents: state.rolloverEvents,
|
||||
ignoredSamples: state.ignoredSamples,
|
||||
comparison: snapshotComparison(state.comparison),
|
||||
status: state.status,
|
||||
statusReason: state.statusReason,
|
||||
updatedAt: state.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function updateDistancePacketComparison(state, sensors, encoderCenterMm, encoderDistanceMm, options = {}) {
|
||||
const comparison = state.comparison || createInitialComparison();
|
||||
state.comparison = comparison;
|
||||
const packetMm = Number(sensors?.distanceMm);
|
||||
const ignored = Boolean(options.ignored);
|
||||
if (!Number.isFinite(packetMm)) {
|
||||
comparison.missingDistanceSamples += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const packetDistanceMm = Math.abs(packetMm);
|
||||
const signedDifferenceMm = packetMm - encoderCenterMm;
|
||||
const distanceDifferenceMm = packetDistanceMm - encoderDistanceMm;
|
||||
comparison.sampleCount += 1;
|
||||
if (ignored) {
|
||||
// Ignored encoder deltas are still recorded as a point-in-time diagnostic,
|
||||
// but they are not folded into the session totals. That keeps reconnect
|
||||
// edges from making the distance-packet comparison look worse than the
|
||||
// odometer's own accepted movement stream.
|
||||
comparison.ignoredSamples += 1;
|
||||
} else {
|
||||
comparison.encoderSessionMm += encoderDistanceMm;
|
||||
comparison.distancePacketSessionMm += packetDistanceMm;
|
||||
comparison.signedEncoderSessionMm += encoderCenterMm;
|
||||
comparison.signedDistancePacketSessionMm += packetMm;
|
||||
}
|
||||
comparison.last = {
|
||||
encoderCenterMm: Math.round(encoderCenterMm),
|
||||
encoderDistanceMm: Math.round(encoderDistanceMm),
|
||||
distancePacketMm: Math.round(packetMm),
|
||||
distancePacketAbsMm: Math.round(packetDistanceMm),
|
||||
signedDifferenceMm: Math.round(signedDifferenceMm),
|
||||
distanceDifferenceMm: Math.round(distanceDifferenceMm),
|
||||
signedDifferencePct: percentDifference(signedDifferenceMm, encoderCenterMm),
|
||||
distanceDifferencePct: percentDifference(distanceDifferenceMm, encoderDistanceMm),
|
||||
ignored,
|
||||
};
|
||||
}
|
||||
|
||||
function emitSnapshot(state, snapshot, options = {}) {
|
||||
const force = Boolean(options.force);
|
||||
const emittedRecently = nowMs() - safeNumber(state.lastEmittedAt, 0) < EMIT_THROTTLE_MS;
|
||||
const statusChanged = state.lastEmittedStatus !== state.status;
|
||||
if (!force && emittedRecently && !statusChanged) return;
|
||||
|
||||
// Odometer updates are presentation data, not control-loop data. Throttling
|
||||
// here keeps browser traffic proportional to what humans can read while the
|
||||
// integration math above still processes every sensor frame.
|
||||
state.lastEmittedAt = nowMs();
|
||||
state.lastEmittedStatus = state.status;
|
||||
odometerEvents.emit('update', { roverId: state.roverId, odometer: snapshot });
|
||||
}
|
||||
|
||||
function getSnapshot(roverId) {
|
||||
return snapshotState(ensureState(roverId));
|
||||
}
|
||||
|
||||
function getSnapshots(roverIds = null) {
|
||||
ensureLoaded();
|
||||
const ids = Array.isArray(roverIds) ? roverIds.map((id) => String(id)) : Array.from(states.keys());
|
||||
return ids.map((id) => snapshotState(ensureState(id))).filter(Boolean);
|
||||
}
|
||||
|
||||
function processSensorFrame(roverId, sensors = {}) {
|
||||
const state = ensureState(roverId);
|
||||
if (!state) return null;
|
||||
const left = Number(sensors?.encoderCountsLeft);
|
||||
const right = Number(sensors?.encoderCountsRight);
|
||||
const sampleAt = nowMs();
|
||||
if (!Number.isInteger(left) || !Number.isInteger(right)) {
|
||||
state.status = 'waiting';
|
||||
state.statusReason = 'encoder counts missing';
|
||||
return snapshotState(state);
|
||||
}
|
||||
|
||||
if (state.lastLeftCount == null || state.lastRightCount == null) {
|
||||
// The first valid frame becomes the baseline because encoder packets are
|
||||
// cumulative counters inside the Roomba, not distance-since-last-poll
|
||||
// packets. Adding the first absolute value would invent mileage whenever
|
||||
// the server or rover reconnects.
|
||||
state.lastLeftCount = left;
|
||||
state.lastRightCount = right;
|
||||
state.lastSampleAt = sampleAt;
|
||||
state.status = 'tracking';
|
||||
state.statusReason = 'baseline ready';
|
||||
state.updatedAt = sampleAt;
|
||||
const snapshot = snapshotState(state);
|
||||
emitSnapshot(state, snapshot, { force: true });
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
const leftCounts = signedEncoderDelta(state.lastLeftCount, left);
|
||||
const rightCounts = signedEncoderDelta(state.lastRightCount, right);
|
||||
const elapsedMs = state.lastSampleAt ? sampleAt - state.lastSampleAt : 0;
|
||||
const mmPerCount = DEFAULT_MM_PER_COUNT * state.calibrationMultiplier;
|
||||
const leftMm = leftCounts * mmPerCount;
|
||||
const rightMm = rightCounts * mmPerCount;
|
||||
const centerMm = (leftMm + rightMm) / 2;
|
||||
const distanceMm = Math.abs(centerMm);
|
||||
const reasonableLimit = maxReasonableDeltaMm(elapsedMs);
|
||||
const leftRolled = crossedRollover(state.lastLeftCount, left, leftCounts);
|
||||
const rightRolled = crossedRollover(state.lastRightCount, right, rightCounts);
|
||||
|
||||
state.lastLeftCount = left;
|
||||
state.lastRightCount = right;
|
||||
state.lastSampleAt = sampleAt;
|
||||
if (leftRolled) state.rolloverEvents += 1;
|
||||
if (rightRolled) state.rolloverEvents += 1;
|
||||
|
||||
if (distanceMm > reasonableLimit) {
|
||||
// A single impossible jump is much more likely to be stale serial data,
|
||||
// a reconnect edge, or corrupt parsing than real movement. The baseline is
|
||||
// still advanced so the next good frame can continue from the new counter.
|
||||
state.ignoredSamples += 1;
|
||||
state.status = 'ignored';
|
||||
state.statusReason = `ignored ${Math.round(distanceMm)} mm jump`;
|
||||
state.lastDelta = {
|
||||
leftCounts,
|
||||
rightCounts,
|
||||
leftMm: Math.round(leftMm),
|
||||
rightMm: Math.round(rightMm),
|
||||
centerMm: Math.round(centerMm),
|
||||
distanceMm: 0,
|
||||
elapsedMs,
|
||||
ignored: true,
|
||||
};
|
||||
updateDistancePacketComparison(state, sensors, centerMm, distanceMm, { ignored: true });
|
||||
state.updatedAt = sampleAt;
|
||||
const snapshot = snapshotState(state);
|
||||
emitSnapshot(state, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
state.totalMm += distanceMm;
|
||||
state.sessionMm += distanceMm;
|
||||
updateDistancePacketComparison(state, sensors, centerMm, distanceMm);
|
||||
state.lastIntegratedAt = sampleAt;
|
||||
state.status = 'tracking';
|
||||
state.statusReason = distanceMm > 0 ? 'integrated encoder delta' : 'no movement';
|
||||
state.lastDelta = {
|
||||
leftCounts,
|
||||
rightCounts,
|
||||
leftMm: Math.round(leftMm),
|
||||
rightMm: Math.round(rightMm),
|
||||
centerMm: Math.round(centerMm),
|
||||
distanceMm: Math.round(distanceMm),
|
||||
elapsedMs,
|
||||
ignored: false,
|
||||
};
|
||||
state.updatedAt = sampleAt;
|
||||
persistSoon();
|
||||
const snapshot = snapshotState(state);
|
||||
emitSnapshot(state, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function resetSession(roverId) {
|
||||
const state = ensureState(roverId);
|
||||
if (!state) return null;
|
||||
state.sessionMm = 0;
|
||||
state.comparison = createInitialComparison();
|
||||
state.updatedAt = nowMs();
|
||||
const snapshot = snapshotState(state);
|
||||
emitSnapshot(state, snapshot, { force: true });
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
process.on('exit', () => {
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = null;
|
||||
}
|
||||
saveNow();
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
ENCODER_MODULUS,
|
||||
DEFAULT_MM_PER_COUNT,
|
||||
odometerEvents,
|
||||
getSnapshot,
|
||||
getSnapshots,
|
||||
processSensorFrame,
|
||||
resetSession,
|
||||
saveNow,
|
||||
};
|
||||
@@ -5,6 +5,7 @@ const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('roverManager');
|
||||
const { sendAlert } = require('../alertService');
|
||||
const { parseSensorFrame } = require('../../helpers/sensorDecoder');
|
||||
const odometerService = require('../odometerService');
|
||||
const { MODES, getMode } = require('../modeManager');
|
||||
const { isAdmin, isLockdownAdmin, roleEvents } = require('../roleService');
|
||||
const { publishEvent } = require('../eventBus');
|
||||
@@ -174,6 +175,7 @@ const sensorPipeline = createSensorPipeline({
|
||||
ALERT_COLOR,
|
||||
sendAlert,
|
||||
publishEvent,
|
||||
processOdometerFrame: odometerService.processSensorFrame,
|
||||
isPrivateRecord,
|
||||
isPrivateOpen,
|
||||
getPrivateSafety,
|
||||
@@ -197,6 +199,11 @@ function canReplayRoverId(roverId, socket = null) {
|
||||
return socket ? isRoverVisibleToSocket(record, socket) : false;
|
||||
}
|
||||
|
||||
function getOdometersForSocket(socket) {
|
||||
const visibleIds = getRosterForSocket(socket).map((entry) => entry.id);
|
||||
return odometerService.getSnapshots(visibleIds);
|
||||
}
|
||||
|
||||
roleEvents.on('change', ({ socket, role }) => {
|
||||
if (role === 'spectator') {
|
||||
enableSpectator(socket);
|
||||
@@ -231,6 +238,16 @@ registerSocketHandlers({
|
||||
setPrivateOpen,
|
||||
lockRover,
|
||||
setPrivateSafety,
|
||||
getOdometersForSocket,
|
||||
});
|
||||
|
||||
odometerService.odometerEvents.on('update', ({ roverId, odometer }) => {
|
||||
io.sockets.sockets.forEach((socket) => {
|
||||
const record = rovers.get(String(roverId));
|
||||
if (isRoverVisibleToSocket(record, socket)) {
|
||||
socket.volatile.emit('odometer:update', { roverId, odometer });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
setInterval(tickPrivateAutoClose, PRIVATE_AUTO_CLOSE_TICK_MS);
|
||||
@@ -262,4 +279,5 @@ module.exports = {
|
||||
canRequestControl,
|
||||
applyPrivateDriveSafety,
|
||||
canReplayRoverId,
|
||||
getOdometersForSocket,
|
||||
};
|
||||
|
||||
@@ -29,6 +29,7 @@ function createSensorPipeline(deps) {
|
||||
ALERT_COLOR,
|
||||
sendAlert,
|
||||
publishEvent,
|
||||
processOdometerFrame,
|
||||
isPrivateRecord,
|
||||
isPrivateOpen,
|
||||
getPrivateSafety,
|
||||
@@ -364,6 +365,9 @@ function createSensorPipeline(deps) {
|
||||
const decoded = parseSensorFrame(frame.data);
|
||||
record.lastSensor = { raw: frame, decoded };
|
||||
record.batteryState = computeBatteryState(record, decoded);
|
||||
if (typeof processOdometerFrame === 'function') {
|
||||
processOdometerFrame(roverId, decoded);
|
||||
}
|
||||
updateMovement(record, decoded);
|
||||
const hasDockInfo = decoded?.chargingSources != null;
|
||||
if (hasDockInfo) {
|
||||
|
||||
@@ -28,6 +28,7 @@ function registerSocketHandlers(deps) {
|
||||
setPrivateOpen,
|
||||
lockRover,
|
||||
setPrivateSafety,
|
||||
getOdometersForSocket,
|
||||
} = deps;
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
@@ -177,6 +178,14 @@ function registerSocketHandlers(deps) {
|
||||
cb({ success: true });
|
||||
}
|
||||
|
||||
function handleOdometerSubscribe(_, cb = () => {}) {
|
||||
try {
|
||||
cb({ success: true, odometers: getOdometersForSocket(socket) });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
socket.on('requestControl', handleRequestControl);
|
||||
socket.on('session:requestControl', handleRequestControl);
|
||||
socket.on('releaseControl', handleReleaseControl);
|
||||
@@ -187,6 +196,7 @@ function registerSocketHandlers(deps) {
|
||||
socket.on('session:privateSafety:set', handlePrivateSafetySet);
|
||||
socket.on('subscribeAll', handleSubscribeAll);
|
||||
socket.on('session:subscribeAll', handleSubscribeAll);
|
||||
socket.on('odometer:subscribe', handleOdometerSubscribe);
|
||||
|
||||
socket.on('disconnecting', () => {
|
||||
logger.info('Socket disconnecting', socket.id);
|
||||
|
||||
@@ -94,6 +94,7 @@ function buildSession(socket) {
|
||||
mode: getMode(),
|
||||
isLocalNetwork: isLocalNetwork(getSocketIp(socket)),
|
||||
roster,
|
||||
odometers: roverManager.getOdometersForSocket(socket),
|
||||
assignment: {
|
||||
...assignment,
|
||||
roverId: assignmentRoverId,
|
||||
|
||||
@@ -1,34 +1,22 @@
|
||||
5. barcode wiki links
|
||||
6. implement multitabbing prevention using the identity system
|
||||
7. overseer improvements
|
||||
1. make it able to see more stuff
|
||||
1. mark a stat as CHANGED FROM (oldstata) if it changed since the last cycle
|
||||
2. when rovers are docked / undocked
|
||||
3. whos driving rovers
|
||||
4. rover battery levels / low
|
||||
5. the button box rewards and counts
|
||||
6. barcode whatever stuff
|
||||
7. odometers
|
||||
8. basically every stat possible
|
||||
2. make the overseer panel better
|
||||
1. show status of generation if possible, tokens or percentage. possibly stream from ollama unless i cant with tools
|
||||
8. roomba odometer
|
||||
7. roomba odometer
|
||||
1. in activities tab
|
||||
2. use wheel encoders
|
||||
3. run averages, like 2 meters and how many encoder counts, 20 times
|
||||
4. have global odometers for each rover, tagged based on name
|
||||
9. make google tts the default everywhere but roverd
|
||||
10. fix rover request spam queue cheat
|
||||
11. fix up ALL discord admin commands
|
||||
8. make google tts the default everywhere but roverd
|
||||
9. fix rover request spam queue cheat
|
||||
10. fix up ALL discord admin commands
|
||||
1. make sure all permissions are correct
|
||||
2. fuzzy search all the things
|
||||
3. dont break on multi word nicknames
|
||||
4. make all rs commands work form both the site chat and discord
|
||||
1. make sure all the permissions are correct
|
||||
12. make alert feed.jsx show more alerts at once
|
||||
13. unify typing row and chat row, should be simple
|
||||
14. fix google TTS speeds
|
||||
15. fix this:
|
||||
11. make alert feed.jsx show more alerts at once
|
||||
12. unify typing row and chat row, should be simple
|
||||
13. fix google TTS speeds
|
||||
14. fix this:
|
||||
`Jun 18 15:14:18 roombaserver.local node[216731]: /home/daniel/MultiRoombaRover/server/src/services/roverManager/socketHandlers.js:92
|
||||
Jun 18 15:14:18 roombaserver.local node[216731]: cb({ error: err.message });
|
||||
Jun 18 15:14:18 roombaserver.local node[216731]: ^
|
||||
|
||||
@@ -42,6 +42,7 @@ import { useSessionSelector } from './context/SessionContext.jsx';
|
||||
import { useTelemetryVisualPolicy } from './context/TelemetryContext.jsx';
|
||||
import ButtonBoxPanel from './components/ButtonBoxPanel/index.jsx';
|
||||
import BarcodeGamesPanel from './components/BarcodeGamesPanel/index.jsx';
|
||||
import OdometerPanel from './components/OdometerPanel/index.jsx';
|
||||
import RewardRunOverlay from './components/RewardRunOverlay/index.jsx';
|
||||
import SocketConnectionPill from './components/SocketConnectionPill/index.jsx';
|
||||
import { pageBackgroundClass, themeGapClass, themeStackClass } from './themeFlags.js';
|
||||
@@ -192,6 +193,7 @@ function MobileFeatureTabs({
|
||||
{/* activities tab */}
|
||||
<TabPanel id="activities">
|
||||
<div className={`flex flex-col ${themeGapClass}`}>
|
||||
<OdometerPanel />
|
||||
<BarcodeGamesPanel />
|
||||
<ButtonBoxPanel />
|
||||
<KinectPanel />
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
// Odometer Panel
|
||||
// Purpose: Renders persistent rover distance totals derived from Roomba wheel encoders.
|
||||
// Scope: Keeps odometer display and socket subscription logic isolated from the raw sensor panel.
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useSocket } from '../../context/SocketContext.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
|
||||
function mapByRoverId(entries = []) {
|
||||
const next = {};
|
||||
entries.forEach((entry) => {
|
||||
if (!entry?.roverId) return;
|
||||
next[String(entry.roverId)] = entry;
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
function mergeOdometerMap(previous, entries = []) {
|
||||
const next = { ...previous };
|
||||
entries.forEach((entry) => {
|
||||
if (!entry?.roverId) return;
|
||||
next[String(entry.roverId)] = entry;
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
function formatDistance(mm) {
|
||||
const value = Number(mm);
|
||||
if (!Number.isFinite(value)) return '--';
|
||||
if (value >= 1000 * 1000) return `${(value / (1000 * 1000)).toFixed(2)} km`;
|
||||
if (value >= 1000) return `${(value / 1000).toFixed(1)} m`;
|
||||
return `${Math.round(value)} mm`;
|
||||
}
|
||||
|
||||
function formatCount(value) {
|
||||
return Number.isFinite(Number(value)) ? String(Math.round(Number(value))) : '--';
|
||||
}
|
||||
|
||||
function formatSignedDistance(mm) {
|
||||
const value = Number(mm);
|
||||
if (!Number.isFinite(value)) return '--';
|
||||
const sign = value > 0 ? '+' : '';
|
||||
return `${sign}${value < 0 ? '-' : ''}${formatDistance(Math.abs(value))}`;
|
||||
}
|
||||
|
||||
function formatPercent(value) {
|
||||
return Number.isFinite(Number(value)) ? `${Number(value).toFixed(1)}%` : '--';
|
||||
}
|
||||
|
||||
function formatAge(timestamp, now) {
|
||||
const value = Number(timestamp);
|
||||
if (!Number.isFinite(value)) return '--';
|
||||
const ageMs = Math.max(0, now - value);
|
||||
if (ageMs < 1500) return 'now';
|
||||
if (ageMs < 60 * 1000) return `${Math.round(ageMs / 1000)}s ago`;
|
||||
return `${Math.round(ageMs / (60 * 1000))}m ago`;
|
||||
}
|
||||
|
||||
function roverLabel(rover) {
|
||||
return rover?.name || rover?.id || 'Unknown rover';
|
||||
}
|
||||
|
||||
export default function OdometerPanel() {
|
||||
const socket = useSocket();
|
||||
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||
const roster = useSessionSelector((state) => state.session?.roster ?? []);
|
||||
const sessionOdometers = useSessionSelector((state) => state.session?.odometers ?? []);
|
||||
const sessionOdometerMap = useMemo(() => mapByRoverId(sessionOdometers), [sessionOdometers]);
|
||||
const [liveOdometerMap, setLiveOdometerMap] = useState({});
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
function handleUpdate({ roverId, odometer } = {}) {
|
||||
if (!roverId || !odometer) return;
|
||||
setLiveOdometerMap((previous) => ({
|
||||
...previous,
|
||||
[String(roverId)]: odometer,
|
||||
}));
|
||||
}
|
||||
|
||||
socket.on('odometer:update', handleUpdate);
|
||||
socket.emit('odometer:subscribe', {}, (response = {}) => {
|
||||
if (Array.isArray(response.odometers)) {
|
||||
setLiveOdometerMap((previous) => mergeOdometerMap(previous, response.odometers));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
socket.off('odometer:update', handleUpdate);
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
setNow(Date.now());
|
||||
}, 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const odometerMap = useMemo(
|
||||
() => ({
|
||||
...sessionOdometerMap,
|
||||
...liveOdometerMap,
|
||||
}),
|
||||
[liveOdometerMap, sessionOdometerMap],
|
||||
);
|
||||
|
||||
const visibleRows = useMemo(
|
||||
() =>
|
||||
roster.map((rover) => ({
|
||||
rover,
|
||||
odometer: odometerMap[String(rover.id)] || null,
|
||||
})),
|
||||
[odometerMap, roster],
|
||||
);
|
||||
|
||||
const primaryRoverId = assignedRoverId || visibleRows[0]?.rover?.id || null;
|
||||
const primaryRover = visibleRows.find((entry) => String(entry.rover?.id) === String(primaryRoverId))?.rover || null;
|
||||
const primary = primaryRoverId ? odometerMap[String(primaryRoverId)] || null : null;
|
||||
|
||||
return (
|
||||
<CardFrame title="Rover odometer" clipOverflow={false} bodyClassName="space-y-0.5 text-base text-slate-100">
|
||||
{!primaryRoverId ? (
|
||||
<p className="text-sm text-slate-500">No rover odometer data yet.</p>
|
||||
) : (
|
||||
<>
|
||||
<OdometerSummary odometer={primary} rover={primaryRover} now={now} />
|
||||
<div className="grid gap-0.5 md:grid-cols-2 xl:grid-cols-3">
|
||||
<EncoderDetails odometer={primary} />
|
||||
<DistancePacketComparison odometer={primary} />
|
||||
<CalibrationDetails odometer={primary} now={now} />
|
||||
</div>
|
||||
<RoverOdometerList rows={visibleRows} primaryRoverId={primaryRoverId} />
|
||||
</>
|
||||
)}
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function OdometerSummary({ odometer, rover, now }) {
|
||||
const status = odometer?.status || 'waiting';
|
||||
const statusReason = odometer?.statusReason || 'waiting for encoder sample';
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-sm font-semibold text-slate-200">{roverLabel(rover)}</div>
|
||||
<div className="grid grid-cols-2 gap-0.5 md:grid-cols-3">
|
||||
<Metric label="Total distance" value={formatDistance(odometer?.totalMm)} />
|
||||
<Metric label="Session distance" value={formatDistance(odometer?.sessionMm)} />
|
||||
<Metric label="Last update" value={formatAge(odometer?.updatedAt, now)} />
|
||||
<Metric label="Status" value={status} />
|
||||
<Metric label="Reason" value={statusReason} />
|
||||
<Metric label="Calibration" value={`${Number(odometer?.calibrationMultiplier || 1).toFixed(3)}x`} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value }) {
|
||||
return (
|
||||
<div className="surface flex items-center justify-between gap-0.5 px-1 py-0.5 text-sm">
|
||||
<span className="text-slate-400">{label}</span>
|
||||
<span className="min-w-0 truncate text-right text-slate-100">{value ?? '--'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EncoderDetails({ odometer }) {
|
||||
const delta = odometer?.lastDelta || null;
|
||||
return (
|
||||
<DetailCard title="Wheel encoders">
|
||||
<ValueRow label="Left count" value={formatCount(odometer?.lastLeftCount)} />
|
||||
<ValueRow label="Right count" value={formatCount(odometer?.lastRightCount)} />
|
||||
<ValueRow label="Left delta" value={formatCount(delta?.leftCounts)} />
|
||||
<ValueRow label="Right delta" value={formatCount(delta?.rightCounts)} />
|
||||
<ValueRow label="Center delta" value={formatDistance(delta?.distanceMm)} />
|
||||
<ValueRow label="Rollover events" value={formatCount(odometer?.rolloverEvents)} />
|
||||
<ValueRow label="Ignored samples" value={formatCount(odometer?.ignoredSamples)} />
|
||||
</DetailCard>
|
||||
);
|
||||
}
|
||||
|
||||
function DistancePacketComparison({ odometer }) {
|
||||
const comparison = odometer?.comparison || null;
|
||||
const last = comparison?.last || null;
|
||||
return (
|
||||
<DetailCard title="Distance packet comparison">
|
||||
<ValueRow label="Last encoder" value={formatSignedDistance(last?.encoderCenterMm)} />
|
||||
<ValueRow label="Last packet" value={formatSignedDistance(last?.distancePacketMm)} />
|
||||
<ValueRow label="Last signed diff" value={`${formatSignedDistance(last?.signedDifferenceMm)} · ${formatPercent(last?.signedDifferencePct)}`} />
|
||||
<ValueRow label="Session encoder" value={formatDistance(comparison?.encoderSessionMm)} />
|
||||
<ValueRow label="Session packet" value={formatDistance(comparison?.distancePacketSessionMm)} />
|
||||
<ValueRow label="Session diff" value={`${formatSignedDistance(comparison?.sessionDifferenceMm)} · ${formatPercent(comparison?.sessionDifferencePct)}`} />
|
||||
<ValueRow label="Samples" value={`${formatCount(comparison?.sampleCount)} compared · ${formatCount(comparison?.missingDistanceSamples)} missing`} />
|
||||
</DetailCard>
|
||||
);
|
||||
}
|
||||
|
||||
function CalibrationDetails({ odometer, now }) {
|
||||
return (
|
||||
<DetailCard title="Conversion">
|
||||
<ValueRow label="Source" value="wheel encoders" />
|
||||
<ValueRow label="Wheel diameter" value={`${formatCount(odometer?.wheelDiameterMm)} mm`} />
|
||||
<ValueRow label="Counts per rev" value={formatCount(odometer?.countsPerRevolution)} />
|
||||
<ValueRow label="Base mm per count" value={Number(odometer?.rawMmPerCount || 0).toFixed(4)} />
|
||||
<ValueRow label="Active mm per count" value={Number(odometer?.mmPerCount || 0).toFixed(4)} />
|
||||
<ValueRow label="Last sample" value={formatAge(odometer?.lastSampleAt, now)} />
|
||||
</DetailCard>
|
||||
);
|
||||
}
|
||||
|
||||
function RoverOdometerList({ rows, primaryRoverId }) {
|
||||
if (!rows.length) return null;
|
||||
return (
|
||||
<DetailCard title="All rovers">
|
||||
{rows.map(({ rover, odometer }) => {
|
||||
const active = String(rover?.id) === String(primaryRoverId);
|
||||
return (
|
||||
<div
|
||||
key={rover?.id}
|
||||
className={`flex items-center justify-between gap-1 rounded px-1 py-0.5 ${
|
||||
active ? 'bg-slate-700/70 text-slate-50' : 'text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<span className="min-w-0 truncate">{roverLabel(rover)}</span>
|
||||
<span className="shrink-0 text-right text-slate-100">{formatDistance(odometer?.totalMm)}</span>
|
||||
<span className="shrink-0 text-right text-slate-400">{odometer?.status || 'waiting'}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</DetailCard>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailCard({ title, children }) {
|
||||
return (
|
||||
<div className="surface space-y-0.5 p-1 text-sm">
|
||||
<div className="text-[0.78rem] font-semibold leading-none text-slate-200">{title}</div>
|
||||
<div className="space-y-0.5">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ValueRow({ label, value }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-0.5">
|
||||
<span className="text-slate-300">{label}</span>
|
||||
<span className="min-w-0 truncate text-right text-slate-100">{value ?? '--'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import ButtonBoxPanel from '../ButtonBoxPanel/index.jsx';
|
||||
import BarcodeGamesPanel from '../BarcodeGamesPanel/index.jsx';
|
||||
import OdometerPanel from '../OdometerPanel/index.jsx';
|
||||
import OverseerPreferencePanel from '../OverseerPreferencePanel/index.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
@@ -365,6 +366,7 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
|
||||
{/* activities tab */}
|
||||
<TabPanel id="activities">
|
||||
<div className={`flex flex-col ${themeGapClass}`}>
|
||||
<OdometerPanel />
|
||||
<BarcodeGamesPanel />
|
||||
<ButtonBoxPanel />
|
||||
<KinectPanel />
|
||||
|
||||
Reference in New Issue
Block a user