set default tab to help. add beep keys.

This commit is contained in:
legop3
2025-12-08 13:57:27 -05:00
parent 161929e280
commit 6580de08d4
20 changed files with 254 additions and 21 deletions
@@ -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">
+2
View File
@@ -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) {
+22
View File
@@ -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> */}
+21 -1
View File
@@ -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,
],
);
+35 -1
View File
@@ -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,
}),
[
+8 -1
View File
@@ -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 = [
+18 -1
View File
@@ -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;
+4
View File
@@ -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;
}
+8
View File
@@ -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' },
],
},
],
},
{