mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
wheel speed sensors and wheel layer rework
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
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user