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
@@ -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 };
});