mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-14 10:42:45 -05:00
basically moving docking flows to the server instead of being in the web UI
This commit is contained in:
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
autoChargeTimeout = 1 * time.Second
|
||||
autoChargeTimeout = 10 * time.Second
|
||||
autoChargeCooldown = 0 * time.Minute
|
||||
sourceHomeBase = 1 << 1
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 };
|
||||
@@ -274,6 +274,7 @@ odometerService.odometerEvents.on('update', ({ roverId, odometer }) => {
|
||||
setInterval(tickPrivateAutoClose, PRIVATE_AUTO_CLOSE_TICK_MS);
|
||||
|
||||
module.exports = {
|
||||
stopDockGuard,
|
||||
upsertRover,
|
||||
removeRover,
|
||||
lockRover,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
// Drive Dock Action
|
||||
// Purpose: Defines the Drive Dock Action module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import '../MobileControls/mobileControls.css';
|
||||
import { useControlActions, useControlSelector } from '../../controls/index.js';
|
||||
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
|
||||
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
|
||||
import ControlHint from '../ControlHint/index.jsx';
|
||||
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||
import { deriveDriveDockStateFromTelemetry } from './driveDockState.js';
|
||||
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
|
||||
|
||||
@@ -91,14 +90,13 @@ export default function DriveDockAction({
|
||||
const isMobile = layout === 'mobile';
|
||||
const roverId = useControlSelector((control) => control.state.roverId);
|
||||
const actions = useControlActions();
|
||||
const dockAssist = useManualDockAssist();
|
||||
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
||||
const state = driveDockState ?? deriveDriveDockStateFromTelemetry(dockTelemetry);
|
||||
const { driving, docked, charging, dockingInProgress } = state;
|
||||
const [pending, setPending] = useState(null);
|
||||
const pending = dockTelemetry.dockingPhase === 'undocking' ? 'drive' : null;
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const manualAssistActive = Boolean(dockAssist.active);
|
||||
const manualAssistActive = Boolean(dockTelemetry.cameraLocked);
|
||||
|
||||
const driveDisabled = !roverId || pending !== null;
|
||||
const dockDisabled = !roverId || pending !== null;
|
||||
@@ -124,64 +122,37 @@ export default function DriveDockAction({
|
||||
const chargeValue = charging ? 'Charging' : docked ? 'Charging soon...' : '—';
|
||||
const chargeTone = charging ? 'good' : docked ? 'warn' : 'bad';
|
||||
|
||||
const handleReturnToDrive = async () => {
|
||||
if (!roverId || pending) return;
|
||||
if (isMobile) triggerTouchHaptic('button');
|
||||
setPending('drive');
|
||||
try {
|
||||
dockAssist.exitAssist();
|
||||
actions.setMode('drive');
|
||||
await actions.runMacro('drive-sequence');
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartDrive = async () => {
|
||||
const handleStartDrive = () => {
|
||||
if (!roverId || pending) return;
|
||||
if (isMobile) triggerTouchHaptic('button');
|
||||
setConfirmOpen(false);
|
||||
setShowModal(false);
|
||||
setPending('drive');
|
||||
try {
|
||||
dockAssist.exitAssist();
|
||||
actions.setMode('drive');
|
||||
await actions.runMacro('drive-sequence');
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
actions.undock();
|
||||
};
|
||||
|
||||
const handleConfirmDock = async () => {
|
||||
const handleConfirmDock = () => {
|
||||
if (!roverId || pending) return;
|
||||
if (isMobile) triggerTouchHaptic('button');
|
||||
setPending('dock');
|
||||
try {
|
||||
dockAssist.enterAssist();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setPending(null);
|
||||
setConfirmOpen(false);
|
||||
setShowModal(false);
|
||||
}
|
||||
actions.sendCommand('enterDocking');
|
||||
setConfirmOpen(false);
|
||||
setShowModal(false);
|
||||
};
|
||||
|
||||
const handleOpenDock = () => {
|
||||
if (dockDisabled) return;
|
||||
if (isMobile) triggerTouchHaptic('button');
|
||||
if (manualAssistActive) {
|
||||
dockAssist.exitAssist();
|
||||
actions.sendCommand('cancelDocking');
|
||||
return;
|
||||
}
|
||||
setShowModal(true);
|
||||
setConfirmOpen(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (dockTelemetry.dockingError) alert(dockTelemetry.dockingError);
|
||||
}, [dockTelemetry.dockingError]);
|
||||
|
||||
// The drive/dock card appears inside the mobile controls and can be held or
|
||||
// tapped repeatedly; attach mobile touch suppression directly to the card so
|
||||
// Safari does not select labels or open callouts during those interactions.
|
||||
@@ -254,7 +225,7 @@ export default function DriveDockAction({
|
||||
<button
|
||||
type="button"
|
||||
disabled={driveDisabled}
|
||||
onClick={handleReturnToDrive}
|
||||
onClick={handleStartDrive}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
className={`${baseCardClasses} ${filledHeight} ${compactHeight} ${ctaText} ${layoutClass} ${ctaSize} ${amberCta}`}
|
||||
>
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function DriverVideo({ layoutFormat = 'desktop' }) {
|
||||
mobileHud={mobileHud}
|
||||
labelScale={1}
|
||||
/>
|
||||
<ManualDockAssistOverlay mobileHud={mobileHud} />
|
||||
<ManualDockAssistOverlay roverId={roverId} mobileHud={mobileHud} />
|
||||
<HudChatInput compact={mobileHud} />
|
||||
<OvercurrentOverlay compact={mobileHud} />
|
||||
<LowBatteryOverlay compact={mobileHud} />
|
||||
|
||||
@@ -56,7 +56,7 @@ export const ACTIONS = [
|
||||
{ id: 'allAux', label: 'All cleaning motors', kind: 'button', section: 'Aux buttons' },
|
||||
{ id: 'mainReverse', label: 'Main reverse toggle', kind: 'button', section: 'Brush toggles' },
|
||||
{ id: 'sideReverse', label: 'Side reverse toggle', kind: 'button', section: 'Brush toggles' },
|
||||
{ id: 'driveMacro', label: 'Drive / undock sequence', kind: 'button', section: 'Mode controls' },
|
||||
{ id: 'driveMacro', label: 'Drive / undock', kind: 'button', section: 'Mode controls' },
|
||||
{ id: 'dockMacro', label: 'Manual docking assist', kind: 'button', section: 'Mode controls' },
|
||||
{ id: 'headlightToggle', label: 'Headlight toggle', kind: 'button', section: 'Camera' },
|
||||
{ id: 'laserToggle', label: 'Laser toggle', kind: 'button', section: 'Camera' },
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
import { dockTelemetryEqual, selectDockTelemetry } from '../../../context/telemetryViews.js';
|
||||
import { useTelemetrySelector } from '../../../context/TelemetryContext.jsx';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useManualDockAssist } from '../../../features/manualDockAssist/useManualDockAssist.js';
|
||||
|
||||
function ManualDockAssistOverlay({ mobileHud = false }) {
|
||||
const { visible, statusLabel, statusTone, active, charging } = useManualDockAssist({ manageLifecycle: true });
|
||||
function ManualDockAssistOverlay({ roverId, mobileHud = false }) {
|
||||
const telemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
||||
const active = telemetry.cameraLocked;
|
||||
const charging = telemetry.dockingPhase === 'charging';
|
||||
const visible = active || telemetry.homeBase;
|
||||
const statusLabel = charging ? 'Docked and charging' : telemetry.homeBase ? 'Docked' : 'Docking assist active';
|
||||
const statusTone = charging ? 'good' : telemetry.homeBase ? 'warn' : 'active';
|
||||
const [popupMessage, setPopupMessage] = useState('');
|
||||
const timerRef = useRef(null);
|
||||
const prevActiveRef = useRef(active);
|
||||
const [previousActive, setPreviousActive] = useState(active);
|
||||
|
||||
useEffect(() => {
|
||||
const prevActive = prevActiveRef.current;
|
||||
const enabledNow = active && !prevActive;
|
||||
const autoDisabledOnCharge = !active && prevActive && charging;
|
||||
if (enabledNow) {
|
||||
setPopupMessage('Dock assist mode enabled');
|
||||
} else if (autoDisabledOnCharge) {
|
||||
setPopupMessage('Docking successful! Thank you!');
|
||||
}
|
||||
prevActiveRef.current = active;
|
||||
}, [active, charging]);
|
||||
// These messages are presentation only; telemetry owns the operation itself.
|
||||
if (previousActive !== active) {
|
||||
setPreviousActive(active);
|
||||
if (active) setPopupMessage('Dock assist mode enabled');
|
||||
else if (charging) setPopupMessage('Docking successful! Thank you!');
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!popupMessage) return undefined;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useTelemetrySelector } from '../../../../context/TelemetryContext.jsx';
|
||||
import { selectDockTelemetry, dockTelemetryEqual } from '../../../../context/telemetryViews.js';
|
||||
// Bottom-right Corner Pod
|
||||
// Purpose: Provides a compact circular camera-tilt control using the existing servo command path.
|
||||
import { useCallback } from 'react';
|
||||
@@ -31,7 +33,7 @@ export default function BottomRightPod({ roverId }) {
|
||||
const layout = useLayout();
|
||||
const [open, setOpen] = usePodVisibility('camera', true);
|
||||
const camera = useControlSelector((control) => control.state.camera);
|
||||
const dockAssistActive = useControlSelector((control) => Boolean(control.state.manualDockAssist?.active));
|
||||
const { cameraLocked } = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
||||
const { setServoAngle } = useControlActions();
|
||||
const canControl = useCanControlRover(roverId);
|
||||
const config = camera?.config;
|
||||
@@ -47,7 +49,7 @@ export default function BottomRightPod({ roverId }) {
|
||||
const cameraPodOpen = showCameraControls && enabled && open;
|
||||
|
||||
const updateFromPointer = useCallback((event) => {
|
||||
if (!canControl || !enabled || dockAssistActive || !(max > min)) return;
|
||||
if (!canControl || !enabled || cameraLocked || !(max > min)) return;
|
||||
const bounds = event.currentTarget.getBoundingClientRect();
|
||||
const x = ((event.clientX - bounds.left) / bounds.width) * 144;
|
||||
const y = ((event.clientY - bounds.top) / bounds.height) * 144;
|
||||
@@ -64,7 +66,7 @@ export default function BottomRightPod({ roverId }) {
|
||||
}
|
||||
const nextFraction = arcDegrees / ARC_SWEEP_DEGREES;
|
||||
setServoAngle(min + nextFraction * (max - min));
|
||||
}, [canControl, dockAssistActive, enabled, max, min, setServoAngle]);
|
||||
}, [canControl, cameraLocked, enabled, max, min, setServoAngle]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useControlActions } from '../../../../controls/index.js';
|
||||
import ControlHint from '../../../ControlHint/index.jsx';
|
||||
import { useTelemetrySelector } from '../../../../context/TelemetryContext.jsx';
|
||||
import { dockTelemetryEqual, selectDockTelemetry } from '../../../../context/telemetryViews.js';
|
||||
import { useManualDockAssist } from '../../../../features/manualDockAssist/useManualDockAssist.js';
|
||||
import { useSettingsNamespace } from '../../../../settings/index.js';
|
||||
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
|
||||
import { useLayout } from '../../../../layouts/LayoutContext.jsx';
|
||||
@@ -253,9 +252,6 @@ export default function DockingHud({ roverId }) {
|
||||
const layout = useLayout();
|
||||
const actions = useControlActions();
|
||||
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
||||
// This replaces ManualDockAssistOverlay as the current HUD's one lifecycle owner. It preserves the
|
||||
// success sounds, camera positioning, speed cap, and automatic exit after charging begins.
|
||||
const dockAssist = useManualDockAssist({ manageLifecycle: true });
|
||||
const canControl = useCanControlRover(roverId);
|
||||
const batteryState = useSessionSelector((state) => {
|
||||
const rover = (state.session?.roster || []).find((entry) => String(entry.id) === String(roverId));
|
||||
@@ -268,15 +264,14 @@ export default function DockingHud({ roverId }) {
|
||||
const [dockExpansionOpen, setDockExpansionOpen] = usePodVisibility('dockAssist', true);
|
||||
// The action name is presentation state as well as busy state. Keeping the
|
||||
// reason prevents a passive, already-undocked rover from ever saying "Undocking".
|
||||
const [pendingAction, setPendingAction] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [showUndockTransition, setShowUndockTransition] = useState(false);
|
||||
|
||||
const docked = Boolean(dockTelemetry?.homeBase);
|
||||
const oiMode = String(dockTelemetry?.oiModeLabel || '').toLowerCase();
|
||||
// The established UI contract treats exactly passive + undocked as the Roomba's
|
||||
// autonomous docking attempt. Unknown telemetry must not fabricate that state.
|
||||
const autoDocking = !docked && !dockAssist.active && oiMode === 'passive';
|
||||
const autoDocking = !docked && !dockTelemetry.cameraLocked && oiMode === 'passive';
|
||||
const pendingAction = (dockTelemetry.dockingPhase === 'undocking') ? (docked ? 'undocking' : 'resuming') : null;
|
||||
const pending = pendingAction !== null;
|
||||
/* Mobile already presents its own touch-oriented driving controls. The docked
|
||||
action therefore keeps its plain-language instruction without advertising a
|
||||
@@ -287,55 +282,22 @@ export default function DockingHud({ roverId }) {
|
||||
// The camera arc is the shared circular-pod reference size. Keep the dock expansion flush
|
||||
// against the battery shell after enlarging that gauge to the same 8.5-rem footprint.
|
||||
const cornerOffsetClass = batteryPodOpen ? 'right-34' : 'right-0';
|
||||
const previousDockedRef = useRef(docked);
|
||||
const [previousDocked, setPreviousDocked] = useState(docked);
|
||||
const finishUndockTransition = useCallback(() => setShowUndockTransition(false), []);
|
||||
|
||||
useEffect(() => {
|
||||
const wasDocked = previousDockedRef.current;
|
||||
// Only a real live transition plays the cue. An already-undocked rover must not animate
|
||||
// merely because the user opened or refreshed the page.
|
||||
if (wasDocked && !docked) {
|
||||
setShowUndockTransition(true);
|
||||
} else if (docked) {
|
||||
setShowUndockTransition(false);
|
||||
}
|
||||
previousDockedRef.current = docked;
|
||||
}, [docked]);
|
||||
// Presentation-only transition; mounting with an undocked rover does not animate.
|
||||
if (previousDocked !== docked) {
|
||||
setPreviousDocked(docked);
|
||||
setShowUndockTransition(!docked);
|
||||
}
|
||||
|
||||
const startDriving = async (action) => {
|
||||
const startDriving = () => {
|
||||
if (!roverId || pending || !canControl) return;
|
||||
setPendingAction(action);
|
||||
setError('');
|
||||
try {
|
||||
// The established drive sequence is the canonical undock path: it restores the camera,
|
||||
// enters full Open Interface mode, and performs the short physical back-away from the dock.
|
||||
dockAssist.exitAssist();
|
||||
actions.setMode('drive');
|
||||
await actions.runMacro('drive-sequence');
|
||||
} catch (caughtError) {
|
||||
setError(caughtError?.message || 'Unable to start driving. Please try again.');
|
||||
} finally {
|
||||
setPendingAction(null);
|
||||
}
|
||||
actions.undock();
|
||||
};
|
||||
|
||||
const startUndocking = () => {
|
||||
startDriving('undocking');
|
||||
};
|
||||
const resumeDriving = () => {
|
||||
startDriving('resuming');
|
||||
};
|
||||
|
||||
const startDocking = () => {
|
||||
if (!roverId || pending || !canControl) return;
|
||||
setError('');
|
||||
try {
|
||||
// Enter on the first click. The explanation appears as the resulting active state instead
|
||||
// of forcing the user through a modal and a second confirmation action.
|
||||
dockAssist.enterAssist();
|
||||
} catch (caughtError) {
|
||||
setError(caughtError?.message || 'Unable to start dock assist. Please try again.');
|
||||
}
|
||||
actions.sendCommand('enterDocking');
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -358,20 +320,20 @@ export default function DockingHud({ roverId }) {
|
||||
driveKeyLabel={driveKeyLabel}
|
||||
pending={pendingAction === 'undocking'}
|
||||
controlsDisabled={!canControl}
|
||||
error={error}
|
||||
onUndock={startUndocking}
|
||||
error={dockTelemetry.dockingError}
|
||||
onUndock={startDriving}
|
||||
/>
|
||||
) : autoDocking || pendingAction === 'resuming' ? (
|
||||
<AutoDockingAction
|
||||
driveKeyLabel={driveKeyLabel}
|
||||
pending={pendingAction === 'resuming'}
|
||||
controlsDisabled={!canControl}
|
||||
error={error}
|
||||
onResumeDriving={resumeDriving}
|
||||
error={dockTelemetry.dockingError}
|
||||
onResumeDriving={startDriving}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{dockAssist.active ? (
|
||||
{dockTelemetry.cameraLocked ? (
|
||||
/* Dock assist needs the unobscured camera image. A thin cyan frame communicates the
|
||||
temporary mode without adding another instruction surface over the video. */
|
||||
<div className="pointer-events-none absolute inset-0 z-50 ring-8 ring-inset ring-cyan-200/90" aria-hidden="true" />
|
||||
@@ -379,15 +341,15 @@ export default function DockingHud({ roverId }) {
|
||||
{/* Desktop keeps the compact corner entry point. Mobile starts assist from
|
||||
its dedicated control column, but once active it still mounts this component
|
||||
so the centered camera instruction and cancel action remain available. */}
|
||||
{layout === 'desktop' || dockAssist.active ? (
|
||||
{layout === 'desktop' || dockTelemetry.cameraLocked || dockTelemetry.dockingError ? (
|
||||
<DockAssistAction
|
||||
active={dockAssist.active}
|
||||
active={dockTelemetry.cameraLocked}
|
||||
pending={pending}
|
||||
controlsDisabled={!canControl}
|
||||
error={error}
|
||||
error={dockTelemetry.dockingError}
|
||||
dockKeyLabel={dockKeyLabel}
|
||||
onDock={startDocking}
|
||||
onCancel={dockAssist.exitAssist}
|
||||
onCancel={() => actions.sendCommand('cancelDocking')}
|
||||
cornerOffsetClass={cornerOffsetClass}
|
||||
open={dockExpansionOpen}
|
||||
onOpenChange={setDockExpansionOpen}
|
||||
|
||||
@@ -7,7 +7,6 @@ import './mobileControls.css';
|
||||
import { useControlActions, useControlSelector } from '../../controls/index.js';
|
||||
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
|
||||
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
|
||||
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||
import useCanControlRover from '../../hooks/useCanControlRover.js';
|
||||
import HornControl from '../HornControl/index.jsx';
|
||||
import GPIOToggleControl from '../GPIOToggleControl/index.jsx';
|
||||
@@ -33,7 +32,6 @@ function AuxColumnContent({ accessoriesAvailable, onShowAccessories }) {
|
||||
const laserState = useControlSelector((control) => control.pipeline?.laserState);
|
||||
const pipelineHorn = useControlSelector((control) => control.pipeline?.horn);
|
||||
const { setServoAngle, setHeadlight, setLaser, setAuxMotors, startHorn, stopHorn } = useControlActions();
|
||||
const dockAssist = useManualDockAssist();
|
||||
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
||||
const canControl = useCanControlRover(roverId);
|
||||
// Turn ownership is the common mutation boundary for every control in this
|
||||
@@ -44,7 +42,8 @@ function AuxColumnContent({ accessoriesAvailable, onShowAccessories }) {
|
||||
const drivingMode = String(dockTelemetry?.oiModeLabel || '').toLowerCase() === 'full';
|
||||
const vacuumDisabled = controlsDisabled
|
||||
|| docked
|
||||
|| (!drivingMode && !dockAssist.active);
|
||||
|| dockTelemetry.movementLocked
|
||||
|| (!drivingMode && !dockTelemetry.cameraLocked);
|
||||
const activeAuxButtonRef = useRef(null);
|
||||
const cameraConfig = camera?.config;
|
||||
const cameraEnabled = Boolean(roverId && camera?.enabled && cameraConfig);
|
||||
@@ -60,7 +59,7 @@ function AuxColumnContent({ accessoriesAvailable, onShowAccessories }) {
|
||||
: typeof cameraConfig?.homeAngle === 'number'
|
||||
? cameraConfig.homeAngle
|
||||
: (cameraMin + cameraMax) / 2;
|
||||
const cameraDisabled = Boolean(controlsDisabled || dockAssist.cameraLocked);
|
||||
const cameraDisabled = Boolean(controlsDisabled || dockTelemetry.cameraLocked);
|
||||
/*
|
||||
The mobile tilt track shares the same precision flag as desktop tilt. This
|
||||
keeps the servo fine-step behavior tied to the selected movement mode rather
|
||||
|
||||
@@ -2,19 +2,18 @@
|
||||
// Purpose: Assembles the mobile movement column, which is the right column by default.
|
||||
// Scope: Owns movement availability while the in-video DockingHud owns every dock/undock action.
|
||||
import { FaChargingStation } from 'react-icons/fa';
|
||||
import { useControlSelector } from '../../controls/index.js';
|
||||
import { useControlActions, useControlSelector } from '../../controls/index.js';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
|
||||
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
|
||||
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||
import useCanControlRover from '../../hooks/useCanControlRover.js';
|
||||
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
|
||||
import ControlPadPanel from './ControlPadPanel.jsx';
|
||||
|
||||
function MovementColumnContent({ layout }) {
|
||||
const roverId = useControlSelector((control) => control.state.roverId);
|
||||
const actions = useControlActions();
|
||||
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
||||
const dockAssist = useManualDockAssist();
|
||||
const canControl = useCanControlRover(roverId);
|
||||
const batteryState = useSessionSelector((state) => {
|
||||
const rover = (state.session?.roster || []).find((entry) => String(entry.id) === String(roverId));
|
||||
@@ -25,20 +24,11 @@ function MovementColumnContent({ layout }) {
|
||||
const docked = Boolean(dockTelemetry?.homeBase);
|
||||
const drivingMode = String(dockTelemetry?.oiModeLabel || '').toLowerCase() === 'full';
|
||||
|
||||
/*
|
||||
DockingHud is now the only place where a user starts driving or enters docking
|
||||
assist. This column only decides whether touch movement is safe. Requiring both
|
||||
an undocked rover and full OI mode keeps the pad inert throughout the undock
|
||||
macro, including the interval where dock contact and OI mode change separately.
|
||||
|
||||
Manual assist is the deliberate exception to the normal OI-mode requirement:
|
||||
its entire purpose is to let the user drive onto the dock under the assist speed
|
||||
cap. Actual dock contact still disables movement immediately.
|
||||
*/
|
||||
const movementDisabled = !roverId
|
||||
|| !canControl
|
||||
|| docked
|
||||
|| (!drivingMode && !dockAssist.active);
|
||||
|| dockTelemetry.movementLocked
|
||||
|| (!drivingMode && !dockTelemetry.cameraLocked);
|
||||
// Both controls share the same hardware-availability boundary. Dock assist
|
||||
// changes their actions, not who is allowed to command the rover.
|
||||
const dockActionDisabled = movementDisabled;
|
||||
@@ -49,10 +39,10 @@ function MovementColumnContent({ layout }) {
|
||||
// Mobile deliberately enters assist on the first tap. The centered video HUD
|
||||
// provides the resulting instruction, so a confirmation modal would only add
|
||||
// friction between intent and the camera-guided docking task.
|
||||
if (dockAssist.active) {
|
||||
dockAssist.exitAssist();
|
||||
if (dockTelemetry.cameraLocked) {
|
||||
actions.sendCommand('cancelDocking');
|
||||
} else {
|
||||
dockAssist.enterAssist();
|
||||
actions.sendCommand('enterDocking');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -63,7 +53,7 @@ function MovementColumnContent({ layout }) {
|
||||
disabled={dockActionDisabled}
|
||||
onClick={handleDockAction}
|
||||
className={`mobile-touch-control flex min-h-18 shrink-0 items-center justify-center gap-1.5 rounded-xl border-2 px-2 text-base font-semibold shadow-md transition disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
dockAssist.active
|
||||
dockTelemetry.cameraLocked
|
||||
? 'border-cyan-300/70 bg-cyan-900 text-cyan-50'
|
||||
: batteryUrgent
|
||||
? 'border-red-300/80 bg-red-950 text-red-50'
|
||||
@@ -75,7 +65,7 @@ function MovementColumnContent({ layout }) {
|
||||
<FaChargingStation className="text-lg" aria-hidden="true" />
|
||||
{/* The mobile column remains the only mobile entry point into dock assist. Battery
|
||||
severity makes that existing action more obvious without adding another control. */}
|
||||
<span>{dockAssist.active ? 'Exit dock assist' : batteryUrgent ? 'Dock now' : batteryLow ? 'Dock and charge soon' : 'Dock and charge'}</span>
|
||||
<span>{dockTelemetry.cameraLocked ? 'Exit dock assist' : batteryUrgent ? 'Dock now' : batteryLow ? 'Dock and charge soon' : 'Dock and charge'}</span>
|
||||
</button>
|
||||
{/* Keeping the pad mounted prevents the mobile columns from changing size while
|
||||
the centered video HUD explains and performs dock-related transitions. */}
|
||||
|
||||
@@ -411,7 +411,7 @@ export default function SettingsPanel() {
|
||||
<span className="font-semibold text-white">Swap control columns (put joystick on the left)</span>
|
||||
</SettingRow>
|
||||
</CardFrame>
|
||||
<CardFrame title="Macros" bodyClassName="space-y-1 p-1 text-sm">
|
||||
<CardFrame title="Driving" bodyClassName="space-y-1 p-1 text-sm">
|
||||
<SettingRow inline>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -419,7 +419,7 @@ export default function SettingsPanel() {
|
||||
checked={driveMacroBackoffEnabled}
|
||||
onChange={handleDriveMacroBackoffEnabled}
|
||||
/>
|
||||
<span className="font-semibold text-white">Enable backward bump in drive macro</span>
|
||||
<span className="font-semibold text-white">Back away from the dock when undocking</span>
|
||||
</SettingRow>
|
||||
</CardFrame>
|
||||
<CardFrame title="Audio" className="col-span-full" bodyClassName="space-y-1 p-1 text-sm">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 } },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -55,7 +55,3 @@ export function bytesToBase64(bytes) {
|
||||
}
|
||||
throw new Error('No base64 encoder available');
|
||||
}
|
||||
|
||||
export function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Control State Reducer
|
||||
// Purpose: Implements reducer transitions for control-system runtime state. Scope: Centralizes deterministic state updates for input, mode, and dispatch events.
|
||||
import { DEFAULT_KEYMAP, DEFAULT_MACROS, SONG_DEFAULT_NOTE } from './constants.js';
|
||||
import { DEFAULT_KEYMAP, SONG_DEFAULT_NOTE } from './constants.js';
|
||||
|
||||
function createDriveState() {
|
||||
return {
|
||||
@@ -49,12 +49,6 @@ function createMicState() {
|
||||
};
|
||||
}
|
||||
|
||||
function createManualDockAssistState() {
|
||||
return {
|
||||
active: false,
|
||||
};
|
||||
}
|
||||
|
||||
export const initialControlState = {
|
||||
roverId: null,
|
||||
mode: 'drive',
|
||||
@@ -64,9 +58,7 @@ export const initialControlState = {
|
||||
song: createSongState(),
|
||||
horn: createHornState(),
|
||||
mic: createMicState(),
|
||||
manualDockAssist: createManualDockAssistState(),
|
||||
lastControlIntentAt: 0,
|
||||
macros: DEFAULT_MACROS,
|
||||
keymap: DEFAULT_KEYMAP,
|
||||
inputs: {},
|
||||
// Generic accessory controls do not currently report state back from roverd.
|
||||
@@ -86,7 +78,6 @@ export function controlReducer(state, action) {
|
||||
song: action.payload ? state.song : createSongState(),
|
||||
horn: action.payload ? state.horn : createHornState(),
|
||||
mic: action.payload ? state.mic : createMicState(),
|
||||
manualDockAssist: action.payload ? state.manualDockAssist : createManualDockAssistState(),
|
||||
lastControlIntentAt: action.payload ? state.lastControlIntentAt : 0,
|
||||
};
|
||||
case 'control/set-mode':
|
||||
@@ -169,11 +160,6 @@ export function controlReducer(state, action) {
|
||||
...state,
|
||||
keymap: { ...state.keymap, ...(action.payload ?? {}) },
|
||||
};
|
||||
case 'control/set-macros':
|
||||
return {
|
||||
...state,
|
||||
macros: Array.isArray(action.payload) ? action.payload : state.macros,
|
||||
};
|
||||
case 'control/reset':
|
||||
return {
|
||||
...state,
|
||||
@@ -222,14 +208,6 @@ export function controlReducer(state, action) {
|
||||
pttActive: Boolean(action.payload),
|
||||
},
|
||||
};
|
||||
case 'control/set-manual-dock-assist':
|
||||
return {
|
||||
...state,
|
||||
manualDockAssist: {
|
||||
...(state.manualDockAssist || createManualDockAssistState()),
|
||||
active: Boolean(action.payload),
|
||||
},
|
||||
};
|
||||
case 'control/set-peripheral-value': {
|
||||
const roverId = String(action.payload?.roverId || '');
|
||||
const peripheralId = String(action.payload?.peripheralId || '');
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
|
||||
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
|
||||
// Gamepad Input Manager
|
||||
// Purpose: Converts polled gamepad state into normalized control actions/commands. Scope: Integrates bindings, deadzone math, and dispatch callbacks for driving.
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
|
||||
@@ -17,7 +19,6 @@ import {
|
||||
} from './gamepadBindings.js';
|
||||
import { subscribeGamepadHub } from './gamepadHub.js';
|
||||
import { isTextEntryActive } from './inputFocusUtils.js';
|
||||
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||
import {
|
||||
isControllerControlLocked,
|
||||
markControllerDisconnected,
|
||||
@@ -113,12 +114,12 @@ function cycleHomeAssistant(latest, targetState) {
|
||||
|
||||
export default function GamepadInputManager() {
|
||||
const {
|
||||
setMode,
|
||||
setDriveVector,
|
||||
setAuxMotors,
|
||||
setServoAngle,
|
||||
setCameraAxisIntent,
|
||||
runMacro,
|
||||
undock,
|
||||
sendCommand,
|
||||
toggleHeadlight,
|
||||
toggleLaser,
|
||||
registerInputState,
|
||||
@@ -131,7 +132,7 @@ export default function GamepadInputManager() {
|
||||
const cameraAngle = useControlSelector((control) => control.state.camera?.angle);
|
||||
const cameraConfig = useControlSelector((control) => control.state.camera?.config);
|
||||
const roverId = useControlSelector((control) => control.state.roverId);
|
||||
const dockAssist = useManualDockAssist();
|
||||
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
||||
const { focusChat } = useChatActions();
|
||||
const { isChatFocused } = useChatFocus();
|
||||
const { homeAssistantSetState, pushAlert } = useSessionActions();
|
||||
@@ -283,7 +284,7 @@ export default function GamepadInputManager() {
|
||||
role === 'lockdown' || (role === 'admin' && sessionMode !== 'lockdown'),
|
||||
cameraAngle,
|
||||
cameraConfig,
|
||||
dockAssist,
|
||||
dockTelemetry,
|
||||
focusChat,
|
||||
gamepadSettings,
|
||||
homeAssistant,
|
||||
@@ -292,7 +293,8 @@ export default function GamepadInputManager() {
|
||||
pushAlert,
|
||||
registerInputState,
|
||||
roverId,
|
||||
runMacro,
|
||||
undock,
|
||||
sendCommand,
|
||||
saveGamepadSettings,
|
||||
saveVideoSettings,
|
||||
sendSong,
|
||||
@@ -300,8 +302,7 @@ export default function GamepadInputManager() {
|
||||
setCameraAxisIntent,
|
||||
setDriveVector,
|
||||
setMicPttActive,
|
||||
setMode,
|
||||
setSongNote,
|
||||
setSongNote,
|
||||
setServoAngle,
|
||||
songNote,
|
||||
startHorn,
|
||||
@@ -462,15 +463,13 @@ export default function GamepadInputManager() {
|
||||
}
|
||||
|
||||
if (outputs.buttons.driveMacro && handleButtonEdge('driveMacro', true)) {
|
||||
latest.dockAssist.exitAssist();
|
||||
latest.setMode('drive');
|
||||
latest.runMacro('drive-sequence');
|
||||
latest.undock();
|
||||
} else if (!outputs.buttons.driveMacro) {
|
||||
handleButtonEdge('driveMacro', false);
|
||||
}
|
||||
|
||||
if (outputs.buttons.dockMacro && handleButtonEdge('dockMacro', true)) {
|
||||
latest.dockAssist.toggleAssist();
|
||||
latest.sendCommand(latest.dockTelemetry.cameraLocked ? 'cancelDocking' : 'enterDocking');
|
||||
} else if (!outputs.buttons.dockMacro) {
|
||||
handleButtonEdge('dockMacro', false);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
|
||||
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
|
||||
// Keyboard Input Manager
|
||||
// Purpose: Captures and translates keyboard events into normalized control intents. Scope: Owns keydown/keyup listeners and dispatch coordination for drive controls.
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
|
||||
@@ -10,7 +12,6 @@ import { markKeyboardInputActive } from './controllerRuntime.js';
|
||||
import { isTextInputElement } from './inputFocusUtils.js';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { INPUT_SETTINGS_DEFAULTS, VIDEO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
||||
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||
import {
|
||||
SONG_DEFAULT_DURATION,
|
||||
SONG_DEFAULT_NOTE,
|
||||
@@ -86,12 +87,12 @@ function shouldIgnoreEvent(event) {
|
||||
|
||||
export default function KeyboardInputManager() {
|
||||
const {
|
||||
setMode,
|
||||
setDriveVector,
|
||||
setAuxMotors,
|
||||
nudgeServo,
|
||||
setCameraAxisIntent,
|
||||
runMacro,
|
||||
undock,
|
||||
sendCommand,
|
||||
stopAllMotion,
|
||||
registerInputState,
|
||||
setCameraPrecisionMode,
|
||||
@@ -107,12 +108,12 @@ export default function KeyboardInputManager() {
|
||||
const cameraNudgeDegrees = useControlSelector((control) => control.state.camera?.config?.nudgeDegrees);
|
||||
const songNote = useControlSelector((control) => control.state.song?.note);
|
||||
const roverId = useControlSelector((control) => control.state.roverId);
|
||||
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
||||
const hornActive = useControlSelector((control) => Boolean(control.state.horn?.active));
|
||||
const homeAssistant = useSessionSelector((state) => state.session?.homeAssistant || null);
|
||||
const role = useSessionSelector((state) => state.session?.role || null);
|
||||
const mode = useSessionSelector((state) => state.session?.mode || null);
|
||||
const adminCanControlLockedLights = role === 'lockdown' || (role === 'admin' && mode !== 'lockdown');
|
||||
const dockAssist = useManualDockAssist();
|
||||
const { homeAssistantSetState, pushAlert } = useSessionActions();
|
||||
const { focusChat } = useChatActions();
|
||||
const { isChatFocused } = useChatFocus();
|
||||
@@ -386,7 +387,7 @@ export default function KeyboardInputManager() {
|
||||
// before the browser can deliver the next keyboard event after a committed render.
|
||||
latestRef.current = {
|
||||
actionTokens,
|
||||
dockAssist,
|
||||
dockTelemetry,
|
||||
focusChat,
|
||||
homeAssistant,
|
||||
homeAssistantSetState,
|
||||
@@ -398,7 +399,8 @@ export default function KeyboardInputManager() {
|
||||
pushAlert,
|
||||
registerInputState,
|
||||
roverId,
|
||||
runMacro,
|
||||
undock,
|
||||
sendCommand,
|
||||
saveVideoSettings,
|
||||
sendSong,
|
||||
servoRepeatMs,
|
||||
@@ -408,8 +410,7 @@ export default function KeyboardInputManager() {
|
||||
setCameraPrecisionMode,
|
||||
setDriveVector,
|
||||
setMicPttActive,
|
||||
setMode,
|
||||
setSongNote,
|
||||
setSongNote,
|
||||
songNote,
|
||||
startHorn,
|
||||
stopAllMotion,
|
||||
@@ -452,11 +453,9 @@ export default function KeyboardInputManager() {
|
||||
|
||||
if (newlyPressed.length > 0) {
|
||||
if (newlyPressed.some((token) => latest.keymap.driveMacro?.has(token))) {
|
||||
latest.dockAssist.exitAssist();
|
||||
latest.setMode('drive');
|
||||
latest.runMacro('drive-sequence');
|
||||
latest.undock();
|
||||
} else if (newlyPressed.some((token) => latest.keymap.dockMacro?.has(token))) {
|
||||
latest.dockAssist.toggleAssist();
|
||||
latest.sendCommand(latest.dockTelemetry.cameraLocked ? 'cancelDocking' : 'enterDocking');
|
||||
} else if (newlyPressed.some((token) => latest.keymap.headlightToggle?.has(token))) {
|
||||
latest.toggleHeadlight();
|
||||
} else if (newlyPressed.some((token) => latest.keymap.laserToggle?.has(token))) {
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useControlActions, useControlSelector } from '../../controls/index.js';
|
||||
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
|
||||
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
|
||||
|
||||
export function useManualDockAssist(options = {}) {
|
||||
const { manageLifecycle = false } = options;
|
||||
const roverId = useControlSelector((control) => control.state.roverId);
|
||||
const active = useControlSelector((control) => Boolean(control.state.manualDockAssist?.active));
|
||||
const actions = useControlActions();
|
||||
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
||||
const chargingLabel = dockTelemetry.chargingStateLabel || '';
|
||||
const docked = Boolean(dockTelemetry.homeBase);
|
||||
const charging = docked && chargingLabel.toLowerCase() !== 'not charging' && chargingLabel !== '';
|
||||
const wasDockedRef = useRef(false);
|
||||
|
||||
const enterAssist = useCallback(() => {
|
||||
actions.setManualDockAssistActive(true);
|
||||
}, [actions]);
|
||||
|
||||
const exitAssist = useCallback(() => {
|
||||
actions.setManualDockAssistActive(false);
|
||||
}, [actions]);
|
||||
|
||||
const toggleAssist = useCallback(() => {
|
||||
actions.toggleManualDockAssist();
|
||||
}, [actions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!manageLifecycle) return;
|
||||
const justDocked = docked && !wasDockedRef.current;
|
||||
const justUndocked = !docked && wasDockedRef.current;
|
||||
if (active && justDocked) {
|
||||
// Stop on the first home-base contact so continued input cannot push the rover against the dock.
|
||||
actions.stopAllMotion();
|
||||
actions.sendSong([{ note: 84, duration: 6 }], { slot: 1 });
|
||||
} else if (active && justUndocked) {
|
||||
actions.sendSong([{ note: 72, duration: 6 }], { slot: 1 });
|
||||
}
|
||||
wasDockedRef.current = docked;
|
||||
}, [actions, active, docked, manageLifecycle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!manageLifecycle || !active || !charging) return;
|
||||
exitAssist();
|
||||
}, [active, charging, exitAssist, manageLifecycle]);
|
||||
|
||||
const statusLabel = charging ? 'Docked and charging' : docked ? 'Docked' : 'Docking assist active';
|
||||
const statusTone = charging ? 'good' : docked ? 'warn' : 'active';
|
||||
const visible = active || docked;
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
active,
|
||||
docked,
|
||||
charging,
|
||||
cameraLocked: active,
|
||||
visible,
|
||||
statusLabel,
|
||||
statusTone,
|
||||
enterAssist,
|
||||
exitAssist,
|
||||
toggleAssist,
|
||||
}),
|
||||
[active, charging, docked, enterAssist, exitAssist, statusLabel, statusTone, toggleAssist, visible],
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { dockTelemetryEqual, selectDockTelemetry } from '../../../context/telemetryViews.js';
|
||||
import { useTelemetrySelector } from '../../../context/TelemetryContext.jsx';
|
||||
// Right Pane Tabs
|
||||
// Purpose: Defines the Right Pane Tabs module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
@@ -22,7 +24,6 @@ import { useSessionSelector } from '../../../context/SessionContext.jsx';
|
||||
import OverseerPreferencePanel from '../../../components/OverseerPreferencePanel/index.jsx';
|
||||
import CardFrame from '../../../components/CardFrame/index.jsx';
|
||||
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useManualDockAssist } from '../../../features/manualDockAssist/useManualDockAssist.js';
|
||||
import { themeGapClass, themeStackClass } from '../../../themes/index.js';
|
||||
import ActivitiesTab from '../tabs/shared/ActivitiesTab/index.jsx';
|
||||
import VipTab from '../tabs/shared/VipTab/index.jsx';
|
||||
@@ -62,7 +63,7 @@ function DriveDockPanel() {
|
||||
const laserState = useControlSelector((control) => control.pipeline?.laserState);
|
||||
const pipelineHorn = useControlSelector((control) => control.pipeline?.horn);
|
||||
const { setServoAngle, setHeadlight, setLaser, startHorn, stopHorn } = useControlActions();
|
||||
const dockAssist = useManualDockAssist();
|
||||
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
||||
const driveDockState = useDriveDockState(roverId);
|
||||
const hideInlineControls = driveDockState.docked && !driveDockState.driving;
|
||||
|
||||
@@ -85,7 +86,7 @@ function DriveDockPanel() {
|
||||
const hornLabel = <ControlHint actionId="hornHonk" />;
|
||||
const upLabel = <ControlHint actionId="cameraUp" />;
|
||||
const downLabel = <ControlHint actionId="cameraDown" />;
|
||||
const cameraDisabled = Boolean(!roverId || dockAssist.cameraLocked);
|
||||
const cameraDisabled = Boolean(!roverId || dockTelemetry.cameraLocked);
|
||||
/*
|
||||
Precision movement mode also tightens the servo slider step. The command
|
||||
path still sends ordinary angle targets; only the UI increment changes while
|
||||
|
||||
Reference in New Issue
Block a user