mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
set default tab to help. add beep keys.
This commit is contained in:
@@ -1,10 +1,14 @@
|
||||
import { useSession } from '../context/SessionContext.jsx';
|
||||
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
|
||||
import { useVideoRequests } from '../hooks/useVideoRequests.js';
|
||||
import { useControlSystem } from '../controls/index.js';
|
||||
import VideoTile from './VideoTile.jsx';
|
||||
|
||||
export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
|
||||
const { session } = useSession();
|
||||
const {
|
||||
state: { song },
|
||||
} = useControlSystem();
|
||||
const roverId = session?.assignment?.roverId;
|
||||
const rosterEntry =
|
||||
roverId && session?.roster ? session.roster.find((item) => String(item.id) === String(roverId)) : null;
|
||||
@@ -37,6 +41,7 @@ export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
|
||||
telemetryFrame={frame}
|
||||
batteryConfig={batteryConfig}
|
||||
layoutFormat={layoutFormat}
|
||||
songNote={song?.note}
|
||||
/>
|
||||
) : (
|
||||
<div className="panel-muted content-center text-center text-sm text-slate-400 aspect-video">
|
||||
|
||||
@@ -25,6 +25,8 @@ const KEY_ACTIONS = [
|
||||
{ id: 'driveMacro', label: 'Drive Macro', group: 'Macros' },
|
||||
{ id: 'dockMacro', label: 'Dock Macro', group: 'Macros' },
|
||||
{ id: 'chatFocus', label: 'Toggle Chat', group: 'Chat' },
|
||||
{ id: 'songNoteUp', label: 'Song Note Up', group: 'Audio' },
|
||||
{ id: 'songNoteDown', label: 'Song Note Down', group: 'Audio' },
|
||||
];
|
||||
|
||||
function groupActions(actions) {
|
||||
|
||||
@@ -4,6 +4,15 @@ import { WhepPlayer } from '../lib/whepPlayer.js';
|
||||
const RESTART_DELAY_MS = 2000;
|
||||
const UNMUTE_RETRY_MS = 3000;
|
||||
|
||||
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||
|
||||
function formatNoteLabel(note) {
|
||||
if (typeof note !== 'number' || !Number.isFinite(note)) return '--';
|
||||
const name = NOTE_NAMES[note % 12] || '?';
|
||||
const octave = Math.floor(note / 12) - 1;
|
||||
return `${name}${octave}`;
|
||||
}
|
||||
|
||||
function buildBatteryVisual(charge, config) {
|
||||
const full = config?.Full;
|
||||
const warn = config?.Warn;
|
||||
@@ -40,6 +49,7 @@ export default function VideoTile({
|
||||
layoutFormat = 'desktop',
|
||||
hudVariant = 'default',
|
||||
driverLabel = null,
|
||||
songNote = null,
|
||||
}) {
|
||||
const videoRef = useRef(null);
|
||||
const audioRef = useRef(null);
|
||||
@@ -265,6 +275,7 @@ export default function VideoTile({
|
||||
variant={hudVariant}
|
||||
driverLabel={driverLabel}
|
||||
battery={batteryVisual}
|
||||
songNote={songNote}
|
||||
/>
|
||||
<OvercurrentOverlay motors={overcurrentMotors} />
|
||||
<LowBatteryOverlay charge={batteryCharge} config={batteryConfig} />
|
||||
@@ -325,6 +336,7 @@ function HudOverlay({
|
||||
variant = 'default',
|
||||
driverLabel = null,
|
||||
battery,
|
||||
songNote = null,
|
||||
}) {
|
||||
const sensors = frame?.sensors;
|
||||
const bumps = sensors?.bumpsAndWheelDrops || {};
|
||||
@@ -350,6 +362,11 @@ function HudOverlay({
|
||||
<span>Status: {status}</span>
|
||||
{audioStatus ? <div>Audio: {audioStatus}</div> : null}
|
||||
</div>
|
||||
{songNote != null ? (
|
||||
<div className="absolute right-1 top-1 rounded bg-black/70 px-1 py-0.25 text-[0.65rem] font-semibold text-emerald-200">
|
||||
Song {formatNoteLabel(songNote)} <span className="text-slate-400">({songNote})</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="absolute left-1 top-1/2 flex -translate-y-1/2 flex-col gap-0.25 bg-black/70 px-1 py-0.75 text-[0.65rem] text-slate-100">
|
||||
<span className="text-[0.6rem] uppercase tracking-wide text-slate-400">Telemetry</span>
|
||||
@@ -382,6 +399,11 @@ function HudOverlay({
|
||||
<span>Status: {status}</span>
|
||||
{audioStatus ? <div>Audio: {audioStatus}</div> : null}
|
||||
</div>
|
||||
{songNote != null ? (
|
||||
<div className="absolute right-1 top-1 rounded bg-black/70 px-1 py-0.25 text-[0.65rem] font-semibold text-emerald-200">
|
||||
Song {formatNoteLabel(songNote)} <span className="text-slate-400">({songNote})</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="absolute bottom-0.5 left-1/2 flex -translate-x-1/2 gap-0.5 bg-black/80 px-0.5 py-0.5 text-slate-100">
|
||||
<span>Rover: "{label || 'Unnamed Rover'}"</span>
|
||||
{/* <span>{pulse ? 'Sensors active' : 'No recent sensors'}</span> */}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useReducer,
|
||||
import { controlReducer, initialControlState } from './controlReducer.js';
|
||||
import { computeDifferentialSpeeds, clamp } from './controlMath.js';
|
||||
import { useCommandPipeline } from './commandPipeline.js';
|
||||
import { DEFAULT_KEYMAP, DEFAULT_MACROS } from './constants.js';
|
||||
import { DEFAULT_KEYMAP, DEFAULT_MACROS, SONG_DEFAULT_NOTE } from './constants.js';
|
||||
import { canonicalizeKeyInput } from './keymapUtils.js';
|
||||
import { useSettingsNamespace } from '../settings/index.js';
|
||||
import { useSession } from '../context/SessionContext.jsx';
|
||||
@@ -258,6 +258,22 @@ export function ControlSystemProvider({ children }) {
|
||||
pipeline.sendNightVision('toggle');
|
||||
}, [pipeline]);
|
||||
|
||||
const setSongNote = useCallback(
|
||||
(note) => {
|
||||
const next = typeof note === 'number' ? note : SONG_DEFAULT_NOTE;
|
||||
dispatch({ type: 'control/set-song-note', payload: next });
|
||||
return next;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const sendSong = useCallback(
|
||||
(notes, options) => {
|
||||
return pipeline.sendSong(notes, options);
|
||||
},
|
||||
[pipeline],
|
||||
);
|
||||
|
||||
const registerInputState = useCallback((source, data) => {
|
||||
dispatch({ type: 'control/register-input-state', payload: { source, state: data } });
|
||||
}, []);
|
||||
@@ -282,6 +298,8 @@ export function ControlSystemProvider({ children }) {
|
||||
updateKeyBinding,
|
||||
resetKeyBindings,
|
||||
registerInputState,
|
||||
setSongNote,
|
||||
sendSong,
|
||||
},
|
||||
}),
|
||||
[
|
||||
@@ -301,6 +319,8 @@ export function ControlSystemProvider({ children }) {
|
||||
updateKeyBinding,
|
||||
resetKeyBindings,
|
||||
registerInputState,
|
||||
setSongNote,
|
||||
sendSong,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useSocket } from '../context/SocketContext.jsx';
|
||||
import { useSession } from '../context/SessionContext.jsx';
|
||||
import { AUX_LIMITS, COMMAND_DELAY_MS, OI_COMMANDS } from './constants.js';
|
||||
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() {
|
||||
@@ -151,6 +158,32 @@ export function useCommandPipeline() {
|
||||
[emitCommand, nightVision, 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,
|
||||
@@ -164,6 +197,7 @@ export function useCommandPipeline() {
|
||||
sendServoAngle,
|
||||
sendOiCommand,
|
||||
sendNightVision,
|
||||
sendSong,
|
||||
runMacroSteps,
|
||||
}),
|
||||
[
|
||||
|
||||
@@ -10,7 +10,12 @@ export const DRIVE_LIMITS = {
|
||||
boostSpeed: 400,
|
||||
};
|
||||
|
||||
export const COMMAND_DELAY_MS = 200;
|
||||
export const COMMAND_DELAY_MS = 100;
|
||||
|
||||
export const SONG_NOTE_RANGE = [31, 127];
|
||||
export const SONG_DEFAULT_NOTE = 60;
|
||||
export const SONG_DEFAULT_DURATION = 32;
|
||||
export const SONG_REPEAT_MS = 150;
|
||||
|
||||
export const OI_COMMANDS = {
|
||||
start: [128],
|
||||
@@ -40,6 +45,8 @@ export const DEFAULT_KEYMAP = {
|
||||
driveMacro: ['f'],
|
||||
dockMacro: ['g'],
|
||||
chatFocus: ['enter'],
|
||||
songNoteUp: ['ArrowUp'],
|
||||
songNoteDown: ['ArrowDown'],
|
||||
};
|
||||
|
||||
export const DEFAULT_MACROS = [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DEFAULT_KEYMAP, DEFAULT_MACROS } from './constants.js';
|
||||
import { DEFAULT_KEYMAP, DEFAULT_MACROS, SONG_DEFAULT_NOTE } from './constants.js';
|
||||
|
||||
function createDriveState() {
|
||||
return {
|
||||
@@ -21,12 +21,19 @@ function createCameraState() {
|
||||
};
|
||||
}
|
||||
|
||||
function createSongState() {
|
||||
return {
|
||||
note: SONG_DEFAULT_NOTE,
|
||||
};
|
||||
}
|
||||
|
||||
export const initialControlState = {
|
||||
roverId: null,
|
||||
mode: 'drive',
|
||||
drive: createDriveState(),
|
||||
aux: createAuxState(),
|
||||
camera: createCameraState(),
|
||||
song: createSongState(),
|
||||
macros: DEFAULT_MACROS,
|
||||
keymap: DEFAULT_KEYMAP,
|
||||
inputs: {},
|
||||
@@ -40,6 +47,7 @@ export function controlReducer(state, action) {
|
||||
roverId: action.payload ?? null,
|
||||
drive: action.payload ? state.drive : createDriveState(),
|
||||
aux: action.payload ? state.aux : createAuxState(),
|
||||
song: action.payload ? state.song : createSongState(),
|
||||
};
|
||||
case 'control/set-mode':
|
||||
return state.mode === action.payload
|
||||
@@ -114,6 +122,15 @@ export function controlReducer(state, action) {
|
||||
...state,
|
||||
drive: createDriveState(),
|
||||
aux: createAuxState(),
|
||||
song: createSongState(),
|
||||
};
|
||||
case 'control/set-song-note':
|
||||
return {
|
||||
...state,
|
||||
song: {
|
||||
...(state.song || createSongState()),
|
||||
note: action.payload ?? SONG_DEFAULT_NOTE,
|
||||
},
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
|
||||
@@ -4,11 +4,19 @@ import { useChat } from '../../context/ChatContext.jsx';
|
||||
import { normalizeKeymapEntries, tokensForEvent } from '../keymapUtils.js';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { INPUT_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
||||
import {
|
||||
SONG_DEFAULT_DURATION,
|
||||
SONG_DEFAULT_NOTE,
|
||||
SONG_NOTE_RANGE,
|
||||
SONG_REPEAT_MS,
|
||||
} from '../constants.js';
|
||||
|
||||
const SOURCE = 'keyboard';
|
||||
const ZERO_VECTOR = { x: 0, y: 0, boost: false };
|
||||
const ZERO_AUX = { main: 0, side: 0, vacuum: 0 };
|
||||
const SERVO_REPEAT_MS = 110;
|
||||
const NOTE_MIN = SONG_NOTE_RANGE[0];
|
||||
const NOTE_MAX = SONG_NOTE_RANGE[1];
|
||||
|
||||
function clampSpeed(value, fallback) {
|
||||
const num = Number(value);
|
||||
@@ -95,6 +103,8 @@ export default function KeyboardInputManager() {
|
||||
stopAllMotion,
|
||||
registerInputState,
|
||||
toggleNightVision,
|
||||
setSongNote,
|
||||
sendSong,
|
||||
},
|
||||
} = useControlSystem();
|
||||
const { focusChat, blurChat, isChatFocused } = useChat();
|
||||
@@ -126,6 +136,7 @@ export default function KeyboardInputManager() {
|
||||
const lastVectorRef = useRef(ZERO_VECTOR);
|
||||
const lastAuxRef = useRef(ZERO_AUX);
|
||||
const servoIntervalRef = useRef(null);
|
||||
const songIntervalRef = useRef(null);
|
||||
|
||||
const driveFromKeys = useCallback(() => {
|
||||
const tokensSnapshot = new Set(activeTokensRef.current);
|
||||
@@ -194,14 +205,66 @@ export default function KeyboardInputManager() {
|
||||
servoIntervalRef.current = setTimeout(tick, 0);
|
||||
}, [computeServoDirection, nudgeServo, servoStep, stopServoLoop]);
|
||||
|
||||
const stopSongLoop = useCallback(() => {
|
||||
if (songIntervalRef.current) {
|
||||
clearTimeout(songIntervalRef.current);
|
||||
songIntervalRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const computeSongDirection = useCallback(() => {
|
||||
const tokensSnapshot = new Set(activeTokensRef.current);
|
||||
const up = bindingActive(keymap.songNoteUp, tokensSnapshot);
|
||||
const down = bindingActive(keymap.songNoteDown, tokensSnapshot);
|
||||
return (up ? 1 : 0) - (down ? 1 : 0);
|
||||
}, [keymap]);
|
||||
|
||||
const triggerSongChange = useCallback(
|
||||
(direction) => {
|
||||
if (direction === 0) return;
|
||||
const current = typeof state.song?.note === 'number' ? state.song.note : SONG_DEFAULT_NOTE;
|
||||
let next = current + direction;
|
||||
if (next > NOTE_MAX) {
|
||||
next = NOTE_MIN;
|
||||
} else if (next < NOTE_MIN) {
|
||||
next = NOTE_MAX;
|
||||
}
|
||||
const finalNote = setSongNote(next);
|
||||
sendSong([{ note: finalNote, duration: SONG_DEFAULT_DURATION }], { slot: 0 });
|
||||
},
|
||||
[sendSong, setSongNote, state.song?.note],
|
||||
);
|
||||
|
||||
const ensureSongLoop = useCallback(() => {
|
||||
const direction = computeSongDirection();
|
||||
if (direction === 0) {
|
||||
stopSongLoop();
|
||||
return;
|
||||
}
|
||||
if (songIntervalRef.current) {
|
||||
return;
|
||||
}
|
||||
const tick = () => {
|
||||
const nextDirection = computeSongDirection();
|
||||
if (nextDirection === 0) {
|
||||
stopSongLoop();
|
||||
return;
|
||||
}
|
||||
triggerSongChange(nextDirection);
|
||||
songIntervalRef.current = setTimeout(tick, SONG_REPEAT_MS);
|
||||
};
|
||||
songIntervalRef.current = setTimeout(tick, 0);
|
||||
}, [computeSongDirection, stopSongLoop, triggerSongChange]);
|
||||
|
||||
const resetAll = useCallback(() => {
|
||||
activeTokensRef.current.clear();
|
||||
lastVectorRef.current = ZERO_VECTOR;
|
||||
lastAuxRef.current = ZERO_AUX;
|
||||
stopServoLoop();
|
||||
stopSongLoop();
|
||||
stopAllMotion();
|
||||
registerInputState(SOURCE, { keys: [], vector: ZERO_VECTOR, aux: ZERO_AUX });
|
||||
}, [registerInputState, stopAllMotion, stopServoLoop]);
|
||||
}, [registerInputState, stopAllMotion, stopServoLoop, stopSongLoop]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(event) {
|
||||
@@ -238,6 +301,7 @@ export default function KeyboardInputManager() {
|
||||
}
|
||||
|
||||
ensureServoLoop();
|
||||
ensureSongLoop();
|
||||
driveFromKeys();
|
||||
}
|
||||
|
||||
@@ -245,6 +309,7 @@ export default function KeyboardInputManager() {
|
||||
const tokens = tokensForEvent(event);
|
||||
tokens.forEach((token) => activeTokensRef.current.delete(token));
|
||||
ensureServoLoop();
|
||||
ensureSongLoop();
|
||||
driveFromKeys();
|
||||
}
|
||||
|
||||
@@ -265,6 +330,7 @@ export default function KeyboardInputManager() {
|
||||
blurChat,
|
||||
driveFromKeys,
|
||||
ensureServoLoop,
|
||||
ensureSongLoop,
|
||||
focusChat,
|
||||
isChatFocused,
|
||||
keymap.chatFocus,
|
||||
@@ -274,6 +340,7 @@ export default function KeyboardInputManager() {
|
||||
runMacro,
|
||||
setMode,
|
||||
stopAllMotion,
|
||||
stopSongLoop,
|
||||
]);
|
||||
|
||||
const latestResetAllRef = useRef(resetAll);
|
||||
@@ -288,8 +355,9 @@ export default function KeyboardInputManager() {
|
||||
useEffect(
|
||||
() => () => {
|
||||
stopServoLoop();
|
||||
stopSongLoop();
|
||||
},
|
||||
[stopServoLoop],
|
||||
[stopServoLoop, stopSongLoop],
|
||||
);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -92,5 +92,9 @@ export function formatKeyLabel(value) {
|
||||
if (canonical === '/') return '/ or ?';
|
||||
if (canonical === '-') return '- or _';
|
||||
if (canonical === '=') return '= or +';
|
||||
if (canonical === 'arrowup') return 'Arrow Up';
|
||||
if (canonical === 'arrowdown') return 'Arrow Down';
|
||||
if (canonical === 'arrowleft') return 'Arrow Left';
|
||||
if (canonical === 'arrowright') return 'Arrow Right';
|
||||
return canonical.length === 1 ? canonical.toUpperCase() : canonical;
|
||||
}
|
||||
|
||||
@@ -80,6 +80,14 @@ export const HELP_CONTENT = {
|
||||
{ action: 'auxAllForward', label: 'All motors forward' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'audio',
|
||||
title: 'Audio / Song',
|
||||
items: [
|
||||
{ action: 'songNoteUp', label: 'Song note up' },
|
||||
{ action: 'songNoteDown', label: 'Song note down' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user