diff --git a/dist/roverd b/dist/roverd
index 850b7117..e888869c 100755
Binary files a/dist/roverd and b/dist/roverd differ
diff --git a/dist/servoverifier b/dist/servoverifier
index ef6e8c8f..73f97a24 100755
Binary files a/dist/servoverifier and b/dist/servoverifier differ
diff --git a/pi/roverd/sensor_stream.go b/pi/roverd/sensor_stream.go
index 9345a95c..037ab4db 100644
--- a/pi/roverd/sensor_stream.go
+++ b/pi/roverd/sensor_stream.go
@@ -14,7 +14,7 @@ import (
const (
sensorHeader = 19
sensorReadTimeout = 150 * time.Millisecond
- sensorThrottleMinimum = 250 * time.Millisecond
+ sensorThrottleMinimum = 50 * time.Millisecond
)
type SensorStreamer struct {
diff --git a/webui/src/components/TelemetryPanel.jsx b/webui/src/components/TelemetryPanel.jsx
index 5f0c57ff..17d6a865 100644
--- a/webui/src/components/TelemetryPanel.jsx
+++ b/webui/src/components/TelemetryPanel.jsx
@@ -15,6 +15,7 @@ export default function TelemetryPanel() {
const sensors = frame?.sensors || {};
const voltage = sensors.voltageMv != null ? `${(sensors.voltageMv / 1000).toFixed(2)} V` : null;
const current = sensors.currentMa != null ? `${sensors.currentMa} mA` : null;
+ const batteryTemp = sensors.batteryTemperatureC != null ? `${sensors.batteryTemperatureC} °C` : null;
const charge = sensors.batteryChargeMah;
const capacity = sensors.batteryCapacityMah;
const updated = frame?.receivedAt ? new Date(frame.receivedAt).toLocaleTimeString() : null;
@@ -65,24 +66,22 @@ export default function TelemetryPanel() {
+
-
-
Cliff Sensors
-
-
-
-
-
-
-
+
-
-
-
-
-
+
>
)}
@@ -165,3 +164,369 @@ function MotorCurrentBar({ label, value, overcurrent }) {
);
}
+
+function DirtMeter({ label, value }) {
+ const pct = value == null ? 0 : Math.max(0, Math.min(100, (value / 255) * 100));
+ const width = `${pct}%`;
+ return (
+
+
+
+ {label}
+ {value != null ? value : '--'}
+
+
+ );
+}
+
+function IrPill({ label, value }) {
+ return (
+
+ {label}: {value != null && value !== 0 ? value : '--'}
+
+ );
+}
+
+function SituationalMap({ sensors, variant = 'full' }) {
+ const size = variant === 'mini' ? 190 : 240;
+ const center = size / 2;
+ const innerCircle = variant === 'mini' ? 78 : 96;
+ const lightRingInner = variant === 'mini' ? 68 : 82;
+ const lightRingOuter = variant === 'mini' ? 78 : 92;
+ const cliffRingInner = variant === 'mini' ? 58 : 72;
+ const cliffRingOuter = variant === 'mini' ? 68 : 82;
+ const bumpRingInner = variant === 'mini' ? 82 : 104;
+ const bumpRingOuter = variant === 'mini' ? 90 : 114;
+ const wheelLineOffset = variant === 'mini' ? 34 : 42;
+
+ const lightSignals = [
+ sensors?.lightBumpLeftSignal,
+ sensors?.lightBumpFrontLeftSignal,
+ sensors?.lightBumpCenterLeftSignal,
+ sensors?.lightBumpCenterRightSignal,
+ sensors?.lightBumpFrontRightSignal,
+ sensors?.lightBumpRightSignal,
+ ].filter((v) => v != null);
+ const observedLightMax = lightSignals.length ? Math.max(...lightSignals) : 0;
+ const effectiveLightMax = Math.max(observedLightMax, 1200); // adaptive gain; these rarely hit 4095
+ const bumps = sensors?.bumpsAndWheelDrops || {};
+ const wheelCurrentLeft = sensors?.wheelLeftCurrentMa ?? 0;
+ const wheelCurrentRight = sensors?.wheelRightCurrentMa ?? 0;
+ const sideBrushCurrent = sensors?.sideBrushCurrentMa ?? 0;
+ const mainBrushCurrent = sensors?.mainBrushCurrentMa ?? 0;
+ const wheelOver = sensors?.wheelOvercurrents || {};
+
+ const lightSegments = [
+ { label: 'L', start: -100, end: -70, value: sensors?.lightBumpLeftSignal, active: sensors?.lightBumper?.left },
+ { label: 'FL', start: -70, end: -35, value: sensors?.lightBumpFrontLeftSignal, active: sensors?.lightBumper?.frontLeft },
+ { label: 'CL', start: -35, end: -5, value: sensors?.lightBumpCenterLeftSignal, active: sensors?.lightBumper?.centerLeft },
+ { label: 'CR', start: 5, end: 35, value: sensors?.lightBumpCenterRightSignal, active: sensors?.lightBumper?.centerRight },
+ { label: 'FR', start: 35, end: 70, value: sensors?.lightBumpFrontRightSignal, active: sensors?.lightBumper?.frontRight },
+ { label: 'R', start: 70, end: 100, value: sensors?.lightBumpRightSignal, active: sensors?.lightBumper?.right },
+ ];
+
+ const cliffSegments = [
+ { label: 'Cliff L', start: -95, end: -70, value: sensors?.cliffLeftSignal, active: sensors?.cliffLeft },
+ { label: 'Cliff FL', start: -45, end: -15, value: sensors?.cliffFrontLeftSignal, active: sensors?.cliffFrontLeft },
+ { label: 'Cliff FR', start: 15, end: 45, value: sensors?.cliffFrontRightSignal, active: sensors?.cliffFrontRight },
+ { label: 'Cliff R', start: 70, end: 95, value: sensors?.cliffRightSignal, active: sensors?.cliffRight },
+ ];
+
+ return (
+
+
Top-down
+
0–{effectiveLightMax}
+
+
+ );
+}
+
+function ArcSegment({ cx, cy, rInner, rOuter, startDeg, endDeg, color, pulse = false, opacity = 1 }) {
+ const rMid = (rInner + rOuter) / 2;
+ const strokeWidth = rOuter - rInner;
+ const path = describeArc(cx, cy, rMid, startDeg, endDeg);
+ return (
+ <>
+
+ {pulse ? (
+
+ ) : null}
+ >
+ );
+}
+
+function describeArc(cx, cy, r, startDeg, endDeg) {
+ let start = startDeg;
+ let end = endDeg;
+ if (end <= start) end += 360;
+ const startPt = polarToCartesian(cx, cy, r, start);
+ const endPt = polarToCartesian(cx, cy, r, end);
+ return ['M', startPt.x, startPt.y, 'A', r, r, 0, 0, 1, endPt.x, endPt.y].join(' ');
+}
+
+function polarToCartesian(cx, cy, r, angleDeg) {
+ const rad = toRad(angleDeg);
+ return {
+ x: cx + r * Math.cos(rad),
+ y: cy + r * Math.sin(rad),
+ };
+}
+
+function toRad(angleDeg) {
+ return ((angleDeg - 90) * Math.PI) / 180;
+}
+
+function clamp01(value) {
+ return Math.max(0, Math.min(1, value));
+}
+
+function currentColor(value, overcurrent) {
+ if (overcurrent) return '#ef4444';
+ const mag = Math.abs(value);
+ if (mag > 900) return '#f59e0b';
+ if (mag > 300) return '#22c55e';
+ return '#94a3b8';
+}
+
+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 = 30;
+ const currentW = 12;
+ const dropW = 8;
+ const gap = 4;
+ const currentFill = barH * pct;
+ const sign = label === 'L' ? -1 : 1;
+ const currentCenterX = sign * (-dropW / 2 - gap / 2);
+ const dropCenterX = sign * (currentW / 2 + gap / 2);
+ return (
+
+
+
+
+
+ {label}
+
+
+ );
+}
+
+function SideBrushVisual({ cx, cy, current, overcurrent }) {
+ const mag = Math.abs(current);
+ const color = currentColor(current, overcurrent);
+ const opacity = clamp01(mag / 1000) * 0.6 + 0.3;
+ const armLength = 26;
+ const spinDuration = mag > 10 ? Math.max(0.35, 2.2 - mag / 500) : null;
+ return (
+
+
+ {[0, 120, 240].map((deg) => {
+ const rad = toRad(deg);
+ const x2 = cx + armLength * Math.cos(rad);
+ const y2 = cy + armLength * Math.sin(rad);
+ return ;
+ })}
+ {overcurrent ? : null}
+
+ );
+}
+
+function MainBrushVisual({ cx, cy, current, overcurrent, variant }) {
+ const mag = Math.abs(current);
+ const color = currentColor(current, overcurrent);
+ const opacity = clamp01(mag / 1000) * 0.6 + 0.3;
+ const rollerWidth = 44;
+ const rollerHeight = 8;
+ const patternA = `main-brush-pattern-a-${variant}`;
+ const patternB = `main-brush-pattern-b-${variant}`;
+ const dur = mag > 10 ? Math.max(0.35, 2.4 - mag / 500) : null;
+ const dir = current >= 0 ? 1 : -1;
+ return (
+
+
+ {[patternA, patternB].map((id, idx) => (
+
+
+
+ {dur ? (
+
+ ) : null}
+
+ ))}
+
+
+
+ {overcurrent ? (
+
+ ) : null}
+
+ );
+}