mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
slopfixing / issue 004
This commit is contained in:
@@ -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
@@ -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>
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 || {};
|
||||
|
||||
Reference in New Issue
Block a user