diff --git a/dist/hornverifier b/dist/hornverifier
index 67c1f649..8a202a52 100755
Binary files a/dist/hornverifier and b/dist/hornverifier differ
diff --git a/dist/roverd b/dist/roverd
index 9556fdae..b77dfda8 100755
Binary files a/dist/roverd and b/dist/roverd differ
diff --git a/dist/roverd-debian-laptop b/dist/roverd-debian-laptop
index c9b48a0b..69b3940d 100755
Binary files a/dist/roverd-debian-laptop and b/dist/roverd-debian-laptop differ
diff --git a/dist/servoverifier b/dist/servoverifier
index 6a16f5a4..b32f7e89 100755
Binary files a/dist/servoverifier and b/dist/servoverifier differ
diff --git a/pi/roverd/auto_charge.go b/pi/roverd/auto_charge.go
index 14549e2d..8d043fa1 100644
--- a/pi/roverd/auto_charge.go
+++ b/pi/roverd/auto_charge.go
@@ -7,7 +7,7 @@ import (
)
const (
- autoChargeTimeout = 1 * time.Second
+ autoChargeTimeout = 10 * time.Second
autoChargeCooldown = 0 * time.Minute
sourceHomeBase = 1 << 1
)
diff --git a/server/src/services/commandService/index.js b/server/src/services/commandService/index.js
index 0a896f89..880daa6a 100644
--- a/server/src/services/commandService/index.js
+++ b/server/src/services/commandService/index.js
@@ -85,7 +85,7 @@ function normalizeOutboundCommandPayload(payload = {}) {
};
}
-function issueCommand(roverId, payload) {
+function issueCommand(roverId, payload, options = {}) {
const record = roverManager.rovers.get(roverId);
if (!record || !record.ws) {
throw new Error('Rover offline');
@@ -93,6 +93,11 @@ function issueCommand(roverId, payload) {
if (payload?.type === 'laser' && isLaserCommandBlockedByRoomLightLock(payload)) {
throw new Error('Laser disabled while room lights are locked on');
}
+ if (options.dockingOwned && payload.driveDirect && getCommandMotionMagnitude('drive', payload) > 0
+ && Date.now() < (driveCooldowns.get(roverId) || 0)) {
+ throw new Error('Drive blocked: safety cooldown');
+ }
+ payload = require('../dockingService').protectCommand(record, payload, options);
const id = uuidv4();
const normalizedPayload = normalizeOutboundCommandPayload(payload);
const message = { ...normalizedPayload, id };
@@ -124,7 +129,14 @@ overcurrentProtectionService.configureCommandIssuer((roverId, payload) => {
*/
return false;
}
- issueCommand(roverId, payload);
+ const record = roverManager.rovers.get(roverId);
+ const reverseAdjustment = payload.driveDirect
+ && (payload.driveDirect.left < 0 || payload.driveDirect.right < 0)
+ && payload.driveDirect.left <= 0 && payload.driveDirect.right <= 0
+ && require('../dockingService').canAdjustReverse(record);
+ // Only protection's adjustments to the active reverse inherit controller
+ // ownership. Zero remains an ordinary stop and cancels the sequence.
+ issueCommand(roverId, payload, { dockingOwned: Boolean(reverseAdjustment) });
return true;
});
@@ -315,6 +327,22 @@ function submitCommand(socket, { roverId, type, data } = {}, cb) {
if (!isSongCommand && !isRebootCommand && !isUpdateCommand && !roverManager.canDrive(roverId, socket)) {
throw new Error('Not your turn or no control');
}
+ const record = roverManager.rovers.get(roverId);
+ if (!record?.ws) throw new Error('Rover offline');
+ const docking = require('../dockingService');
+ // The controller owns wheel output during undocking. Discard all ordinary
+ // drive input, including neutral, before it can alter protection's intent.
+ if (docking.shouldIgnoreDriveInput(record, payload)) {
+ reply({ ignored: true, reason: 'undocking' });
+ return;
+ }
+ // Reject before recording held motor intent: otherwise a rejected input
+ // could be replayed by protection as soon as the docking lock is released.
+ if (!docking.intents.has(type)) {
+ const guarded = docking.protectCommand(record, { ...payload, type });
+ type = guarded.type;
+ payload = guarded;
+ }
const driveDirect = payload?.driveDirect;
if (type === 'drive' && driveDirect) {
if (!isAdminSocket) {
@@ -361,7 +389,14 @@ function submitCommand(socket, { roverId, type, data } = {}, cb) {
bypassed: isAdminSocket,
});
}
- const id = issueCommand(roverId, { type, ...payload });
+ const isDockingIntent = docking.intents.has(type);
+ const id = isDockingIntent
+ ? docking.issueIntent(roverManager.rovers.get(roverId), type, payload, socket)
+ : issueCommand(roverId, { ...payload, type });
+ if (isDockingIntent) {
+ // The server acknowledges accepting the intent; telemetry reports its completion.
+ io.emit('commandAck', { roverId, id, status: 'ok' });
+ }
commandEvents.emit('observation', {
ts: Date.now(),
roverId: String(roverId),
diff --git a/server/src/services/dockingService/index.js b/server/src/services/dockingService/index.js
new file mode 100644
index 00000000..2ded154f
--- /dev/null
+++ b/server/src/services/dockingService/index.js
@@ -0,0 +1,273 @@
+// Server-owned docking operations.
+const { v4: uuidv4 } = require('uuid');
+const io = require('../../globals/io');
+const logger = require('../../globals/logger').child('dockingService');
+const states = new WeakMap();
+const intents = new Set(['enterDocking', 'cancelDocking', 'undock']);
+const ASSIST_SPEED = 60;
+const SENSOR_TIMEOUT_MS = 1500;
+const UNDOCK_TIMEOUT_MS = 4000;
+const REVERSE_TIMEOUT_MS = 1000;
+
+function stateFor(record) {
+ if (!states.has(record)) {
+ states.set(record, {
+ contact: null, charging: false, latched: false, assist: false,
+ operation: null, error: null, seenAt: 0, timer: null,
+ });
+ }
+ return states.get(record);
+}
+
+function snapshot(record) {
+ const state = stateFor(record);
+ return {
+ phase: state.operation ? 'undocking' : state.contact ? (state.charging ? 'charging' : 'docked') : state.assist ? 'assisting' : 'idle',
+ movementLocked: Boolean(state.operation || state.latched || state.error || state.contact == null),
+ cameraLocked: state.assist,
+ error: state.error,
+ };
+}
+
+function publish(record) {
+ if (!record.lastSensor?.decoded) return;
+ record.lastSensor.decoded = { ...record.lastSensor.decoded, docking: snapshot(record) };
+ // State transitions use the same telemetry event, including failures when the
+ // physical sensor stream has stalled. Never manufacture a new sensor timestamp.
+ io.to(record.room).emit('sensorFrame', {
+ roverId: record.id, frame: record.lastSensor.raw, sensors: record.lastSensor.decoded,
+ overcurrentProtection: record.lastOvercurrentProtection || null,
+ });
+}
+
+function send(record, payload) {
+ return require('../commandService').issueCommand(record.id, payload, { dockingOwned: true });
+}
+function stop(record) {
+ // Forget the previous held intent so protection cannot replay it after unlock.
+ require('../overcurrentProtectionService').protectCommand(record.id, 'drive', { driveDirect: { left: 0, right: 0 } });
+ send(record, { type: 'drive', driveDirect: { left: 0, right: 0 } });
+}
+function stopAllMotion(record) {
+ stop(record);
+ const payload = { motorPwm: { main: 0, side: 0, vacuum: 0 } };
+ require('../overcurrentProtectionService').protectCommand(record.id, 'motors', payload);
+ send(record, { type: 'motors', ...payload });
+}
+function mode(record, opcode) {
+ send(record, { type: 'raw', raw: Buffer.from([opcode]).toString('base64') });
+}
+function camera(record, angle) {
+ if (record.meta?.cameraServo?.enabled) send(record, { type: 'servo', servo: { angle } });
+}
+function song(record, notes, slot = 0, duration = 10) {
+ send(record, { type: 'song', song: { slot, notes: notes.map((note) => ({ note, duration })) } });
+}
+function finishAssist(record) {
+ const state = stateFor(record);
+ if (!state.assist) return;
+ state.assist = false;
+ camera(record, 0);
+ song(record, [83, 76]);
+}
+function clearOperation(state) {
+ clearTimeout(state.timer);
+ state.timer = null;
+ state.operation = null;
+}
+function fail(record, code, message) {
+ const state = stateFor(record);
+ clearOperation(state);
+ state.error = { code, message };
+ state.latched = true;
+ try { stop(record); } catch (error) {
+ logger.warn('Unable to stop docking operation', { roverId: record.id, error: error.message });
+ }
+ publish(record);
+}
+function armDeadline(record) {
+ const state = stateFor(record);
+ clearTimeout(state.timer);
+ if (!state.operation) return;
+ const deadline = Math.min(
+ state.seenAt + SENSOR_TIMEOUT_MS,
+ state.operation.startedAt + UNDOCK_TIMEOUT_MS,
+ state.operation.reversingAt ? state.operation.reversingAt + REVERSE_TIMEOUT_MS : Infinity,
+ );
+ state.timer = setTimeout(() => {
+ const stale = Date.now() - state.seenAt >= SENSOR_TIMEOUT_MS;
+ fail(record, stale ? 'telemetryUnavailable' : 'undockTimeout', stale ? 'Rover telemetry stopped during undocking.' : 'Unable to leave the dock.');
+ }, Math.max(1, deadline - Date.now()));
+ state.timer.unref?.();
+}
+
+function handleIntent(record, type, payload, socket) {
+ const state = stateFor(record);
+ if (type === 'cancelDocking') {
+ if (state.operation) fail(record, 'undockCancelled', 'Undocking stopped.');
+ stop(record);
+ finishAssist(record);
+ publish(record);
+ return;
+ }
+ state.error = null;
+ require('../roverManager').stopDockGuard(record.id);
+ stop(record);
+ if (type === 'enterDocking') {
+ state.latched = false;
+ if (!state.assist) {
+ state.assist = true;
+ camera(record, record.meta?.cameraServo?.minAngle ?? -45);
+ song(record, [76, 83]);
+ }
+ } else {
+ finishAssist(record);
+ camera(record, 0);
+ state.operation = { startedAt: Date.now(), socket, backoff: payload.backoff !== false, reversing: false };
+ // Full mode works from an initialized OI; start only when OI is off.
+ if (record.lastSensor?.decoded?.oiMode?.code === 0) mode(record, 128);
+ mode(record, 132);
+ armDeadline(record);
+ }
+ publish(record);
+}
+
+function processTelemetry(record, sensors) {
+ const state = stateFor(record);
+ if (!sensors?.chargingSources || !sensors?.oiMode) return snapshot(record);
+ const previousContact = state.contact;
+ state.seenAt = Date.now();
+ state.contact = Boolean(sensors.chargingSources.homeBase);
+ state.charging = [1, 2, 3, 4].includes(sensors.chargingState?.code);
+ if (previousContact == null) state.latched = state.contact;
+ try {
+ const operation = state.operation;
+ if (operation) {
+ const manager = require('../roverManager');
+ if (!operation.socket?.connected || !manager.canDrive(record.id, operation.socket)) {
+ fail(record, 'controlLost', 'Undocking stopped because control changed.');
+ } else if (Date.now() - operation.startedAt >= UNDOCK_TIMEOUT_MS
+ || (operation.reversingAt && Date.now() - operation.reversingAt >= REVERSE_TIMEOUT_MS)) {
+ fail(record, 'undockTimeout', 'Unable to leave the dock.');
+ } else if (sensors.oiMode.label === 'full') {
+ if (!state.contact || !operation.backoff) {
+ stop(record);
+ clearOperation(state);
+ state.latched = state.contact;
+ } else if (!operation.reversing) {
+ operation.reversing = true;
+ operation.reversingAt = Date.now();
+ const speed = Math.max(1, Math.min(500, Number(record.meta?.maxWheelSpeed) || 500));
+ // Track the controller's own intent in existing protection so stalls
+ // still stop it and subsequent adjustments refer to this reverse only.
+ const drive = require('../overcurrentProtectionService').protectCommand(record.id, 'drive', {
+ driveDirect: { left: -speed, right: -speed },
+ }, { bypassed: require('../roleService').isAdmin(operation.socket) });
+ if (!drive.driveDirect.left && !drive.driveDirect.right) throw new Error('Undocking blocked by wheel protection');
+ send(record, { ...drive, type: 'drive' });
+ }
+ } else if (operation.reversing) {
+ fail(record, 'modeChanged', 'Undocking stopped because the Roomba mode changed.');
+ }
+ armDeadline(record);
+ } else if (state.contact) {
+ state.latched = true;
+ if (previousContact !== true) {
+ stopAllMotion(record);
+ mode(record, 143);
+ if (state.assist) song(record, [84], 1, 6);
+ }
+ if (state.charging) finishAssist(record);
+ } else if (previousContact === true && state.assist) {
+ song(record, [72], 1, 6);
+ }
+ } catch (error) {
+ fail(record, 'commandFailed', error.message);
+ }
+ return snapshot(record);
+}
+
+function shouldIgnoreDriveInput(record, payload) {
+ if (!stateFor(record).operation) return false;
+ if (payload.driveDirect) return true;
+ const raw = payload.raw ? Buffer.from(payload.raw, 'base64') : null;
+ return Boolean(raw && [137, 145].includes(raw[0]));
+}
+
+function protectCommand(record, payload, { dockingOwned = false } = {}) {
+ if (dockingOwned) return payload;
+ const state = stateFor(record);
+ // Raw OI drive packets must obey the same movement lock. Other raw commands,
+ // including mode changes, remain available and can interrupt the operation.
+ const raw = payload.raw ? Buffer.from(payload.raw, 'base64') : null;
+ if (state.operation && raw && [128, 131, 132, 133, 134, 135, 136, 143].includes(raw[0])) {
+ // Explicit mode commands remain available and take precedence over an
+ // automatic sequence, including while it is still waiting for full mode.
+ fail(record, 'modeChanged', 'Undocking stopped by a Roomba mode command.');
+ }
+ if (raw && [137, 145].includes(raw[0])) {
+ if (raw.length !== 5) throw new Error('Invalid raw drive command');
+ const first = raw.readInt16BE(1);
+ const second = raw.readInt16BE(3);
+ if (raw[0] === 137 && first !== 0 && state.assist && !snapshot(record).movementLocked) {
+ const limited = Buffer.from(raw);
+ limited.writeInt16BE(Math.max(-ASSIST_SPEED, Math.min(ASSIST_SPEED, first)), 1);
+ return { ...payload, raw: limited.toString('base64') };
+ }
+ if (raw[0] === 145) {
+ return protectCommand(record, { type: 'drive', driveDirect: { left: second, right: first } });
+ }
+ if (first === 0 && state.operation) fail(record, 'undockStopped', 'Undocking stopped by a stop command.');
+ if (first !== 0 && snapshot(record).movementLocked) throw new Error('Drive blocked while docked or undocking');
+ }
+ // User drive input is discarded at submitCommand before reaching here.
+ // Internal safety stops still interrupt the controller through this path.
+ if (payload.driveDirect) {
+ const { left, right } = payload.driveDirect;
+ if (!Number(left) && !Number(right) && state.operation) {
+ fail(record, 'undockStopped', 'Undocking stopped by a stop command.');
+ }
+ if ((Number(left) || Number(right)) && snapshot(record).movementLocked) throw new Error('Drive blocked while docked or undocking');
+ if (state.assist) {
+ const scale = Math.min(1, ASSIST_SPEED / Math.max(Math.abs(left), Math.abs(right), 1));
+ return { ...payload, driveDirect: { left: Math.round(left * scale), right: Math.round(right * scale) } };
+ }
+ }
+ if (payload.servo && state.assist) throw new Error('Camera locked during dock assist');
+ return payload;
+}
+
+function disconnect(record) {
+ const state = stateFor(record);
+ if (state.operation) fail(record, 'roverDisconnected', 'Rover disconnected during undocking.');
+ clearOperation(state);
+ state.seenAt = 0;
+ state.contact = null;
+ state.assist = false;
+}
+
+function canAdjustReverse(record) {
+ return Boolean(record && stateFor(record).operation?.reversing);
+}
+
+function issueIntent(record, type, payload, socket) {
+ const state = stateFor(record);
+ // Validate before mutating, so duplicate/rejected requests cannot disturb an
+ // operation that is already in progress.
+ if (type !== 'cancelDocking') {
+ if (state.operation) throw new Error('Undocking is already in progress');
+ if (Date.now() - state.seenAt >= SENSOR_TIMEOUT_MS || state.contact == null) {
+ throw new Error('Waiting for current rover telemetry');
+ }
+ if (type === 'enterDocking' && state.contact) throw new Error('Rover is already docked');
+ }
+ const id = uuidv4();
+ try {
+ handleIntent(record, type, payload, socket);
+ } catch (error) {
+ fail(record, 'commandFailed', error.message);
+ throw error;
+ }
+ return id;
+}
+module.exports = { shouldIgnoreDriveInput, canAdjustReverse, snapshot, intents, issueIntent, processTelemetry, protectCommand, disconnect };
diff --git a/server/src/services/roverManager/index.js b/server/src/services/roverManager/index.js
index e8ae329b..14c1e8c0 100644
--- a/server/src/services/roverManager/index.js
+++ b/server/src/services/roverManager/index.js
@@ -274,6 +274,7 @@ odometerService.odometerEvents.on('update', ({ roverId, odometer }) => {
setInterval(tickPrivateAutoClose, PRIVATE_AUTO_CLOSE_TICK_MS);
module.exports = {
+ stopDockGuard,
upsertRover,
removeRover,
lockRover,
diff --git a/server/src/services/roverManager/rosterLifecycle.js b/server/src/services/roverManager/rosterLifecycle.js
index 362738bc..504335ce 100644
--- a/server/src/services/roverManager/rosterLifecycle.js
+++ b/server/src/services/roverManager/rosterLifecycle.js
@@ -117,6 +117,7 @@ function createRosterLifecycle(deps) {
offline, while removeRoverDrivers still receives the captured record so
it can clean the reverse membership index and Socket.IO room membership.
*/
+ require('../dockingService').disconnect(record);
rovers.delete(id);
removeRoverDrivers(id, record);
stopDockGuard(id);
diff --git a/server/src/services/roverManager/sensorPipeline.js b/server/src/services/roverManager/sensorPipeline.js
index 193eeaa4..ba866cbb 100644
--- a/server/src/services/roverManager/sensorPipeline.js
+++ b/server/src/services/roverManager/sensorPipeline.js
@@ -389,6 +389,10 @@ function createSensorPipeline(deps) {
function evaluateDockGuard(record, sensors) {
if (!record || !sensors) return;
+ if (sensors.docking?.phase === 'undocking' || sensors.docking?.phase === 'assisting') {
+ stopDockGuard(record.id);
+ return;
+ }
const state = getDockGuardState(record.id);
const now = Date.now();
const docked = Boolean(sensors?.chargingSources?.homeBase);
@@ -564,6 +568,10 @@ function createSensorPipeline(deps) {
};
record.lastSensor = { raw: frame, decoded };
}
+ if (decoded) {
+ decoded = { ...decoded, docking: require('../dockingService').processTelemetry(record, decoded) };
+ record.lastSensor = { raw: frame, decoded };
+ }
/*
Rover manager remains responsible only for decoding and routing sensor
frames. The dedicated service receives the completed sensor object after
@@ -577,6 +585,7 @@ function createSensorPipeline(deps) {
const overcurrentProtection = decoded && typeof processOvercurrentTelemetry === 'function'
? processOvercurrentTelemetry(roverId, decoded)
: null;
+ record.lastOvercurrentProtection = overcurrentProtection;
updateMovement(record, decoded);
const hasDockInfo = decoded?.chargingSources != null;
if (hasDockInfo) {
@@ -589,6 +598,10 @@ function createSensorPipeline(deps) {
if (bumps?.bumpLeft || bumps?.bumpRight) record.lastBumpAt = Date.now();
handlePrivateButtonHold(record, decoded);
evaluatePrivateSafety(record, decoded);
+ if (decoded) {
+ decoded = { ...decoded, docking: require('../dockingService').snapshot(record) };
+ record.lastSensor = { raw: frame, decoded };
+ }
io.to(record.room).volatile.emit('sensorFrame', {
roverId,
frame,
diff --git a/webui/src/components/DriveDockAction/driveDockState.js b/webui/src/components/DriveDockAction/driveDockState.js
index 069f30a3..939aa498 100644
--- a/webui/src/components/DriveDockAction/driveDockState.js
+++ b/webui/src/components/DriveDockAction/driveDockState.js
@@ -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 };
}
diff --git a/webui/src/components/DriveDockAction/index.jsx b/webui/src/components/DriveDockAction/index.jsx
index cec25319..2d192620 100644
--- a/webui/src/components/DriveDockAction/index.jsx
+++ b/webui/src/components/DriveDockAction/index.jsx
@@ -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({
{/* Keeping the pad mounted prevents the mobile columns from changing size while
the centered video HUD explains and performs dock-related transitions. */}
diff --git a/webui/src/components/SettingsPanel/index.jsx b/webui/src/components/SettingsPanel/index.jsx
index 18fb4bc3..283ae561 100644
--- a/webui/src/components/SettingsPanel/index.jsx
+++ b/webui/src/components/SettingsPanel/index.jsx
@@ -411,7 +411,7 @@ export default function SettingsPanel() {
Swap control columns (put joystick on the left)
-
+
- Enable backward bump in drive macro
+ Back away from the dock when undocking
diff --git a/webui/src/context/telemetryViews.js b/webui/src/context/telemetryViews.js
index 049f4931..dd457616 100644
--- a/webui/src/context/telemetryViews.js
+++ b/webui/src/context/telemetryViews.js
@@ -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
diff --git a/webui/src/controls/ControlContext.jsx b/webui/src/controls/ControlContext.jsx
index 78546e47..4c38d932 100644
--- a/webui/src/controls/ControlContext.jsx
+++ b/webui/src/controls/ControlContext.jsx
@@ -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,
diff --git a/webui/src/controls/commandPipeline.js b/webui/src/controls/commandPipeline.js
index 188d7536..d20b85ae 100644
--- a/webui/src/controls/commandPipeline.js
+++ b/webui/src/controls/commandPipeline.js
@@ -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,
],
);
}
diff --git a/webui/src/controls/constants.js b/webui/src/controls/constants.js
index 2fa1daed..5b83b6bb 100644
--- a/webui/src/controls/constants.js
+++ b/webui/src/controls/constants.js
@@ -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 } },
- ],
- },
-];
diff --git a/webui/src/controls/controlMath.js b/webui/src/controls/controlMath.js
index 0c540ca6..116a719a 100644
--- a/webui/src/controls/controlMath.js
+++ b/webui/src/controls/controlMath.js
@@ -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));
-}
diff --git a/webui/src/controls/controlReducer.js b/webui/src/controls/controlReducer.js
index cc325513..36b59a68 100644
--- a/webui/src/controls/controlReducer.js
+++ b/webui/src/controls/controlReducer.js
@@ -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 || '');
diff --git a/webui/src/controls/inputs/GamepadInputManager.jsx b/webui/src/controls/inputs/GamepadInputManager.jsx
index d7621c67..9cba4332 100644
--- a/webui/src/controls/inputs/GamepadInputManager.jsx
+++ b/webui/src/controls/inputs/GamepadInputManager.jsx
@@ -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);
}
diff --git a/webui/src/controls/inputs/KeyboardInputManager.jsx b/webui/src/controls/inputs/KeyboardInputManager.jsx
index 6324430d..48fd5ab8 100644
--- a/webui/src/controls/inputs/KeyboardInputManager.jsx
+++ b/webui/src/controls/inputs/KeyboardInputManager.jsx
@@ -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))) {
diff --git a/webui/src/features/manualDockAssist/useManualDockAssist.js b/webui/src/features/manualDockAssist/useManualDockAssist.js
deleted file mode 100644
index e7b51a0a..00000000
--- a/webui/src/features/manualDockAssist/useManualDockAssist.js
+++ /dev/null
@@ -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],
- );
-}
diff --git a/webui/src/layouts/driver/DesktopRightPaneTabs/index.jsx b/webui/src/layouts/driver/DesktopRightPaneTabs/index.jsx
index 1e3ff92d..d5d8d552 100644
--- a/webui/src/layouts/driver/DesktopRightPaneTabs/index.jsx
+++ b/webui/src/layouts/driver/DesktopRightPaneTabs/index.jsx
@@ -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 = ;
const upLabel = ;
const downLabel = ;
- 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