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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -12,7 +12,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<!-- site-metadata:inject -->
<!-- analytics:inject -->
<script type="module" crossorigin src="/assets/index-BNuqn1jZ.js"></script>
<script type="module" crossorigin src="/assets/index-CAUR32ig.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D2qTDWX0.css">
</head>
<body>
@@ -401,6 +401,15 @@ function handleWorkerMessage(message = {}) {
updateStatus('starting', 'Starting Bluetooth discovery.');
} else if (workerState === 'discovering') {
updateStatus('waiting-for-sync', 'Press the red Sync button underneath the board.');
} else if (workerState === 'device-detected') {
// Preserve the worker's exact identification stage instead of leaving the
// panel apparently unchanged when an adapter sees only the board's address.
// This is intentionally not a feed alert because ambient unresolved devices
// can appear during commissioning and the state is already visible locally.
updateStatus(
'identifying',
message.error || 'Bluetooth device detected; checking whether it is the Balance Board.',
);
} else if (workerState === 'pairing') {
updateStatus('pairing', 'Board found. Pairing now.');
} else if (workerState === 'connected') {
@@ -83,6 +83,8 @@ constexpr uint32_t kControllerFastConnectableSetting = 1U << 2;
constexpr int kManagementCommandTimeoutMs = 2000;
constexpr int kFrameIntervalMs = 50;
constexpr int kDiscoveryRestartDelayMs = 1000;
constexpr int kCandidateIdentityRetryMs = 500;
constexpr int kCandidateIdleRetryMs = 5000;
constexpr const char* kDiscoveryTimeoutSeconds = "86400";
constexpr uint16_t kHidControlPsm = 0x0011;
constexpr uint16_t kHidInterruptPsm = 0x0013;
@@ -130,6 +132,12 @@ struct RunningCommand {
std::string transcript;
};
struct DiscoveryCandidate {
BluetoothAddress address;
uint64_t last_seen_at = 0;
uint64_t next_identity_check_at = 0;
};
struct ManagementRuntimeState {
// Runtime reassertions are asynchronous so a temporary controller setting
// change cannot block PIN or HID handling. Track each outstanding opcode to
@@ -353,8 +361,9 @@ void stop_command(RunningCommand* command) {
}
}
std::optional<BluetoothAddress> take_discovered_board(RunningCommand* discovery,
bool* discovery_started) {
std::optional<BluetoothAddress> take_discovered_board(
RunningCommand* discovery, bool* discovery_started,
std::vector<DiscoveryCandidate>* candidates) {
if (!discovery) return std::nullopt;
std::size_t newline = discovery->pending_output.find('\n');
while (newline != std::string::npos) {
@@ -368,14 +377,45 @@ std::optional<BluetoothAddress> take_discovered_board(RunningCommand* discovery,
*discovery_started = true;
}
// A Classic device is initially announced by address and receives its name
// in a later change event. Parse every complete scan line so either BlueZ
// form works, but require the exact Nintendo board name before accepting an
// address. A nearby Wiimote must never become eligible for the raw PIN.
if (line.find(kBoardBluetoothName) != std::string::npos) {
const std::size_t device_prefix = line.find("Device ");
if (device_prefix != std::string::npos && line.size() >= device_prefix + 24) {
if (auto address = parse_address(line.substr(device_prefix + 7, 17))) return address;
const std::size_t device_prefix = line.find("Device ");
if (device_prefix != std::string::npos && line.size() >= device_prefix + 24) {
const auto address = parse_address(line.substr(device_prefix + 7, 17));
if (!address.has_value()) {
newline = discovery->pending_output.find('\n');
continue;
}
// The exact remote name remains definitive whenever the adapter resolves
// it during the board's short red-Sync window.
if (line.find(kBoardBluetoothName) != std::string::npos) return address;
// Some adapters initially report only "Device <address> <address>" and
// never finish remote-name resolution before the board powers down. Keep
// those unresolved devices as candidates so their BlueZ properties can be
// inspected without stopping the discovery session. Named ambient devices
// such as TVs are not candidates, which prevents normal room traffic from
// replacing the panel's useful waiting-for-Sync message.
const std::string trailing = line.substr(device_prefix + 24);
const bool address_only = trailing.empty() ||
trailing.find(address->display) != std::string::npos;
if (address_only && candidates) {
const uint64_t now = monotonic_ms();
const auto existing = std::find_if(
candidates->begin(), candidates->end(),
[&](const DiscoveryCandidate& candidate) {
return candidate.address.display == address->display;
});
if (existing == candidates->end()) {
candidates->push_back({*address, now, now});
emit_status("device-detected", address->display,
"Bluetooth device detected; checking whether it is the Balance Board.");
} else {
// A fresh scan event generally means the physical button was pressed
// again. Recheck immediately even if this candidate had previously
// fallen back to the slower idle retry interval.
existing->last_seen_at = now;
existing->next_identity_check_at = now;
}
}
}
newline = discovery->pending_output.find('\n');
@@ -383,6 +423,23 @@ std::optional<BluetoothAddress> take_discovered_board(RunningCommand* discovery,
return std::nullopt;
}
bool candidate_is_balance_board(const BluetoothAddress& address) {
const CommandResult info = run_command({
"bluetoothctl", "--timeout", "2", "info", address.display});
if (info.output.find(kBoardBluetoothName) != std::string::npos) return true;
// Original Wii input devices identify as legacy-pairing gaming peripherals.
// This fallback is deliberately applied only to an address-only device seen
// during active commissioning. That physical red-Sync action is the selection
// boundary when an adapter cannot resolve Nintendo's remote name in time.
const bool gaming_peripheral =
info.output.find("Class: 0x00002504") != std::string::npos &&
info.output.find("Icon: input-gaming") != std::string::npos;
const bool legacy_pairing =
info.output.find("LegacyPairing: yes") != std::string::npos;
return gaming_peripheral && legacy_pairing;
}
std::string command_error_summary(const std::string& raw, const std::string& fallback) {
std::string summary;
summary.reserve(std::min<std::size_t>(raw.size(), 400));
@@ -472,16 +529,37 @@ void commissioning_loop(PairingSharedState* shared) {
std::optional<BluetoothAddress> address;
bool discovery_started = false;
std::vector<DiscoveryCandidate> candidates;
while (running.load() && !address.has_value()) {
const bool discovery_running = collect_command_output(&discovery);
const bool was_started = discovery_started;
address = take_discovered_board(&discovery, &discovery_started);
address = take_discovered_board(
&discovery, &discovery_started, &candidates);
if (!was_started && discovery_started) {
// This status clears any prior scanner error and tells the browser that
// the server is genuinely listening for the board's red Sync button.
emit_status("discovering");
}
if (address.has_value()) break;
const uint64_t now = monotonic_ms();
for (auto& candidate : candidates) {
if (now < candidate.next_identity_check_at) continue;
if (candidate_is_balance_board(candidate.address)) {
address = candidate.address;
break;
}
// Query quickly while a newly pressed board is still awake, then back
// off once it has been absent for several seconds. A later scan event
// resets this deadline immediately, so another button press never waits
// for the idle interval and an unidentified device cannot cause a
// permanent stream of bluetoothctl processes.
const bool recently_seen = now - candidate.last_seen_at < 10000;
candidate.next_identity_check_at = now +
(recently_seen ? kCandidateIdentityRetryMs : kCandidateIdleRetryMs);
}
if (address.has_value()) break;
if (!discovery_running) {
const std::string detail = command_error_summary(
discovery.transcript, "bluetoothctl exited unexpectedly");
@@ -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(() => {