mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-15 11:12:16 -05:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c71267873a |
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 (
|
const (
|
||||||
autoChargeTimeout = 10 * time.Second
|
autoChargeTimeout = 1 * time.Second
|
||||||
autoChargeCooldown = 0 * time.Minute
|
autoChargeCooldown = 0 * time.Minute
|
||||||
sourceHomeBase = 1 << 1
|
sourceHomeBase = 1 << 1
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Configuration Database Migrations
|
// Configuration Database Migrations
|
||||||
// Purpose: Applies ordered, transactional schema changes to the configuration and administration database.
|
// Purpose: Applies ordered, transactional schema changes to the configuration and administration database.
|
||||||
// Scope: Owns database structure only; configuration-document evolution belongs to the ordered definition and validation.
|
// Scope: Upgrades database structure and stored configuration documents before current-schema validation.
|
||||||
const migrations = [
|
const migrations = [
|
||||||
{
|
{
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -84,6 +84,34 @@ const migrations = [
|
|||||||
`).run();
|
`).run();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
version: 4,
|
||||||
|
run(db) {
|
||||||
|
const rows = db.prepare('SELECT id, config_json FROM configuration_revisions').all();
|
||||||
|
const update = db.prepare('UPDATE configuration_revisions SET config_json = ? WHERE id = ?');
|
||||||
|
// Upgrade history as well as the active revision so a settings rollback
|
||||||
|
// cannot reintroduce fields rejected by the new Automations schema.
|
||||||
|
for (const row of rows) {
|
||||||
|
const config = JSON.parse(row.config_json);
|
||||||
|
let changed = false;
|
||||||
|
for (const item of config.homeAssistantActivities?.items || []) {
|
||||||
|
const hasAction = Object.hasOwn(item, 'idleAction');
|
||||||
|
const hasValue = Object.hasOwn(item, 'idleValue');
|
||||||
|
if (!hasAction && !hasValue) continue;
|
||||||
|
item.automations ??= {};
|
||||||
|
item.automations.idle ??= {};
|
||||||
|
// Preserve any explicitly saved new-format setting when both formats
|
||||||
|
// exist, including an empty text value used to clear an entity.
|
||||||
|
if (hasAction && !Object.hasOwn(item.automations.idle, 'action')) item.automations.idle.action = item.idleAction;
|
||||||
|
if (hasValue && !Object.hasOwn(item.automations.idle, 'value')) item.automations.idle.value = item.idleValue;
|
||||||
|
delete item.idleAction;
|
||||||
|
delete item.idleValue;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (changed) update.run(JSON.stringify(config), row.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function applySchemaMigrations(db) {
|
function applySchemaMigrations(db) {
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ function normalizeOutboundCommandPayload(payload = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function issueCommand(roverId, payload, options = {}) {
|
function issueCommand(roverId, payload) {
|
||||||
const record = roverManager.rovers.get(roverId);
|
const record = roverManager.rovers.get(roverId);
|
||||||
if (!record || !record.ws) {
|
if (!record || !record.ws) {
|
||||||
throw new Error('Rover offline');
|
throw new Error('Rover offline');
|
||||||
@@ -93,11 +93,6 @@ function issueCommand(roverId, payload, options = {}) {
|
|||||||
if (payload?.type === 'laser' && isLaserCommandBlockedByRoomLightLock(payload)) {
|
if (payload?.type === 'laser' && isLaserCommandBlockedByRoomLightLock(payload)) {
|
||||||
throw new Error('Laser disabled while room lights are locked on');
|
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 id = uuidv4();
|
||||||
const normalizedPayload = normalizeOutboundCommandPayload(payload);
|
const normalizedPayload = normalizeOutboundCommandPayload(payload);
|
||||||
const message = { ...normalizedPayload, id };
|
const message = { ...normalizedPayload, id };
|
||||||
@@ -129,14 +124,7 @@ overcurrentProtectionService.configureCommandIssuer((roverId, payload) => {
|
|||||||
*/
|
*/
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const record = roverManager.rovers.get(roverId);
|
issueCommand(roverId, payload);
|
||||||
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;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -327,22 +315,6 @@ function submitCommand(socket, { roverId, type, data } = {}, cb) {
|
|||||||
if (!isSongCommand && !isRebootCommand && !isUpdateCommand && !roverManager.canDrive(roverId, socket)) {
|
if (!isSongCommand && !isRebootCommand && !isUpdateCommand && !roverManager.canDrive(roverId, socket)) {
|
||||||
throw new Error('Not your turn or no control');
|
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;
|
const driveDirect = payload?.driveDirect;
|
||||||
if (type === 'drive' && driveDirect) {
|
if (type === 'drive' && driveDirect) {
|
||||||
if (!isAdminSocket) {
|
if (!isAdminSocket) {
|
||||||
@@ -389,14 +361,7 @@ function submitCommand(socket, { roverId, type, data } = {}, cb) {
|
|||||||
bypassed: isAdminSocket,
|
bypassed: isAdminSocket,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const isDockingIntent = docking.intents.has(type);
|
const id = issueCommand(roverId, { type, ...payload });
|
||||||
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', {
|
commandEvents.emit('observation', {
|
||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
roverId: String(roverId),
|
roverId: String(roverId),
|
||||||
|
|||||||
@@ -1,302 +0,0 @@
|
|||||||
// 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 };
|
|
||||||
@@ -4,15 +4,12 @@
|
|||||||
const logger = require('../../globals/logger').child('idleService');
|
const logger = require('../../globals/logger').child('idleService');
|
||||||
const io = require('../../globals/io');
|
const io = require('../../globals/io');
|
||||||
const { getRole, roleEvents } = require('../roleService');
|
const { getRole, roleEvents } = require('../roleService');
|
||||||
const { getMode, MODES, modeEvents } = require('../modeManager');
|
|
||||||
const { isDeterred, verificationEvents } = require('../verificationService');
|
|
||||||
const { IDLE_TIMEOUT_MS } = require('./constants');
|
const { IDLE_TIMEOUT_MS } = require('./constants');
|
||||||
const { runtime } = require('./state');
|
const { runtime } = require('./state');
|
||||||
const { runIdleActions } = require('./actions');
|
const { runIdleActions } = require('./actions');
|
||||||
const activityControls = require('../homeAssistantActivitiesService');
|
const activityControls = require('../homeAssistantActivitiesService');
|
||||||
|
|
||||||
function getActivitySnapshot() {
|
function getActivitySnapshot() {
|
||||||
const mode = getMode();
|
|
||||||
let onlineUsers = 0;
|
let onlineUsers = 0;
|
||||||
let onlineAdmins = 0;
|
let onlineAdmins = 0;
|
||||||
let onlineSpectators = 0;
|
let onlineSpectators = 0;
|
||||||
@@ -32,16 +29,12 @@ function getActivitySnapshot() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
if (
|
Lockdown admins are counted with regular admins because both represent a
|
||||||
isDeterred(socket)
|
person with operator-level access who may be supervising the room without
|
||||||
|| (mode === MODES.ADMIN && role !== 'admin' && role !== 'lockdown')
|
actively driving a rover. Plain users also count even before they request
|
||||||
|| (mode === MODES.LOCKDOWN && role !== 'lockdown')
|
control, which is the behavior this service now needs.
|
||||||
) {
|
*/
|
||||||
onlineIgnored += 1;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (role === 'admin' || role === 'lockdown') {
|
if (role === 'admin' || role === 'lockdown') {
|
||||||
onlineAdmins += 1;
|
onlineAdmins += 1;
|
||||||
return;
|
return;
|
||||||
@@ -97,7 +90,7 @@ function scheduleIdleTimer() {
|
|||||||
runtime.deadlineAt = null;
|
runtime.deadlineAt = null;
|
||||||
const activity = getActivitySnapshot();
|
const activity = getActivitySnapshot();
|
||||||
if (activity.totalActive > 0) {
|
if (activity.totalActive > 0) {
|
||||||
logger.info('Idle automation skipped; eligible operator online', activity);
|
logger.info('Idle automation skipped; user or admin online', activity);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
runtime.lastTriggeredAt = Date.now();
|
runtime.lastTriggeredAt = Date.now();
|
||||||
@@ -121,12 +114,18 @@ function scheduleIdleTimer() {
|
|||||||
function refreshIdleState() {
|
function refreshIdleState() {
|
||||||
const activity = getActivitySnapshot();
|
const activity = getActivitySnapshot();
|
||||||
logger.info('Idle state refresh', activity);
|
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);
|
activityControls.setOperatorsOnline(activity.totalActive > 0);
|
||||||
if (activity.totalActive > 0) {
|
if (activity.totalActive > 0) {
|
||||||
clearIdleTimer();
|
clearIdleTimer();
|
||||||
if (runtime.idleActionsCompleted) {
|
if (runtime.idleActionsCompleted) {
|
||||||
logger.info('Idle action one-shot reset; operator is online again', activity);
|
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;
|
runtime.idleActionsCompleted = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -134,13 +133,17 @@ function refreshIdleState() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
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();
|
refreshIdleState();
|
||||||
socket.on('disconnect', refreshIdleState);
|
socket.on('disconnect', refreshIdleState);
|
||||||
});
|
});
|
||||||
|
|
||||||
roleEvents.on('change', refreshIdleState);
|
roleEvents.on('change', refreshIdleState);
|
||||||
modeEvents.on('change', refreshIdleState);
|
|
||||||
verificationEvents.on('change', refreshIdleState);
|
|
||||||
|
|
||||||
refreshIdleState();
|
refreshIdleState();
|
||||||
|
|
||||||
|
|||||||
@@ -274,7 +274,6 @@ odometerService.odometerEvents.on('update', ({ roverId, odometer }) => {
|
|||||||
setInterval(tickPrivateAutoClose, PRIVATE_AUTO_CLOSE_TICK_MS);
|
setInterval(tickPrivateAutoClose, PRIVATE_AUTO_CLOSE_TICK_MS);
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
stopDockGuard,
|
|
||||||
upsertRover,
|
upsertRover,
|
||||||
removeRover,
|
removeRover,
|
||||||
lockRover,
|
lockRover,
|
||||||
|
|||||||
@@ -117,7 +117,6 @@ function createRosterLifecycle(deps) {
|
|||||||
offline, while removeRoverDrivers still receives the captured record so
|
offline, while removeRoverDrivers still receives the captured record so
|
||||||
it can clean the reverse membership index and Socket.IO room membership.
|
it can clean the reverse membership index and Socket.IO room membership.
|
||||||
*/
|
*/
|
||||||
require('../dockingService').disconnect(record);
|
|
||||||
rovers.delete(id);
|
rovers.delete(id);
|
||||||
removeRoverDrivers(id, record);
|
removeRoverDrivers(id, record);
|
||||||
stopDockGuard(id);
|
stopDockGuard(id);
|
||||||
|
|||||||
@@ -389,10 +389,6 @@ function createSensorPipeline(deps) {
|
|||||||
|
|
||||||
function evaluateDockGuard(record, sensors) {
|
function evaluateDockGuard(record, sensors) {
|
||||||
if (!record || !sensors) return;
|
if (!record || !sensors) return;
|
||||||
if (sensors.docking?.phase === 'undocking' || sensors.docking?.phase === 'assisting') {
|
|
||||||
stopDockGuard(record.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const state = getDockGuardState(record.id);
|
const state = getDockGuardState(record.id);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const docked = Boolean(sensors?.chargingSources?.homeBase);
|
const docked = Boolean(sensors?.chargingSources?.homeBase);
|
||||||
@@ -568,10 +564,6 @@ function createSensorPipeline(deps) {
|
|||||||
};
|
};
|
||||||
record.lastSensor = { raw: frame, decoded };
|
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
|
Rover manager remains responsible only for decoding and routing sensor
|
||||||
frames. The dedicated service receives the completed sensor object after
|
frames. The dedicated service receives the completed sensor object after
|
||||||
@@ -585,7 +577,6 @@ function createSensorPipeline(deps) {
|
|||||||
const overcurrentProtection = decoded && typeof processOvercurrentTelemetry === 'function'
|
const overcurrentProtection = decoded && typeof processOvercurrentTelemetry === 'function'
|
||||||
? processOvercurrentTelemetry(roverId, decoded)
|
? processOvercurrentTelemetry(roverId, decoded)
|
||||||
: null;
|
: null;
|
||||||
record.lastOvercurrentProtection = overcurrentProtection;
|
|
||||||
updateMovement(record, decoded);
|
updateMovement(record, decoded);
|
||||||
const hasDockInfo = decoded?.chargingSources != null;
|
const hasDockInfo = decoded?.chargingSources != null;
|
||||||
if (hasDockInfo) {
|
if (hasDockInfo) {
|
||||||
@@ -598,10 +589,6 @@ function createSensorPipeline(deps) {
|
|||||||
if (bumps?.bumpLeft || bumps?.bumpRight) record.lastBumpAt = Date.now();
|
if (bumps?.bumpLeft || bumps?.bumpRight) record.lastBumpAt = Date.now();
|
||||||
handlePrivateButtonHold(record, decoded);
|
handlePrivateButtonHold(record, decoded);
|
||||||
evaluatePrivateSafety(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', {
|
io.to(record.room).volatile.emit('sensorFrame', {
|
||||||
roverId,
|
roverId,
|
||||||
frame,
|
frame,
|
||||||
|
|||||||
@@ -3,9 +3,20 @@
|
|||||||
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
|
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
|
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 admin ui for VIP and private requests instead of only through discord
|
1. add new discord channel: live status
|
||||||
2. make roverd self update checkout to main branch
|
1. one concise message which contains the status of all the rovers
|
||||||
3. fix this:
|
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:
|
||||||
`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]: /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]: cb({ error: err.message });
|
||||||
Jun 18 15:14:18 roombaserver.local node[216731]: ^
|
Jun 18 15:14:18 roombaserver.local node[216731]: ^
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ export function deriveDriveDockStateFromTelemetry(dockTelemetry) {
|
|||||||
const oiNormalized = oiLabel.toLowerCase();
|
const oiNormalized = oiLabel.toLowerCase();
|
||||||
const chargingLabel = dockTelemetry?.chargingStateLabel || '';
|
const chargingLabel = dockTelemetry?.chargingStateLabel || '';
|
||||||
const docked = Boolean(dockTelemetry?.homeBase);
|
const docked = Boolean(dockTelemetry?.homeBase);
|
||||||
const charging = dockTelemetry?.dockingPhase === 'charging';
|
const charging = docked && chargingLabel.toLowerCase() !== 'not charging' && chargingLabel !== '';
|
||||||
const driving = oiNormalized === 'full' && !dockTelemetry?.movementLocked;
|
const driving = oiNormalized === 'full';
|
||||||
const dockedNotCharging = docked && !charging;
|
const dockedNotCharging = docked && !charging;
|
||||||
const dockingInProgress = !docked && !charging && oiNormalized === 'passive' && dockTelemetry?.dockingPhase !== 'undocking';
|
const dockingInProgress = !docked && !charging && oiNormalized === 'passive';
|
||||||
return { driving, docked, charging, dockedNotCharging, dockingInProgress, oiLabel, chargingLabel };
|
return { driving, docked, charging, dockedNotCharging, dockingInProgress, oiLabel, chargingLabel };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
// Drive Dock Action
|
// Drive Dock Action
|
||||||
// Purpose: Defines the Drive Dock Action module and the local helpers/components used in this file.
|
// 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.
|
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||||
import { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import '../MobileControls/mobileControls.css';
|
import '../MobileControls/mobileControls.css';
|
||||||
import { useControlActions, useControlSelector } from '../../controls/index.js';
|
import { useControlActions, useControlSelector } from '../../controls/index.js';
|
||||||
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
|
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
|
||||||
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
|
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
|
||||||
import ControlHint from '../ControlHint/index.jsx';
|
import ControlHint from '../ControlHint/index.jsx';
|
||||||
|
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||||
import { deriveDriveDockStateFromTelemetry } from './driveDockState.js';
|
import { deriveDriveDockStateFromTelemetry } from './driveDockState.js';
|
||||||
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
|
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
|
||||||
|
|
||||||
@@ -90,13 +91,14 @@ export default function DriveDockAction({
|
|||||||
const isMobile = layout === 'mobile';
|
const isMobile = layout === 'mobile';
|
||||||
const roverId = useControlSelector((control) => control.state.roverId);
|
const roverId = useControlSelector((control) => control.state.roverId);
|
||||||
const actions = useControlActions();
|
const actions = useControlActions();
|
||||||
|
const dockAssist = useManualDockAssist();
|
||||||
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
||||||
const state = driveDockState ?? deriveDriveDockStateFromTelemetry(dockTelemetry);
|
const state = driveDockState ?? deriveDriveDockStateFromTelemetry(dockTelemetry);
|
||||||
const { driving, docked, charging, dockingInProgress } = state;
|
const { driving, docked, charging, dockingInProgress } = state;
|
||||||
const pending = dockTelemetry.dockingPhase === 'undocking' ? 'drive' : null;
|
const [pending, setPending] = useState(null);
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const manualAssistActive = Boolean(dockTelemetry.cameraLocked);
|
const manualAssistActive = Boolean(dockAssist.active);
|
||||||
|
|
||||||
const driveDisabled = !roverId || pending !== null;
|
const driveDisabled = !roverId || pending !== null;
|
||||||
const dockDisabled = !roverId || pending !== null;
|
const dockDisabled = !roverId || pending !== null;
|
||||||
@@ -122,37 +124,64 @@ export default function DriveDockAction({
|
|||||||
const chargeValue = charging ? 'Charging' : docked ? 'Charging soon...' : '—';
|
const chargeValue = charging ? 'Charging' : docked ? 'Charging soon...' : '—';
|
||||||
const chargeTone = charging ? 'good' : docked ? 'warn' : 'bad';
|
const chargeTone = charging ? 'good' : docked ? 'warn' : 'bad';
|
||||||
|
|
||||||
const handleStartDrive = () => {
|
const handleReturnToDrive = async () => {
|
||||||
if (!roverId || pending) return;
|
if (!roverId || pending) return;
|
||||||
if (isMobile) triggerTouchHaptic('button');
|
if (isMobile) triggerTouchHaptic('button');
|
||||||
setConfirmOpen(false);
|
setPending('drive');
|
||||||
setShowModal(false);
|
try {
|
||||||
actions.undock();
|
dockAssist.exitAssist();
|
||||||
|
actions.setMode('drive');
|
||||||
|
await actions.runMacro('drive-sequence');
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
} finally {
|
||||||
|
setPending(null);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirmDock = () => {
|
const handleStartDrive = async () => {
|
||||||
if (!roverId || pending) return;
|
if (!roverId || pending) return;
|
||||||
if (isMobile) triggerTouchHaptic('button');
|
if (isMobile) triggerTouchHaptic('button');
|
||||||
actions.sendCommand('enterDocking');
|
|
||||||
setConfirmOpen(false);
|
setConfirmOpen(false);
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
|
setPending('drive');
|
||||||
|
try {
|
||||||
|
dockAssist.exitAssist();
|
||||||
|
actions.setMode('drive');
|
||||||
|
await actions.runMacro('drive-sequence');
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
} finally {
|
||||||
|
setPending(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmDock = async () => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenDock = () => {
|
const handleOpenDock = () => {
|
||||||
if (dockDisabled) return;
|
if (dockDisabled) return;
|
||||||
if (isMobile) triggerTouchHaptic('button');
|
if (isMobile) triggerTouchHaptic('button');
|
||||||
if (manualAssistActive) {
|
if (manualAssistActive) {
|
||||||
actions.sendCommand('cancelDocking');
|
dockAssist.exitAssist();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
setConfirmOpen(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
|
// 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
|
// tapped repeatedly; attach mobile touch suppression directly to the card so
|
||||||
// Safari does not select labels or open callouts during those interactions.
|
// Safari does not select labels or open callouts during those interactions.
|
||||||
@@ -225,7 +254,7 @@ export default function DriveDockAction({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={driveDisabled}
|
disabled={driveDisabled}
|
||||||
onClick={handleStartDrive}
|
onClick={handleReturnToDrive}
|
||||||
onContextMenu={(event) => event.preventDefault()}
|
onContextMenu={(event) => event.preventDefault()}
|
||||||
className={`${baseCardClasses} ${filledHeight} ${compactHeight} ${ctaText} ${layoutClass} ${ctaSize} ${amberCta}`}
|
className={`${baseCardClasses} ${filledHeight} ${compactHeight} ${ctaText} ${layoutClass} ${ctaSize} ${amberCta}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export default function DriverVideo({ layoutFormat = 'desktop' }) {
|
|||||||
mobileHud={mobileHud}
|
mobileHud={mobileHud}
|
||||||
labelScale={1}
|
labelScale={1}
|
||||||
/>
|
/>
|
||||||
<ManualDockAssistOverlay roverId={roverId} mobileHud={mobileHud} />
|
<ManualDockAssistOverlay mobileHud={mobileHud} />
|
||||||
<HudChatInput compact={mobileHud} />
|
<HudChatInput compact={mobileHud} />
|
||||||
<OvercurrentOverlay compact={mobileHud} />
|
<OvercurrentOverlay compact={mobileHud} />
|
||||||
<LowBatteryOverlay compact={mobileHud} />
|
<LowBatteryOverlay compact={mobileHud} />
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export const ACTIONS = [
|
|||||||
{ id: 'allAux', label: 'All cleaning motors', kind: 'button', section: 'Aux buttons' },
|
{ id: 'allAux', label: 'All cleaning motors', kind: 'button', section: 'Aux buttons' },
|
||||||
{ id: 'mainReverse', label: 'Main reverse toggle', kind: 'button', section: 'Brush toggles' },
|
{ id: 'mainReverse', label: 'Main reverse toggle', kind: 'button', section: 'Brush toggles' },
|
||||||
{ id: 'sideReverse', label: 'Side reverse toggle', kind: 'button', section: 'Brush toggles' },
|
{ id: 'sideReverse', label: 'Side reverse toggle', kind: 'button', section: 'Brush toggles' },
|
||||||
{ id: 'driveMacro', label: 'Drive / undock', kind: 'button', section: 'Mode controls' },
|
{ id: 'driveMacro', label: 'Drive / undock sequence', kind: 'button', section: 'Mode controls' },
|
||||||
{ id: 'dockMacro', label: 'Manual docking assist', 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: 'headlightToggle', label: 'Headlight toggle', kind: 'button', section: 'Camera' },
|
||||||
{ id: 'laserToggle', label: 'Laser toggle', kind: 'button', section: 'Camera' },
|
{ id: 'laserToggle', label: 'Laser toggle', kind: 'button', section: 'Camera' },
|
||||||
|
|||||||
@@ -1,24 +1,23 @@
|
|||||||
import { dockTelemetryEqual, selectDockTelemetry } from '../../../context/telemetryViews.js';
|
|
||||||
import { useTelemetrySelector } from '../../../context/TelemetryContext.jsx';
|
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useManualDockAssist } from '../../../features/manualDockAssist/useManualDockAssist.js';
|
||||||
|
|
||||||
function ManualDockAssistOverlay({ roverId, mobileHud = false }) {
|
function ManualDockAssistOverlay({ mobileHud = false }) {
|
||||||
const telemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
const { visible, statusLabel, statusTone, active, charging } = useManualDockAssist({ manageLifecycle: true });
|
||||||
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 [popupMessage, setPopupMessage] = useState('');
|
||||||
const timerRef = useRef(null);
|
const timerRef = useRef(null);
|
||||||
const [previousActive, setPreviousActive] = useState(active);
|
const prevActiveRef = useRef(active);
|
||||||
|
|
||||||
// These messages are presentation only; telemetry owns the operation itself.
|
useEffect(() => {
|
||||||
if (previousActive !== active) {
|
const prevActive = prevActiveRef.current;
|
||||||
setPreviousActive(active);
|
const enabledNow = active && !prevActive;
|
||||||
if (active) setPopupMessage('Dock assist mode enabled');
|
const autoDisabledOnCharge = !active && prevActive && charging;
|
||||||
else if (charging) setPopupMessage('Docking successful! Thank you!');
|
if (enabledNow) {
|
||||||
}
|
setPopupMessage('Dock assist mode enabled');
|
||||||
|
} else if (autoDisabledOnCharge) {
|
||||||
|
setPopupMessage('Docking successful! Thank you!');
|
||||||
|
}
|
||||||
|
prevActiveRef.current = active;
|
||||||
|
}, [active, charging]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!popupMessage) return undefined;
|
if (!popupMessage) return undefined;
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { useTelemetrySelector } from '../../../../context/TelemetryContext.jsx';
|
|
||||||
import { selectDockTelemetry, dockTelemetryEqual } from '../../../../context/telemetryViews.js';
|
|
||||||
// Bottom-right Corner Pod
|
// Bottom-right Corner Pod
|
||||||
// Purpose: Provides a compact circular camera-tilt control using the existing servo command path.
|
// Purpose: Provides a compact circular camera-tilt control using the existing servo command path.
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
@@ -33,7 +31,7 @@ export default function BottomRightPod({ roverId }) {
|
|||||||
const layout = useLayout();
|
const layout = useLayout();
|
||||||
const [open, setOpen] = usePodVisibility('camera', true);
|
const [open, setOpen] = usePodVisibility('camera', true);
|
||||||
const camera = useControlSelector((control) => control.state.camera);
|
const camera = useControlSelector((control) => control.state.camera);
|
||||||
const { cameraLocked } = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
const dockAssistActive = useControlSelector((control) => Boolean(control.state.manualDockAssist?.active));
|
||||||
const { setServoAngle } = useControlActions();
|
const { setServoAngle } = useControlActions();
|
||||||
const canControl = useCanControlRover(roverId);
|
const canControl = useCanControlRover(roverId);
|
||||||
const config = camera?.config;
|
const config = camera?.config;
|
||||||
@@ -49,7 +47,7 @@ export default function BottomRightPod({ roverId }) {
|
|||||||
const cameraPodOpen = showCameraControls && enabled && open;
|
const cameraPodOpen = showCameraControls && enabled && open;
|
||||||
|
|
||||||
const updateFromPointer = useCallback((event) => {
|
const updateFromPointer = useCallback((event) => {
|
||||||
if (!canControl || !enabled || cameraLocked || !(max > min)) return;
|
if (!canControl || !enabled || dockAssistActive || !(max > min)) return;
|
||||||
const bounds = event.currentTarget.getBoundingClientRect();
|
const bounds = event.currentTarget.getBoundingClientRect();
|
||||||
const x = ((event.clientX - bounds.left) / bounds.width) * 144;
|
const x = ((event.clientX - bounds.left) / bounds.width) * 144;
|
||||||
const y = ((event.clientY - bounds.top) / bounds.height) * 144;
|
const y = ((event.clientY - bounds.top) / bounds.height) * 144;
|
||||||
@@ -66,7 +64,7 @@ export default function BottomRightPod({ roverId }) {
|
|||||||
}
|
}
|
||||||
const nextFraction = arcDegrees / ARC_SWEEP_DEGREES;
|
const nextFraction = arcDegrees / ARC_SWEEP_DEGREES;
|
||||||
setServoAngle(min + nextFraction * (max - min));
|
setServoAngle(min + nextFraction * (max - min));
|
||||||
}, [canControl, cameraLocked, enabled, max, min, setServoAngle]);
|
}, [canControl, dockAssistActive, enabled, max, min, setServoAngle]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useControlActions } from '../../../../controls/index.js';
|
|||||||
import ControlHint from '../../../ControlHint/index.jsx';
|
import ControlHint from '../../../ControlHint/index.jsx';
|
||||||
import { useTelemetrySelector } from '../../../../context/TelemetryContext.jsx';
|
import { useTelemetrySelector } from '../../../../context/TelemetryContext.jsx';
|
||||||
import { dockTelemetryEqual, selectDockTelemetry } from '../../../../context/telemetryViews.js';
|
import { dockTelemetryEqual, selectDockTelemetry } from '../../../../context/telemetryViews.js';
|
||||||
|
import { useManualDockAssist } from '../../../../features/manualDockAssist/useManualDockAssist.js';
|
||||||
import { useSettingsNamespace } from '../../../../settings/index.js';
|
import { useSettingsNamespace } from '../../../../settings/index.js';
|
||||||
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
|
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
|
||||||
import { useLayout } from '../../../../layouts/LayoutContext.jsx';
|
import { useLayout } from '../../../../layouts/LayoutContext.jsx';
|
||||||
@@ -252,6 +253,9 @@ export default function DockingHud({ roverId }) {
|
|||||||
const layout = useLayout();
|
const layout = useLayout();
|
||||||
const actions = useControlActions();
|
const actions = useControlActions();
|
||||||
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
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 canControl = useCanControlRover(roverId);
|
||||||
const batteryState = useSessionSelector((state) => {
|
const batteryState = useSessionSelector((state) => {
|
||||||
const rover = (state.session?.roster || []).find((entry) => String(entry.id) === String(roverId));
|
const rover = (state.session?.roster || []).find((entry) => String(entry.id) === String(roverId));
|
||||||
@@ -264,14 +268,15 @@ export default function DockingHud({ roverId }) {
|
|||||||
const [dockExpansionOpen, setDockExpansionOpen] = usePodVisibility('dockAssist', true);
|
const [dockExpansionOpen, setDockExpansionOpen] = usePodVisibility('dockAssist', true);
|
||||||
// The action name is presentation state as well as busy state. Keeping the
|
// The action name is presentation state as well as busy state. Keeping the
|
||||||
// reason prevents a passive, already-undocked rover from ever saying "Undocking".
|
// 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 [showUndockTransition, setShowUndockTransition] = useState(false);
|
||||||
|
|
||||||
const docked = Boolean(dockTelemetry?.homeBase);
|
const docked = Boolean(dockTelemetry?.homeBase);
|
||||||
const oiMode = String(dockTelemetry?.oiModeLabel || '').toLowerCase();
|
const oiMode = String(dockTelemetry?.oiModeLabel || '').toLowerCase();
|
||||||
// The established UI contract treats exactly passive + undocked as the Roomba's
|
// The established UI contract treats exactly passive + undocked as the Roomba's
|
||||||
// autonomous docking attempt. Unknown telemetry must not fabricate that state.
|
// autonomous docking attempt. Unknown telemetry must not fabricate that state.
|
||||||
const autoDocking = !docked && !dockTelemetry.cameraLocked && oiMode === 'passive';
|
const autoDocking = !docked && !dockAssist.active && oiMode === 'passive';
|
||||||
const pendingAction = (dockTelemetry.dockingPhase === 'undocking') ? (docked ? 'undocking' : 'resuming') : null;
|
|
||||||
const pending = pendingAction !== null;
|
const pending = pendingAction !== null;
|
||||||
/* Mobile already presents its own touch-oriented driving controls. The docked
|
/* Mobile already presents its own touch-oriented driving controls. The docked
|
||||||
action therefore keeps its plain-language instruction without advertising a
|
action therefore keeps its plain-language instruction without advertising a
|
||||||
@@ -282,22 +287,55 @@ export default function DockingHud({ roverId }) {
|
|||||||
// The camera arc is the shared circular-pod reference size. Keep the dock expansion flush
|
// 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.
|
// against the battery shell after enlarging that gauge to the same 8.5-rem footprint.
|
||||||
const cornerOffsetClass = batteryPodOpen ? 'right-34' : 'right-0';
|
const cornerOffsetClass = batteryPodOpen ? 'right-34' : 'right-0';
|
||||||
const [previousDocked, setPreviousDocked] = useState(docked);
|
const previousDockedRef = useRef(docked);
|
||||||
const finishUndockTransition = useCallback(() => setShowUndockTransition(false), []);
|
const finishUndockTransition = useCallback(() => setShowUndockTransition(false), []);
|
||||||
|
|
||||||
// Presentation-only transition; mounting with an undocked rover does not animate.
|
useEffect(() => {
|
||||||
if (previousDocked !== docked) {
|
const wasDocked = previousDockedRef.current;
|
||||||
setPreviousDocked(docked);
|
// Only a real live transition plays the cue. An already-undocked rover must not animate
|
||||||
setShowUndockTransition(!docked);
|
// merely because the user opened or refreshed the page.
|
||||||
}
|
if (wasDocked && !docked) {
|
||||||
|
setShowUndockTransition(true);
|
||||||
|
} else if (docked) {
|
||||||
|
setShowUndockTransition(false);
|
||||||
|
}
|
||||||
|
previousDockedRef.current = docked;
|
||||||
|
}, [docked]);
|
||||||
|
|
||||||
const startDriving = () => {
|
const startDriving = async (action) => {
|
||||||
if (!roverId || pending || !canControl) return;
|
if (!roverId || pending || !canControl) return;
|
||||||
actions.undock();
|
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);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const startUndocking = () => {
|
||||||
|
startDriving('undocking');
|
||||||
|
};
|
||||||
|
const resumeDriving = () => {
|
||||||
|
startDriving('resuming');
|
||||||
|
};
|
||||||
|
|
||||||
const startDocking = () => {
|
const startDocking = () => {
|
||||||
if (!roverId || pending || !canControl) return;
|
if (!roverId || pending || !canControl) return;
|
||||||
actions.sendCommand('enterDocking');
|
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.');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -320,20 +358,20 @@ export default function DockingHud({ roverId }) {
|
|||||||
driveKeyLabel={driveKeyLabel}
|
driveKeyLabel={driveKeyLabel}
|
||||||
pending={pendingAction === 'undocking'}
|
pending={pendingAction === 'undocking'}
|
||||||
controlsDisabled={!canControl}
|
controlsDisabled={!canControl}
|
||||||
error={dockTelemetry.dockingError}
|
error={error}
|
||||||
onUndock={startDriving}
|
onUndock={startUndocking}
|
||||||
/>
|
/>
|
||||||
) : autoDocking || pendingAction === 'resuming' ? (
|
) : autoDocking || pendingAction === 'resuming' ? (
|
||||||
<AutoDockingAction
|
<AutoDockingAction
|
||||||
driveKeyLabel={driveKeyLabel}
|
driveKeyLabel={driveKeyLabel}
|
||||||
pending={pendingAction === 'resuming'}
|
pending={pendingAction === 'resuming'}
|
||||||
controlsDisabled={!canControl}
|
controlsDisabled={!canControl}
|
||||||
error={dockTelemetry.dockingError}
|
error={error}
|
||||||
onResumeDriving={startDriving}
|
onResumeDriving={resumeDriving}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{dockTelemetry.cameraLocked ? (
|
{dockAssist.active ? (
|
||||||
/* Dock assist needs the unobscured camera image. A thin cyan frame communicates the
|
/* Dock assist needs the unobscured camera image. A thin cyan frame communicates the
|
||||||
temporary mode without adding another instruction surface over the video. */
|
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" />
|
<div className="pointer-events-none absolute inset-0 z-50 ring-8 ring-inset ring-cyan-200/90" aria-hidden="true" />
|
||||||
@@ -341,15 +379,15 @@ export default function DockingHud({ roverId }) {
|
|||||||
{/* Desktop keeps the compact corner entry point. Mobile starts assist from
|
{/* Desktop keeps the compact corner entry point. Mobile starts assist from
|
||||||
its dedicated control column, but once active it still mounts this component
|
its dedicated control column, but once active it still mounts this component
|
||||||
so the centered camera instruction and cancel action remain available. */}
|
so the centered camera instruction and cancel action remain available. */}
|
||||||
{layout === 'desktop' || dockTelemetry.cameraLocked || dockTelemetry.dockingError ? (
|
{layout === 'desktop' || dockAssist.active ? (
|
||||||
<DockAssistAction
|
<DockAssistAction
|
||||||
active={dockTelemetry.cameraLocked}
|
active={dockAssist.active}
|
||||||
pending={pending}
|
pending={pending}
|
||||||
controlsDisabled={!canControl}
|
controlsDisabled={!canControl}
|
||||||
error={dockTelemetry.dockingError}
|
error={error}
|
||||||
dockKeyLabel={dockKeyLabel}
|
dockKeyLabel={dockKeyLabel}
|
||||||
onDock={startDocking}
|
onDock={startDocking}
|
||||||
onCancel={() => actions.sendCommand('cancelDocking')}
|
onCancel={dockAssist.exitAssist}
|
||||||
cornerOffsetClass={cornerOffsetClass}
|
cornerOffsetClass={cornerOffsetClass}
|
||||||
open={dockExpansionOpen}
|
open={dockExpansionOpen}
|
||||||
onOpenChange={setDockExpansionOpen}
|
onOpenChange={setDockExpansionOpen}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import './mobileControls.css';
|
|||||||
import { useControlActions, useControlSelector } from '../../controls/index.js';
|
import { useControlActions, useControlSelector } from '../../controls/index.js';
|
||||||
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
|
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
|
||||||
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
|
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
|
||||||
|
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||||
import useCanControlRover from '../../hooks/useCanControlRover.js';
|
import useCanControlRover from '../../hooks/useCanControlRover.js';
|
||||||
import HornControl from '../HornControl/index.jsx';
|
import HornControl from '../HornControl/index.jsx';
|
||||||
import GPIOToggleControl from '../GPIOToggleControl/index.jsx';
|
import GPIOToggleControl from '../GPIOToggleControl/index.jsx';
|
||||||
@@ -32,6 +33,7 @@ function AuxColumnContent({ accessoriesAvailable, onShowAccessories }) {
|
|||||||
const laserState = useControlSelector((control) => control.pipeline?.laserState);
|
const laserState = useControlSelector((control) => control.pipeline?.laserState);
|
||||||
const pipelineHorn = useControlSelector((control) => control.pipeline?.horn);
|
const pipelineHorn = useControlSelector((control) => control.pipeline?.horn);
|
||||||
const { setServoAngle, setHeadlight, setLaser, setAuxMotors, startHorn, stopHorn } = useControlActions();
|
const { setServoAngle, setHeadlight, setLaser, setAuxMotors, startHorn, stopHorn } = useControlActions();
|
||||||
|
const dockAssist = useManualDockAssist();
|
||||||
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
||||||
const canControl = useCanControlRover(roverId);
|
const canControl = useCanControlRover(roverId);
|
||||||
// Turn ownership is the common mutation boundary for every control in this
|
// Turn ownership is the common mutation boundary for every control in this
|
||||||
@@ -42,8 +44,7 @@ function AuxColumnContent({ accessoriesAvailable, onShowAccessories }) {
|
|||||||
const drivingMode = String(dockTelemetry?.oiModeLabel || '').toLowerCase() === 'full';
|
const drivingMode = String(dockTelemetry?.oiModeLabel || '').toLowerCase() === 'full';
|
||||||
const vacuumDisabled = controlsDisabled
|
const vacuumDisabled = controlsDisabled
|
||||||
|| docked
|
|| docked
|
||||||
|| dockTelemetry.movementLocked
|
|| (!drivingMode && !dockAssist.active);
|
||||||
|| (!drivingMode && !dockTelemetry.cameraLocked);
|
|
||||||
const activeAuxButtonRef = useRef(null);
|
const activeAuxButtonRef = useRef(null);
|
||||||
const cameraConfig = camera?.config;
|
const cameraConfig = camera?.config;
|
||||||
const cameraEnabled = Boolean(roverId && camera?.enabled && cameraConfig);
|
const cameraEnabled = Boolean(roverId && camera?.enabled && cameraConfig);
|
||||||
@@ -59,7 +60,7 @@ function AuxColumnContent({ accessoriesAvailable, onShowAccessories }) {
|
|||||||
: typeof cameraConfig?.homeAngle === 'number'
|
: typeof cameraConfig?.homeAngle === 'number'
|
||||||
? cameraConfig.homeAngle
|
? cameraConfig.homeAngle
|
||||||
: (cameraMin + cameraMax) / 2;
|
: (cameraMin + cameraMax) / 2;
|
||||||
const cameraDisabled = Boolean(controlsDisabled || dockTelemetry.cameraLocked);
|
const cameraDisabled = Boolean(controlsDisabled || dockAssist.cameraLocked);
|
||||||
/*
|
/*
|
||||||
The mobile tilt track shares the same precision flag as desktop tilt. This
|
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
|
keeps the servo fine-step behavior tied to the selected movement mode rather
|
||||||
|
|||||||
@@ -2,18 +2,19 @@
|
|||||||
// Purpose: Assembles the mobile movement column, which is the right column by default.
|
// 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.
|
// Scope: Owns movement availability while the in-video DockingHud owns every dock/undock action.
|
||||||
import { FaChargingStation } from 'react-icons/fa';
|
import { FaChargingStation } from 'react-icons/fa';
|
||||||
import { useControlActions, useControlSelector } from '../../controls/index.js';
|
import { useControlSelector } from '../../controls/index.js';
|
||||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
|
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
|
||||||
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
|
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
|
||||||
|
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||||
import useCanControlRover from '../../hooks/useCanControlRover.js';
|
import useCanControlRover from '../../hooks/useCanControlRover.js';
|
||||||
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
|
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
|
||||||
import ControlPadPanel from './ControlPadPanel.jsx';
|
import ControlPadPanel from './ControlPadPanel.jsx';
|
||||||
|
|
||||||
function MovementColumnContent({ layout }) {
|
function MovementColumnContent({ layout }) {
|
||||||
const roverId = useControlSelector((control) => control.state.roverId);
|
const roverId = useControlSelector((control) => control.state.roverId);
|
||||||
const actions = useControlActions();
|
|
||||||
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
||||||
|
const dockAssist = useManualDockAssist();
|
||||||
const canControl = useCanControlRover(roverId);
|
const canControl = useCanControlRover(roverId);
|
||||||
const batteryState = useSessionSelector((state) => {
|
const batteryState = useSessionSelector((state) => {
|
||||||
const rover = (state.session?.roster || []).find((entry) => String(entry.id) === String(roverId));
|
const rover = (state.session?.roster || []).find((entry) => String(entry.id) === String(roverId));
|
||||||
@@ -24,11 +25,20 @@ function MovementColumnContent({ layout }) {
|
|||||||
const docked = Boolean(dockTelemetry?.homeBase);
|
const docked = Boolean(dockTelemetry?.homeBase);
|
||||||
const drivingMode = String(dockTelemetry?.oiModeLabel || '').toLowerCase() === 'full';
|
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
|
const movementDisabled = !roverId
|
||||||
|| !canControl
|
|| !canControl
|
||||||
|| docked
|
|| docked
|
||||||
|| dockTelemetry.movementLocked
|
|| (!drivingMode && !dockAssist.active);
|
||||||
|| (!drivingMode && !dockTelemetry.cameraLocked);
|
|
||||||
// Both controls share the same hardware-availability boundary. Dock assist
|
// Both controls share the same hardware-availability boundary. Dock assist
|
||||||
// changes their actions, not who is allowed to command the rover.
|
// changes their actions, not who is allowed to command the rover.
|
||||||
const dockActionDisabled = movementDisabled;
|
const dockActionDisabled = movementDisabled;
|
||||||
@@ -39,10 +49,10 @@ function MovementColumnContent({ layout }) {
|
|||||||
// Mobile deliberately enters assist on the first tap. The centered video HUD
|
// Mobile deliberately enters assist on the first tap. The centered video HUD
|
||||||
// provides the resulting instruction, so a confirmation modal would only add
|
// provides the resulting instruction, so a confirmation modal would only add
|
||||||
// friction between intent and the camera-guided docking task.
|
// friction between intent and the camera-guided docking task.
|
||||||
if (dockTelemetry.cameraLocked) {
|
if (dockAssist.active) {
|
||||||
actions.sendCommand('cancelDocking');
|
dockAssist.exitAssist();
|
||||||
} else {
|
} else {
|
||||||
actions.sendCommand('enterDocking');
|
dockAssist.enterAssist();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -53,7 +63,7 @@ function MovementColumnContent({ layout }) {
|
|||||||
disabled={dockActionDisabled}
|
disabled={dockActionDisabled}
|
||||||
onClick={handleDockAction}
|
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 ${
|
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 ${
|
||||||
dockTelemetry.cameraLocked
|
dockAssist.active
|
||||||
? 'border-cyan-300/70 bg-cyan-900 text-cyan-50'
|
? 'border-cyan-300/70 bg-cyan-900 text-cyan-50'
|
||||||
: batteryUrgent
|
: batteryUrgent
|
||||||
? 'border-red-300/80 bg-red-950 text-red-50'
|
? 'border-red-300/80 bg-red-950 text-red-50'
|
||||||
@@ -65,7 +75,7 @@ function MovementColumnContent({ layout }) {
|
|||||||
<FaChargingStation className="text-lg" aria-hidden="true" />
|
<FaChargingStation className="text-lg" aria-hidden="true" />
|
||||||
{/* The mobile column remains the only mobile entry point into dock assist. Battery
|
{/* The mobile column remains the only mobile entry point into dock assist. Battery
|
||||||
severity makes that existing action more obvious without adding another control. */}
|
severity makes that existing action more obvious without adding another control. */}
|
||||||
<span>{dockTelemetry.cameraLocked ? 'Exit dock assist' : batteryUrgent ? 'Dock now' : batteryLow ? 'Dock and charge soon' : 'Dock and charge'}</span>
|
<span>{dockAssist.active ? 'Exit dock assist' : batteryUrgent ? 'Dock now' : batteryLow ? 'Dock and charge soon' : 'Dock and charge'}</span>
|
||||||
</button>
|
</button>
|
||||||
{/* Keeping the pad mounted prevents the mobile columns from changing size while
|
{/* Keeping the pad mounted prevents the mobile columns from changing size while
|
||||||
the centered video HUD explains and performs dock-related transitions. */}
|
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>
|
<span className="font-semibold text-white">Swap control columns (put joystick on the left)</span>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
</CardFrame>
|
</CardFrame>
|
||||||
<CardFrame title="Driving" bodyClassName="space-y-1 p-1 text-sm">
|
<CardFrame title="Macros" bodyClassName="space-y-1 p-1 text-sm">
|
||||||
<SettingRow inline>
|
<SettingRow inline>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -419,7 +419,7 @@ export default function SettingsPanel() {
|
|||||||
checked={driveMacroBackoffEnabled}
|
checked={driveMacroBackoffEnabled}
|
||||||
onChange={handleDriveMacroBackoffEnabled}
|
onChange={handleDriveMacroBackoffEnabled}
|
||||||
/>
|
/>
|
||||||
<span className="font-semibold text-white">Back away from the dock when undocking</span>
|
<span className="font-semibold text-white">Enable backward bump in drive macro</span>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
</CardFrame>
|
</CardFrame>
|
||||||
<CardFrame title="Audio" className="col-span-full" bodyClassName="space-y-1 p-1 text-sm">
|
<CardFrame title="Audio" className="col-span-full" bodyClassName="space-y-1 p-1 text-sm">
|
||||||
|
|||||||
@@ -11,10 +11,6 @@ const EMPTY_BATTERY_TELEMETRY = Object.freeze({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const EMPTY_DOCK_TELEMETRY = Object.freeze({
|
const EMPTY_DOCK_TELEMETRY = Object.freeze({
|
||||||
dockingPhase: 'idle',
|
|
||||||
movementLocked: true,
|
|
||||||
cameraLocked: false,
|
|
||||||
dockingError: '',
|
|
||||||
oiModeLabel: 'Unknown',
|
oiModeLabel: 'Unknown',
|
||||||
chargingStateLabel: '',
|
chargingStateLabel: '',
|
||||||
homeBase: false,
|
homeBase: false,
|
||||||
@@ -131,10 +127,6 @@ export function selectDockTelemetry(frame) {
|
|||||||
const sensors = frame?.sensors;
|
const sensors = frame?.sensors;
|
||||||
if (!sensors) return EMPTY_DOCK_TELEMETRY;
|
if (!sensors) return EMPTY_DOCK_TELEMETRY;
|
||||||
return {
|
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',
|
oiModeLabel: sensors.oiMode?.label || 'Unknown',
|
||||||
chargingStateLabel: sensors.chargingState?.label || '',
|
chargingStateLabel: sensors.chargingState?.label || '',
|
||||||
// OI packet 34 is the authoritative charging-sources packet. Charging
|
// OI packet 34 is the authoritative charging-sources packet. Charging
|
||||||
|
|||||||
@@ -17,11 +17,13 @@ import { computeDifferentialSpeeds, clamp } from './controlMath.js';
|
|||||||
import { useCommandPipeline } from './commandPipeline.js';
|
import { useCommandPipeline } from './commandPipeline.js';
|
||||||
import {
|
import {
|
||||||
DEFAULT_KEYMAP,
|
DEFAULT_KEYMAP,
|
||||||
|
DEFAULT_MACROS,
|
||||||
HORN_HEAT_COOL_PER_SEC,
|
HORN_HEAT_COOL_PER_SEC,
|
||||||
HORN_HEAT_RESUME_THRESHOLD,
|
HORN_HEAT_RESUME_THRESHOLD,
|
||||||
HORN_HEAT_UP_PER_SEC,
|
HORN_HEAT_UP_PER_SEC,
|
||||||
HORN_MAX_FREQUENCY,
|
HORN_MAX_FREQUENCY,
|
||||||
HORN_MAX_MS,
|
HORN_MAX_MS,
|
||||||
|
MANUAL_DOCK_ASSIST_MAX_SPEED,
|
||||||
SONG_DEFAULT_NOTE,
|
SONG_DEFAULT_NOTE,
|
||||||
} from './constants.js';
|
} from './constants.js';
|
||||||
import { canonicalizeKeyInput } from './keymapUtils.js';
|
import { canonicalizeKeyInput } from './keymapUtils.js';
|
||||||
@@ -30,8 +32,6 @@ import { useSessionActions, useSessionSelector } from '../context/SessionContext
|
|||||||
import { HORN_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
|
import { HORN_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
|
||||||
import { useOvercurrentLimiter } from './overcurrentLimiter.js';
|
import { useOvercurrentLimiter } from './overcurrentLimiter.js';
|
||||||
import { usePtzControlAdapter } from './ptzControlAdapter.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';
|
import { trackAnalyticsEvent } from '../analytics/index.js';
|
||||||
|
|
||||||
const ControlSystemContext = createContext(null);
|
const ControlSystemContext = createContext(null);
|
||||||
@@ -49,8 +49,7 @@ const CONTROL_ACTION_NAMES = [
|
|||||||
'setCameraAxisIntent',
|
'setCameraAxisIntent',
|
||||||
'goServoHome',
|
'goServoHome',
|
||||||
'setCameraPrecisionMode',
|
'setCameraPrecisionMode',
|
||||||
'undock',
|
'runMacro',
|
||||||
'sendCommand',
|
|
||||||
'stopAllMotion',
|
'stopAllMotion',
|
||||||
'sendOiCommand',
|
'sendOiCommand',
|
||||||
'setSensorStream',
|
'setSensorStream',
|
||||||
@@ -61,6 +60,8 @@ const CONTROL_ACTION_NAMES = [
|
|||||||
'updateKeyBinding',
|
'updateKeyBinding',
|
||||||
'resetKeyBindings',
|
'resetKeyBindings',
|
||||||
'registerInputState',
|
'registerInputState',
|
||||||
|
'setManualDockAssistActive',
|
||||||
|
'toggleManualDockAssist',
|
||||||
'setSongNote',
|
'setSongNote',
|
||||||
'sendSong',
|
'sendSong',
|
||||||
'startHorn',
|
'startHorn',
|
||||||
@@ -82,6 +83,23 @@ function clampServoAngle(config, value) {
|
|||||||
return clamp(value, min, max);
|
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 }) {
|
export function ControlSystemProvider({ children }) {
|
||||||
const [state, dispatch] = useReducer(controlReducer, initialControlState);
|
const [state, dispatch] = useReducer(controlReducer, initialControlState);
|
||||||
const snapshotRef = useRef(null);
|
const snapshotRef = useRef(null);
|
||||||
@@ -140,7 +158,7 @@ export function ControlSystemProvider({ children }) {
|
|||||||
const {
|
const {
|
||||||
value: controlSettings,
|
value: controlSettings,
|
||||||
save: saveControlSettings,
|
save: saveControlSettings,
|
||||||
} = useSettingsNamespace('controls', { keymap: DEFAULT_KEYMAP });
|
} = useSettingsNamespace('controls', { keymap: DEFAULT_KEYMAP, macros: DEFAULT_MACROS });
|
||||||
const { value: pageSettings } = useSettingsNamespace('page', { driveMacroBackoffEnabled: true });
|
const { value: pageSettings } = useSettingsNamespace('page', { driveMacroBackoffEnabled: true });
|
||||||
const { value: hornSettings } = useSettingsNamespace('horn', HORN_SETTINGS_DEFAULTS);
|
const { value: hornSettings } = useSettingsNamespace('horn', HORN_SETTINGS_DEFAULTS);
|
||||||
const driveMacroBackoffEnabled =
|
const driveMacroBackoffEnabled =
|
||||||
@@ -148,8 +166,6 @@ export function ControlSystemProvider({ children }) {
|
|||||||
? pageSettings.driveMacroBackoffEnabled
|
? pageSettings.driveMacroBackoffEnabled
|
||||||
: true;
|
: true;
|
||||||
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
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 homeAssistantEntities = useSessionSelector((state) => state.session?.homeAssistant?.entities ?? []);
|
||||||
const roomLightsLocked = useSessionSelector((state) =>
|
const roomLightsLocked = useSessionSelector((state) =>
|
||||||
Boolean(state.session?.homeAssistant?.lightPolicy?.locked || state.session?.homeAssistant?.lightPolicy?.lockedOn),
|
Boolean(state.session?.homeAssistant?.lightPolicy?.locked || state.session?.homeAssistant?.lightPolicy?.lockedOn),
|
||||||
@@ -222,7 +238,10 @@ export function ControlSystemProvider({ children }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mergedKeymap = { ...DEFAULT_KEYMAP, ...(controlSettings?.keymap || {}) };
|
const mergedKeymap = { ...DEFAULT_KEYMAP, ...(controlSettings?.keymap || {}) };
|
||||||
dispatch({ type: 'control/set-keymap', payload: mergedKeymap });
|
dispatch({ type: 'control/set-keymap', payload: mergedKeymap });
|
||||||
}, [controlSettings?.keymap]);
|
if (controlSettings?.macros) {
|
||||||
|
dispatch({ type: 'control/set-macros', payload: controlSettings.macros });
|
||||||
|
}
|
||||||
|
}, [controlSettings?.keymap, controlSettings?.macros]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const config = pipeline.servoConfig;
|
const config = pipeline.servoConfig;
|
||||||
@@ -249,10 +268,11 @@ export function ControlSystemProvider({ children }) {
|
|||||||
typeof state.camera.angle === 'number' ? state.camera.angle : servoAngleRef.current;
|
typeof state.camera.angle === 'number' ? state.camera.angle : servoAngleRef.current;
|
||||||
}, [state.camera.angle]);
|
}, [state.camera.angle]);
|
||||||
|
|
||||||
const { enableSensorStream } = pipeline;
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (roverId) enableSensorStream();
|
if (pipeline.roverId) {
|
||||||
}, [roverId, enableSensorStream]);
|
pipeline.enableSensorStream();
|
||||||
|
}
|
||||||
|
}, [pipeline.roverId, pipeline.enableSensorStream]);
|
||||||
|
|
||||||
const setMode = useCallback(
|
const setMode = useCallback(
|
||||||
(mode) => {
|
(mode) => {
|
||||||
@@ -275,6 +295,21 @@ export function ControlSystemProvider({ children }) {
|
|||||||
const setDriveVector = useCallback(
|
const setDriveVector = useCallback(
|
||||||
(vector, meta = {}) => {
|
(vector, meta = {}) => {
|
||||||
const speedOptions = { ...(meta.speedOptions || {}) };
|
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 computed = computeDifferentialSpeeds(vector, speedOptions);
|
||||||
const isMoving = computed.speeds.left !== 0 || computed.speeds.right !== 0;
|
const isMoving = computed.speeds.left !== 0 || computed.speeds.right !== 0;
|
||||||
if (pipeline.roverId && isMoving && driveEngagementRoverRef.current !== pipeline.roverId) {
|
if (pipeline.roverId && isMoving && driveEngagementRoverRef.current !== pipeline.roverId) {
|
||||||
@@ -292,7 +327,7 @@ export function ControlSystemProvider({ children }) {
|
|||||||
if (ptzControls.applyDriveVector(vector, meta)) return;
|
if (ptzControls.applyDriveVector(vector, meta)) return;
|
||||||
pipeline.sendDriveDirect(computed.speeds);
|
pipeline.sendDriveDirect(computed.speeds);
|
||||||
},
|
},
|
||||||
[pipeline, ptzControls, recordControlIntent],
|
[pipeline, ptzControls, recordControlIntent, state.manualDockAssist?.active],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setAuxMotors = useCallback(
|
const setAuxMotors = useCallback(
|
||||||
@@ -334,22 +369,23 @@ export function ControlSystemProvider({ children }) {
|
|||||||
}, [saveControlSettings]);
|
}, [saveControlSettings]);
|
||||||
|
|
||||||
const setServoAngle = useCallback(
|
const setServoAngle = useCallback(
|
||||||
(value) => {
|
(value, options = {}) => {
|
||||||
/*
|
/*
|
||||||
Absolute servo positions belong only to rover hardware. PTZ zoom now
|
Absolute servo positions belong only to rover hardware. PTZ zoom now
|
||||||
enters through setCameraAxisIntent as a signed held velocity, so this
|
enters through setCameraAxisIntent as a signed held velocity, so this
|
||||||
function must not infer zoom direction by comparing unrelated absolute
|
function must not infer zoom direction by comparing unrelated absolute
|
||||||
angle values from camera controls.
|
angle values from gamepad/manual-dock callers.
|
||||||
*/
|
*/
|
||||||
if (!pipeline.servoConfig) return;
|
if (!pipeline.servoConfig) return;
|
||||||
if (cameraLocked) return;
|
const force = Boolean(options?.force);
|
||||||
|
if (state.manualDockAssist?.active && !force) return;
|
||||||
const clamped = clampServoAngle(pipeline.servoConfig, value);
|
const clamped = clampServoAngle(pipeline.servoConfig, value);
|
||||||
dispatch({ type: 'control/set-camera-angle', payload: clamped });
|
dispatch({ type: 'control/set-camera-angle', payload: clamped });
|
||||||
pipeline.sendServoAngle(clamped);
|
pipeline.sendServoAngle(clamped);
|
||||||
servoAngleRef.current = clamped;
|
servoAngleRef.current = clamped;
|
||||||
recordControlIntent();
|
recordControlIntent();
|
||||||
},
|
},
|
||||||
[pipeline, recordControlIntent, cameraLocked],
|
[pipeline, recordControlIntent, state.manualDockAssist?.active],
|
||||||
);
|
);
|
||||||
|
|
||||||
const nudgeServo = useCallback(
|
const nudgeServo = useCallback(
|
||||||
@@ -421,19 +457,35 @@ export function ControlSystemProvider({ children }) {
|
|||||||
dispatch({ type: 'control/set-camera-precision-mode', payload: Boolean(active) });
|
dispatch({ type: 'control/set-camera-precision-mode', payload: Boolean(active) });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const sendCommand = useCallback((type, data = {}) => {
|
const runMacro = useCallback(
|
||||||
recordControlIntent();
|
async (macroId) => {
|
||||||
pipeline.emitCommand({ type, data }, (result) => {
|
const macro = state.macros.find((item) => item.id === macroId) || null;
|
||||||
if (result?.error) alert(result.error);
|
if (!macro) return;
|
||||||
});
|
let macroToRun = macro;
|
||||||
}, [pipeline, recordControlIntent]);
|
if (macroId === 'drive-sequence') {
|
||||||
|
turnOnAllLights();
|
||||||
const undock = useCallback(() => {
|
if (!homeAssistantEntities?.length) {
|
||||||
dispatch({ type: 'control/set-mode', payload: 'drive' });
|
pendingLightsRef.current = true;
|
||||||
turnOnAllLights();
|
}
|
||||||
if (!homeAssistantEntities?.length) pendingLightsRef.current = true;
|
recordControlIntent();
|
||||||
sendCommand('undock', { backoff: driveMacroBackoffEnabled });
|
if (!driveMacroBackoffEnabled) {
|
||||||
}, [driveMacroBackoffEnabled, homeAssistantEntities, turnOnAllLights, sendCommand]);
|
macroToRun = {
|
||||||
|
...macro,
|
||||||
|
steps: removeDriveSequenceBackoff(macro.steps),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await pipeline.runMacroSteps(macroToRun);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
driveMacroBackoffEnabled,
|
||||||
|
pipeline,
|
||||||
|
recordControlIntent,
|
||||||
|
homeAssistantEntities,
|
||||||
|
state.macros,
|
||||||
|
turnOnAllLights,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
const stopAllMotion = useCallback(() => {
|
const stopAllMotion = useCallback(() => {
|
||||||
dispatch({
|
dispatch({
|
||||||
@@ -639,6 +691,32 @@ export function ControlSystemProvider({ children }) {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [dispatch, hornNeedsTick]);
|
}, [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) => {
|
const registerInputState = useCallback((source, data) => {
|
||||||
dispatch({ type: 'control/register-input-state', payload: { source, state: data } });
|
dispatch({ type: 'control/register-input-state', payload: { source, state: data } });
|
||||||
}, []);
|
}, []);
|
||||||
@@ -680,8 +758,7 @@ export function ControlSystemProvider({ children }) {
|
|||||||
setCameraAxisIntent,
|
setCameraAxisIntent,
|
||||||
goServoHome,
|
goServoHome,
|
||||||
setCameraPrecisionMode,
|
setCameraPrecisionMode,
|
||||||
undock,
|
runMacro,
|
||||||
sendCommand,
|
|
||||||
stopAllMotion,
|
stopAllMotion,
|
||||||
sendOiCommand,
|
sendOiCommand,
|
||||||
setSensorStream,
|
setSensorStream,
|
||||||
@@ -692,6 +769,8 @@ export function ControlSystemProvider({ children }) {
|
|||||||
updateKeyBinding,
|
updateKeyBinding,
|
||||||
resetKeyBindings,
|
resetKeyBindings,
|
||||||
registerInputState,
|
registerInputState,
|
||||||
|
setManualDockAssistActive,
|
||||||
|
toggleManualDockAssist,
|
||||||
setSongNote,
|
setSongNote,
|
||||||
sendSong,
|
sendSong,
|
||||||
startHorn,
|
startHorn,
|
||||||
@@ -708,8 +787,7 @@ export function ControlSystemProvider({ children }) {
|
|||||||
setCameraAxisIntent,
|
setCameraAxisIntent,
|
||||||
goServoHome,
|
goServoHome,
|
||||||
setCameraPrecisionMode,
|
setCameraPrecisionMode,
|
||||||
undock,
|
runMacro,
|
||||||
sendCommand,
|
|
||||||
stopAllMotion,
|
stopAllMotion,
|
||||||
sendOiCommand,
|
sendOiCommand,
|
||||||
setSensorStream,
|
setSensorStream,
|
||||||
@@ -720,6 +798,8 @@ export function ControlSystemProvider({ children }) {
|
|||||||
updateKeyBinding,
|
updateKeyBinding,
|
||||||
resetKeyBindings,
|
resetKeyBindings,
|
||||||
registerInputState,
|
registerInputState,
|
||||||
|
setManualDockAssistActive,
|
||||||
|
toggleManualDockAssist,
|
||||||
setSongNote,
|
setSongNote,
|
||||||
sendSong,
|
sendSong,
|
||||||
startHorn,
|
startHorn,
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ import { useSocket } from '../context/SocketContext.jsx';
|
|||||||
import { useSessionSelector } from '../context/SessionContext.jsx';
|
import { useSessionSelector } from '../context/SessionContext.jsx';
|
||||||
import {
|
import {
|
||||||
AUX_LIMITS,
|
AUX_LIMITS,
|
||||||
|
COMMAND_DELAY_MS,
|
||||||
OI_COMMANDS,
|
OI_COMMANDS,
|
||||||
SONG_DEFAULT_DURATION,
|
SONG_DEFAULT_DURATION,
|
||||||
SONG_DEFAULT_NOTE,
|
SONG_DEFAULT_NOTE,
|
||||||
SONG_NOTE_RANGE,
|
SONG_NOTE_RANGE,
|
||||||
} from './constants.js';
|
} from './constants.js';
|
||||||
import { bytesToBase64, clampRange } from './controlMath.js';
|
import { bytesToBase64, clampRange, sleep } from './controlMath.js';
|
||||||
|
|
||||||
export function useCommandPipeline(options = {}) {
|
export function useCommandPipeline(options = {}) {
|
||||||
const { driveTransform, auxTransform } = options;
|
const { driveTransform, auxTransform } = options;
|
||||||
@@ -52,17 +53,8 @@ export function useCommandPipeline(options = {}) {
|
|||||||
const laserState = useMemo(() => rosterEntry?.laser?.state ?? null, [rosterEntry]);
|
const laserState = useMemo(() => rosterEntry?.laser?.state ?? null, [rosterEntry]);
|
||||||
const emitCommand = useCallback(
|
const emitCommand = useCallback(
|
||||||
(payload, cb) => {
|
(payload, cb) => {
|
||||||
if (!roverId || !socket.connected) {
|
if (!roverId) return;
|
||||||
cb?.({ error: 'Rover control connection unavailable' });
|
socket.emit('command', { roverId, ...payload }, cb);
|
||||||
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],
|
[socket, roverId],
|
||||||
);
|
);
|
||||||
@@ -150,6 +142,41 @@ export function useCommandPipeline(options = {}) {
|
|||||||
[emitCommand, enableSensorStream, roverId],
|
[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(
|
const sendHeadlight = useCallback(
|
||||||
(action = 'toggle') => {
|
(action = 'toggle') => {
|
||||||
if (!roverId || !headlight) return null;
|
if (!roverId || !headlight) return null;
|
||||||
@@ -250,6 +277,7 @@ export function useCommandPipeline(options = {}) {
|
|||||||
sendHorn,
|
sendHorn,
|
||||||
sendPeripheralControl,
|
sendPeripheralControl,
|
||||||
sendSong,
|
sendSong,
|
||||||
|
runMacroSteps,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
roverId,
|
roverId,
|
||||||
@@ -272,6 +300,7 @@ export function useCommandPipeline(options = {}) {
|
|||||||
sendHorn,
|
sendHorn,
|
||||||
sendPeripheralControl,
|
sendPeripheralControl,
|
||||||
sendSong,
|
sendSong,
|
||||||
|
runMacroSteps,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ export const DRIVE_LIMITS = {
|
|||||||
boostSpeed: 400,
|
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_NOTE_RANGE = [31, 127];
|
||||||
export const SONG_DEFAULT_NOTE = 60;
|
export const SONG_DEFAULT_NOTE = 60;
|
||||||
export const SONG_DEFAULT_DURATION = 8;
|
export const SONG_DEFAULT_DURATION = 8;
|
||||||
@@ -60,3 +64,23 @@ export const DEFAULT_KEYMAP = {
|
|||||||
homeAssistantOn: ['ArrowRight'],
|
homeAssistantOn: ['ArrowRight'],
|
||||||
homeAssistantOff: ['ArrowLeft'],
|
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,3 +55,7 @@ export function bytesToBase64(bytes) {
|
|||||||
}
|
}
|
||||||
throw new Error('No base64 encoder available');
|
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
|
// Control State Reducer
|
||||||
// Purpose: Implements reducer transitions for control-system runtime state. Scope: Centralizes deterministic state updates for input, mode, and dispatch events.
|
// Purpose: Implements reducer transitions for control-system runtime state. Scope: Centralizes deterministic state updates for input, mode, and dispatch events.
|
||||||
import { DEFAULT_KEYMAP, SONG_DEFAULT_NOTE } from './constants.js';
|
import { DEFAULT_KEYMAP, DEFAULT_MACROS, SONG_DEFAULT_NOTE } from './constants.js';
|
||||||
|
|
||||||
function createDriveState() {
|
function createDriveState() {
|
||||||
return {
|
return {
|
||||||
@@ -49,6 +49,12 @@ function createMicState() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createManualDockAssistState() {
|
||||||
|
return {
|
||||||
|
active: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const initialControlState = {
|
export const initialControlState = {
|
||||||
roverId: null,
|
roverId: null,
|
||||||
mode: 'drive',
|
mode: 'drive',
|
||||||
@@ -58,7 +64,9 @@ export const initialControlState = {
|
|||||||
song: createSongState(),
|
song: createSongState(),
|
||||||
horn: createHornState(),
|
horn: createHornState(),
|
||||||
mic: createMicState(),
|
mic: createMicState(),
|
||||||
|
manualDockAssist: createManualDockAssistState(),
|
||||||
lastControlIntentAt: 0,
|
lastControlIntentAt: 0,
|
||||||
|
macros: DEFAULT_MACROS,
|
||||||
keymap: DEFAULT_KEYMAP,
|
keymap: DEFAULT_KEYMAP,
|
||||||
inputs: {},
|
inputs: {},
|
||||||
// Generic accessory controls do not currently report state back from roverd.
|
// Generic accessory controls do not currently report state back from roverd.
|
||||||
@@ -78,6 +86,7 @@ export function controlReducer(state, action) {
|
|||||||
song: action.payload ? state.song : createSongState(),
|
song: action.payload ? state.song : createSongState(),
|
||||||
horn: action.payload ? state.horn : createHornState(),
|
horn: action.payload ? state.horn : createHornState(),
|
||||||
mic: action.payload ? state.mic : createMicState(),
|
mic: action.payload ? state.mic : createMicState(),
|
||||||
|
manualDockAssist: action.payload ? state.manualDockAssist : createManualDockAssistState(),
|
||||||
lastControlIntentAt: action.payload ? state.lastControlIntentAt : 0,
|
lastControlIntentAt: action.payload ? state.lastControlIntentAt : 0,
|
||||||
};
|
};
|
||||||
case 'control/set-mode':
|
case 'control/set-mode':
|
||||||
@@ -160,6 +169,11 @@ export function controlReducer(state, action) {
|
|||||||
...state,
|
...state,
|
||||||
keymap: { ...state.keymap, ...(action.payload ?? {}) },
|
keymap: { ...state.keymap, ...(action.payload ?? {}) },
|
||||||
};
|
};
|
||||||
|
case 'control/set-macros':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
macros: Array.isArray(action.payload) ? action.payload : state.macros,
|
||||||
|
};
|
||||||
case 'control/reset':
|
case 'control/reset':
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
@@ -208,6 +222,14 @@ export function controlReducer(state, action) {
|
|||||||
pttActive: Boolean(action.payload),
|
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': {
|
case 'control/set-peripheral-value': {
|
||||||
const roverId = String(action.payload?.roverId || '');
|
const roverId = String(action.payload?.roverId || '');
|
||||||
const peripheralId = String(action.payload?.peripheralId || '');
|
const peripheralId = String(action.payload?.peripheralId || '');
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
|
|
||||||
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
|
|
||||||
// Gamepad Input Manager
|
// Gamepad Input Manager
|
||||||
// Purpose: Converts polled gamepad state into normalized control actions/commands. Scope: Integrates bindings, deadzone math, and dispatch callbacks for driving.
|
// 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';
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
|
||||||
@@ -19,6 +17,7 @@ import {
|
|||||||
} from './gamepadBindings.js';
|
} from './gamepadBindings.js';
|
||||||
import { subscribeGamepadHub } from './gamepadHub.js';
|
import { subscribeGamepadHub } from './gamepadHub.js';
|
||||||
import { isTextEntryActive } from './inputFocusUtils.js';
|
import { isTextEntryActive } from './inputFocusUtils.js';
|
||||||
|
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||||
import {
|
import {
|
||||||
isControllerControlLocked,
|
isControllerControlLocked,
|
||||||
markControllerDisconnected,
|
markControllerDisconnected,
|
||||||
@@ -114,12 +113,12 @@ function cycleHomeAssistant(latest, targetState) {
|
|||||||
|
|
||||||
export default function GamepadInputManager() {
|
export default function GamepadInputManager() {
|
||||||
const {
|
const {
|
||||||
|
setMode,
|
||||||
setDriveVector,
|
setDriveVector,
|
||||||
setAuxMotors,
|
setAuxMotors,
|
||||||
setServoAngle,
|
setServoAngle,
|
||||||
setCameraAxisIntent,
|
setCameraAxisIntent,
|
||||||
undock,
|
runMacro,
|
||||||
sendCommand,
|
|
||||||
toggleHeadlight,
|
toggleHeadlight,
|
||||||
toggleLaser,
|
toggleLaser,
|
||||||
registerInputState,
|
registerInputState,
|
||||||
@@ -132,7 +131,7 @@ export default function GamepadInputManager() {
|
|||||||
const cameraAngle = useControlSelector((control) => control.state.camera?.angle);
|
const cameraAngle = useControlSelector((control) => control.state.camera?.angle);
|
||||||
const cameraConfig = useControlSelector((control) => control.state.camera?.config);
|
const cameraConfig = useControlSelector((control) => control.state.camera?.config);
|
||||||
const roverId = useControlSelector((control) => control.state.roverId);
|
const roverId = useControlSelector((control) => control.state.roverId);
|
||||||
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
const dockAssist = useManualDockAssist();
|
||||||
const { focusChat } = useChatActions();
|
const { focusChat } = useChatActions();
|
||||||
const { isChatFocused } = useChatFocus();
|
const { isChatFocused } = useChatFocus();
|
||||||
const { homeAssistantSetState, pushAlert } = useSessionActions();
|
const { homeAssistantSetState, pushAlert } = useSessionActions();
|
||||||
@@ -284,7 +283,7 @@ export default function GamepadInputManager() {
|
|||||||
role === 'lockdown' || (role === 'admin' && sessionMode !== 'lockdown'),
|
role === 'lockdown' || (role === 'admin' && sessionMode !== 'lockdown'),
|
||||||
cameraAngle,
|
cameraAngle,
|
||||||
cameraConfig,
|
cameraConfig,
|
||||||
dockTelemetry,
|
dockAssist,
|
||||||
focusChat,
|
focusChat,
|
||||||
gamepadSettings,
|
gamepadSettings,
|
||||||
homeAssistant,
|
homeAssistant,
|
||||||
@@ -293,8 +292,7 @@ export default function GamepadInputManager() {
|
|||||||
pushAlert,
|
pushAlert,
|
||||||
registerInputState,
|
registerInputState,
|
||||||
roverId,
|
roverId,
|
||||||
undock,
|
runMacro,
|
||||||
sendCommand,
|
|
||||||
saveGamepadSettings,
|
saveGamepadSettings,
|
||||||
saveVideoSettings,
|
saveVideoSettings,
|
||||||
sendSong,
|
sendSong,
|
||||||
@@ -302,7 +300,8 @@ export default function GamepadInputManager() {
|
|||||||
setCameraAxisIntent,
|
setCameraAxisIntent,
|
||||||
setDriveVector,
|
setDriveVector,
|
||||||
setMicPttActive,
|
setMicPttActive,
|
||||||
setSongNote,
|
setMode,
|
||||||
|
setSongNote,
|
||||||
setServoAngle,
|
setServoAngle,
|
||||||
songNote,
|
songNote,
|
||||||
startHorn,
|
startHorn,
|
||||||
@@ -463,13 +462,15 @@ export default function GamepadInputManager() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (outputs.buttons.driveMacro && handleButtonEdge('driveMacro', true)) {
|
if (outputs.buttons.driveMacro && handleButtonEdge('driveMacro', true)) {
|
||||||
latest.undock();
|
latest.dockAssist.exitAssist();
|
||||||
|
latest.setMode('drive');
|
||||||
|
latest.runMacro('drive-sequence');
|
||||||
} else if (!outputs.buttons.driveMacro) {
|
} else if (!outputs.buttons.driveMacro) {
|
||||||
handleButtonEdge('driveMacro', false);
|
handleButtonEdge('driveMacro', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (outputs.buttons.dockMacro && handleButtonEdge('dockMacro', true)) {
|
if (outputs.buttons.dockMacro && handleButtonEdge('dockMacro', true)) {
|
||||||
latest.sendCommand(latest.dockTelemetry.cameraLocked ? 'cancelDocking' : 'enterDocking');
|
latest.dockAssist.toggleAssist();
|
||||||
} else if (!outputs.buttons.dockMacro) {
|
} else if (!outputs.buttons.dockMacro) {
|
||||||
handleButtonEdge('dockMacro', false);
|
handleButtonEdge('dockMacro', false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
|
|
||||||
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
|
|
||||||
// Keyboard Input Manager
|
// Keyboard Input Manager
|
||||||
// Purpose: Captures and translates keyboard events into normalized control intents. Scope: Owns keydown/keyup listeners and dispatch coordination for drive controls.
|
// 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';
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
|
||||||
@@ -12,6 +10,7 @@ import { markKeyboardInputActive } from './controllerRuntime.js';
|
|||||||
import { isTextInputElement } from './inputFocusUtils.js';
|
import { isTextInputElement } from './inputFocusUtils.js';
|
||||||
import { useSettingsNamespace } from '../../settings/index.js';
|
import { useSettingsNamespace } from '../../settings/index.js';
|
||||||
import { INPUT_SETTINGS_DEFAULTS, VIDEO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
import { INPUT_SETTINGS_DEFAULTS, VIDEO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
||||||
|
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||||
import {
|
import {
|
||||||
SONG_DEFAULT_DURATION,
|
SONG_DEFAULT_DURATION,
|
||||||
SONG_DEFAULT_NOTE,
|
SONG_DEFAULT_NOTE,
|
||||||
@@ -87,12 +86,12 @@ function shouldIgnoreEvent(event) {
|
|||||||
|
|
||||||
export default function KeyboardInputManager() {
|
export default function KeyboardInputManager() {
|
||||||
const {
|
const {
|
||||||
|
setMode,
|
||||||
setDriveVector,
|
setDriveVector,
|
||||||
setAuxMotors,
|
setAuxMotors,
|
||||||
nudgeServo,
|
nudgeServo,
|
||||||
setCameraAxisIntent,
|
setCameraAxisIntent,
|
||||||
undock,
|
runMacro,
|
||||||
sendCommand,
|
|
||||||
stopAllMotion,
|
stopAllMotion,
|
||||||
registerInputState,
|
registerInputState,
|
||||||
setCameraPrecisionMode,
|
setCameraPrecisionMode,
|
||||||
@@ -108,12 +107,12 @@ export default function KeyboardInputManager() {
|
|||||||
const cameraNudgeDegrees = useControlSelector((control) => control.state.camera?.config?.nudgeDegrees);
|
const cameraNudgeDegrees = useControlSelector((control) => control.state.camera?.config?.nudgeDegrees);
|
||||||
const songNote = useControlSelector((control) => control.state.song?.note);
|
const songNote = useControlSelector((control) => control.state.song?.note);
|
||||||
const roverId = useControlSelector((control) => control.state.roverId);
|
const roverId = useControlSelector((control) => control.state.roverId);
|
||||||
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
|
||||||
const hornActive = useControlSelector((control) => Boolean(control.state.horn?.active));
|
const hornActive = useControlSelector((control) => Boolean(control.state.horn?.active));
|
||||||
const homeAssistant = useSessionSelector((state) => state.session?.homeAssistant || null);
|
const homeAssistant = useSessionSelector((state) => state.session?.homeAssistant || null);
|
||||||
const role = useSessionSelector((state) => state.session?.role || null);
|
const role = useSessionSelector((state) => state.session?.role || null);
|
||||||
const mode = useSessionSelector((state) => state.session?.mode || null);
|
const mode = useSessionSelector((state) => state.session?.mode || null);
|
||||||
const adminCanControlLockedLights = role === 'lockdown' || (role === 'admin' && mode !== 'lockdown');
|
const adminCanControlLockedLights = role === 'lockdown' || (role === 'admin' && mode !== 'lockdown');
|
||||||
|
const dockAssist = useManualDockAssist();
|
||||||
const { homeAssistantSetState, pushAlert } = useSessionActions();
|
const { homeAssistantSetState, pushAlert } = useSessionActions();
|
||||||
const { focusChat } = useChatActions();
|
const { focusChat } = useChatActions();
|
||||||
const { isChatFocused } = useChatFocus();
|
const { isChatFocused } = useChatFocus();
|
||||||
@@ -387,7 +386,7 @@ export default function KeyboardInputManager() {
|
|||||||
// before the browser can deliver the next keyboard event after a committed render.
|
// before the browser can deliver the next keyboard event after a committed render.
|
||||||
latestRef.current = {
|
latestRef.current = {
|
||||||
actionTokens,
|
actionTokens,
|
||||||
dockTelemetry,
|
dockAssist,
|
||||||
focusChat,
|
focusChat,
|
||||||
homeAssistant,
|
homeAssistant,
|
||||||
homeAssistantSetState,
|
homeAssistantSetState,
|
||||||
@@ -399,8 +398,7 @@ export default function KeyboardInputManager() {
|
|||||||
pushAlert,
|
pushAlert,
|
||||||
registerInputState,
|
registerInputState,
|
||||||
roverId,
|
roverId,
|
||||||
undock,
|
runMacro,
|
||||||
sendCommand,
|
|
||||||
saveVideoSettings,
|
saveVideoSettings,
|
||||||
sendSong,
|
sendSong,
|
||||||
servoRepeatMs,
|
servoRepeatMs,
|
||||||
@@ -410,7 +408,8 @@ export default function KeyboardInputManager() {
|
|||||||
setCameraPrecisionMode,
|
setCameraPrecisionMode,
|
||||||
setDriveVector,
|
setDriveVector,
|
||||||
setMicPttActive,
|
setMicPttActive,
|
||||||
setSongNote,
|
setMode,
|
||||||
|
setSongNote,
|
||||||
songNote,
|
songNote,
|
||||||
startHorn,
|
startHorn,
|
||||||
stopAllMotion,
|
stopAllMotion,
|
||||||
@@ -453,9 +452,11 @@ export default function KeyboardInputManager() {
|
|||||||
|
|
||||||
if (newlyPressed.length > 0) {
|
if (newlyPressed.length > 0) {
|
||||||
if (newlyPressed.some((token) => latest.keymap.driveMacro?.has(token))) {
|
if (newlyPressed.some((token) => latest.keymap.driveMacro?.has(token))) {
|
||||||
latest.undock();
|
latest.dockAssist.exitAssist();
|
||||||
|
latest.setMode('drive');
|
||||||
|
latest.runMacro('drive-sequence');
|
||||||
} else if (newlyPressed.some((token) => latest.keymap.dockMacro?.has(token))) {
|
} else if (newlyPressed.some((token) => latest.keymap.dockMacro?.has(token))) {
|
||||||
latest.sendCommand(latest.dockTelemetry.cameraLocked ? 'cancelDocking' : 'enterDocking');
|
latest.dockAssist.toggleAssist();
|
||||||
} else if (newlyPressed.some((token) => latest.keymap.headlightToggle?.has(token))) {
|
} else if (newlyPressed.some((token) => latest.keymap.headlightToggle?.has(token))) {
|
||||||
latest.toggleHeadlight();
|
latest.toggleHeadlight();
|
||||||
} else if (newlyPressed.some((token) => latest.keymap.laserToggle?.has(token))) {
|
} else if (newlyPressed.some((token) => latest.keymap.laserToggle?.has(token))) {
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
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,5 +1,3 @@
|
|||||||
import { dockTelemetryEqual, selectDockTelemetry } from '../../../context/telemetryViews.js';
|
|
||||||
import { useTelemetrySelector } from '../../../context/TelemetryContext.jsx';
|
|
||||||
// Right Pane Tabs
|
// Right Pane Tabs
|
||||||
// Purpose: Defines the Right Pane Tabs module and the local helpers/components used in this file.
|
// 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.
|
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||||
@@ -24,6 +22,7 @@ import { useSessionSelector } from '../../../context/SessionContext.jsx';
|
|||||||
import OverseerPreferencePanel from '../../../components/OverseerPreferencePanel/index.jsx';
|
import OverseerPreferencePanel from '../../../components/OverseerPreferencePanel/index.jsx';
|
||||||
import CardFrame from '../../../components/CardFrame/index.jsx';
|
import CardFrame from '../../../components/CardFrame/index.jsx';
|
||||||
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useManualDockAssist } from '../../../features/manualDockAssist/useManualDockAssist.js';
|
||||||
import { themeGapClass, themeStackClass } from '../../../themes/index.js';
|
import { themeGapClass, themeStackClass } from '../../../themes/index.js';
|
||||||
import ActivitiesTab from '../tabs/shared/ActivitiesTab/index.jsx';
|
import ActivitiesTab from '../tabs/shared/ActivitiesTab/index.jsx';
|
||||||
import VipTab from '../tabs/shared/VipTab/index.jsx';
|
import VipTab from '../tabs/shared/VipTab/index.jsx';
|
||||||
@@ -63,7 +62,7 @@ function DriveDockPanel() {
|
|||||||
const laserState = useControlSelector((control) => control.pipeline?.laserState);
|
const laserState = useControlSelector((control) => control.pipeline?.laserState);
|
||||||
const pipelineHorn = useControlSelector((control) => control.pipeline?.horn);
|
const pipelineHorn = useControlSelector((control) => control.pipeline?.horn);
|
||||||
const { setServoAngle, setHeadlight, setLaser, startHorn, stopHorn } = useControlActions();
|
const { setServoAngle, setHeadlight, setLaser, startHorn, stopHorn } = useControlActions();
|
||||||
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
const dockAssist = useManualDockAssist();
|
||||||
const driveDockState = useDriveDockState(roverId);
|
const driveDockState = useDriveDockState(roverId);
|
||||||
const hideInlineControls = driveDockState.docked && !driveDockState.driving;
|
const hideInlineControls = driveDockState.docked && !driveDockState.driving;
|
||||||
|
|
||||||
@@ -86,7 +85,7 @@ function DriveDockPanel() {
|
|||||||
const hornLabel = <ControlHint actionId="hornHonk" />;
|
const hornLabel = <ControlHint actionId="hornHonk" />;
|
||||||
const upLabel = <ControlHint actionId="cameraUp" />;
|
const upLabel = <ControlHint actionId="cameraUp" />;
|
||||||
const downLabel = <ControlHint actionId="cameraDown" />;
|
const downLabel = <ControlHint actionId="cameraDown" />;
|
||||||
const cameraDisabled = Boolean(!roverId || dockTelemetry.cameraLocked);
|
const cameraDisabled = Boolean(!roverId || dockAssist.cameraLocked);
|
||||||
/*
|
/*
|
||||||
Precision movement mode also tightens the servo slider step. The command
|
Precision movement mode also tightens the servo slider step. The command
|
||||||
path still sends ordinary angle targets; only the UI increment changes while
|
path still sends ordinary angle targets; only the UI increment changes while
|
||||||
|
|||||||
Reference in New Issue
Block a user