wheel speed sensors and wheel layer rework

This commit is contained in:
legop3
2026-07-07 12:21:08 -04:00
parent 2d75935e65
commit 656bf90e7f
9 changed files with 233 additions and 45 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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-CT_oLzKM.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-AEvF9veu.css">
<script type="module" crossorigin src="/assets/index-Bs95YMTt.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BfZ_TWsb.css">
</head>
<body>
<div id="root"></div>
@@ -65,6 +65,11 @@ function ensureLoaded() {
lastSampleAt: null,
lastIntegratedAt: null,
lastDelta: null,
wheelSpeedsMmPerSecond: {
left: null,
right: null,
center: null,
},
rolloverEvents: 0,
ignoredSamples: 0,
status: 'waiting',
@@ -96,6 +101,11 @@ function ensureState(roverId) {
lastSampleAt: null,
lastIntegratedAt: null,
lastDelta: null,
wheelSpeedsMmPerSecond: {
left: null,
right: null,
center: null,
},
rolloverEvents: 0,
ignoredSamples: 0,
status: 'waiting',
@@ -187,6 +197,11 @@ function snapshotState(state) {
lastSampleAt: state.lastSampleAt,
lastIntegratedAt: state.lastIntegratedAt,
lastDelta: state.lastDelta,
wheelSpeedsMmPerSecond: state.wheelSpeedsMmPerSecond || {
left: null,
right: null,
center: null,
},
rolloverEvents: state.rolloverEvents,
ignoredSamples: state.ignoredSamples,
status: state.status,
@@ -228,6 +243,16 @@ function processSensorFrame(roverId, sensors = {}) {
if (!Number.isInteger(left) || !Number.isInteger(right)) {
state.status = 'waiting';
state.statusReason = 'encoder counts missing';
/*
Speed is derived from consecutive encoder samples. When either encoder is
absent, keeping stale speed values would make the socket payload look like
a live sensor even though the required source data is missing.
*/
state.wheelSpeedsMmPerSecond = {
left: null,
right: null,
center: null,
};
return snapshotState(state);
}
@@ -241,6 +266,16 @@ function processSensorFrame(roverId, sensors = {}) {
state.lastSampleAt = sampleAt;
state.status = 'tracking';
state.statusReason = 'baseline ready';
/*
The baseline frame has valid encoder positions, but no previous sample to
compare against. Reporting zero here is intentional: it gives clients a
stable wheel-speed sensor shape immediately without inventing movement.
*/
state.wheelSpeedsMmPerSecond = {
left: 0,
right: 0,
center: 0,
};
state.updatedAt = sampleAt;
const snapshot = snapshotState(state);
emitSnapshot(state, snapshot, { force: true });
@@ -258,6 +293,30 @@ function processSensorFrame(roverId, sensors = {}) {
const reasonableLimit = maxReasonableDeltaMm(elapsedMs);
const leftRolled = crossedRollover(state.lastLeftCount, left, leftCounts);
const rightRolled = crossedRollover(state.lastRightCount, right, rightCounts);
const elapsedSeconds = elapsedMs > 0 ? elapsedMs / 1000 : null;
/*
The Roomba Open Interface encoder packets are cumulative wheel counts. The
odometer already converts each signed count delta into millimeters using the
configured Create wheel diameter/counts-per-revolution constants, so wheel
speed is the same per-wheel millimeter delta divided by the wall-clock time
between accepted sensor frames.
*/
const rawWheelSpeedsMmPerSecond = elapsedSeconds
? {
left: leftMm / elapsedSeconds,
right: rightMm / elapsedSeconds,
center: centerMm / elapsedSeconds,
}
: {
left: 0,
right: 0,
center: 0,
};
const wheelSpeedsMmPerSecond = {
left: Math.round(rawWheelSpeedsMmPerSecond.left),
right: Math.round(rawWheelSpeedsMmPerSecond.right),
center: Math.round(rawWheelSpeedsMmPerSecond.center),
};
state.lastLeftCount = left;
state.lastRightCount = right;
@@ -272,6 +331,16 @@ function processSensorFrame(roverId, sensors = {}) {
state.ignoredSamples += 1;
state.status = 'ignored';
state.statusReason = `ignored ${Math.round(distanceMm)} mm jump`;
/*
Rejected encoder jumps are deliberately not surfaced as speed. They are
most often reconnect/corruption edges, and showing their implied velocity
would produce a dramatic but false wheel-speed sensor spike in the UI.
*/
state.wheelSpeedsMmPerSecond = {
left: null,
right: null,
center: null,
};
state.lastDelta = {
leftCounts,
rightCounts,
@@ -280,6 +349,9 @@ function processSensorFrame(roverId, sensors = {}) {
centerMm: Math.round(centerMm),
distanceMm: 0,
elapsedMs,
leftSpeedMmPerSecond: null,
rightSpeedMmPerSecond: null,
centerSpeedMmPerSecond: null,
ignored: true,
};
state.updatedAt = sampleAt;
@@ -293,6 +365,7 @@ function processSensorFrame(roverId, sensors = {}) {
state.lastIntegratedAt = sampleAt;
state.status = 'tracking';
state.statusReason = distanceMm > 0 ? 'integrated encoder delta' : 'no movement';
state.wheelSpeedsMmPerSecond = wheelSpeedsMmPerSecond;
state.lastDelta = {
leftCounts,
rightCounts,
@@ -301,6 +374,9 @@ function processSensorFrame(roverId, sensors = {}) {
centerMm: Math.round(centerMm),
distanceMm: Math.round(distanceMm),
elapsedMs,
leftSpeedMmPerSecond: wheelSpeedsMmPerSecond.left,
rightSpeedMmPerSecond: wheelSpeedsMmPerSecond.right,
centerSpeedMmPerSecond: wheelSpeedsMmPerSecond.center,
ignored: false,
};
state.updatedAt = sampleAt;
@@ -529,11 +529,27 @@ function createSensorPipeline(deps) {
const record = rovers.get(roverId);
if (!record) return;
record.lastSeen = Date.now();
const decoded = parseSensorFrame(frame.data);
let decoded = parseSensorFrame(frame.data);
record.lastSensor = { raw: frame, decoded };
record.batteryState = computeBatteryState(record, decoded);
let odometer = null;
if (typeof processOdometerFrame === 'function') {
processOdometerFrame(roverId, decoded);
odometer = processOdometerFrame(roverId, decoded);
}
if (decoded && odometer?.wheelSpeedsMmPerSecond) {
/*
Wheel speed is not a native packet in the Roomba sensor stream; it is a
synthesized sensor produced from the same encoder deltas that drive the
persistent odometer. Merging it into the decoded sensor object keeps the
public socket contract simple: clients still read one JSON `sensors`
payload per frame, with calculated values clearly grouped under their
own wheel-speed key.
*/
decoded = {
...decoded,
wheelSpeedsMmPerSecond: odometer.wheelSpeedsMmPerSecond,
};
record.lastSensor = { raw: frame, decoded };
}
updateMovement(record, decoded);
const hasDockInfo = decoded?.chargingSources != null;
@@ -10,6 +10,8 @@ const EMPTY_WHEEL_TELEMETRY = Object.freeze({
rightWheelOvercurrent: false,
wheelLeftCurrentMa: 0,
wheelRightCurrentMa: 0,
wheelLeftSpeedMmPerSecond: null,
wheelRightSpeedMmPerSecond: null,
});
function selectWheelTelemetry(frame) {
@@ -17,6 +19,7 @@ function selectWheelTelemetry(frame) {
if (!sensors) return EMPTY_WHEEL_TELEMETRY;
const bumps = sensors.bumpsAndWheelDrops || {};
const wheelOver = sensors.wheelOvercurrents || {};
const wheelSpeeds = sensors.wheelSpeedsMmPerSecond || {};
return {
wheelDropLeft: Boolean(bumps.wheelDropLeft),
wheelDropRight: Boolean(bumps.wheelDropRight),
@@ -24,6 +27,14 @@ function selectWheelTelemetry(frame) {
rightWheelOvercurrent: Boolean(wheelOver.rightWheel),
wheelLeftCurrentMa: rawNumber(sensors.wheelLeftCurrentMa, 0),
wheelRightCurrentMa: rawNumber(sensors.wheelRightCurrentMa, 0),
/*
Speed is synthesized on the server from encoder deltas, so it can be null
on the first frame or after an ignored encoder jump. Keeping null distinct
from 0 lets the wheel visual show "no valid speed sample" without making
an unknown state look like a stopped rover.
*/
wheelLeftSpeedMmPerSecond: rawNumber(wheelSpeeds.left, null),
wheelRightSpeedMmPerSecond: rawNumber(wheelSpeeds.right, null),
};
}
@@ -36,6 +47,7 @@ function WheelLayer({ roverId, sensors, geometry }) {
cx={geometry.centerX - geometry.wheelLineOffset}
cy={geometry.centerY}
current={telemetry.wheelLeftCurrentMa}
speed={telemetry.wheelLeftSpeedMmPerSecond}
drop={telemetry.wheelDropLeft}
overcurrent={telemetry.leftWheelOvercurrent}
label="L"
@@ -44,6 +56,7 @@ function WheelLayer({ roverId, sensors, geometry }) {
cx={geometry.centerX + geometry.wheelLineOffset}
cy={geometry.centerY}
current={telemetry.wheelRightCurrentMa}
speed={telemetry.wheelRightSpeedMmPerSecond}
drop={telemetry.wheelDropRight}
overcurrent={telemetry.rightWheelOvercurrent}
label="R"
+112 -29
View File
@@ -88,40 +88,123 @@ export const ConeSegment = React.memo(function ConeSegment({ cx, cy, rBase, rTip
return <path d={fg} fill={color} opacity={1} stroke="none" />;
});
export const WheelVisual = React.memo(function WheelVisual({ cx, cy, current, drop, overcurrent, label }) {
const mag = Math.abs(current);
const pct = clamp01(mag / 1200);
const color = currentColor(current, overcurrent);
const barH = 56;
const currentW = 14;
const dropW = 9;
export const WheelVisual = React.memo(function WheelVisual({ cx, cy, current, speed, drop, overcurrent, label }) {
const currentMagnitude = Math.abs(current);
const currentPercent = clamp01(currentMagnitude / 1200);
const currentFillColor = currentColor(current, overcurrent);
const hasSpeed = Number.isFinite(Number(speed));
const speedValue = hasSpeed ? Number(speed) : 0;
const speedMagnitude = Math.abs(speedValue);
const speedPercent = clamp01(speedMagnitude / 500);
const barH = 52;
const barW = 8;
const gap = 3;
const currentFill = barH * pct;
const sign = label === 'L' ? -1 : 1;
const currentCenterX = sign * (-dropW / 2 - gap / 2);
const dropCenterX = sign * (currentW / 2 + gap / 2);
const groupWidth = 24;
const groupHeight = 58;
const barTop = -barH / 2;
const barBottom = barH / 2;
const outsideSign = label === 'L' ? -1 : 1;
const insideSign = -outsideSign;
/*
The wheel glyphs mirror each other around the robot body. Current belongs
on the inside edge because it is a motor/load signal tied to the chassis,
while speed belongs on the outside edge where wheel motion is easiest to
read at a glance.
*/
const speedCenterX = outsideSign * (barW / 2 + gap / 2);
const currentCenterX = insideSign * (barW / 2 + gap / 2);
const currentFill = barH * currentPercent;
const speedFill = (barH / 2) * speedPercent;
const speedIsForward = speedValue >= 0;
const speedFillY = speedIsForward ? -speedFill : 0;
const speedColor = hasSpeed ? (speedIsForward ? '#38bdf8' : '#f59e0b') : '#475569';
const dropLabelRotation = label === 'L' ? -90 : 90;
const groupWidth = currentW + dropW + gap + 2;
const groupHeight = barH + 4;
return (
<g transform={`translate(${cx},${cy})`}>
<rect x={-groupWidth / 2} y={-groupHeight / 2} width={groupWidth} height={groupHeight} rx="4" fill="none" stroke="#64748b" strokeWidth="1" />
<rect x={currentCenterX - currentW / 2} y={-barH / 2} width={currentW} height={barH} fill="#0f172a" stroke="#0f172a" strokeWidth="1" rx="2" />
<rect x={currentCenterX - currentW / 2} y={barH / 2 - currentFill} width={currentW} height={currentFill} fill={color} className={overcurrent ? 'animate-pulse' : ''} />
<rect x={dropCenterX - dropW / 2} y={-barH / 2} width={dropW} height={barH} fill={drop ? '#ef4444' : '#475569'} className={drop ? 'animate-pulse' : ''} rx="2" />
{drop ? (
<text
x={dropCenterX}
y={0}
textAnchor="middle"
dominantBaseline="central"
transform={`rotate(${dropLabelRotation} ${dropCenterX} 0)`}
className="pointer-events-none fill-white text-[0.48rem] font-bold"
>
Dropped
</text>
<rect
x={-groupWidth / 2}
y={-groupHeight / 2}
width={groupWidth}
height={groupHeight}
rx="4"
fill="none"
stroke={overcurrent ? '#ef4444' : '#64748b'}
strokeWidth={overcurrent ? '2' : '1'}
className={overcurrent ? 'animate-pulse' : ''}
/>
{/*
Keep the speed bar in the same compact visual language as the original
wheel current bar. The only extra cue is the zero line: encoder-derived
forward speed fills above it, while reverse speed fills below it.
*/}
<rect x={speedCenterX - barW / 2} y={barTop} width={barW} height={barH} fill="#0f172a" stroke="#1e293b" strokeWidth="1" rx="2" />
<line
x1={speedCenterX - barW / 2}
y1="0"
x2={speedCenterX + barW / 2}
y2="0"
stroke="#64748b"
strokeWidth="1"
/>
{hasSpeed && speedFill > 0 ? (
<rect
x={speedCenterX - barW / 2}
y={speedFillY}
width={barW}
height={speedFill}
fill={speedColor}
rx="1.5"
/>
) : null}
<text x={0} y={barH / 2 + 10} textAnchor="middle" className="fill-slate-200 text-[0.7rem]">{label}</text>
{/*
Current stays as the familiar bottom-up load meter. Keeping both bars
narrow avoids turning this layer into a dashboard and preserves the
original top-down sensor-map density.
*/}
<rect x={currentCenterX - barW / 2} y={barTop} width={barW} height={barH} fill="#0f172a" stroke="#1e293b" strokeWidth="1" rx="2" />
<rect
x={currentCenterX - barW / 2}
y={barBottom - currentFill}
width={barW}
height={currentFill}
fill={currentFillColor}
className={overcurrent ? 'animate-pulse' : ''}
rx="1.5"
/>
{drop ? (
<>
{/*
The dropped state covers the existing compact wheel visual instead
of adding another status column. This satisfies the "whole wheel is
dropped" meaning without increasing the layer footprint.
*/}
<rect
x={-groupWidth / 2}
y={-groupHeight / 2}
width={groupWidth}
height={groupHeight}
rx="4"
fill="#ef4444"
opacity="0.36"
className="animate-pulse"
/>
<text
x="0"
y="2"
textAnchor="middle"
dominantBaseline="central"
transform={`rotate(${dropLabelRotation} 0 2)`}
className="pointer-events-none fill-white text-[0.52rem] font-bold"
>
Dropped
</text>
</>
) : null}
<text x={0} y={barBottom + 10} textAnchor="middle" className="fill-slate-200 text-[0.7rem]">{label}</text>
</g>
);
});