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..34a787b4 --- /dev/null +++ b/server/src/services/dockingService/index.js @@ -0,0 +1,302 @@ +// 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; +// Preserve the working OI preparation sequence; only backing away is sensor-driven. +const MODE_COMMAND_DELAY_MS = 100; +const FULL_MODE_SETTLE_MS = 300; + +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, contactDockPending: false, + }); + } + 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, { playExitSong = true } = {}) { + const state = stateFor(record); + if (!state.assist) return; + state.assist = false; + camera(record, 0); + if (playExitSong) 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; + state.contactDockPending = false; + 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, + stage: 'start', nextStepAt: Date.now() + MODE_COMMAND_DELAY_MS, + }; + mode(record, 128); + 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 (Date.now() < operation.nextStepAt) { + // Advance only on fresh telemetry, after the previous OI command settles. + } else if (operation.stage === 'start') { + mode(record, 143); + operation.stage = 'dock'; + operation.nextStepAt = Date.now() + MODE_COMMAND_DELAY_MS; + } else if (operation.stage === 'dock') { + mode(record, 132); + operation.stage = 'full'; + operation.nextStepAt = Date.now() + FULL_MODE_SETTLE_MS; + } 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); + state.contactDockPending = true; + if (state.assist) song(record, [84], 1, 6); + } else if (state.contactDockPending && !state.charging) { + const speeds = sensors.wheelSpeedsMmPerSecond; + // A stop request does not prove the wheels stopped on the contacts. + // Require a subsequent frame with contact and measured stationary wheels. + if (speeds?.left === 0 && speeds?.right === 0) { + mode(record, 143); + state.contactDockPending = false; + } + } + if (state.charging) { + state.contactDockPending = false; + // An immediate exit song would replace the contact beep on the Roomba. + finishAssist(record, { playExitSong: false }); + } + } else { + state.contactDockPending = false; + 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'); + } + // Already at the requested destination; acknowledge without restarting assist. + if (type === 'enterDocking' && state.contact) return uuidv4(); + } + 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/idleService/index.js b/server/src/services/idleService/index.js index 2020f9b9..b238b393 100644 --- a/server/src/services/idleService/index.js +++ b/server/src/services/idleService/index.js @@ -4,12 +4,15 @@ const logger = require('../../globals/logger').child('idleService'); const io = require('../../globals/io'); const { getRole, roleEvents } = require('../roleService'); +const { getMode, MODES, modeEvents } = require('../modeManager'); +const { isDeterred, verificationEvents } = require('../verificationService'); const { IDLE_TIMEOUT_MS } = require('./constants'); const { runtime } = require('./state'); const { runIdleActions } = require('./actions'); const activityControls = require('../homeAssistantActivitiesService'); function getActivitySnapshot() { + const mode = getMode(); let onlineUsers = 0; let onlineAdmins = 0; let onlineSpectators = 0; @@ -29,12 +32,16 @@ function getActivitySnapshot() { return; } - /* - Lockdown admins are counted with regular admins because both represent a - person with operator-level access who may be supervising the room without - actively driving a rover. Plain users also count even before they request - control, which is the behavior this service now needs. - */ + + if ( + isDeterred(socket) + || (mode === MODES.ADMIN && role !== 'admin' && role !== 'lockdown') + || (mode === MODES.LOCKDOWN && role !== 'lockdown') + ) { + onlineIgnored += 1; + return; + } + if (role === 'admin' || role === 'lockdown') { onlineAdmins += 1; return; @@ -90,7 +97,7 @@ function scheduleIdleTimer() { runtime.deadlineAt = null; const activity = getActivitySnapshot(); if (activity.totalActive > 0) { - logger.info('Idle automation skipped; user or admin online', activity); + logger.info('Idle automation skipped; eligible operator online', activity); return; } runtime.lastTriggeredAt = Date.now(); @@ -114,18 +121,12 @@ function scheduleIdleTimer() { function refreshIdleState() { const activity = getActivitySnapshot(); logger.info('Idle state refresh', activity); - // Share the exact idle presence policy; Activity Controls must not count spectators or invent a second role filter. activityControls.setOperatorsOnline(activity.totalActive > 0); if (activity.totalActive > 0) { clearIdleTimer(); if (runtime.idleActionsCompleted) { logger.info('Idle action one-shot reset; operator is online again', activity); } - /* - A user/admin coming online starts a new activity window. When the room - later becomes idle again, the cleanup pipeline should be allowed to run - once for that new idle period. - */ runtime.idleActionsCompleted = false; return; } @@ -133,17 +134,13 @@ function refreshIdleState() { } io.on('connection', (socket) => { - /* - A user/admin can be online without ever touching rover controls, so socket - presence has to be a first-class idle signal. The disconnect hook is just as - important: it is what starts the idle timeout after the last non-spectator - leaves, even if no driving event happens around that departure. - */ refreshIdleState(); socket.on('disconnect', refreshIdleState); }); roleEvents.on('change', refreshIdleState); +modeEvents.on('change', refreshIdleState); +verificationEvents.on('change', refreshIdleState); refreshIdleState(); 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/to-do.md b/to-do.md index 366b7f85..60ad413a 100644 --- a/to-do.md +++ b/to-do.md @@ -3,20 +3,9 @@ research how midis can be played easier with drag and drop, auto selection based on which playback mode would fit, more useful and clear toggles, sample midi files to analyze how they would play on a single tone system, better live playback to see what notes are actually playing from each source and what simply wont play on the speaker and make sure each toggle and counter actually has a purpose besides debugging or something more useful to the user, note skipped is just for debugging ``` -1. add new discord channel: live status - 1. one concise message which contains the status of all the rovers - 2. bot clears the channel, then sends a new message - 3. has a fancy big embed that shows way more stuff - 4. also has a small text copy at the top (readable on smartwatch lol) - 1. ``` - freaky: docked - wall-e: user1 driving - bweeble: NEEDS HELP - ``` -2. setting to disable replay popups in spectator settings menu -3. add admin ui for VIP and private requests instead of only through discord -4. make roverd self update checkout to main branch -5. fix this: +1. add admin ui for VIP and private requests instead of only through discord +2. make roverd self update checkout to main branch +3. fix this: `Jun 18 15:14:18 roombaserver.local node[216731]: /home/daniel/MultiRoombaRover/server/src/services/roverManager/socketHandlers.js:92 Jun 18 15:14:18 roombaserver.local node[216731]: cb({ error: err.message }); Jun 18 15:14:18 roombaserver.local node[216731]: ^ 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