slopfixing / issue 004

This commit is contained in:
legop3
2026-06-12 12:33:00 -04:00
parent dc193d09db
commit cc06709674
29 changed files with 855 additions and 338 deletions
@@ -1,3 +1,4 @@
# FIXED!
# Issue 002: ControlContext Broad Invalidation
## Summary
@@ -1,3 +1,4 @@
# FIXED!
# Issue 003: High-Volume Socket Log Stream
## Summary
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -12,7 +12,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
<title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-CiUVS_ut.js"></script>
<script type="module" crossorigin src="/assets/index-CcFK0l2M.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BVKa8Zph.css">
</head>
<body>
+2
View File
@@ -39,6 +39,7 @@ import GlobalObjectiveBanner from './components/GlobalObjectiveBanner/index.jsx'
import RoverQueuesPanel from './components/RoverQueuesPanel/index.jsx';
import VipPanel from './components/VipPanel/index.jsx';
import { useSessionSelector } from './context/SessionContext.jsx';
import { useTelemetryVisualPolicy } from './context/TelemetryContext.jsx';
import ButtonBoxPanel from './components/ButtonBoxPanel/index.jsx';
import RewardRunOverlay from './components/RewardRunOverlay/index.jsx';
import SocketConnectionPill from './components/SocketConnectionPill/index.jsx';
@@ -262,6 +263,7 @@ function App() {
function AppWithProviders({ layout, isDesktop, fullscreen }) {
useDefaultNickname();
useUserIdentitySync();
useTelemetryVisualPolicy({ mobile: !isDesktop });
const {
visible: fullscreenVisible,
mode: fullscreenMode,
@@ -0,0 +1,24 @@
// Drive Dock State
// Purpose: Selects only dock-related telemetry fields needed by drive/dock controls.
// Scope: Keeps the action component focused on rendering while this hook owns telemetry subscription details.
import { useMemo } from 'react';
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
export function deriveDriveDockStateFromTelemetry(dockTelemetry) {
const oiLabel = dockTelemetry?.oiModeLabel || 'Unknown';
const oiNormalized = oiLabel.toLowerCase();
const chargingLabel = dockTelemetry?.chargingStateLabel || '';
const docked = Boolean(dockTelemetry?.homeBase);
const charging = docked && chargingLabel.toLowerCase() !== 'not charging' && chargingLabel !== '';
const driving = oiNormalized === 'full';
const dockedNotCharging = docked && !charging;
const dockingInProgress = !docked && !charging && oiNormalized === 'passive';
return { driving, docked, charging, dockedNotCharging, dockingInProgress, oiLabel, chargingLabel };
}
export function useDriveDockState(roverId) {
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
return useMemo(() => deriveDriveDockStateFromTelemetry(dockTelemetry), [dockTelemetry]);
}
+7 -23
View File
@@ -1,31 +1,15 @@
// Drive Dock Action
// Purpose: Defines the Drive Dock Action 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, useState } from 'react';
import { useState } from 'react';
import { useControlActions, useControlSelector } from '../../controls/index.js';
import { useTelemetryFrame } from '../../context/TelemetryContext.jsx';
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
import { formatKeyLabel } from '../../controls/keymapUtils.js';
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
import { deriveDriveDockStateFromTelemetry } from './driveDockState.js';
export function deriveDriveDockState(frame) {
const sensors = frame?.sensors || {};
const oiLabel = sensors.oiMode?.label || 'Unknown';
const oiNormalized = oiLabel.toLowerCase();
const chargingLabel = sensors.chargingState?.label || '';
const docked = Boolean(sensors.chargingSources?.homeBase);
const charging = docked && chargingLabel.toLowerCase() !== 'not charging' && chargingLabel !== '';
const driving = oiNormalized === 'full';
const dockedNotCharging = docked && !charging;
const dockingInProgress = !docked && !charging && oiNormalized === 'passive';
return { driving, docked, charging, dockedNotCharging, dockingInProgress, oiLabel, chargingLabel };
}
export function useDriveDockState(roverId) {
const frame = useTelemetryFrame(roverId);
return useMemo(() => deriveDriveDockState(frame), [frame]);
}
function StatusRow({ label, value, tone = 'neutral' }) {
function StatusRow({ value, tone = 'neutral' }) {
const toneClasses =
tone === 'good'
? 'border-emerald-200 bg-emerald-600 text-white'
@@ -107,8 +91,8 @@ export default function DriveDockAction({
const keymap = useControlSelector((control) => control.state.keymap);
const actions = useControlActions();
const dockAssist = useManualDockAssist();
const frame = useTelemetryFrame(roverId);
const state = driveDockState ?? deriveDriveDockState(frame);
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
const state = driveDockState ?? deriveDriveDockStateFromTelemetry(dockTelemetry);
const { driving, docked, charging, dockingInProgress } = state;
const [pending, setPending] = useState(null);
const [confirmOpen, setConfirmOpen] = useState(false);
@@ -1,5 +1,6 @@
import { useSessionSelector } from '../../../context/SessionContext.jsx';
import { useTelemetryFrame } from '../../../context/TelemetryContext.jsx';
import { useVisualTelemetrySelector } from '../../../context/TelemetryContext.jsx';
import { batteryTelemetryEqual, selectBatteryTelemetry } from '../../../context/telemetryViews.js';
import LightBumpBars from '../LightBumpBars/index.jsx';
import { buildBatteryVisual } from '../../../lib/battery.js';
import BatteryBar from '../../BatteryBar/index.jsx';
@@ -7,8 +8,7 @@ import BatteryBar from '../../BatteryBar/index.jsx';
export default function DriverBottomStrip({ roverId = null, mobileHud = false }) {
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const effectiveRoverId = roverId ?? assignedRoverId;
const frame = useTelemetryFrame(effectiveRoverId);
const sensors = frame?.sensors ?? null;
const batteryTelemetry = useVisualTelemetrySelector(effectiveRoverId, selectBatteryTelemetry, batteryTelemetryEqual);
const batteryConfig = useSessionSelector((state) => {
if (!effectiveRoverId) return null;
const roster = state.session?.roster || [];
@@ -16,7 +16,7 @@ export default function DriverBottomStrip({ roverId = null, mobileHud = false })
return rover?.battery ?? null;
});
const batteryVisual = buildBatteryVisual({
charge: sensors?.batteryChargeMah ?? null,
charge: batteryTelemetry?.batteryChargeMah ?? null,
config: batteryConfig,
});
@@ -2,6 +2,7 @@ import TopDownMap from '../../TopDownMap/index.jsx';
export default function HudMapOverlay({
sensors,
mapTelemetry = null,
show = true,
mapPosition = 'top-center',
layoutFormat = 'desktop',
@@ -32,7 +33,7 @@ export default function HudMapOverlay({
return (
<div className="pointer-events-none absolute rounded" style={mapStyle}>
<TopDownMap sensors={sensors} size={240} overlay />
<TopDownMap sensors={sensors} mapTelemetry={mapTelemetry} size={240} overlay />
</div>
);
}
@@ -1,17 +1,20 @@
export default function SpectatorTelemetryOverlay({ sensors, mobileHud = false }) {
export default function SpectatorTelemetryOverlay({ sensors, telemetry = null, mobileHud = false }) {
const voltageMv = telemetry?.voltageMv ?? sensors?.voltageMv ?? null;
const currentMa = telemetry?.currentMa ?? sensors?.currentMa ?? null;
const batteryChargeMah = telemetry?.batteryChargeMah ?? sensors?.batteryChargeMah ?? null;
const oiLabel = telemetry?.oiModeLabel ?? sensors?.oiMode?.label ?? 'Unknown';
const docked = telemetry?.homeBase ?? Boolean(sensors?.chargingSources?.homeBase);
const chargingLabel = telemetry?.chargingStateLabel ?? sensors?.chargingState?.label ?? '';
const statusPadClass = mobileHud ? 'px-0.25 py-0.25' : 'px-1 py-0.5';
const telemetryPosClass = mobileHud ? 'left-0.5 top-1/2' : 'left-1 top-1/2';
const telemetryTextClass = mobileHud ? 'text-[0.45rem]' : 'text-[0.65rem]';
const telemetryEntries = [
['Voltage', sensors?.voltageMv != null ? `${(sensors.voltageMv / 1000).toFixed(2)} V` : '--'],
['Current', sensors?.currentMa != null ? `${sensors.currentMa} mA` : '--'],
['Charge', sensors?.batteryChargeMah != null ? `${sensors.batteryChargeMah}` : '--'],
['OI', sensors?.oiMode?.label || '--'],
['Voltage', voltageMv != null ? `${(voltageMv / 1000).toFixed(2)} V` : '--'],
['Current', currentMa != null ? `${currentMa} mA` : '--'],
['Charge', batteryChargeMah != null ? `${batteryChargeMah}` : '--'],
['Oi', oiLabel || '--'],
];
const docked = Boolean(sensors?.chargingSources?.homeBase);
const chargingLabel = sensors?.chargingState?.label || '';
const charging = Boolean(chargingLabel && chargingLabel.toLowerCase() !== 'not charging');
const oiLabel = sensors?.oiMode?.label || 'Unknown';
const oiNormalized = oiLabel.toLowerCase();
const oiTone =
oiNormalized === 'full'
@@ -38,7 +41,7 @@ export default function SpectatorTelemetryOverlay({ sensors, mobileHud = false }
<span className={`rounded px-1.5 py-0.5 ${chargingTone}`}>
{charging ? 'Charging' : docked ? 'Not charging' : 'Not charging'}
</span>
<span className={`rounded px-1.5 py-0.5 ${oiTone}`}>OI: {oiLabel}</span>
<span className={`rounded px-1.5 py-0.5 ${oiTone}`}>Oi: {oiLabel}</span>
</div>
{telemetryEntries.map(([labelText, value]) => (
<span key={labelText} className="flex items-center justify-between gap-0.5">
@@ -4,7 +4,13 @@
import React from 'react';
import { useHudMapSetting } from '../../../hooks/useHudMapSetting.js';
import { useSessionSelector } from '../../../context/SessionContext.jsx';
import { useTelemetryFrame } from '../../../context/TelemetryContext.jsx';
import { useVisualTelemetrySelector } from '../../../context/TelemetryContext.jsx';
import {
mapTelemetryEqual,
selectSpectatorTelemetry,
selectVisualMapTelemetry,
spectatorTelemetryEqual,
} from '../../../context/telemetryViews.js';
import RoverLabelOverlay from './RoverLabelOverlay.jsx';
import SpectatorTelemetryOverlay from './SpectatorTelemetryOverlay.jsx';
import HudMapOverlay from './HudMapOverlay.jsx';
@@ -24,7 +30,8 @@ function HudOverlay({
}) {
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const effectiveRoverId = roverId ?? assignedRoverId;
const frame = useTelemetryFrame(effectiveRoverId);
const mapTelemetry = useVisualTelemetrySelector(effectiveRoverId, selectVisualMapTelemetry, mapTelemetryEqual);
const spectatorTelemetry = useVisualTelemetrySelector(effectiveRoverId, selectSpectatorTelemetry, spectatorTelemetryEqual);
const rosterInfo = useSessionSelector((state) => {
if (!effectiveRoverId) return { label: null, roverColor: null };
const roster = state.session?.roster || [];
@@ -41,7 +48,8 @@ function HudOverlay({
const match = users.find((u) => String(u.socketId || '') === String(activeId || ''));
return match?.nickname || match?.name || null;
});
const resolvedSensors = sensors ?? frame?.sensors ?? null;
const resolvedMapTelemetry = sensors ? null : mapTelemetry;
const resolvedSensors = sensors ?? null;
const resolvedLabel = label ?? rosterInfo.label ?? null;
const resolvedRoverColor = roverColor ?? rosterInfo.roverColor ?? null;
const resolvedDriverLabel = driverLabel ?? derivedDriverLabel;
@@ -66,7 +74,7 @@ function HudOverlay({
return (
<>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<SpectatorTelemetryOverlay sensors={resolvedSensors} mobileHud={isMobile} />
<SpectatorTelemetryOverlay sensors={resolvedSensors} telemetry={sensors ? null : spectatorTelemetry} mobileHud={isMobile} />
<RoverLabelOverlay
variant="spectator"
label={resolvedLabel}
@@ -78,6 +86,7 @@ function HudOverlay({
</div>
<HudMapOverlay
sensors={resolvedSensors}
mapTelemetry={resolvedMapTelemetry}
show={resolvedShowTopDown}
mapPosition={resolvedMapPosition}
layoutFormat={layoutFormat}
@@ -98,6 +107,7 @@ function HudOverlay({
/>
<HudMapOverlay
sensors={resolvedSensors}
mapTelemetry={resolvedMapTelemetry}
show={resolvedShowTopDown && variant !== 'spectator'}
mapPosition={resolvedMapPosition}
layoutFormat={layoutFormat}
@@ -3,21 +3,23 @@
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import React from 'react';
import { useSessionSelector } from '../../../context/SessionContext.jsx';
import { useTelemetryFrame } from '../../../context/TelemetryContext.jsx';
import { useVisualTelemetrySelector } from '../../../context/TelemetryContext.jsx';
import { lightBumpTelemetryEqual, selectLightBumpTelemetry } from '../../../context/telemetryViews.js';
function LightBumpBars({ roverId = null, sensors }) {
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const effectiveRoverId = roverId ?? assignedRoverId;
const frame = useTelemetryFrame(effectiveRoverId);
const resolvedSensors = sensors ?? frame?.sensors ?? null;
const values = [
resolvedSensors?.lightBumpLeftSignal,
resolvedSensors?.lightBumpFrontLeftSignal,
resolvedSensors?.lightBumpCenterLeftSignal,
resolvedSensors?.lightBumpCenterRightSignal,
resolvedSensors?.lightBumpFrontRightSignal,
resolvedSensors?.lightBumpRightSignal,
];
const selectedValues = useVisualTelemetrySelector(effectiveRoverId, selectLightBumpTelemetry, lightBumpTelemetryEqual);
const values = sensors
? [
sensors?.lightBumpLeftSignal,
sensors?.lightBumpFrontLeftSignal,
sensors?.lightBumpCenterLeftSignal,
sensors?.lightBumpCenterRightSignal,
sensors?.lightBumpFrontRightSignal,
sensors?.lightBumpRightSignal,
]
: selectedValues;
const max = values.filter((v) => v != null).reduce((acc, v) => Math.max(acc, v), 1200);
const eased = (v) => Math.pow(Math.max(0, Math.min(1, (v ?? 0) / max)), 0.35);
const hueFor = (v) => {
@@ -3,30 +3,36 @@
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import React from 'react';
import { useSessionSelector } from '../../../context/SessionContext.jsx';
import { useTelemetryFrame } from '../../../context/TelemetryContext.jsx';
import { useVisualTelemetrySelector } from '../../../context/TelemetryContext.jsx';
import { batteryTelemetryEqual, selectBatteryTelemetry } from '../../../context/telemetryViews.js';
import { buildBatteryVisual } from '../../../lib/battery.js';
function LowBatteryOverlay({ roverId = null, sensors, batteryConfig, compact = false }) {
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const effectiveRoverId = roverId ?? assignedRoverId;
const frame = useTelemetryFrame(effectiveRoverId);
const batteryTelemetry = useVisualTelemetrySelector(effectiveRoverId, selectBatteryTelemetry, batteryTelemetryEqual);
const rosterBatteryConfig = useSessionSelector((state) => {
if (!effectiveRoverId) return null;
const roster = state.session?.roster || [];
const rover = roster.find((entry) => String(entry.id) === String(effectiveRoverId));
return rover?.battery ?? null;
});
const resolvedSensors = sensors ?? frame?.sensors ?? null;
const resolvedBatteryTelemetry = sensors
? {
batteryChargeMah: sensors?.batteryChargeMah ?? null,
batteryCapacityMah: sensors?.batteryCapacityMah ?? null,
}
: batteryTelemetry;
const resolvedBatteryConfig = batteryConfig ?? rosterBatteryConfig;
const battery = buildBatteryVisual({
charge: resolvedSensors?.batteryChargeMah ?? null,
charge: resolvedBatteryTelemetry?.batteryChargeMah ?? null,
config: resolvedBatteryConfig,
});
if (!battery?.available) return null;
if (!battery.warnActive && !battery.urgentActive) return null;
const message = battery.urgentActive
? 'BATTERY VERY LOW, DOCK THE ROVER AND CHARGE IMMEDIATELY!!'
? 'Battery very low, dock the rover and charge immediately!!'
: 'Battery low! please dock and charge the rover soon.';
const containerClass = compact ? 'p-2 top-6' : 'p-4 top-10';
@@ -4,26 +4,26 @@
import React from 'react';
import { useMemo } from 'react';
import { useSessionSelector } from '../../../context/SessionContext.jsx';
import { useTelemetryFrame } from '../../../context/TelemetryContext.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 }) {
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const effectiveRoverId = roverId ?? assignedRoverId;
const frame = useTelemetryFrame(effectiveRoverId);
const selectedOvercurrents = useVisualTelemetrySelector(effectiveRoverId, selectOvercurrentFlags, overcurrentFlagsEqual);
const internalLimiter = useOvercurrentLimiter(effectiveRoverId);
const resolvedSensors = sensors ?? frame?.sensors ?? null;
const resolvedOvercurrents = sensors?.wheelOvercurrents ?? selectedOvercurrents;
const resolvedOvercurrentLimiter = overcurrentLimiter ?? internalLimiter ?? null;
const wheelOvercurrents = resolvedSensors?.wheelOvercurrents || null;
const overcurrentMotors = useMemo(
() =>
wheelOvercurrents == null
resolvedOvercurrents == null
? []
: Object.entries(wheelOvercurrents)
: Object.entries(resolvedOvercurrents)
.filter(([, active]) => Boolean(active))
.map(([key]) => key),
[wheelOvercurrents],
[resolvedOvercurrents],
);
const limiterCaps = resolvedOvercurrentLimiter?.caps || null;
const limiterFill = useMemo(() => {
@@ -56,7 +56,7 @@ function OvercurrentOverlay({ roverId = null, sensors, overcurrentLimiter = null
<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>Overcurrent</div>
<div className={`mt-0 font-medium text-white ${subTextClass}`}>{safeLabels.join(', ')}</div>
</div>
</div>
@@ -1,21 +1,27 @@
import React from 'react';
import { useSessionSelector } from '../../../context/SessionContext.jsx';
import { useTelemetryFrame } from '../../../context/TelemetryContext.jsx';
import { useVisualTelemetrySelector } from '../../../context/TelemetryContext.jsx';
import { batteryTelemetryEqual, selectBatteryTelemetry } from '../../../context/telemetryViews.js';
import { buildBatteryVisual } from '../../../lib/battery.js';
import BatteryBar from '../../BatteryBar/index.jsx';
function VerticalBatteryOverlay({ show = false, roverId = null, sensors, batteryConfig, mobileHud = false }) {
const frame = useTelemetryFrame(roverId);
const batteryTelemetry = useVisualTelemetrySelector(roverId, selectBatteryTelemetry, batteryTelemetryEqual);
const rosterBatteryConfig = useSessionSelector((state) => {
if (!roverId) return null;
const roster = state.session?.roster || [];
const rover = roster.find((entry) => String(entry.id) === String(roverId));
return rover?.battery ?? null;
});
const resolvedSensors = sensors ?? frame?.sensors ?? null;
const resolvedBatteryTelemetry = sensors
? {
batteryChargeMah: sensors?.batteryChargeMah ?? null,
batteryCapacityMah: sensors?.batteryCapacityMah ?? null,
}
: batteryTelemetry;
const resolvedBatteryConfig = batteryConfig ?? rosterBatteryConfig;
const batteryVisual = buildBatteryVisual({
charge: resolvedSensors?.batteryChargeMah ?? null,
charge: resolvedBatteryTelemetry?.batteryChargeMah ?? null,
config: resolvedBatteryConfig,
});
if (!show || !batteryVisual?.available) return null;
@@ -2,7 +2,8 @@
// Purpose: Assembles the mobile movement column, which is the right column by default.
// Scope: Integrates drive/dock actions with the mobile control pad without hiding that dependency inside the pad.
import { useControlSelector } from '../../controls/index.js';
import DriveDockAction, { useDriveDockState } from '../DriveDockAction/index.jsx';
import DriveDockAction from '../DriveDockAction/index.jsx';
import { useDriveDockState } from '../DriveDockAction/driveDockState.js';
import ControlPadPanel from './ControlPadPanel.jsx';
function MovementColumnContent({ layout }) {
@@ -2,7 +2,8 @@
// Purpose: Renders Raspberry Pi host health for the assigned rover. Scope: Uses the separate roverHostStats stream, not Roomba sensorFrame data.
import CardFrame from '../CardFrame/index.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useTelemetryFrame } from '../../context/TelemetryContext.jsx';
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
import { hostStatsEqual, selectHostStats } from '../../context/telemetryViews.js';
const EMPTY_STATS = Object.freeze({});
const EMPTY_WIFI = Object.freeze({});
@@ -249,8 +250,7 @@ function warningMessages(stats) {
export default function PiHostStatsCard() {
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const frame = useTelemetryFrame(roverId);
const stats = frame?.hostStats || EMPTY_STATS;
const stats = useTelemetrySelector(roverId, selectHostStats, hostStatsEqual) || EMPTY_STATS;
const wifi = stats.wifi || EMPTY_WIFI;
const warnings = warningMessages(stats);
const wifiQuality = wifiQualityPercent(wifi);
+6 -5
View File
@@ -11,8 +11,10 @@ import { LinkButtonsPanel } from '../UserListPanel/index.jsx';
import ReplaySourcesPanel from '../ReplaySourcesPanel/index.jsx';
import Tabs, { Tab, TabList, TabPanel, TabPanels } from '../Tabs/index.jsx';
import TopDownMap from '../TopDownMap/index.jsx';
import DriveDockAction, { useDriveDockState } from '../DriveDockAction/index.jsx';
import { useTelemetryFrame } from '../../context/TelemetryContext.jsx';
import DriveDockAction from '../DriveDockAction/index.jsx';
import { useDriveDockState } from '../DriveDockAction/driveDockState.js';
import { useVisualTelemetrySelector } from '../../context/TelemetryContext.jsx';
import { mapTelemetryEqual, selectVisualMapTelemetry } from '../../context/telemetryViews.js';
import { useControlActions, useControlSelector } from '../../controls/index.js';
import RoverQueuesPanel from '../RoverQueuesPanel/index.jsx';
import RawUserPilePanel from '../RawUserPilePanel/index.jsx';
@@ -37,15 +39,14 @@ const CHAT_DOCK_BOTTOM_INSET = 8;
function TopDownMapPanel() {
const roverId = useControlSelector((control) => control.state.roverId);
const frame = useTelemetryFrame(roverId);
const sensors = frame?.sensors || {};
const mapTelemetry = useVisualTelemetrySelector(roverId, selectVisualMapTelemetry, mapTelemetryEqual);
return (
<CardFrame
title = "Roomba sensor view"
>
<div className="aspect-square w-full">
<TopDownMap sensors={sensors} />
<TopDownMap mapTelemetry={mapTelemetry} />
</div>
</CardFrame>
);
@@ -1,6 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { WhepPlayer } from '../../lib/whepPlayer.js';
import { useTelemetryFrame } from '../../context/TelemetryContext.jsx';
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
import {
mainBrushAudioTelemetryEqual,
selectMainBrushAudioTelemetry,
} from '../../context/telemetryViews.js';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useVideoRequests } from '../../hooks/useVideoRequests.js';
import { useRoverSnapshots } from '../../hooks/useRoverSnapshots.js';
@@ -82,8 +86,17 @@ export default function RoverMediaPlayer({
snapshotFeed ?? (effectiveRoverId ? autoSnapshots[effectiveRoverId] || null : null);
const resolvedLabel =
label || rosterEntry?.name || (effectiveRoverId ? `Rover ${effectiveRoverId}` : 'Rover');
const frame = useTelemetryFrame(effectiveRoverId);
const resolvedSensors = sensors ?? frame?.sensors ?? null;
const mainBrushTelemetry = useTelemetrySelector(
effectiveRoverId,
selectMainBrushAudioTelemetry,
mainBrushAudioTelemetryEqual,
);
const resolvedMainBrushTelemetry = sensors
? {
mainBrushCurrentMa: sensors?.mainBrushCurrentMa ?? 0,
mainBrushOvercurrent: Boolean(sensors?.wheelOvercurrents?.mainBrush),
}
: mainBrushTelemetry;
const videoRef = useRef(null);
const audioRef = useRef(null);
const restartTimer = useRef(null);
@@ -129,8 +142,8 @@ export default function RoverMediaPlayer({
: AUDIO_SETTINGS_DEFAULTS.mainBrushDuckAmount;
const baseRoverGain = Math.max(0, Math.min(1, masterVolume * roverVolume));
const mainBrushActive = Boolean(
(Number(resolvedSensors?.mainBrushCurrentMa) || 0) > BRUSH_CURRENT_THRESHOLD_MA ||
resolvedSensors?.wheelOvercurrents?.mainBrush,
(Number(resolvedMainBrushTelemetry?.mainBrushCurrentMa) || 0) > BRUSH_CURRENT_THRESHOLD_MA ||
resolvedMainBrushTelemetry?.mainBrushOvercurrent,
);
const duckGain = mainBrushDuckEnabled && mainBrushActive ? 1 - mainBrushDuckAmount : 1;
const effectiveRoverGain = Math.max(0, Math.min(1, baseRoverGain * duckGain));
+4 -24
View File
@@ -1,27 +1,15 @@
// Telemetry Panel
// Purpose: Defines the Telemetry 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';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useTelemetryFrame } from '../../context/TelemetryContext.jsx';
import { useVisualTelemetrySelector } from '../../context/TelemetryContext.jsx';
import { selectFrameForDisplay } from '../../context/telemetryViews.js';
import { useDockIr } from '../../hooks/useDockIr.js';
import CardFrame from '../CardFrame/index.jsx';
function formatMetric(value, fallback = '--') {
if (value == null || value === '') return fallback;
return value;
}
export default function TelemetryPanel() {
const connected = useSessionSelector((state) => state.connected);
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const activeDriverId = useSessionSelector((state) => {
const id = state.session?.assignment?.roverId;
return id ? state.session?.activeDrivers?.[id] || null : null;
});
const selfSocketId = useSessionSelector((state) => state.session?.socketId || null);
const users = useSessionSelector((state) => state.session?.users ?? []);
const frame = useTelemetryFrame(roverId);
const frame = useVisualTelemetrySelector(roverId, selectFrameForDisplay);
const sensors = frame?.sensors || {};
const dockIr = useDockIr(sensors);
const voltage = sensors.voltageMv != null ? `${(sensors.voltageMv / 1000).toFixed(2)} V` : null;
@@ -29,15 +17,7 @@ export default function TelemetryPanel() {
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;
const rawSnippet = frame?.raw ? frame.raw : null;
const driverLabel = useMemo(() => {
if (!roverId) return 'n/a';
if (!activeDriverId) return 'Available';
if (activeDriverId === selfSocketId) return 'You';
const user = users.find((entry) => entry.socketId === activeDriverId);
return user?.nickname || activeDriverId.slice(0, 6);
}, [activeDriverId, roverId, selfSocketId, users]);
return (
<CardFrame title='Roomba sensor values' clipOverflow={false} bodyClassName="space-y-0.5 text-base text-slate-100">
@@ -79,7 +59,7 @@ function TelemetrySummary({ sensors, voltage, current, batteryTemp, charge, capa
<Metric label="Charge" value={charge != null ? `${charge} mAh` : '--'} />
<Metric label="Capacity" value={capacity != null ? `${capacity} mAh` : '--'} />
<Metric label="Charge" value={chargePct} />
<Metric label="OI mode" value={oiMode} />
<Metric label="Oi mode" value={oiMode} />
<Metric label="Docked" value={docked} />
<Metric label="Charging state" value={charging} />
</div>
@@ -1,20 +1,20 @@
// Top Down Map Content
// Purpose: Defines the Top Down Map Content 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 { buildSegments } from './helpers.js';
import React, { useMemo } from 'react';
import { selectVisualMapTelemetry } from '../../context/telemetryViews.js';
import { buildSegments, cliffColor, lightBumpColor } from './helpers.js';
import {
ArcSegment,
ConeSegment,
WheelVisual,
SideBrushVisual,
MainBrushVisual,
lightBumpColor,
cliffColor,
} from './visuals.jsx';
function TopDownMapContent({ sensors = {}, variant = 'full', size: overrideSize, overlay = false }) {
const size = overrideSize || (variant === 'mini' ? 190 : 260);
const LIGHT_LABELS = ['L', 'FL', 'CL', 'CR', 'FR', 'R'];
function buildGeometry(size, variant) {
const center = size / 2;
const offsetY = size * 0.05;
const centerX = center;
@@ -25,39 +25,56 @@ function TopDownMapContent({ sensors = {}, variant = 'full', size: overrideSize,
const cliffRingInner = innerCircle - 14;
const cliffRingOuter = innerCircle - 6;
const wheelLineOffset = innerCircle * 0.65;
const bumps = sensors?.bumpsAndWheelDrops || {};
const wheelOver = sensors?.wheelOvercurrents || {};
const wheelCurrentLeft = sensors?.wheelLeftCurrentMa ?? 0;
const wheelCurrentRight = sensors?.wheelRightCurrentMa ?? 0;
const sideBrushCurrent = sensors?.sideBrushCurrentMa ?? 0;
const mainBrushCurrent = sensors?.mainBrushCurrentMa ?? 0;
const bumpDepress = 6;
const bumpLeftOffset = bumps.bumpLeft ? bumpDepress : 0;
const bumpRightOffset = bumps.bumpRight ? bumpDepress : 0;
const lightAngles = buildSegments({ count: 6, totalSpan: 140, gap: 6, startAngle: -70 });
const lightLabels = ['L', 'FL', 'CL', 'CR', 'FR', 'R'];
const lightValues = [
sensors?.lightBumpLeftSignal,
sensors?.lightBumpFrontLeftSignal,
sensors?.lightBumpCenterLeftSignal,
sensors?.lightBumpCenterRightSignal,
sensors?.lightBumpFrontRightSignal,
sensors?.lightBumpRightSignal,
];
const lightSegments = lightAngles.map((ang, idx) => ({
label: lightLabels[idx],
start: ang.start,
end: ang.end,
value: lightValues[idx],
}));
const cliffSegments = [
{ label: 'Cliff L', start: -60, end: -46, value: sensors?.cliffLeftSignal, active: sensors?.cliffLeft },
{ label: 'Cliff FL', start: -32, end: -16, value: sensors?.cliffFrontLeftSignal, active: sensors?.cliffFrontLeft },
{ label: 'Cliff FR', start: 16, end: 32, value: sensors?.cliffFrontRightSignal, active: sensors?.cliffFrontRight },
{ label: 'Cliff R', start: 46, end: 60, value: sensors?.cliffRightSignal, active: sensors?.cliffRight },
return {
size,
variant,
centerX,
centerY,
innerCircle,
lightRingInner,
lightRingOuter,
cliffRingInner,
cliffRingOuter,
wheelLineOffset,
lightSegments: lightAngles.map((angle, idx) => ({
label: LIGHT_LABELS[idx],
start: angle.start,
end: angle.end,
})),
cliffSegments: [
{ label: 'Cliff L', start: -60, end: -46, valueKey: 'cliffLeftSignal', activeKey: 'cliffLeft' },
{ label: 'Cliff FL', start: -32, end: -16, valueKey: 'cliffFrontLeftSignal', activeKey: 'cliffFrontLeft' },
{ label: 'Cliff FR', start: 16, end: 32, valueKey: 'cliffFrontRightSignal', activeKey: 'cliffFrontRight' },
{ label: 'Cliff R', start: 46, end: 60, valueKey: 'cliffRightSignal', activeKey: 'cliffRight' },
],
};
}
function TopDownMapContent({ sensors = {}, mapTelemetry = null, variant = 'full', size: overrideSize, overlay = false }) {
const size = overrideSize || (variant === 'mini' ? 190 : 260);
const geometry = useMemo(() => buildGeometry(size, variant), [size, variant]);
// The map accepts either the new selector-produced telemetry shape or the old
// raw sensors prop. Keeping this translation local lets existing non-hot paths
// continue to render while high-frequency callers move to targeted selectors.
const telemetry = mapTelemetry ?? selectVisualMapTelemetry({ sensors });
const wheelCurrentLeft = telemetry.wheelLeftCurrentMa ?? 0;
const wheelCurrentRight = telemetry.wheelRightCurrentMa ?? 0;
const sideBrushCurrent = telemetry.sideBrushCurrentMa ?? 0;
const mainBrushCurrent = telemetry.mainBrushCurrentMa ?? 0;
const bumpDepress = 6;
const bumpLeftOffset = telemetry.bumpLeft ? bumpDepress : 0;
const bumpRightOffset = telemetry.bumpRight ? bumpDepress : 0;
const lightValues = [
telemetry.lightBumpLeftSignal,
telemetry.lightBumpFrontLeftSignal,
telemetry.lightBumpCenterLeftSignal,
telemetry.lightBumpCenterRightSignal,
telemetry.lightBumpFrontRightSignal,
telemetry.lightBumpRightSignal,
];
const lightMaxSamples = lightValues.filter((v) => v != null);
@@ -69,73 +86,72 @@ function TopDownMapContent({ sensors = {}, variant = 'full', size: overrideSize,
style={overlay ? { width: `${size}px`, height: `${size}px` } : { height: '100%', width: '100%', aspectRatio: '1 / 1' }}
>
<svg width="100%" height="100%" viewBox={`0 0 ${size} ${size}`} preserveAspectRatio="xMidYMid meet" className="mx-auto block">
<circle cx={centerX} cy={centerY} r={innerCircle} fill="#0f172a" stroke="#334155" strokeWidth="2" />
<WheelVisual cx={centerX - wheelLineOffset} cy={centerY} current={wheelCurrentLeft} drop={bumps.wheelDropLeft} overcurrent={wheelOver.leftWheel} label="L" />
<WheelVisual cx={centerX + wheelLineOffset} cy={centerY} current={wheelCurrentRight} drop={bumps.wheelDropRight} overcurrent={wheelOver.rightWheel} label="R" />
<SideBrushVisual cx={centerX + innerCircle * 0.65} cy={centerY - innerCircle * 0.55} current={sideBrushCurrent} overcurrent={wheelOver.sideBrush} />
<circle cx={geometry.centerX} cy={geometry.centerY} r={geometry.innerCircle} fill="#0f172a" stroke="#334155" strokeWidth="2" />
<WheelVisual cx={geometry.centerX - geometry.wheelLineOffset} cy={geometry.centerY} current={wheelCurrentLeft} drop={telemetry.wheelDropLeft} overcurrent={telemetry.leftWheelOvercurrent} label="L" />
<WheelVisual cx={geometry.centerX + geometry.wheelLineOffset} cy={geometry.centerY} current={wheelCurrentRight} drop={telemetry.wheelDropRight} overcurrent={telemetry.rightWheelOvercurrent} label="R" />
<SideBrushVisual cx={geometry.centerX + geometry.innerCircle * 0.65} cy={geometry.centerY - geometry.innerCircle * 0.55} current={sideBrushCurrent} overcurrent={telemetry.sideBrushOvercurrent} />
<MainBrushVisual
cx={centerX}
cy={centerY}
cx={geometry.centerX}
cy={geometry.centerY}
current={mainBrushCurrent}
overcurrent={wheelOver.mainBrush}
overcurrent={telemetry.mainBrushOvercurrent}
variant={variant}
dirtLeft={sensors?.dirtDetectLeft}
dirtRight={sensors?.dirtDetect}
/>
{lightSegments.map((seg) => {
const color = lightBumpColor(seg.value, maxLight);
const tipR = lightRingOuter + 4;
{geometry.lightSegments.map((seg, idx) => {
const value = lightValues[idx];
const color = lightBumpColor(value, maxLight);
const tipR = geometry.lightRingOuter + 4;
const baseR = tipR + 28;
return (
<ConeSegment
key={seg.label}
cx={centerX}
cy={centerY}
cx={geometry.centerX}
cy={geometry.centerY}
rBase={baseR}
rTip={tipR}
startDeg={seg.start}
endDeg={seg.end}
color={color}
value={seg.value}
value={value}
max={maxLight}
/>
);
})}
{cliffSegments.map((seg) => (
{geometry.cliffSegments.map((seg) => (
<ArcSegment
key={seg.label}
cx={centerX}
cy={centerY}
rInner={cliffRingInner}
rOuter={cliffRingOuter}
cx={geometry.centerX}
cy={geometry.centerY}
rInner={geometry.cliffRingInner}
rOuter={geometry.cliffRingOuter}
startDeg={seg.start}
endDeg={seg.end}
color={cliffColor(seg.value, seg.active)}
color={cliffColor(telemetry[seg.valueKey], telemetry[seg.activeKey])}
opacity={1}
pulse={Boolean(seg.active)}
pulse={Boolean(telemetry[seg.activeKey])}
/>
))}
<ArcSegment
cx={centerX}
cy={centerY}
rInner={lightRingInner - bumpLeftOffset}
rOuter={lightRingOuter - bumpLeftOffset}
cx={geometry.centerX}
cy={geometry.centerY}
rInner={geometry.lightRingInner - bumpLeftOffset}
rOuter={geometry.lightRingOuter - bumpLeftOffset}
startDeg={-70}
endDeg={-6}
color={bumps.bumpLeft ? '#ef4444' : '#475569'}
color={telemetry.bumpLeft ? '#ef4444' : '#475569'}
opacity={1}
pulse={Boolean(bumps.bumpLeft)}
pulse={Boolean(telemetry.bumpLeft)}
/>
<ArcSegment
cx={centerX}
cy={centerY}
rInner={lightRingInner - bumpRightOffset}
rOuter={lightRingOuter - bumpRightOffset}
cx={geometry.centerX}
cy={geometry.centerY}
rInner={geometry.lightRingInner - bumpRightOffset}
rOuter={geometry.lightRingOuter - bumpRightOffset}
startDeg={6}
endDeg={70}
color={bumps.bumpRight ? '#ef4444' : '#475569'}
color={telemetry.bumpRight ? '#ef4444' : '#475569'}
opacity={1}
pulse={Boolean(bumps.bumpRight)}
pulse={Boolean(telemetry.bumpRight)}
/>
</svg>
</div>
+25 -19
View File
@@ -1,13 +1,16 @@
// visuals
// Purpose: Defines the visuals 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 { clamp01, currentColor, describeArc, lightBumpColor, cliffColor, polarToCartesian, toRad } from './helpers.js';
import React, { useMemo } from 'react';
import { clamp01, currentColor, describeArc, polarToCartesian, toRad } from './helpers.js';
export function ArcSegment({ cx, cy, rInner, rOuter, startDeg, endDeg, color, pulse = false, opacity = 1 }) {
export const ArcSegment = React.memo(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);
const path = useMemo(
() => describeArc(cx, cy, rMid, startDeg, endDeg),
[cx, cy, endDeg, rMid, startDeg],
);
return (
<>
<path d={path} stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" fill="none" opacity={opacity} />
@@ -24,23 +27,28 @@ export function ArcSegment({ cx, cy, rInner, rOuter, startDeg, endDeg, color, pu
) : null}
</>
);
}
});
export function ConeSegment({ cx, cy, rBase, rTip, startDeg, endDeg, color, value, max }) {
export const ConeSegment = React.memo(function ConeSegment({ cx, cy, rBase, rTip, startDeg, endDeg, color, value, max }) {
const mid = (startDeg + endDeg) / 2;
const tip = polarToCartesian(cx, cy, rTip, mid);
const norm = clamp01(value != null ? value / (max || 1) : 0);
const eased = Math.pow(norm, 0.35);
const filledR = rBase - (rBase - rTip) * eased;
const barR = Math.max(rTip, Math.min(filledR, rBase));
const filledA = polarToCartesian(cx, cy, barR, startDeg);
const filledB = polarToCartesian(cx, cy, barR, endDeg);
const fg = `M ${tip.x} ${tip.y} L ${filledA.x} ${filledA.y} L ${filledB.x} ${filledB.y} Z`;
const fg = useMemo(() => {
// The cone geometry is still dynamic because the filled radius changes
// with light-bump strength, but the memo prevents unrelated parent renders
// from rebuilding the SVG path string for every cone.
const tip = polarToCartesian(cx, cy, rTip, mid);
const filledA = polarToCartesian(cx, cy, barR, startDeg);
const filledB = polarToCartesian(cx, cy, barR, endDeg);
return `M ${tip.x} ${tip.y} L ${filledA.x} ${filledA.y} L ${filledB.x} ${filledB.y} Z`;
}, [barR, cx, cy, endDeg, mid, rTip, startDeg]);
return <path d={fg} fill={color} opacity={1} stroke="none" />;
}
});
export function WheelVisual({ cx, cy, current, drop, overcurrent, label }) {
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);
@@ -63,9 +71,9 @@ export function WheelVisual({ cx, cy, current, drop, overcurrent, label }) {
<text x={0} y={barH / 2 + 10} textAnchor="middle" className="fill-slate-200 text-[0.7rem]">{label}</text>
</g>
);
}
});
export function SideBrushVisual({ cx, cy, current, overcurrent }) {
export const SideBrushVisual = React.memo(function SideBrushVisual({ cx, cy, current, overcurrent }) {
let mag = Math.abs(current);
if (mag < 10) mag = 0;
const color = currentColor(current * 3, overcurrent);
@@ -91,9 +99,9 @@ export function SideBrushVisual({ cx, cy, current, overcurrent }) {
{overcurrent ? <circle cx={cx} cy={cy} r={armLength + 8} stroke="#ef4444" strokeWidth="3" fill="none" className="animate-pulse" /> : null}
</g>
);
}
});
export function MainBrushVisual({ cx, cy, current, overcurrent, variant, dirtLeft, dirtRight }) {
export const MainBrushVisual = React.memo(function MainBrushVisual({ cx, cy, current, overcurrent, variant }) {
const mag = Math.abs(current);
const color = currentColor(current, overcurrent);
const opacity = 1;
@@ -135,6 +143,4 @@ export function MainBrushVisual({ cx, cy, current, overcurrent, variant, dirtLef
) : null}
</g>
);
}
export { lightBumpColor, cliffColor };
});
+242 -1
View File
@@ -2,19 +2,72 @@
// Purpose: Maintains shared telemetry snapshots and rover status streams for UI consumers. Scope: Subscribes to telemetry events and exposes normalized read APIs to components.
/* eslint-disable react-refresh/only-export-components */
import { createContext, useContext, useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
import { createContext, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { useSocket } from './SocketContext.jsx';
import { useSessionSelector } from './SessionContext.jsx';
const EMPTY_FRAMES = Object.freeze({});
const EMPTY_FRAME = null;
const DEFAULT_VISUAL_THROTTLE_MS = 250;
const TelemetryContext = createContext(null);
export function shallowArrayEqual(left, right) {
if (Object.is(left, right)) return true;
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
for (let idx = 0; idx < left.length; idx += 1) {
if (!Object.is(left[idx], right[idx])) return false;
}
return true;
}
export function shallowObjectEqual(left, right) {
if (Object.is(left, right)) return true;
if (!left || !right || typeof left !== 'object' || typeof right !== 'object') return false;
const leftKeys = Object.keys(left);
const rightKeys = Object.keys(right);
if (leftKeys.length !== rightKeys.length) return false;
for (const key of leftKeys) {
if (!Object.prototype.hasOwnProperty.call(right, key) || !Object.is(left[key], right[key])) {
return false;
}
}
return true;
}
function selectFrameIdentity(frame) {
return frame;
}
export function TelemetryProvider({ children }) {
const socket = useSocket();
const sessionRole = useSessionSelector((state) => state.session?.role || null);
const framesRef = useRef({});
const roverSubscribersRef = useRef(new Map());
const allSubscribersRef = useRef(new Set());
const visualAllSubscribersRef = useRef(new Set());
const selectorSubscribersRef = useRef(new Map());
const pendingVisualRoversRef = useRef(new Set());
const visualAllPendingRef = useRef(false);
const sessionRoleRef = useRef(sessionRole);
const visualPolicyRef = useRef({ mobile: false });
const visualTimerRef = useRef(null);
sessionRoleRef.current = sessionRole;
const defaultVisualThrottleMs = () => {
// The throttle policy is intentionally sourced from existing app state:
// session role identifies spectator-style pages, and App publishes its
// already-computed layout mode. Telemetry should not independently inspect
// paths or viewport dimensions because that would duplicate page policy and
// drift from the rest of the UI.
const spectator = sessionRoleRef.current === 'spectator';
const mobile = Boolean(visualPolicyRef.current.mobile);
return spectator || mobile ? DEFAULT_VISUAL_THROTTLE_MS : 0;
};
const throttleMsForEntry = (entry) => (
Number.isFinite(entry.throttleMs) ? entry.throttleMs : defaultVisualThrottleMs()
);
const notifyRover = (roverId) => {
const listeners = roverSubscribersRef.current.get(roverId);
@@ -24,6 +77,79 @@ export function TelemetryProvider({ children }) {
allSubscribersRef.current.forEach((listener) => listener());
};
const evaluateSelectorEntries = (roverId, entries) => {
const frame = framesRef.current[roverId] ?? EMPTY_FRAME;
entries.forEach((entry) => {
const nextValue = entry.selector(frame);
if (entry.equalityFn(entry.currentValue, nextValue)) return;
entry.currentValue = nextValue;
entry.listener(nextValue);
});
};
const notifyRawSelectors = (roverId) => {
const entries = selectorSubscribersRef.current.get(roverId);
if (!entries) return;
evaluateSelectorEntries(
roverId,
[...entries].filter((entry) => entry.mode !== 'visual'),
);
};
const flushVisualSelectors = () => {
visualTimerRef.current = null;
const roverIds = [...pendingVisualRoversRef.current];
pendingVisualRoversRef.current.clear();
roverIds.forEach((roverId) => {
const entries = selectorSubscribersRef.current.get(roverId);
if (!entries) return;
evaluateSelectorEntries(
roverId,
[...entries].filter((entry) => entry.mode === 'visual' && entry.throttleMs > 0),
);
});
if (visualAllPendingRef.current) {
visualAllPendingRef.current = false;
visualAllSubscribersRef.current.forEach((listener) => listener());
}
};
const notifyVisualSelectors = (roverId) => {
const entries = selectorSubscribersRef.current.get(roverId);
if (!entries) return;
const visualEntries = [...entries].filter((entry) => entry.mode === 'visual');
const immediateEntries = visualEntries.filter((entry) => throttleMsForEntry(entry) <= 0);
const throttledEntries = visualEntries.filter((entry) => throttleMsForEntry(entry) > 0);
// Desktop visual subscriptions still benefit from field-level selectors, but
// they do not need cadence throttling. Evaluate those entries immediately so
// desktop dashboards keep the same responsiveness they had before this change.
if (immediateEntries.length) {
evaluateSelectorEntries(roverId, immediateEntries);
}
if (!throttledEntries.length) return;
pendingVisualRoversRef.current.add(roverId);
if (visualTimerRef.current) return;
const delay = throttledEntries.reduce(
(lowest, entry) => Math.min(lowest, throttleMsForEntry(entry)),
DEFAULT_VISUAL_THROTTLE_MS,
);
visualTimerRef.current = setTimeout(flushVisualSelectors, delay);
};
const notifyVisualAll = () => {
const throttleMs = defaultVisualThrottleMs();
if (throttleMs <= 0) {
visualAllSubscribersRef.current.forEach((listener) => listener());
return;
}
visualAllPendingRef.current = true;
if (visualTimerRef.current) return;
visualTimerRef.current = setTimeout(flushVisualSelectors, throttleMs);
};
const store = useMemo(
() => ({
getFrames: () => framesRef.current,
@@ -37,6 +163,18 @@ export function TelemetryProvider({ children }) {
allSubscribersRef.current.delete(listener);
};
},
subscribeAllVisual: (listener) => {
visualAllSubscribersRef.current.add(listener);
return () => {
visualAllSubscribersRef.current.delete(listener);
};
},
setVisualPolicy: (policy = {}) => {
visualPolicyRef.current = {
...visualPolicyRef.current,
...policy,
};
},
subscribeRover: (roverId, listener) => {
if (!roverId) return () => {};
let listeners = roverSubscribersRef.current.get(roverId);
@@ -54,6 +192,40 @@ export function TelemetryProvider({ children }) {
}
};
},
subscribeSelector: (roverId, selector, listener, equalityFn = Object.is, options = {}) => {
if (!roverId || typeof selector !== 'function') return () => {};
let listeners = selectorSubscribersRef.current.get(roverId);
if (!listeners) {
listeners = new Set();
selectorSubscribersRef.current.set(roverId, listeners);
}
const mode = options.mode === 'visual' ? 'visual' : 'raw';
const throttleMs =
mode === 'visual' && Number.isFinite(options.throttleMs)
? Math.max(0, options.throttleMs)
: null;
const entry = {
selector,
listener,
equalityFn,
mode,
throttleMs,
// The current value is stored with the subscription so selector
// equality is checked before React is notified. This keeps unrelated
// sensor fields from invalidating components that do not read them.
currentValue: selector(framesRef.current[roverId] ?? EMPTY_FRAME),
};
listeners.add(entry);
return () => {
const current = selectorSubscribersRef.current.get(roverId);
if (!current) return;
current.delete(entry);
if (!current.size) {
selectorSubscribersRef.current.delete(roverId);
}
};
},
}),
[],
);
@@ -73,6 +245,9 @@ export function TelemetryProvider({ children }) {
},
};
notifyRover(roverId);
notifyRawSelectors(roverId);
notifyVisualSelectors(roverId);
notifyVisualAll();
}
function handleRoverHostStats({ roverId, stats = {}, receivedAt = null }) {
@@ -92,6 +267,9 @@ export function TelemetryProvider({ children }) {
},
};
notifyRover(roverId);
notifyRawSelectors(roverId);
notifyVisualSelectors(roverId);
notifyVisualAll();
}
socket.on('sensorFrame', handleSensorFrame);
@@ -99,7 +277,16 @@ export function TelemetryProvider({ children }) {
return () => {
socket.off('sensorFrame', handleSensorFrame);
socket.off('roverHostStats', handleRoverHostStats);
if (visualTimerRef.current) {
clearTimeout(visualTimerRef.current);
visualTimerRef.current = null;
}
};
// The notification helpers above read only refs and constants; tying this
// socket subscription to their render-time identities would churn socket
// listeners without changing what data they read. The socket object is the
// actual external dependency that should resubscribe this effect.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [socket]);
return <TelemetryContext.Provider value={store}>{children}</TelemetryContext.Provider>;
@@ -113,6 +300,29 @@ export function useTelemetryFrames() {
return useSyncExternalStore(store.subscribeAll, store.getFrames, () => EMPTY_FRAMES);
}
export function useVisualTelemetryFrames() {
const store = useContext(TelemetryContext);
if (!store) {
throw new Error('useVisualTelemetryFrames must be used within TelemetryProvider');
}
return useSyncExternalStore(store.subscribeAllVisual, store.getFrames, () => EMPTY_FRAMES);
}
export function useTelemetryVisualPolicy(policy) {
const store = useContext(TelemetryContext);
if (!store) {
throw new Error('useTelemetryVisualPolicy must be used within TelemetryProvider');
}
const mobile = Boolean(policy?.mobile);
useEffect(() => {
store.setVisualPolicy({ mobile });
return () => {
store.setVisualPolicy({ mobile: false });
};
}, [mobile, store]);
}
export function useTelemetryFrame(roverId) {
const store = useContext(TelemetryContext);
if (!store) {
@@ -124,3 +334,34 @@ export function useTelemetryFrame(roverId) {
() => EMPTY_FRAME,
);
}
export function useTelemetrySelector(roverId, selector = selectFrameIdentity, equalityFn = Object.is, options = {}) {
const store = useContext(TelemetryContext);
if (!store) {
throw new Error('useTelemetrySelector must be used within TelemetryProvider');
}
const mode = options.mode === 'visual' ? 'visual' : 'raw';
const throttleMs = Number.isFinite(options.throttleMs) ? Math.max(0, options.throttleMs) : undefined;
const [selectionState, setSelectionState] = useState(() => ({
roverId,
selector,
value: selector(store.getFrame(roverId)),
}));
const renderedSelected =
selectionState.roverId === roverId && selectionState.selector === selector
? selectionState.value
: selector(store.getFrame(roverId));
useEffect(() => {
const publishSelected = (value) => {
setSelectionState({ roverId, selector, value });
};
return store.subscribeSelector(roverId, selector, publishSelected, equalityFn, { mode, throttleMs });
}, [equalityFn, mode, roverId, selector, store, throttleMs]);
return renderedSelected;
}
export function useVisualTelemetrySelector(roverId, selector, equalityFn = Object.is, options = {}) {
return useTelemetrySelector(roverId, selector, equalityFn, { ...options, mode: 'visual' });
}
+218
View File
@@ -0,0 +1,218 @@
// Telemetry Views
// Purpose: Defines small, stable telemetry projections for UI components.
// Scope: Keeps rendering subscriptions focused on the sensor fields each view actually uses.
import { shallowArrayEqual, shallowObjectEqual } from './TelemetryContext.jsx';
export const EMPTY_MAP_TELEMETRY = Object.freeze({
bumpLeft: false,
bumpRight: false,
wheelDropLeft: false,
wheelDropRight: false,
leftWheelOvercurrent: false,
rightWheelOvercurrent: false,
sideBrushOvercurrent: false,
mainBrushOvercurrent: false,
wheelLeftCurrentMa: 0,
wheelRightCurrentMa: 0,
sideBrushCurrentMa: 0,
mainBrushCurrentMa: 0,
lightBumpLeftSignal: null,
lightBumpFrontLeftSignal: null,
lightBumpCenterLeftSignal: null,
lightBumpCenterRightSignal: null,
lightBumpFrontRightSignal: null,
lightBumpRightSignal: null,
cliffLeftSignal: null,
cliffFrontLeftSignal: null,
cliffFrontRightSignal: null,
cliffRightSignal: null,
cliffLeft: false,
cliffFrontLeft: false,
cliffFrontRight: false,
cliffRight: false,
dirtDetectLeft: null,
dirtDetect: null,
});
const EMPTY_BATTERY_TELEMETRY = Object.freeze({
batteryChargeMah: null,
batteryCapacityMah: null,
});
const EMPTY_DOCK_TELEMETRY = Object.freeze({
oiModeLabel: 'Unknown',
chargingStateLabel: '',
homeBase: false,
});
const EMPTY_SPECTATOR_TELEMETRY = Object.freeze({
voltageMv: null,
currentMa: null,
batteryChargeMah: null,
oiModeLabel: 'Unknown',
chargingStateLabel: '',
homeBase: false,
});
const EMPTY_HOST_STATS = Object.freeze({});
const EMPTY_OVERCURRENT_FLAGS = Object.freeze({
leftWheel: false,
rightWheel: false,
mainBrush: false,
sideBrush: false,
});
const EMPTY_MAIN_BRUSH_AUDIO = Object.freeze({
mainBrushCurrentMa: 0,
mainBrushOvercurrent: false,
});
function bucketNumber(value, step) {
// Visual widgets do not benefit from repainting for tiny analog jitter. The
// bucket step intentionally applies only to display selectors; raw telemetry
// remains available through useTelemetryFrame for control and debugging code.
if (value == null || !Number.isFinite(Number(value))) return value ?? null;
return Math.round(Number(value) / step) * step;
}
export function mapTelemetryEqual(left, right) {
return shallowObjectEqual(left, right);
}
export function batteryTelemetryEqual(left, right) {
return shallowObjectEqual(left, right);
}
export function dockTelemetryEqual(left, right) {
return shallowObjectEqual(left, right);
}
export function hostStatsEqual(left, right) {
return shallowObjectEqual(left, right);
}
export function selectVisualMapTelemetry(frame) {
const sensors = frame?.sensors;
if (!sensors) return EMPTY_MAP_TELEMETRY;
const bumps = sensors.bumpsAndWheelDrops || {};
const wheelOver = sensors.wheelOvercurrents || {};
return {
bumpLeft: Boolean(bumps.bumpLeft),
bumpRight: Boolean(bumps.bumpRight),
wheelDropLeft: Boolean(bumps.wheelDropLeft),
wheelDropRight: Boolean(bumps.wheelDropRight),
leftWheelOvercurrent: Boolean(wheelOver.leftWheel),
rightWheelOvercurrent: Boolean(wheelOver.rightWheel),
sideBrushOvercurrent: Boolean(wheelOver.sideBrush),
mainBrushOvercurrent: Boolean(wheelOver.mainBrush),
wheelLeftCurrentMa: bucketNumber(sensors.wheelLeftCurrentMa ?? 0, 25),
wheelRightCurrentMa: bucketNumber(sensors.wheelRightCurrentMa ?? 0, 25),
sideBrushCurrentMa: bucketNumber(sensors.sideBrushCurrentMa ?? 0, 25),
mainBrushCurrentMa: bucketNumber(sensors.mainBrushCurrentMa ?? 0, 25),
lightBumpLeftSignal: bucketNumber(sensors.lightBumpLeftSignal, 25),
lightBumpFrontLeftSignal: bucketNumber(sensors.lightBumpFrontLeftSignal, 25),
lightBumpCenterLeftSignal: bucketNumber(sensors.lightBumpCenterLeftSignal, 25),
lightBumpCenterRightSignal: bucketNumber(sensors.lightBumpCenterRightSignal, 25),
lightBumpFrontRightSignal: bucketNumber(sensors.lightBumpFrontRightSignal, 25),
lightBumpRightSignal: bucketNumber(sensors.lightBumpRightSignal, 25),
cliffLeftSignal: bucketNumber(sensors.cliffLeftSignal, 25),
cliffFrontLeftSignal: bucketNumber(sensors.cliffFrontLeftSignal, 25),
cliffFrontRightSignal: bucketNumber(sensors.cliffFrontRightSignal, 25),
cliffRightSignal: bucketNumber(sensors.cliffRightSignal, 25),
cliffLeft: Boolean(sensors.cliffLeft),
cliffFrontLeft: Boolean(sensors.cliffFrontLeft),
cliffFrontRight: Boolean(sensors.cliffFrontRight),
cliffRight: Boolean(sensors.cliffRight),
dirtDetectLeft: bucketNumber(sensors.dirtDetectLeft, 1),
dirtDetect: bucketNumber(sensors.dirtDetect, 1),
};
}
export function selectLightBumpTelemetry(frame) {
const sensors = frame?.sensors;
if (!sensors) return [];
return [
bucketNumber(sensors.lightBumpLeftSignal, 25),
bucketNumber(sensors.lightBumpFrontLeftSignal, 25),
bucketNumber(sensors.lightBumpCenterLeftSignal, 25),
bucketNumber(sensors.lightBumpCenterRightSignal, 25),
bucketNumber(sensors.lightBumpFrontRightSignal, 25),
bucketNumber(sensors.lightBumpRightSignal, 25),
];
}
export function lightBumpTelemetryEqual(left, right) {
return shallowArrayEqual(left, right);
}
export function selectBatteryTelemetry(frame) {
const sensors = frame?.sensors;
if (!sensors) return EMPTY_BATTERY_TELEMETRY;
return {
batteryChargeMah: sensors.batteryChargeMah ?? null,
batteryCapacityMah: sensors.batteryCapacityMah ?? null,
};
}
export function selectOvercurrentFlags(frame) {
const wheelOvercurrents = frame?.sensors?.wheelOvercurrents;
if (!wheelOvercurrents) return EMPTY_OVERCURRENT_FLAGS;
return {
leftWheel: Boolean(wheelOvercurrents.leftWheel),
rightWheel: Boolean(wheelOvercurrents.rightWheel),
mainBrush: Boolean(wheelOvercurrents.mainBrush),
sideBrush: Boolean(wheelOvercurrents.sideBrush),
};
}
export function selectMainBrushAudioTelemetry(frame) {
const sensors = frame?.sensors;
if (!sensors) return EMPTY_MAIN_BRUSH_AUDIO;
return {
mainBrushCurrentMa: bucketNumber(sensors.mainBrushCurrentMa ?? 0, 25),
mainBrushOvercurrent: Boolean(sensors.wheelOvercurrents?.mainBrush),
};
}
export function mainBrushAudioTelemetryEqual(left, right) {
return shallowObjectEqual(left, right);
}
export function overcurrentFlagsEqual(left, right) {
return shallowObjectEqual(left, right);
}
export function selectDockTelemetry(frame) {
const sensors = frame?.sensors;
if (!sensors) return EMPTY_DOCK_TELEMETRY;
return {
oiModeLabel: sensors.oiMode?.label || 'Unknown',
chargingStateLabel: sensors.chargingState?.label || '',
homeBase: Boolean(sensors.chargingSources?.homeBase),
};
}
export function selectSpectatorTelemetry(frame) {
const sensors = frame?.sensors;
if (!sensors) return EMPTY_SPECTATOR_TELEMETRY;
return {
voltageMv: bucketNumber(sensors.voltageMv, 25),
currentMa: bucketNumber(sensors.currentMa, 25),
batteryChargeMah: sensors.batteryChargeMah ?? null,
oiModeLabel: sensors.oiMode?.label || 'Unknown',
chargingStateLabel: sensors.chargingState?.label || '',
homeBase: Boolean(sensors.chargingSources?.homeBase),
};
}
export function spectatorTelemetryEqual(left, right) {
return shallowObjectEqual(left, right);
}
export function selectHostStats(frame) {
return frame?.hostStats || EMPTY_HOST_STATS;
}
export function selectFrameForDisplay(frame) {
return frame || null;
}
+3 -4
View File
@@ -1,7 +1,8 @@
// 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';
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
import { useTelemetrySelector } from '../context/TelemetryContext.jsx';
import { overcurrentFlagsEqual, selectOvercurrentFlags } from '../context/telemetryViews.js';
import { useSessionSelector } from '../context/SessionContext.jsx';
export const OVERCURRENT_GROUPS = [
@@ -30,9 +31,7 @@ function clampUnit(value) {
export function useOvercurrentLimiter(roverId, options = {}) {
const role = useSessionSelector((state) => state.session?.role || null);
const frame = useTelemetryFrame(roverId);
const sensors = frame?.sensors || {};
const overcurrentFlags = sensors?.wheelOvercurrents || {};
const overcurrentFlags = useTelemetrySelector(roverId, selectOvercurrentFlags, overcurrentFlagsEqual);
const config = useMemo(
() => ({ ...DEFAULT_OVERCURRENT_LIMITS, ...(options.config || {}) }),
[options.config],
@@ -1,7 +1,8 @@
// Display Rover Cell
// Purpose: Shows one rover's room-readable driver and battery status.
// Scope: Reuses shared rover/battery presentation primitives while avoiding controls, queues, and video.
import { useTelemetryFrame } from '../../../context/TelemetryContext.jsx';
import { useVisualTelemetrySelector } from '../../../context/TelemetryContext.jsx';
import { batteryTelemetryEqual, selectBatteryTelemetry } from '../../../context/telemetryViews.js';
import BatteryBar from '../../../components/BatteryBar/index.jsx';
import RoverLabel from '../../../components/RoverLabel/index.jsx';
import AutoFitText from '../../../mini/MiniSummaryApp/components/AutoFitText.jsx';
@@ -17,7 +18,8 @@ function classNames(...values) {
}
export default function DisplayRoverCell({ rover, session }) {
const frame = useTelemetryFrame(rover?.id);
const batteryTelemetry = useVisualTelemetrySelector(rover?.id, selectBatteryTelemetry, batteryTelemetryEqual);
const frame = { sensors: batteryTelemetry };
const visual = getDisplayBatteryVisual({ rover, frame });
const driver = findDriverForRover({ roverId: rover?.id, session });
const stateText = buildRoverStateText(rover, visual);
@@ -1,16 +1,16 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useControlActions, useControlSelector } from '../../controls/index.js';
import { useTelemetryFrame } from '../../context/TelemetryContext.jsx';
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
export function useManualDockAssist(options = {}) {
const { manageLifecycle = false } = options;
const roverId = useControlSelector((control) => control.state.roverId);
const active = useControlSelector((control) => Boolean(control.state.manualDockAssist?.active));
const actions = useControlActions();
const frame = useTelemetryFrame(roverId);
const sensors = frame?.sensors || {};
const chargingLabel = sensors?.chargingState?.label || '';
const docked = Boolean(sensors?.chargingSources?.homeBase);
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
const chargingLabel = dockTelemetry.chargingStateLabel || '';
const docked = Boolean(dockTelemetry.homeBase);
const charging = docked && chargingLabel.toLowerCase() !== 'not charging' && chargingLabel !== '';
const wasDockedRef = useRef(false);
@@ -3,7 +3,7 @@
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useEffect, useMemo, useState } from 'react';
import { useSession } from '../../context/SessionContext.jsx';
import { useTelemetryFrames } from '../../context/TelemetryContext.jsx';
import { useVisualTelemetryFrames } from '../../context/TelemetryContext.jsx';
import { useVideoRequests } from '../../hooks/useVideoRequests.js';
import { useRoverSnapshots } from '../../hooks/useRoverSnapshots.js';
import { useSpectatorMode } from '../../hooks/useSpectatorMode.js';
@@ -25,7 +25,7 @@ export default function MiniSummaryContent() {
useUserIdentitySync();
const inLockdown = session?.mode === 'lockdown';
const canSpectateVideo = Boolean(session?.isLocalNetwork);
const frames = useTelemetryFrames();
const frames = useVisualTelemetryFrames();
const roster = session?.roster ?? [];
const [index, setIndex] = useState(0);
const activeDrivers = session?.activeDrivers || {};