basically moving docking flows to the server instead of being in the web UI

This commit is contained in:
legop3
2026-10-01 21:21:39 -04:00
parent 1ba4ebe5ff
commit ddfc889a8c
30 changed files with 482 additions and 453 deletions
@@ -11,10 +11,10 @@ export function deriveDriveDockStateFromTelemetry(dockTelemetry) {
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 charging = dockTelemetry?.dockingPhase === 'charging';
const driving = oiNormalized === 'full' && !dockTelemetry?.movementLocked;
const dockedNotCharging = docked && !charging;
const dockingInProgress = !docked && !charging && oiNormalized === 'passive';
const dockingInProgress = !docked && !charging && oiNormalized === 'passive' && dockTelemetry?.dockingPhase !== 'undocking';
return { driving, docked, charging, dockedNotCharging, dockingInProgress, oiLabel, chargingLabel };
}
+15 -44
View File
@@ -1,13 +1,12 @@
// 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 { useState } from 'react';
import { useEffect, useState } from 'react';
import '../MobileControls/mobileControls.css';
import { useControlActions, useControlSelector } from '../../controls/index.js';
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
import ControlHint from '../ControlHint/index.jsx';
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
import { deriveDriveDockStateFromTelemetry } from './driveDockState.js';
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
@@ -91,14 +90,13 @@ export default function DriveDockAction({
const isMobile = layout === 'mobile';
const roverId = useControlSelector((control) => control.state.roverId);
const actions = useControlActions();
const dockAssist = useManualDockAssist();
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
const state = driveDockState ?? deriveDriveDockStateFromTelemetry(dockTelemetry);
const { driving, docked, charging, dockingInProgress } = state;
const [pending, setPending] = useState(null);
const pending = dockTelemetry.dockingPhase === 'undocking' ? 'drive' : null;
const [confirmOpen, setConfirmOpen] = useState(false);
const [showModal, setShowModal] = useState(false);
const manualAssistActive = Boolean(dockAssist.active);
const manualAssistActive = Boolean(dockTelemetry.cameraLocked);
const driveDisabled = !roverId || pending !== null;
const dockDisabled = !roverId || pending !== null;
@@ -124,64 +122,37 @@ export default function DriveDockAction({
const chargeValue = charging ? 'Charging' : docked ? 'Charging soon...' : '—';
const chargeTone = charging ? 'good' : docked ? 'warn' : 'bad';
const handleReturnToDrive = async () => {
if (!roverId || pending) return;
if (isMobile) triggerTouchHaptic('button');
setPending('drive');
try {
dockAssist.exitAssist();
actions.setMode('drive');
await actions.runMacro('drive-sequence');
} catch (err) {
alert(err.message);
} finally {
setPending(null);
}
};
const handleStartDrive = async () => {
const handleStartDrive = () => {
if (!roverId || pending) return;
if (isMobile) triggerTouchHaptic('button');
setConfirmOpen(false);
setShowModal(false);
setPending('drive');
try {
dockAssist.exitAssist();
actions.setMode('drive');
await actions.runMacro('drive-sequence');
} catch (err) {
alert(err.message);
} finally {
setPending(null);
}
actions.undock();
};
const handleConfirmDock = async () => {
const handleConfirmDock = () => {
if (!roverId || pending) return;
if (isMobile) triggerTouchHaptic('button');
setPending('dock');
try {
dockAssist.enterAssist();
} catch (err) {
alert(err.message);
} finally {
setPending(null);
setConfirmOpen(false);
setShowModal(false);
}
actions.sendCommand('enterDocking');
setConfirmOpen(false);
setShowModal(false);
};
const handleOpenDock = () => {
if (dockDisabled) return;
if (isMobile) triggerTouchHaptic('button');
if (manualAssistActive) {
dockAssist.exitAssist();
actions.sendCommand('cancelDocking');
return;
}
setShowModal(true);
setConfirmOpen(true);
};
useEffect(() => {
if (dockTelemetry.dockingError) alert(dockTelemetry.dockingError);
}, [dockTelemetry.dockingError]);
// The drive/dock card appears inside the mobile controls and can be held or
// tapped repeatedly; attach mobile touch suppression directly to the card so
// Safari does not select labels or open callouts during those interactions.
@@ -254,7 +225,7 @@ export default function DriveDockAction({
<button
type="button"
disabled={driveDisabled}
onClick={handleReturnToDrive}
onClick={handleStartDrive}
onContextMenu={(event) => event.preventDefault()}
className={`${baseCardClasses} ${filledHeight} ${compactHeight} ${ctaText} ${layoutClass} ${ctaSize} ${amberCta}`}
>
+1 -1
View File
@@ -48,7 +48,7 @@ export default function DriverVideo({ layoutFormat = 'desktop' }) {
mobileHud={mobileHud}
labelScale={1}
/>
<ManualDockAssistOverlay mobileHud={mobileHud} />
<ManualDockAssistOverlay roverId={roverId} mobileHud={mobileHud} />
<HudChatInput compact={mobileHud} />
<OvercurrentOverlay compact={mobileHud} />
<LowBatteryOverlay compact={mobileHud} />
@@ -56,7 +56,7 @@ export const ACTIONS = [
{ id: 'allAux', label: 'All cleaning motors', kind: 'button', section: 'Aux buttons' },
{ id: 'mainReverse', label: 'Main reverse toggle', kind: 'button', section: 'Brush toggles' },
{ id: 'sideReverse', label: 'Side reverse toggle', kind: 'button', section: 'Brush toggles' },
{ id: 'driveMacro', label: 'Drive / undock sequence', kind: 'button', section: 'Mode controls' },
{ id: 'driveMacro', label: 'Drive / undock', kind: 'button', section: 'Mode controls' },
{ id: 'dockMacro', label: 'Manual docking assist', kind: 'button', section: 'Mode controls' },
{ id: 'headlightToggle', label: 'Headlight toggle', kind: 'button', section: 'Camera' },
{ id: 'laserToggle', label: 'Laser toggle', kind: 'button', section: 'Camera' },
@@ -1,23 +1,24 @@
import { dockTelemetryEqual, selectDockTelemetry } from '../../../context/telemetryViews.js';
import { useTelemetrySelector } from '../../../context/TelemetryContext.jsx';
import React, { useEffect, useRef, useState } from 'react';
import { useManualDockAssist } from '../../../features/manualDockAssist/useManualDockAssist.js';
function ManualDockAssistOverlay({ mobileHud = false }) {
const { visible, statusLabel, statusTone, active, charging } = useManualDockAssist({ manageLifecycle: true });
function ManualDockAssistOverlay({ roverId, mobileHud = false }) {
const telemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
const active = telemetry.cameraLocked;
const charging = telemetry.dockingPhase === 'charging';
const visible = active || telemetry.homeBase;
const statusLabel = charging ? 'Docked and charging' : telemetry.homeBase ? 'Docked' : 'Docking assist active';
const statusTone = charging ? 'good' : telemetry.homeBase ? 'warn' : 'active';
const [popupMessage, setPopupMessage] = useState('');
const timerRef = useRef(null);
const prevActiveRef = useRef(active);
const [previousActive, setPreviousActive] = useState(active);
useEffect(() => {
const prevActive = prevActiveRef.current;
const enabledNow = active && !prevActive;
const autoDisabledOnCharge = !active && prevActive && charging;
if (enabledNow) {
setPopupMessage('Dock assist mode enabled');
} else if (autoDisabledOnCharge) {
setPopupMessage('Docking successful! Thank you!');
}
prevActiveRef.current = active;
}, [active, charging]);
// These messages are presentation only; telemetry owns the operation itself.
if (previousActive !== active) {
setPreviousActive(active);
if (active) setPopupMessage('Dock assist mode enabled');
else if (charging) setPopupMessage('Docking successful! Thank you!');
}
useEffect(() => {
if (!popupMessage) return undefined;
@@ -1,3 +1,5 @@
import { useTelemetrySelector } from '../../../../context/TelemetryContext.jsx';
import { selectDockTelemetry, dockTelemetryEqual } from '../../../../context/telemetryViews.js';
// Bottom-right Corner Pod
// Purpose: Provides a compact circular camera-tilt control using the existing servo command path.
import { useCallback } from 'react';
@@ -31,7 +33,7 @@ export default function BottomRightPod({ roverId }) {
const layout = useLayout();
const [open, setOpen] = usePodVisibility('camera', true);
const camera = useControlSelector((control) => control.state.camera);
const dockAssistActive = useControlSelector((control) => Boolean(control.state.manualDockAssist?.active));
const { cameraLocked } = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
const { setServoAngle } = useControlActions();
const canControl = useCanControlRover(roverId);
const config = camera?.config;
@@ -47,7 +49,7 @@ export default function BottomRightPod({ roverId }) {
const cameraPodOpen = showCameraControls && enabled && open;
const updateFromPointer = useCallback((event) => {
if (!canControl || !enabled || dockAssistActive || !(max > min)) return;
if (!canControl || !enabled || cameraLocked || !(max > min)) return;
const bounds = event.currentTarget.getBoundingClientRect();
const x = ((event.clientX - bounds.left) / bounds.width) * 144;
const y = ((event.clientY - bounds.top) / bounds.height) * 144;
@@ -64,7 +66,7 @@ export default function BottomRightPod({ roverId }) {
}
const nextFraction = arcDegrees / ARC_SWEEP_DEGREES;
setServoAngle(min + nextFraction * (max - min));
}, [canControl, dockAssistActive, enabled, max, min, setServoAngle]);
}, [canControl, cameraLocked, enabled, max, min, setServoAngle]);
return (
<>
@@ -8,7 +8,6 @@ import { useControlActions } from '../../../../controls/index.js';
import ControlHint from '../../../ControlHint/index.jsx';
import { useTelemetrySelector } from '../../../../context/TelemetryContext.jsx';
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';
import { useLayout } from '../../../../layouts/LayoutContext.jsx';
@@ -253,9 +252,6 @@ export default function DockingHud({ roverId }) {
const layout = useLayout();
const actions = useControlActions();
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
// This replaces ManualDockAssistOverlay as the current HUD's one lifecycle owner. It preserves the
// success sounds, camera positioning, speed cap, and automatic exit after charging begins.
const dockAssist = useManualDockAssist({ manageLifecycle: true });
const canControl = useCanControlRover(roverId);
const batteryState = useSessionSelector((state) => {
const rover = (state.session?.roster || []).find((entry) => String(entry.id) === String(roverId));
@@ -268,15 +264,14 @@ export default function DockingHud({ roverId }) {
const [dockExpansionOpen, setDockExpansionOpen] = usePodVisibility('dockAssist', true);
// The action name is presentation state as well as busy state. Keeping the
// reason prevents a passive, already-undocked rover from ever saying "Undocking".
const [pendingAction, setPendingAction] = useState(null);
const [error, setError] = useState('');
const [showUndockTransition, setShowUndockTransition] = useState(false);
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.
const autoDocking = !docked && !dockAssist.active && oiMode === 'passive';
const autoDocking = !docked && !dockTelemetry.cameraLocked && oiMode === 'passive';
const pendingAction = (dockTelemetry.dockingPhase === 'undocking') ? (docked ? 'undocking' : 'resuming') : null;
const pending = pendingAction !== null;
/* Mobile already presents its own touch-oriented driving controls. The docked
action therefore keeps its plain-language instruction without advertising a
@@ -287,55 +282,22 @@ export default function DockingHud({ roverId }) {
// The camera arc is the shared circular-pod reference size. Keep the dock expansion flush
// against the battery shell after enlarging that gauge to the same 8.5-rem footprint.
const cornerOffsetClass = batteryPodOpen ? 'right-34' : 'right-0';
const previousDockedRef = useRef(docked);
const [previousDocked, setPreviousDocked] = useState(docked);
const finishUndockTransition = useCallback(() => setShowUndockTransition(false), []);
useEffect(() => {
const wasDocked = previousDockedRef.current;
// Only a real live transition plays the cue. An already-undocked rover must not animate
// merely because the user opened or refreshed the page.
if (wasDocked && !docked) {
setShowUndockTransition(true);
} else if (docked) {
setShowUndockTransition(false);
}
previousDockedRef.current = docked;
}, [docked]);
// Presentation-only transition; mounting with an undocked rover does not animate.
if (previousDocked !== docked) {
setPreviousDocked(docked);
setShowUndockTransition(!docked);
}
const startDriving = async (action) => {
const startDriving = () => {
if (!roverId || pending || !canControl) return;
setPendingAction(action);
setError('');
try {
// The established drive sequence is the canonical undock path: it restores the camera,
// enters full Open Interface mode, and performs the short physical back-away from the dock.
dockAssist.exitAssist();
actions.setMode('drive');
await actions.runMacro('drive-sequence');
} catch (caughtError) {
setError(caughtError?.message || 'Unable to start driving. Please try again.');
} finally {
setPendingAction(null);
}
actions.undock();
};
const startUndocking = () => {
startDriving('undocking');
};
const resumeDriving = () => {
startDriving('resuming');
};
const startDocking = () => {
if (!roverId || pending || !canControl) return;
setError('');
try {
// Enter on the first click. The explanation appears as the resulting active state instead
// of forcing the user through a modal and a second confirmation action.
dockAssist.enterAssist();
} catch (caughtError) {
setError(caughtError?.message || 'Unable to start dock assist. Please try again.');
}
actions.sendCommand('enterDocking');
};
return (
@@ -358,20 +320,20 @@ export default function DockingHud({ roverId }) {
driveKeyLabel={driveKeyLabel}
pending={pendingAction === 'undocking'}
controlsDisabled={!canControl}
error={error}
onUndock={startUndocking}
error={dockTelemetry.dockingError}
onUndock={startDriving}
/>
) : autoDocking || pendingAction === 'resuming' ? (
<AutoDockingAction
driveKeyLabel={driveKeyLabel}
pending={pendingAction === 'resuming'}
controlsDisabled={!canControl}
error={error}
onResumeDriving={resumeDriving}
error={dockTelemetry.dockingError}
onResumeDriving={startDriving}
/>
) : (
<>
{dockAssist.active ? (
{dockTelemetry.cameraLocked ? (
/* Dock assist needs the unobscured camera image. A thin cyan frame communicates the
temporary mode without adding another instruction surface over the video. */
<div className="pointer-events-none absolute inset-0 z-50 ring-8 ring-inset ring-cyan-200/90" aria-hidden="true" />
@@ -379,15 +341,15 @@ export default function DockingHud({ roverId }) {
{/* Desktop keeps the compact corner entry point. Mobile starts assist from
its dedicated control column, but once active it still mounts this component
so the centered camera instruction and cancel action remain available. */}
{layout === 'desktop' || dockAssist.active ? (
{layout === 'desktop' || dockTelemetry.cameraLocked || dockTelemetry.dockingError ? (
<DockAssistAction
active={dockAssist.active}
active={dockTelemetry.cameraLocked}
pending={pending}
controlsDisabled={!canControl}
error={error}
error={dockTelemetry.dockingError}
dockKeyLabel={dockKeyLabel}
onDock={startDocking}
onCancel={dockAssist.exitAssist}
onCancel={() => actions.sendCommand('cancelDocking')}
cornerOffsetClass={cornerOffsetClass}
open={dockExpansionOpen}
onOpenChange={setDockExpansionOpen}
@@ -7,7 +7,6 @@ import './mobileControls.css';
import { useControlActions, useControlSelector } from '../../controls/index.js';
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
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';
import GPIOToggleControl from '../GPIOToggleControl/index.jsx';
@@ -33,7 +32,6 @@ function AuxColumnContent({ accessoriesAvailable, onShowAccessories }) {
const laserState = useControlSelector((control) => control.pipeline?.laserState);
const pipelineHorn = useControlSelector((control) => control.pipeline?.horn);
const { setServoAngle, setHeadlight, setLaser, setAuxMotors, startHorn, stopHorn } = useControlActions();
const dockAssist = useManualDockAssist();
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
const canControl = useCanControlRover(roverId);
// Turn ownership is the common mutation boundary for every control in this
@@ -44,7 +42,8 @@ function AuxColumnContent({ accessoriesAvailable, onShowAccessories }) {
const drivingMode = String(dockTelemetry?.oiModeLabel || '').toLowerCase() === 'full';
const vacuumDisabled = controlsDisabled
|| docked
|| (!drivingMode && !dockAssist.active);
|| dockTelemetry.movementLocked
|| (!drivingMode && !dockTelemetry.cameraLocked);
const activeAuxButtonRef = useRef(null);
const cameraConfig = camera?.config;
const cameraEnabled = Boolean(roverId && camera?.enabled && cameraConfig);
@@ -60,7 +59,7 @@ function AuxColumnContent({ accessoriesAvailable, onShowAccessories }) {
: typeof cameraConfig?.homeAngle === 'number'
? cameraConfig.homeAngle
: (cameraMin + cameraMax) / 2;
const cameraDisabled = Boolean(controlsDisabled || dockAssist.cameraLocked);
const cameraDisabled = Boolean(controlsDisabled || dockTelemetry.cameraLocked);
/*
The mobile tilt track shares the same precision flag as desktop tilt. This
keeps the servo fine-step behavior tied to the selected movement mode rather
@@ -2,19 +2,18 @@
// Purpose: Assembles the mobile movement column, which is the right column by default.
// Scope: Owns movement availability while the in-video DockingHud owns every dock/undock action.
import { FaChargingStation } from 'react-icons/fa';
import { useControlSelector } from '../../controls/index.js';
import { useControlActions, useControlSelector } from '../../controls/index.js';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
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';
import ControlPadPanel from './ControlPadPanel.jsx';
function MovementColumnContent({ layout }) {
const roverId = useControlSelector((control) => control.state.roverId);
const actions = useControlActions();
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
const dockAssist = useManualDockAssist();
const canControl = useCanControlRover(roverId);
const batteryState = useSessionSelector((state) => {
const rover = (state.session?.roster || []).find((entry) => String(entry.id) === String(roverId));
@@ -25,20 +24,11 @@ function MovementColumnContent({ layout }) {
const docked = Boolean(dockTelemetry?.homeBase);
const drivingMode = String(dockTelemetry?.oiModeLabel || '').toLowerCase() === 'full';
/*
DockingHud is now the only place where a user starts driving or enters docking
assist. This column only decides whether touch movement is safe. Requiring both
an undocked rover and full OI mode keeps the pad inert throughout the undock
macro, including the interval where dock contact and OI mode change separately.
Manual assist is the deliberate exception to the normal OI-mode requirement:
its entire purpose is to let the user drive onto the dock under the assist speed
cap. Actual dock contact still disables movement immediately.
*/
const movementDisabled = !roverId
|| !canControl
|| docked
|| (!drivingMode && !dockAssist.active);
|| dockTelemetry.movementLocked
|| (!drivingMode && !dockTelemetry.cameraLocked);
// Both controls share the same hardware-availability boundary. Dock assist
// changes their actions, not who is allowed to command the rover.
const dockActionDisabled = movementDisabled;
@@ -49,10 +39,10 @@ function MovementColumnContent({ layout }) {
// Mobile deliberately enters assist on the first tap. The centered video HUD
// provides the resulting instruction, so a confirmation modal would only add
// friction between intent and the camera-guided docking task.
if (dockAssist.active) {
dockAssist.exitAssist();
if (dockTelemetry.cameraLocked) {
actions.sendCommand('cancelDocking');
} else {
dockAssist.enterAssist();
actions.sendCommand('enterDocking');
}
};
@@ -63,7 +53,7 @@ function MovementColumnContent({ layout }) {
disabled={dockActionDisabled}
onClick={handleDockAction}
className={`mobile-touch-control flex min-h-18 shrink-0 items-center justify-center gap-1.5 rounded-xl border-2 px-2 text-base font-semibold shadow-md transition disabled:cursor-not-allowed disabled:opacity-50 ${
dockAssist.active
dockTelemetry.cameraLocked
? 'border-cyan-300/70 bg-cyan-900 text-cyan-50'
: batteryUrgent
? 'border-red-300/80 bg-red-950 text-red-50'
@@ -75,7 +65,7 @@ function MovementColumnContent({ layout }) {
<FaChargingStation className="text-lg" aria-hidden="true" />
{/* The mobile column remains the only mobile entry point into dock assist. Battery
severity makes that existing action more obvious without adding another control. */}
<span>{dockAssist.active ? 'Exit dock assist' : batteryUrgent ? 'Dock now' : batteryLow ? 'Dock and charge soon' : 'Dock and charge'}</span>
<span>{dockTelemetry.cameraLocked ? 'Exit dock assist' : batteryUrgent ? 'Dock now' : batteryLow ? 'Dock and charge soon' : 'Dock and charge'}</span>
</button>
{/* Keeping the pad mounted prevents the mobile columns from changing size while
the centered video HUD explains and performs dock-related transitions. */}
+2 -2
View File
@@ -411,7 +411,7 @@ export default function SettingsPanel() {
<span className="font-semibold text-white">Swap control columns (put joystick on the left)</span>
</SettingRow>
</CardFrame>
<CardFrame title="Macros" bodyClassName="space-y-1 p-1 text-sm">
<CardFrame title="Driving" bodyClassName="space-y-1 p-1 text-sm">
<SettingRow inline>
<input
type="checkbox"
@@ -419,7 +419,7 @@ export default function SettingsPanel() {
checked={driveMacroBackoffEnabled}
onChange={handleDriveMacroBackoffEnabled}
/>
<span className="font-semibold text-white">Enable backward bump in drive macro</span>
<span className="font-semibold text-white">Back away from the dock when undocking</span>
</SettingRow>
</CardFrame>
<CardFrame title="Audio" className="col-span-full" bodyClassName="space-y-1 p-1 text-sm">
+8
View File
@@ -11,6 +11,10 @@ const EMPTY_BATTERY_TELEMETRY = Object.freeze({
});
const EMPTY_DOCK_TELEMETRY = Object.freeze({
dockingPhase: 'idle',
movementLocked: true,
cameraLocked: false,
dockingError: '',
oiModeLabel: 'Unknown',
chargingStateLabel: '',
homeBase: false,
@@ -127,6 +131,10 @@ export function selectDockTelemetry(frame) {
const sensors = frame?.sensors;
if (!sensors) return EMPTY_DOCK_TELEMETRY;
return {
dockingPhase: sensors.docking?.phase || 'idle',
movementLocked: sensors.docking?.movementLocked ?? true,
cameraLocked: Boolean(sensors.docking?.cameraLocked),
dockingError: sensors.docking?.error?.message || '',
oiModeLabel: sensors.oiMode?.label || 'Unknown',
chargingStateLabel: sensors.chargingState?.label || '',
// OI packet 34 is the authoritative charging-sources packet. Charging
+33 -113
View File
@@ -17,13 +17,11 @@ import { computeDifferentialSpeeds, clamp } from './controlMath.js';
import { useCommandPipeline } from './commandPipeline.js';
import {
DEFAULT_KEYMAP,
DEFAULT_MACROS,
HORN_HEAT_COOL_PER_SEC,
HORN_HEAT_RESUME_THRESHOLD,
HORN_HEAT_UP_PER_SEC,
HORN_MAX_FREQUENCY,
HORN_MAX_MS,
MANUAL_DOCK_ASSIST_MAX_SPEED,
SONG_DEFAULT_NOTE,
} from './constants.js';
import { canonicalizeKeyInput } from './keymapUtils.js';
@@ -32,6 +30,8 @@ import { useSessionActions, useSessionSelector } from '../context/SessionContext
import { HORN_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
import { useOvercurrentLimiter } from './overcurrentLimiter.js';
import { usePtzControlAdapter } from './ptzControlAdapter.js';
import { useTelemetrySelector } from '../context/TelemetryContext.jsx';
import { selectDockTelemetry, dockTelemetryEqual } from '../context/telemetryViews.js';
import { trackAnalyticsEvent } from '../analytics/index.js';
const ControlSystemContext = createContext(null);
@@ -49,7 +49,8 @@ const CONTROL_ACTION_NAMES = [
'setCameraAxisIntent',
'goServoHome',
'setCameraPrecisionMode',
'runMacro',
'undock',
'sendCommand',
'stopAllMotion',
'sendOiCommand',
'setSensorStream',
@@ -60,8 +61,6 @@ const CONTROL_ACTION_NAMES = [
'updateKeyBinding',
'resetKeyBindings',
'registerInputState',
'setManualDockAssistActive',
'toggleManualDockAssist',
'setSongNote',
'sendSong',
'startHorn',
@@ -83,23 +82,6 @@ function clampServoAngle(config, value) {
return clamp(value, min, max);
}
function removeDriveSequenceBackoff(steps = []) {
if (!Array.isArray(steps) || steps.length < 3) return steps;
const last3 = steps.slice(-3);
const [backoffStep, pauseStep, stopStep] = last3;
const isBackoffStep =
backoffStep?.type === 'drive' &&
Number(backoffStep?.speeds?.left) === -300 &&
Number(backoffStep?.speeds?.right) === -300;
const isPauseStep = pauseStep?.type === 'pause' && Number(pauseStep?.duration) === 600;
const isStopStep =
stopStep?.type === 'drive' &&
Number(stopStep?.speeds?.left) === 0 &&
Number(stopStep?.speeds?.right) === 0;
if (!isBackoffStep || !isPauseStep || !isStopStep) return steps;
return steps.slice(0, -3);
}
export function ControlSystemProvider({ children }) {
const [state, dispatch] = useReducer(controlReducer, initialControlState);
const snapshotRef = useRef(null);
@@ -158,7 +140,7 @@ export function ControlSystemProvider({ children }) {
const {
value: controlSettings,
save: saveControlSettings,
} = useSettingsNamespace('controls', { keymap: DEFAULT_KEYMAP, macros: DEFAULT_MACROS });
} = useSettingsNamespace('controls', { keymap: DEFAULT_KEYMAP });
const { value: pageSettings } = useSettingsNamespace('page', { driveMacroBackoffEnabled: true });
const { value: hornSettings } = useSettingsNamespace('horn', HORN_SETTINGS_DEFAULTS);
const driveMacroBackoffEnabled =
@@ -166,6 +148,8 @@ export function ControlSystemProvider({ children }) {
? pageSettings.driveMacroBackoffEnabled
: true;
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
const cameraLocked = Boolean(dockTelemetry.cameraLocked);
const homeAssistantEntities = useSessionSelector((state) => state.session?.homeAssistant?.entities ?? []);
const roomLightsLocked = useSessionSelector((state) =>
Boolean(state.session?.homeAssistant?.lightPolicy?.locked || state.session?.homeAssistant?.lightPolicy?.lockedOn),
@@ -238,10 +222,7 @@ export function ControlSystemProvider({ children }) {
useEffect(() => {
const mergedKeymap = { ...DEFAULT_KEYMAP, ...(controlSettings?.keymap || {}) };
dispatch({ type: 'control/set-keymap', payload: mergedKeymap });
if (controlSettings?.macros) {
dispatch({ type: 'control/set-macros', payload: controlSettings.macros });
}
}, [controlSettings?.keymap, controlSettings?.macros]);
}, [controlSettings?.keymap]);
useEffect(() => {
const config = pipeline.servoConfig;
@@ -268,11 +249,10 @@ export function ControlSystemProvider({ children }) {
typeof state.camera.angle === 'number' ? state.camera.angle : servoAngleRef.current;
}, [state.camera.angle]);
const { enableSensorStream } = pipeline;
useEffect(() => {
if (pipeline.roverId) {
pipeline.enableSensorStream();
}
}, [pipeline.roverId, pipeline.enableSensorStream]);
if (roverId) enableSensorStream();
}, [roverId, enableSensorStream]);
const setMode = useCallback(
(mode) => {
@@ -295,21 +275,6 @@ export function ControlSystemProvider({ children }) {
const setDriveVector = useCallback(
(vector, meta = {}) => {
const speedOptions = { ...(meta.speedOptions || {}) };
if (state.manualDockAssist?.active) {
const capped = Math.max(1, Math.min(MANUAL_DOCK_ASSIST_MAX_SPEED, 500));
speedOptions.maxSpeed = Math.min(
typeof speedOptions.maxSpeed === 'number' ? speedOptions.maxSpeed : capped,
capped,
);
speedOptions.baseSpeed = Math.min(
typeof speedOptions.baseSpeed === 'number' ? speedOptions.baseSpeed : capped,
capped,
);
speedOptions.boostSpeed = Math.min(
typeof speedOptions.boostSpeed === 'number' ? speedOptions.boostSpeed : capped,
capped,
);
}
const computed = computeDifferentialSpeeds(vector, speedOptions);
const isMoving = computed.speeds.left !== 0 || computed.speeds.right !== 0;
if (pipeline.roverId && isMoving && driveEngagementRoverRef.current !== pipeline.roverId) {
@@ -327,7 +292,7 @@ export function ControlSystemProvider({ children }) {
if (ptzControls.applyDriveVector(vector, meta)) return;
pipeline.sendDriveDirect(computed.speeds);
},
[pipeline, ptzControls, recordControlIntent, state.manualDockAssist?.active],
[pipeline, ptzControls, recordControlIntent],
);
const setAuxMotors = useCallback(
@@ -369,23 +334,22 @@ export function ControlSystemProvider({ children }) {
}, [saveControlSettings]);
const setServoAngle = useCallback(
(value, options = {}) => {
(value) => {
/*
Absolute servo positions belong only to rover hardware. PTZ zoom now
enters through setCameraAxisIntent as a signed held velocity, so this
function must not infer zoom direction by comparing unrelated absolute
angle values from gamepad/manual-dock callers.
angle values from camera controls.
*/
if (!pipeline.servoConfig) return;
const force = Boolean(options?.force);
if (state.manualDockAssist?.active && !force) return;
if (cameraLocked) return;
const clamped = clampServoAngle(pipeline.servoConfig, value);
dispatch({ type: 'control/set-camera-angle', payload: clamped });
pipeline.sendServoAngle(clamped);
servoAngleRef.current = clamped;
recordControlIntent();
},
[pipeline, recordControlIntent, state.manualDockAssist?.active],
[pipeline, recordControlIntent, cameraLocked],
);
const nudgeServo = useCallback(
@@ -457,35 +421,19 @@ export function ControlSystemProvider({ children }) {
dispatch({ type: 'control/set-camera-precision-mode', payload: Boolean(active) });
}, []);
const runMacro = useCallback(
async (macroId) => {
const macro = state.macros.find((item) => item.id === macroId) || null;
if (!macro) return;
let macroToRun = macro;
if (macroId === 'drive-sequence') {
turnOnAllLights();
if (!homeAssistantEntities?.length) {
pendingLightsRef.current = true;
}
recordControlIntent();
if (!driveMacroBackoffEnabled) {
macroToRun = {
...macro,
steps: removeDriveSequenceBackoff(macro.steps),
};
}
}
await pipeline.runMacroSteps(macroToRun);
},
[
driveMacroBackoffEnabled,
pipeline,
recordControlIntent,
homeAssistantEntities,
state.macros,
turnOnAllLights,
],
);
const sendCommand = useCallback((type, data = {}) => {
recordControlIntent();
pipeline.emitCommand({ type, data }, (result) => {
if (result?.error) alert(result.error);
});
}, [pipeline, recordControlIntent]);
const undock = useCallback(() => {
dispatch({ type: 'control/set-mode', payload: 'drive' });
turnOnAllLights();
if (!homeAssistantEntities?.length) pendingLightsRef.current = true;
sendCommand('undock', { backoff: driveMacroBackoffEnabled });
}, [driveMacroBackoffEnabled, homeAssistantEntities, turnOnAllLights, sendCommand]);
const stopAllMotion = useCallback(() => {
dispatch({
@@ -691,32 +639,6 @@ export function ControlSystemProvider({ children }) {
return () => clearInterval(interval);
}, [dispatch, hornNeedsTick]);
const setManualDockAssistActive = useCallback(
(active) => {
const nextActive = Boolean(active);
const prevActive = Boolean(state.manualDockAssist?.active);
if (prevActive === nextActive) return;
dispatch({ type: 'control/set-manual-dock-assist', payload: nextActive });
if (!nextActive) {
pipeline.sendSong([{ note: 83, duration: 10 }, { note: 76, duration: 10 }], { slot: 0 });
setServoAngle(0, { force: true });
return;
}
const minAngle =
typeof pipeline.servoConfig?.minAngle === 'number'
? pipeline.servoConfig.minAngle
: -45;
setServoAngle(minAngle, { force: true });
pipeline.sendSong([{ note: 76, duration: 10 }, { note: 83, duration: 10 }], { slot: 0 });
recordControlIntent();
},
[pipeline, recordControlIntent, setServoAngle, state.manualDockAssist?.active],
);
const toggleManualDockAssist = useCallback(() => {
setManualDockAssistActive(!state.manualDockAssist?.active);
}, [setManualDockAssistActive, state.manualDockAssist?.active]);
const registerInputState = useCallback((source, data) => {
dispatch({ type: 'control/register-input-state', payload: { source, state: data } });
}, []);
@@ -758,7 +680,8 @@ export function ControlSystemProvider({ children }) {
setCameraAxisIntent,
goServoHome,
setCameraPrecisionMode,
runMacro,
undock,
sendCommand,
stopAllMotion,
sendOiCommand,
setSensorStream,
@@ -769,8 +692,6 @@ export function ControlSystemProvider({ children }) {
updateKeyBinding,
resetKeyBindings,
registerInputState,
setManualDockAssistActive,
toggleManualDockAssist,
setSongNote,
sendSong,
startHorn,
@@ -787,7 +708,8 @@ export function ControlSystemProvider({ children }) {
setCameraAxisIntent,
goServoHome,
setCameraPrecisionMode,
runMacro,
undock,
sendCommand,
stopAllMotion,
sendOiCommand,
setSensorStream,
@@ -798,8 +720,6 @@ export function ControlSystemProvider({ children }) {
updateKeyBinding,
resetKeyBindings,
registerInputState,
setManualDockAssistActive,
toggleManualDockAssist,
setSongNote,
sendSong,
startHorn,
+12 -41
View File
@@ -5,13 +5,12 @@ import { useSocket } from '../context/SocketContext.jsx';
import { useSessionSelector } from '../context/SessionContext.jsx';
import {
AUX_LIMITS,
COMMAND_DELAY_MS,
OI_COMMANDS,
SONG_DEFAULT_DURATION,
SONG_DEFAULT_NOTE,
SONG_NOTE_RANGE,
} from './constants.js';
import { bytesToBase64, clampRange, sleep } from './controlMath.js';
import { bytesToBase64, clampRange } from './controlMath.js';
export function useCommandPipeline(options = {}) {
const { driveTransform, auxTransform } = options;
@@ -53,8 +52,17 @@ export function useCommandPipeline(options = {}) {
const laserState = useMemo(() => rosterEntry?.laser?.state ?? null, [rosterEntry]);
const emitCommand = useCallback(
(payload, cb) => {
if (!roverId) return;
socket.emit('command', { roverId, ...payload }, cb);
if (!roverId || !socket.connected) {
cb?.({ error: 'Rover control connection unavailable' });
return;
}
if (cb) {
socket.timeout(5000).emit('command', { roverId, ...payload }, (error, result) => {
cb(error ? { error: 'Rover command acknowledgement timed out' } : result);
});
} else {
socket.emit('command', { roverId, ...payload });
}
},
[socket, roverId],
);
@@ -142,41 +150,6 @@ export function useCommandPipeline(options = {}) {
[emitCommand, enableSensorStream, roverId],
);
const runMacroSteps = useCallback(
async (macro) => {
if (!macro || !Array.isArray(macro.steps) || !roverId) return;
for (const step of macro.steps) {
if (!roverId) break;
switch (step.type) {
case 'oi':
sendOiCommand(step.command);
break;
case 'drive':
sendDriveDirect(step.speeds ?? { left: 0, right: 0 });
break;
case 'motors':
sendAuxMotors(step.values ?? {});
break;
case 'servo':
sendServoAngle(step.angle);
break;
case 'pause':
await sleep(step.duration ?? COMMAND_DELAY_MS); // eslint-disable-line no-await-in-loop
break;
default:
break;
}
if (step.delay || step.delayMs) {
const delay = step.delayMs ?? step.delay;
if (typeof delay === 'number' && delay > 0) {
await sleep(delay); // eslint-disable-line no-await-in-loop
}
}
}
},
[roverId, sendOiCommand, sendDriveDirect, sendAuxMotors, sendServoAngle],
);
const sendHeadlight = useCallback(
(action = 'toggle') => {
if (!roverId || !headlight) return null;
@@ -277,7 +250,6 @@ export function useCommandPipeline(options = {}) {
sendHorn,
sendPeripheralControl,
sendSong,
runMacroSteps,
}),
[
roverId,
@@ -300,7 +272,6 @@ export function useCommandPipeline(options = {}) {
sendHorn,
sendPeripheralControl,
sendSong,
runMacroSteps,
],
);
}
-24
View File
@@ -12,10 +12,6 @@ export const DRIVE_LIMITS = {
boostSpeed: 400,
};
export const MANUAL_DOCK_ASSIST_MAX_SPEED = 60;
export const COMMAND_DELAY_MS = 100;
export const SONG_NOTE_RANGE = [31, 127];
export const SONG_DEFAULT_NOTE = 60;
export const SONG_DEFAULT_DURATION = 8;
@@ -64,23 +60,3 @@ export const DEFAULT_KEYMAP = {
homeAssistantOn: ['ArrowRight'],
homeAssistantOff: ['ArrowLeft'],
};
export const DEFAULT_MACROS = [
{
id: 'drive-sequence',
label: 'Drive',
description: 'Start, undock, and full command sequence used by the drive button.',
steps: [
{ type: 'servo', angle: 0 },
{ type: 'oi', command: 'start' },
{ type: 'pause', duration: COMMAND_DELAY_MS },
{ type: 'oi', command: 'dock' },
{ type: 'pause', duration: COMMAND_DELAY_MS },
{ type: 'oi', command: 'full' },
{ type: 'pause', duration: 300 },
{ type: 'drive', speeds: { left: -300, right: -300 } },
{ type: 'pause', duration: 600 },
{ type: 'drive', speeds: { left: 0, right: 0 } },
],
},
];
-4
View File
@@ -55,7 +55,3 @@ export function bytesToBase64(bytes) {
}
throw new Error('No base64 encoder available');
}
export function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
+1 -23
View File
@@ -1,6 +1,6 @@
// Control State Reducer
// Purpose: Implements reducer transitions for control-system runtime state. Scope: Centralizes deterministic state updates for input, mode, and dispatch events.
import { DEFAULT_KEYMAP, DEFAULT_MACROS, SONG_DEFAULT_NOTE } from './constants.js';
import { DEFAULT_KEYMAP, SONG_DEFAULT_NOTE } from './constants.js';
function createDriveState() {
return {
@@ -49,12 +49,6 @@ function createMicState() {
};
}
function createManualDockAssistState() {
return {
active: false,
};
}
export const initialControlState = {
roverId: null,
mode: 'drive',
@@ -64,9 +58,7 @@ export const initialControlState = {
song: createSongState(),
horn: createHornState(),
mic: createMicState(),
manualDockAssist: createManualDockAssistState(),
lastControlIntentAt: 0,
macros: DEFAULT_MACROS,
keymap: DEFAULT_KEYMAP,
inputs: {},
// Generic accessory controls do not currently report state back from roverd.
@@ -86,7 +78,6 @@ export function controlReducer(state, action) {
song: action.payload ? state.song : createSongState(),
horn: action.payload ? state.horn : createHornState(),
mic: action.payload ? state.mic : createMicState(),
manualDockAssist: action.payload ? state.manualDockAssist : createManualDockAssistState(),
lastControlIntentAt: action.payload ? state.lastControlIntentAt : 0,
};
case 'control/set-mode':
@@ -169,11 +160,6 @@ export function controlReducer(state, action) {
...state,
keymap: { ...state.keymap, ...(action.payload ?? {}) },
};
case 'control/set-macros':
return {
...state,
macros: Array.isArray(action.payload) ? action.payload : state.macros,
};
case 'control/reset':
return {
...state,
@@ -222,14 +208,6 @@ export function controlReducer(state, action) {
pttActive: Boolean(action.payload),
},
};
case 'control/set-manual-dock-assist':
return {
...state,
manualDockAssist: {
...(state.manualDockAssist || createManualDockAssistState()),
active: Boolean(action.payload),
},
};
case 'control/set-peripheral-value': {
const roverId = String(action.payload?.roverId || '');
const peripheralId = String(action.payload?.peripheralId || '');
@@ -1,3 +1,5 @@
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
// Gamepad Input Manager
// Purpose: Converts polled gamepad state into normalized control actions/commands. Scope: Integrates bindings, deadzone math, and dispatch callbacks for driving.
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
@@ -17,7 +19,6 @@ import {
} from './gamepadBindings.js';
import { subscribeGamepadHub } from './gamepadHub.js';
import { isTextEntryActive } from './inputFocusUtils.js';
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
import {
isControllerControlLocked,
markControllerDisconnected,
@@ -113,12 +114,12 @@ function cycleHomeAssistant(latest, targetState) {
export default function GamepadInputManager() {
const {
setMode,
setDriveVector,
setAuxMotors,
setServoAngle,
setCameraAxisIntent,
runMacro,
undock,
sendCommand,
toggleHeadlight,
toggleLaser,
registerInputState,
@@ -131,7 +132,7 @@ export default function GamepadInputManager() {
const cameraAngle = useControlSelector((control) => control.state.camera?.angle);
const cameraConfig = useControlSelector((control) => control.state.camera?.config);
const roverId = useControlSelector((control) => control.state.roverId);
const dockAssist = useManualDockAssist();
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
const { focusChat } = useChatActions();
const { isChatFocused } = useChatFocus();
const { homeAssistantSetState, pushAlert } = useSessionActions();
@@ -283,7 +284,7 @@ export default function GamepadInputManager() {
role === 'lockdown' || (role === 'admin' && sessionMode !== 'lockdown'),
cameraAngle,
cameraConfig,
dockAssist,
dockTelemetry,
focusChat,
gamepadSettings,
homeAssistant,
@@ -292,7 +293,8 @@ export default function GamepadInputManager() {
pushAlert,
registerInputState,
roverId,
runMacro,
undock,
sendCommand,
saveGamepadSettings,
saveVideoSettings,
sendSong,
@@ -300,8 +302,7 @@ export default function GamepadInputManager() {
setCameraAxisIntent,
setDriveVector,
setMicPttActive,
setMode,
setSongNote,
setSongNote,
setServoAngle,
songNote,
startHorn,
@@ -462,15 +463,13 @@ export default function GamepadInputManager() {
}
if (outputs.buttons.driveMacro && handleButtonEdge('driveMacro', true)) {
latest.dockAssist.exitAssist();
latest.setMode('drive');
latest.runMacro('drive-sequence');
latest.undock();
} else if (!outputs.buttons.driveMacro) {
handleButtonEdge('driveMacro', false);
}
if (outputs.buttons.dockMacro && handleButtonEdge('dockMacro', true)) {
latest.dockAssist.toggleAssist();
latest.sendCommand(latest.dockTelemetry.cameraLocked ? 'cancelDocking' : 'enterDocking');
} else if (!outputs.buttons.dockMacro) {
handleButtonEdge('dockMacro', false);
}
@@ -1,3 +1,5 @@
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
// Keyboard Input Manager
// Purpose: Captures and translates keyboard events into normalized control intents. Scope: Owns keydown/keyup listeners and dispatch coordination for drive controls.
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
@@ -10,7 +12,6 @@ import { markKeyboardInputActive } from './controllerRuntime.js';
import { isTextInputElement } from './inputFocusUtils.js';
import { useSettingsNamespace } from '../../settings/index.js';
import { INPUT_SETTINGS_DEFAULTS, VIDEO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
import {
SONG_DEFAULT_DURATION,
SONG_DEFAULT_NOTE,
@@ -86,12 +87,12 @@ function shouldIgnoreEvent(event) {
export default function KeyboardInputManager() {
const {
setMode,
setDriveVector,
setAuxMotors,
nudgeServo,
setCameraAxisIntent,
runMacro,
undock,
sendCommand,
stopAllMotion,
registerInputState,
setCameraPrecisionMode,
@@ -107,12 +108,12 @@ export default function KeyboardInputManager() {
const cameraNudgeDegrees = useControlSelector((control) => control.state.camera?.config?.nudgeDegrees);
const songNote = useControlSelector((control) => control.state.song?.note);
const roverId = useControlSelector((control) => control.state.roverId);
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
const hornActive = useControlSelector((control) => Boolean(control.state.horn?.active));
const homeAssistant = useSessionSelector((state) => state.session?.homeAssistant || null);
const role = useSessionSelector((state) => state.session?.role || null);
const mode = useSessionSelector((state) => state.session?.mode || null);
const adminCanControlLockedLights = role === 'lockdown' || (role === 'admin' && mode !== 'lockdown');
const dockAssist = useManualDockAssist();
const { homeAssistantSetState, pushAlert } = useSessionActions();
const { focusChat } = useChatActions();
const { isChatFocused } = useChatFocus();
@@ -386,7 +387,7 @@ export default function KeyboardInputManager() {
// before the browser can deliver the next keyboard event after a committed render.
latestRef.current = {
actionTokens,
dockAssist,
dockTelemetry,
focusChat,
homeAssistant,
homeAssistantSetState,
@@ -398,7 +399,8 @@ export default function KeyboardInputManager() {
pushAlert,
registerInputState,
roverId,
runMacro,
undock,
sendCommand,
saveVideoSettings,
sendSong,
servoRepeatMs,
@@ -408,8 +410,7 @@ export default function KeyboardInputManager() {
setCameraPrecisionMode,
setDriveVector,
setMicPttActive,
setMode,
setSongNote,
setSongNote,
songNote,
startHorn,
stopAllMotion,
@@ -452,11 +453,9 @@ export default function KeyboardInputManager() {
if (newlyPressed.length > 0) {
if (newlyPressed.some((token) => latest.keymap.driveMacro?.has(token))) {
latest.dockAssist.exitAssist();
latest.setMode('drive');
latest.runMacro('drive-sequence');
latest.undock();
} else if (newlyPressed.some((token) => latest.keymap.dockMacro?.has(token))) {
latest.dockAssist.toggleAssist();
latest.sendCommand(latest.dockTelemetry.cameraLocked ? 'cancelDocking' : 'enterDocking');
} else if (newlyPressed.some((token) => latest.keymap.headlightToggle?.has(token))) {
latest.toggleHeadlight();
} else if (newlyPressed.some((token) => latest.keymap.laserToggle?.has(token))) {
@@ -1,67 +0,0 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useControlActions, useControlSelector } from '../../controls/index.js';
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 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);
const enterAssist = useCallback(() => {
actions.setManualDockAssistActive(true);
}, [actions]);
const exitAssist = useCallback(() => {
actions.setManualDockAssistActive(false);
}, [actions]);
const toggleAssist = useCallback(() => {
actions.toggleManualDockAssist();
}, [actions]);
useEffect(() => {
if (!manageLifecycle) return;
const justDocked = docked && !wasDockedRef.current;
const justUndocked = !docked && wasDockedRef.current;
if (active && justDocked) {
// Stop on the first home-base contact so continued input cannot push the rover against the dock.
actions.stopAllMotion();
actions.sendSong([{ note: 84, duration: 6 }], { slot: 1 });
} else if (active && justUndocked) {
actions.sendSong([{ note: 72, duration: 6 }], { slot: 1 });
}
wasDockedRef.current = docked;
}, [actions, active, docked, manageLifecycle]);
useEffect(() => {
if (!manageLifecycle || !active || !charging) return;
exitAssist();
}, [active, charging, exitAssist, manageLifecycle]);
const statusLabel = charging ? 'Docked and charging' : docked ? 'Docked' : 'Docking assist active';
const statusTone = charging ? 'good' : docked ? 'warn' : 'active';
const visible = active || docked;
return useMemo(
() => ({
active,
docked,
charging,
cameraLocked: active,
visible,
statusLabel,
statusTone,
enterAssist,
exitAssist,
toggleAssist,
}),
[active, charging, docked, enterAssist, exitAssist, statusLabel, statusTone, toggleAssist, visible],
);
}
@@ -1,3 +1,5 @@
import { dockTelemetryEqual, selectDockTelemetry } from '../../../context/telemetryViews.js';
import { useTelemetrySelector } from '../../../context/TelemetryContext.jsx';
// Right Pane Tabs
// Purpose: Defines the Right Pane Tabs module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
@@ -22,7 +24,6 @@ import { useSessionSelector } from '../../../context/SessionContext.jsx';
import OverseerPreferencePanel from '../../../components/OverseerPreferencePanel/index.jsx';
import CardFrame from '../../../components/CardFrame/index.jsx';
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useManualDockAssist } from '../../../features/manualDockAssist/useManualDockAssist.js';
import { themeGapClass, themeStackClass } from '../../../themes/index.js';
import ActivitiesTab from '../tabs/shared/ActivitiesTab/index.jsx';
import VipTab from '../tabs/shared/VipTab/index.jsx';
@@ -62,7 +63,7 @@ function DriveDockPanel() {
const laserState = useControlSelector((control) => control.pipeline?.laserState);
const pipelineHorn = useControlSelector((control) => control.pipeline?.horn);
const { setServoAngle, setHeadlight, setLaser, startHorn, stopHorn } = useControlActions();
const dockAssist = useManualDockAssist();
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
const driveDockState = useDriveDockState(roverId);
const hideInlineControls = driveDockState.docked && !driveDockState.driving;
@@ -85,7 +86,7 @@ function DriveDockPanel() {
const hornLabel = <ControlHint actionId="hornHonk" />;
const upLabel = <ControlHint actionId="cameraUp" />;
const downLabel = <ControlHint actionId="cameraDown" />;
const cameraDisabled = Boolean(!roverId || dockAssist.cameraLocked);
const cameraDisabled = Boolean(!roverId || dockTelemetry.cameraLocked);
/*
Precision movement mode also tightens the servo slider step. The command
path still sends ordinary angle targets; only the UI increment changes while