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