ui race condition fix

This commit is contained in:
legop3
2026-08-18 23:15:24 -04:00
parent 8e7d31dcdd
commit 271197f33c
16 changed files with 138 additions and 63 deletions
@@ -4,14 +4,14 @@
import { useMemo } from 'react';
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
import { dockTelemetryEqual, isDockedChargingState, resolveDocked, selectDockTelemetry } from '../../context/telemetryViews.js';
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 = resolveDocked(dockTelemetry);
const charging = isDockedChargingState(chargingLabel);
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';
@@ -4,7 +4,7 @@ import { createElement, useMemo } from 'react';
import { FaArrowDown, FaArrowUp, FaBatteryHalf, FaBolt, FaExclamationTriangle, FaMemory, FaThermometerHalf, FaWifi } from 'react-icons/fa';
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
import { useTelemetrySelector } from '../../../../context/TelemetryContext.jsx';
import { hostStatsEqual, resolveDocked, selectHostStats, selectSpectatorTelemetry, spectatorTelemetryEqual } from '../../../../context/telemetryViews.js';
import { hostStatsEqual, selectHostStats, selectSpectatorTelemetry, spectatorTelemetryEqual } from '../../../../context/telemetryViews.js';
import CornerPodToggle from './CornerPodToggle.jsx';
import ExpansionToggle from './ExpansionToggle.jsx';
import usePodVisibility from './usePodVisibility.js';
@@ -101,7 +101,7 @@ export default function TopRightPod({ roverId }) {
const memoryTone = memoryUsed >= 90 ? 'bg-red-400' : memoryUsed >= 75 ? 'bg-amber-400' : 'bg-violet-400';
const download = finite(wifi.downloadMbps);
const upload = finite(wifi.uploadMbps);
const docked = resolveDocked(electrical);
const docked = Boolean(electrical?.homeBase);
const warningMessage = urgentBattery ? 'BATTERY CRITICAL, DOCK NOW' : 'Battery low, please dock soon.';
return (
@@ -7,7 +7,7 @@ import { FaChargingStation, FaChevronDown } from 'react-icons/fa';
import { useControlActions, useControlSelector } from '../../../../controls/index.js';
import { formatKeyLabel } from '../../../../controls/keymapUtils.js';
import { useTelemetrySelector } from '../../../../context/TelemetryContext.jsx';
import { dockTelemetryEqual, resolveDocked, selectDockTelemetry } from '../../../../context/telemetryViews.js';
import { dockTelemetryEqual, selectDockTelemetry } from '../../../../context/telemetryViews.js';
import { useManualDockAssist } from '../../../../features/manualDockAssist/useManualDockAssist.js';
import { useSettingsNamespace } from '../../../../settings/index.js';
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
@@ -256,7 +256,7 @@ export default function DockingHud({ roverId }) {
const [error, setError] = useState('');
const [showUndockTransition, setShowUndockTransition] = useState(false);
const docked = resolveDocked(dockTelemetry);
const docked = Boolean(dockTelemetry?.homeBase);
const oiMode = String(dockTelemetry?.oiModeLabel || '').toLowerCase();
// The established UI contract treats exactly passive + undocked as the Roomba's
// autonomous docking attempt. Unknown telemetry must not fabricate that state.
@@ -6,7 +6,7 @@ import { FaBullhorn, FaCrosshairs, FaLightbulb } from 'react-icons/fa';
import './mobileControls.css';
import { useControlActions, useControlSelector } from '../../controls/index.js';
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
import { dockTelemetryEqual, resolveDocked, selectDockTelemetry } from '../../context/telemetryViews.js';
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
import useCanControlRover from '../../hooks/useCanControlRover.js';
import HornControl from '../HornControl/index.jsx';
@@ -37,7 +37,7 @@ function AuxColumnContent() {
// column. Dock and OI state are applied separately only where the hardware
// command itself depends on the Roomba being able to drive.
const controlsDisabled = !roverId || !canControl;
const docked = resolveDocked(dockTelemetry);
const docked = Boolean(dockTelemetry?.homeBase);
const drivingMode = String(dockTelemetry?.oiModeLabel || '').toLowerCase() === 'full';
const vacuumDisabled = controlsDisabled
|| docked
@@ -5,7 +5,7 @@ import { FaChargingStation } from 'react-icons/fa';
import { useControlSelector } from '../../controls/index.js';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
import { dockTelemetryEqual, resolveDocked, selectDockTelemetry } from '../../context/telemetryViews.js';
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
import useCanControlRover from '../../hooks/useCanControlRover.js';
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
@@ -22,7 +22,7 @@ function MovementColumnContent({ layout }) {
});
const batteryUrgent = Boolean(batteryState?.urgentActive);
const batteryLow = Boolean(batteryState?.warnActive || batteryUrgent);
const docked = resolveDocked(dockTelemetry);
const docked = Boolean(dockTelemetry?.homeBase);
const drivingMode = String(dockTelemetry?.oiModeLabel || '').toLowerCase() === 'full';
/*
+8
View File
@@ -217,6 +217,14 @@ export function TelemetryProvider({ children }) {
currentValue: selector(framesRef.current[roverId] ?? EMPTY_FRAME),
};
listeners.add(entry);
// React renders before effects subscribe. A sensor frame can therefore
// arrive after the hook's render-time read but before this entry exists.
// Publish the exact snapshot used to initialize the subscription so the
// component cannot remain stuck on that stale render-time value until a
// selected field changes again. Registering first is important: any
// frame arriving after this point will also notify the listener normally.
listener(entry.currentValue);
return () => {
const current = selectorSubscribersRef.current.get(roverId);
if (!current) return;
+3 -21
View File
@@ -37,27 +37,6 @@ const EMPTY_MAIN_BRUSH_AUDIO = Object.freeze({
mainBrushOvercurrent: false,
});
// These labels represent an active charging relationship reported by the
// Roomba. That is independently useful dock evidence: some already-docked
// rovers report charging before the home-base contact bit changes again.
const DOCKED_CHARGING_STATES = new Set([
'reconditioning charging',
'full charging',
'trickle charging',
'waiting',
]);
export function isDockedChargingState(chargingStateLabel) {
return DOCKED_CHARGING_STATES.has(String(chargingStateLabel || '').trim().toLowerCase());
}
export function resolveDocked(dockTelemetry) {
// Dock contact and active charging are complementary sensor evidence, not
// fallback state. Treating either as sufficient prevents UI controls from
// claiming a charging rover should be driven onto a dock it already occupies.
return Boolean(dockTelemetry?.homeBase) || isDockedChargingState(dockTelemetry?.chargingStateLabel);
}
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
@@ -147,6 +126,9 @@ export function selectDockTelemetry(frame) {
return {
oiModeLabel: sensors.oiMode?.label || 'Unknown',
chargingStateLabel: sensors.chargingState?.label || '',
// OI packet 34 is the authoritative charging-sources packet. Charging
// state describes the battery charger state and must not stand in for the
// physical home-base contact bit.
homeBase: Boolean(sensors.chargingSources?.homeBase),
};
}
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useControlActions, useControlSelector } from '../../controls/index.js';
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
import { dockTelemetryEqual, isDockedChargingState, resolveDocked, selectDockTelemetry } from '../../context/telemetryViews.js';
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
export function useManualDockAssist(options = {}) {
const { manageLifecycle = false } = options;
@@ -10,10 +10,8 @@ export function useManualDockAssist(options = {}) {
const actions = useControlActions();
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
const chargingLabel = dockTelemetry.chargingStateLabel || '';
const docked = resolveDocked(dockTelemetry);
// Use the same explicit charging labels that can establish dock presence.
// A non-empty fault or unknown label must not masquerade as active charging.
const charging = isDockedChargingState(chargingLabel);
const docked = Boolean(dockTelemetry.homeBase);
const charging = docked && chargingLabel.toLowerCase() !== 'not charging' && chargingLabel !== '';
const wasDockedRef = useRef(false);
const enterAssist = useCallback(() => {