Files
MultiRoombaRover/webui/src/controls/commandPipeline.js
T
2026-09-08 23:11:03 -04:00

307 lines
8.7 KiB
JavaScript

// Control Command Pipeline
// Purpose: Converts normalized inputs into command packets sent to the server. Scope: Applies throttling/coalescing/safety filters before socket command emission.
import { useCallback, useMemo } from 'react';
import { useSocket } from '../context/SocketContext.jsx';
import { useSessionSelector } from '../context/SessionContext.jsx';
import {
AUX_LIMITS,
COMMAND_DELAY_MS,
OI_COMMANDS,
SONG_DEFAULT_DURATION,
SONG_DEFAULT_NOTE,
SONG_NOTE_RANGE,
} from './constants.js';
import { bytesToBase64, clampRange, sleep } from './controlMath.js';
export function useCommandPipeline(options = {}) {
const { driveTransform, auxTransform } = options;
const socket = useSocket();
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const roster = useSessionSelector((state) => state.session?.roster ?? []);
const rosterEntry = useMemo(() => {
if (!roverId || !Array.isArray(roster)) return null;
return roster.find((entry) => String(entry.id) === String(roverId)) || null;
}, [roverId, roster]);
const servoConfig = useMemo(() => {
if (!rosterEntry?.cameraServo || !rosterEntry.cameraServo.enabled) return null;
return rosterEntry.cameraServo;
}, [rosterEntry]);
const headlight = useMemo(() => {
if (!rosterEntry?.headlight || !rosterEntry.headlight.enabled) return null;
return rosterEntry.headlight;
}, [rosterEntry]);
const laser = useMemo(() => {
if (!rosterEntry?.laser || !rosterEntry.laser.enabled) return null;
return rosterEntry.laser;
}, [rosterEntry]);
const horn = useMemo(() => {
if (!rosterEntry?.horn || !rosterEntry.horn.enabled) return null;
return rosterEntry.horn;
}, [rosterEntry]);
const peripherals = useMemo(
() => (Array.isArray(rosterEntry?.peripherals) ? rosterEntry.peripherals : []),
[rosterEntry],
);
const headlightState = useMemo(() => rosterEntry?.headlight?.state ?? null, [rosterEntry]);
const laserState = useMemo(() => rosterEntry?.laser?.state ?? null, [rosterEntry]);
const emitCommand = useCallback(
(payload, cb) => {
if (!roverId) return;
socket.emit('command', { roverId, ...payload }, cb);
},
[socket, roverId],
);
const enableSensorStream = useCallback(() => {
if (!roverId) return;
emitCommand({
type: 'sensorStream',
data: { sensorStream: { enable: true } },
});
}, [emitCommand, roverId]);
const sendDriveDirect = useCallback(
(speeds) => {
const rawPayload = {
left: clampRange(speeds?.left ?? 0, [-500, 500]),
right: clampRange(speeds?.right ?? 0, [-500, 500]),
};
const transformed = driveTransform ? driveTransform(rawPayload) : rawPayload;
const payload = {
left: clampRange(transformed?.left ?? 0, [-500, 500]),
right: clampRange(transformed?.right ?? 0, [-500, 500]),
};
if (!roverId) return null;
emitCommand({
type: 'drive',
data: { driveDirect: payload },
});
return payload;
},
[driveTransform, emitCommand, roverId],
);
const sendAuxMotors = useCallback(
({ main = 0, side = 0, vacuum = 0 } = {}) => {
if (!roverId) return null;
const rawPayload = {
main: clampRange(main, AUX_LIMITS.main),
side: clampRange(side, AUX_LIMITS.side),
vacuum: clampRange(vacuum, AUX_LIMITS.vacuum),
};
const transformed = auxTransform ? auxTransform(rawPayload) : rawPayload;
const payload = {
main: clampRange(transformed?.main ?? 0, AUX_LIMITS.main),
side: clampRange(transformed?.side ?? 0, AUX_LIMITS.side),
vacuum: clampRange(transformed?.vacuum ?? 0, AUX_LIMITS.vacuum),
};
emitCommand({
type: 'motors',
data: { motorPwm: payload },
});
return payload;
},
[auxTransform, emitCommand, roverId],
);
const sendServoAngle = useCallback(
(angle) => {
if (!roverId || !servoConfig) return null;
emitCommand({
type: 'servo',
data: { servo: { angle } },
});
return angle;
},
[emitCommand, roverId, servoConfig],
);
const sendOiCommand = useCallback(
(keyOrBytes) => {
if (!roverId) return false;
const bytes = Array.isArray(keyOrBytes)
? keyOrBytes
: typeof keyOrBytes === 'string'
? OI_COMMANDS[keyOrBytes]
: null;
if (!bytes) return false;
emitCommand({
type: 'raw',
data: { raw: bytesToBase64(bytes) },
});
enableSensorStream();
return true;
},
[emitCommand, enableSensorStream, roverId],
);
const runMacroSteps = useCallback(
async (macro) => {
if (!macro || !Array.isArray(macro.steps) || !roverId) return;
for (const step of macro.steps) {
if (!roverId) break;
switch (step.type) {
case 'oi':
sendOiCommand(step.command);
break;
case 'drive':
sendDriveDirect(step.speeds ?? { left: 0, right: 0 });
break;
case 'motors':
sendAuxMotors(step.values ?? {});
break;
case 'servo':
sendServoAngle(step.angle);
break;
case 'pause':
await sleep(step.duration ?? COMMAND_DELAY_MS); // eslint-disable-line no-await-in-loop
break;
default:
break;
}
if (step.delay || step.delayMs) {
const delay = step.delayMs ?? step.delay;
if (typeof delay === 'number' && delay > 0) {
await sleep(delay); // eslint-disable-line no-await-in-loop
}
}
}
},
[roverId, sendOiCommand, sendDriveDirect, sendAuxMotors, sendServoAngle],
);
const sendHeadlight = useCallback(
(action = 'toggle') => {
if (!roverId || !headlight) return null;
emitCommand({
type: 'headlight',
data: { headlight: { action } },
});
return action;
},
[emitCommand, headlight, roverId],
);
const sendLaser = useCallback(
(action = 'toggle') => {
if (!roverId || !laser) return null;
emitCommand({
type: 'laser',
data: { laser: { action } },
});
return action;
},
[emitCommand, laser, roverId],
);
const sendHorn = useCallback(
(payload) => {
if (!roverId) return null;
emitCommand({
type: 'horn',
data: { horn: payload },
});
return payload;
},
[emitCommand, roverId],
);
const sendPeripheralControl = useCallback(
(peripheralId, controlId, value) => {
if (!roverId || !peripheralId || !controlId) return null;
const peripheral = { id: peripheralId, control: controlId, value };
// Peripheral commands deliberately use the same command envelope as all
// other rover actuation. This keeps turn authorization, acknowledgements,
// and rover WebSocket routing in the server's existing command boundary.
emitCommand({
type: 'peripheral',
data: { peripheral },
});
return peripheral;
},
[emitCommand, roverId],
);
const sendSong = useCallback(
(notes = [], options = {}) => {
if (!roverId) return null;
const prepared =
Array.isArray(notes) && notes.length > 0
? notes
: [{ note: SONG_DEFAULT_NOTE, duration: SONG_DEFAULT_DURATION }];
const payloadNotes = prepared.slice(0, 16).map((entry) => ({
note: clampRange(Math.round(entry?.note ?? SONG_DEFAULT_NOTE), SONG_NOTE_RANGE),
duration: clampRange(Math.round(entry?.duration ?? SONG_DEFAULT_DURATION), [1, 255]),
}));
emitCommand({
type: 'song',
data: {
song: {
notes: payloadNotes,
slot: options.slot,
loop: options.loop,
},
},
});
return payloadNotes;
},
[emitCommand, roverId],
);
return useMemo(
() => ({
roverId,
rosterEntry,
servoConfig,
headlight,
headlightState,
laser,
laserState,
horn,
peripherals,
emitCommand,
enableSensorStream,
sendDriveDirect,
sendAuxMotors,
sendServoAngle,
sendOiCommand,
sendHeadlight,
sendLaser,
sendHorn,
sendPeripheralControl,
sendSong,
runMacroSteps,
}),
[
roverId,
rosterEntry,
servoConfig,
headlight,
headlightState,
laser,
laserState,
horn,
peripherals,
emitCommand,
enableSensorStream,
sendDriveDirect,
sendAuxMotors,
sendServoAngle,
sendOiCommand,
sendHeadlight,
sendLaser,
sendHorn,
sendPeripheralControl,
sendSong,
runMacroSteps,
],
);
}