mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
307 lines
8.7 KiB
JavaScript
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,
|
|
],
|
|
);
|
|
}
|