npm run build

This commit is contained in:
legop3
2026-04-24 20:37:40 -04:00
parent 0c93aacdf6
commit f23a0e15c3
25 changed files with 386 additions and 257 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -11,7 +11,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-BrQIm3tI.js"></script>
<script type="module" crossorigin src="/assets/index-BnYVDOhM.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-gFnxuq00.css">
</head>
<body>
+2 -2
View File
@@ -34,7 +34,7 @@ import useUserIdentitySync from './hooks/useUserIdentitySync.js';
import CommunityGoalBanner from './components/CommunityGoalBanner.jsx';
import RoverQueuesPanel from './components/RoverQueuesPanel.jsx';
import VipPanel from './components/VipPanel.jsx';
import { useSession } from './context/SessionContext.jsx';
import { useSessionSelector } from './context/SessionContext.jsx';
import ButtonBoxPanel from './components/ButtonBoxPanel.jsx';
import RewardRunOverlay from './components/RewardRunOverlay.jsx';
@@ -91,7 +91,7 @@ function MobileFeatureTabs({
roomPanelId,
showTelemetry = true,
}) {
const { session } = useSession();
const session = useSessionSelector((state) => state.session);
const { state: controlState } = useControlSystem();
const { value: vipAudio } = useSettingsNamespace('vipAudio', { openMicEnabled: false, pttMode: 'live' });
const vipDotClass = session?.isVerified ? 'bg-emerald-400' : 'bg-amber-400';
+4 -2
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { useSession } from '../context/SessionContext.jsx';
import { useSessionActions, useSessionSelector } from '../context/SessionContext.jsx';
import { useSocket } from '../context/SocketContext.jsx';
import ChatMessageRow from './ChatMessageRow.jsx';
import ChatTypingRow from './ChatTypingRow.jsx';
@@ -16,7 +16,9 @@ function buildKey(alert) {
}
export default function AlertFeed({ scale = 1 }) {
const { alerts, session, pushAlert } = useSession();
const alerts = useSessionSelector((state) => state.alerts);
const session = useSessionSelector((state) => state.session);
const { pushAlert } = useSessionActions();
const socket = useSocket();
const [now, setNow] = useState(() => Date.now());
+2 -2
View File
@@ -1,8 +1,8 @@
import { useState } from 'react';
import { useSession } from '../context/SessionContext.jsx';
import { useSessionActions } from '../context/SessionContext.jsx';
export default function AuthPanel() {
const { login, setRole } = useSession();
const { login, setRole } = useSessionActions();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
+2 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useChat } from '../context/ChatContext.jsx';
import { useSession } from '../context/SessionContext.jsx';
import { useSessionSelector } from '../context/SessionContext.jsx';
import { useSettingsNamespace } from '../settings/index.js';
import ChatMessageRow from './ChatMessageRow.jsx';
import ChatTypingRow from './ChatTypingRow.jsx';
@@ -14,7 +14,7 @@ export default function ChatPanel({
fillHeight = false,
allowSpectatorInput = false,
}) {
const { session } = useSession();
const session = useSessionSelector((state) => state.session);
const {
messages,
typing,
+2 -2
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useSession } from '../context/SessionContext.jsx';
import { useSessionSelector } from '../context/SessionContext.jsx';
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
import { useVideoRequests } from '../hooks/useVideoRequests.js';
import { useRoverSnapshots } from '../hooks/useRoverSnapshots.js';
@@ -7,7 +7,7 @@ import { useControlSystem } from '../controls/index.js';
import VideoTile from './VideoTile.jsx';
export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
const { session } = useSession();
const session = useSessionSelector((state) => state.session);
const {
state: { song, lastControlIntentAt },
overcurrentLimiter,
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useSession } from '../context/SessionContext.jsx';
import { useSessionActions, useSessionSelector } from '../context/SessionContext.jsx';
import { useControlSystem } from '../controls/index.js';
import { formatKeyLabel } from '../controls/keymapUtils.js';
@@ -240,8 +240,9 @@ export default function HomeAssistantControls() {
const {
state: { keymap },
} = useControlSystem();
const { session, homeAssistantToggle, homeAssistantSetLightColor, homeAssistantSetLightWhite } =
useSession();
const session = useSessionSelector((state) => state.session);
const { homeAssistantToggle, homeAssistantSetLightColor, homeAssistantSetLightWhite } =
useSessionActions();
const ha = session?.homeAssistant;
const entities = useMemo(() => ha?.entities || [], [ha?.entities]);
const lightPolicy = ha?.lightPolicy || null;
+2 -2
View File
@@ -1,7 +1,7 @@
import { useSession } from '../context/SessionContext.jsx';
import { useSessionSelector } from '../context/SessionContext.jsx';
export default function LogPanel() {
const { logs } = useSession();
const logs = useSessionSelector((state) => state.logs);
return (
<div className="panel-section space-y-0.5 text-base">
<div className="flex items-center justify-between text-sm text-slate-400">
+2 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import AuthPanel from './AuthPanel.jsx';
import { useSession } from '../context/SessionContext.jsx';
import { useSessionSelector } from '../context/SessionContext.jsx';
import SocialButton from './SocialButton.jsx';
import ChatPanel from './ChatPanel.jsx';
import NicknameForm from './NicknameForm.jsx';
@@ -25,7 +25,7 @@ function getModeDetails(mode = 'admin') {
}
export default function ModeGateOverlay() {
const { session } = useSession();
const session = useSessionSelector((state) => state.session);
const mode = session?.mode;
const role = session?.role;
const reason = session?.adminReason?.text || '';
+2 -2
View File
@@ -17,7 +17,7 @@ import NightVisionControl from './NightVisionControl.jsx';
import HornControl from './HornControl.jsx';
import CameraTiltControl from './CameraTiltControl.jsx';
import VipPanel from './VipPanel.jsx';
import { useSession } from '../context/SessionContext.jsx';
import { useSessionSelector } from '../context/SessionContext.jsx';
import { useSettingsNamespace } from '../settings/index.js';
import ButtonBoxPanel from './ButtonBoxPanel.jsx';
@@ -112,7 +112,7 @@ function DriveDockPanel() {
}
export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
const { session } = useSession();
const session = useSessionSelector((state) => state.session);
const { state: controlState } = useControlSystem();
const { value: vipAudio } = useSettingsNamespace('vipAudio', { openMicEnabled: false, pttMode: 'live' });
const vipDotClass = session?.isVerified ? 'bg-emerald-400' : 'bg-red-600';
+3 -2
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { useSession } from '../context/SessionContext.jsx';
import { useSessionActions, useSessionSelector } from '../context/SessionContext.jsx';
import { roverNameChromeStyle } from '../lib/roverColor.js';
function classNames(...values) {
@@ -42,7 +42,8 @@ function formatLabel(user, selfId) {
}
export default function RoverQueuesPanel({ title = 'Rovers' }) {
const { session, requestControl } = useSession();
const session = useSessionSelector((state) => state.session);
const { requestControl } = useSessionActions();
const [pending, setPending] = useState({});
const [now, setNow] = useState(() => Date.now());
+3 -2
View File
@@ -1,5 +1,5 @@
import { useMemo } from 'react';
import { useSession } from '../context/SessionContext.jsx';
import { useSessionSelector } from '../context/SessionContext.jsx';
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
import { useDockIr } from '../hooks/useDockIr.js';
@@ -9,7 +9,8 @@ function formatMetric(value, fallback = '--') {
}
export default function TelemetryPanel() {
const { connected, session } = useSession();
const connected = useSessionSelector((state) => state.connected);
const session = useSessionSelector((state) => state.session);
const roverId = session?.assignment?.roverId;
const frame = useTelemetryFrame(roverId);
const sensors = frame?.sensors || {};
+3 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef } from 'react';
import turnSound from '../assets/turn_alert.mp3';
import { useSession } from '../context/SessionContext.jsx';
import { useSessionActions, useSessionSelector } from '../context/SessionContext.jsx';
import { useSettingsNamespace } from '../settings/index.js';
import { AUDIO_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
@@ -21,7 +21,8 @@ function useAudio(src, volume = 1) {
}
export default function TurnAlertListener() {
const { session, pushAlert } = useSession();
const session = useSessionSelector((state) => state.session);
const { pushAlert } = useSessionActions();
const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
const masterVolume = Number.isFinite(audioSettings?.masterVolume) ? audioSettings.masterVolume : AUDIO_SETTINGS_DEFAULTS.masterVolume;
const alertVolume = Number.isFinite(audioSettings?.alertVolume) ? audioSettings.alertVolume : AUDIO_SETTINGS_DEFAULTS.alertVolume;
+3 -11
View File
@@ -3,7 +3,7 @@ import { WhepPlayer } from '../lib/whepPlayer.js';
import TopDownMap from './TopDownMap.jsx';
import { useHudMapSetting } from '../hooks/useHudMapSetting.js';
import { useChat } from '../context/ChatContext.jsx';
import { useSession } from '../context/SessionContext.jsx';
import { useSessionSelector } from '../context/SessionContext.jsx';
import { useSettingsNamespace } from '../settings/index.js';
import { AUDIO_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
import SocialButton from './SocialButton.jsx';
@@ -42,7 +42,7 @@ export default function VideoTile({
isActiveDriver = false,
idleSkipSeconds = null,
}) {
const { session } = useSession();
const session = useSessionSelector((state) => state.session);
const videoRef = useRef(null);
const audioRef = useRef(null);
const restartTimer = useRef(null);
@@ -740,14 +740,6 @@ function HudOverlay({
labelScale = 1,
}) {
const bumps = sensors?.bumpsAndWheelDrops || {};
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const interval = setInterval(() => setNow(Date.now()), 150);
return () => clearInterval(interval);
}, []);
const pulse = frame?.receivedAt ? now - frame.receivedAt < 200 : false;
const isMobile = mobileHud;
const portraitMobile = layoutFormat === 'mobile-portrait';
const statusTextClass = isMobile ? 'text-[0.45rem]' : 'text-[0.65rem]';
@@ -1004,7 +996,7 @@ function LowBatteryOverlay({ battery, compact = false }) {
}
function HudChatInput({ compact = false }) {
const { session } = useSession();
const session = useSessionSelector((state) => state.session);
const { sendMessage, onInputFocus, onInputBlur, blurChat, registerInputRef, setTypingActive } = useChat();
const { value: ttsSettings } = useSettingsNamespace('tts', { engine: 'flite', voice: 'rms', pitch: 50 });
const [draft, setDraft] = useState('');
+3 -2
View File
@@ -2,7 +2,7 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { useSocket } from './SocketContext.jsx';
import { useSession } from './SessionContext.jsx';
import { useSessionActions, useSessionSelector } from './SessionContext.jsx';
import messageSound from '../assets/message.mp3';
import { useSettingsNamespace } from '../settings/index.js';
import { AUDIO_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
@@ -22,7 +22,8 @@ const ChatContext = createContext({
export function ChatProvider({ children }) {
const socket = useSocket();
const { session, pushAlert } = useSession();
const session = useSessionSelector((state) => state.session);
const { pushAlert } = useSessionActions();
const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
const [messages, setMessages] = useState([]);
const [typing, setTyping] = useState([]);
+130 -73
View File
@@ -1,42 +1,19 @@
/* eslint-disable react-refresh/only-export-components */
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { useSocket } from './SocketContext.jsx';
const SessionContext = createContext({
const INITIAL_STATE = {
connected: false,
session: null,
logs: [],
adminLogs: [],
llmCommentaryState: null,
llmCommentaryStatus: null,
identifySession: async () => {},
login: async () => {},
setRole: async () => {},
requestControl: async () => {},
releaseControl: async () => {},
subscribeAll: async () => {},
homeAssistantToggle: async () => {},
homeAssistantSetState: async () => {},
homeAssistantSetLightColor: async () => {},
homeAssistantSetLightWhite: async () => {},
setNickname: async () => {},
requestVerification: async () => {},
requestPrivateRoverAccess: async () => {},
triggerReplay: async () => {},
setCommunityGoal: async () => {},
setAdminReason: async () => {},
rebootRover: async () => {},
rebootServer: async () => {},
playUploadedAudio: async () => {},
stopUploadedAudio: async () => {},
startMicWhip: async () => {},
readyMicWhip: async () => {},
stopMicWhip: async () => {},
setAudioLevels: async () => {},
setPrivateSafety: async () => {},
llmControl: async () => {},
});
alerts: [],
};
const SessionContext = createContext(null);
function useAckEmitter(socket) {
return useCallback(
@@ -54,66 +31,108 @@ function useAckEmitter(socket) {
);
}
function normalizeSelector(selector) {
return typeof selector === 'function' ? selector : (state) => state;
}
export function SessionProvider({ children }) {
const socket = useSocket();
const emitWithAck = useAckEmitter(socket);
const [session, setSession] = useState(null);
const [logs, setLogs] = useState([]);
const [adminLogs, setAdminLogs] = useState([]);
const [llmCommentaryState, setLlmCommentaryState] = useState(null);
const [llmCommentaryStatus, setLlmCommentaryStatus] = useState(null);
const [alerts, setAlerts] = useState([]);
const [connected, setConnected] = useState(socket.connected);
const stateRef = useRef({ ...INITIAL_STATE, connected: socket.connected });
const subscribersRef = useRef(new Set());
const getState = useCallback(() => stateRef.current, []);
const setState = useCallback((updater) => {
const prev = stateRef.current;
const next = typeof updater === 'function' ? updater(prev) : { ...prev, ...(updater || {}) };
if (next === prev) return;
stateRef.current = next;
subscribersRef.current.forEach((sub) => {
const nextSelected = sub.selector(next);
if (!sub.equalityFn(sub.current, nextSelected)) {
sub.current = nextSelected;
sub.listener(nextSelected);
}
});
}, []);
const subscribe = useCallback((selector, listener, equalityFn = Object.is) => {
const normalizedSelector = normalizeSelector(selector);
const sub = {
selector: normalizedSelector,
equalityFn,
listener,
current: normalizedSelector(stateRef.current),
};
subscribersRef.current.add(sub);
return () => {
subscribersRef.current.delete(sub);
};
}, []);
useEffect(() => {
const onConnect = () => setConnected(true);
const onDisconnect = () => setConnected(false);
const onConnect = () => {
setState((prev) => (prev.connected ? prev : { ...prev, connected: true }));
};
const onDisconnect = () => {
setState((prev) => (prev.connected ? { ...prev, connected: false } : prev));
};
socket.on('connect', onConnect);
socket.on('disconnect', onDisconnect);
return () => {
socket.off('connect', onConnect);
socket.off('disconnect', onDisconnect);
};
}, [socket]);
}, [setState, socket]);
useEffect(() => {
function handleSession(payload) {
setSession(payload);
setState((prev) => ({ ...prev, session: payload }));
}
function handleLogInit(entries = []) {
setLogs(entries);
setState((prev) => ({ ...prev, logs: entries }));
}
function handleLogEntry(entry) {
setLogs((prev) => [...prev.slice(-199), entry]);
setState((prev) => ({ ...prev, logs: [...prev.logs.slice(-199), entry] }));
}
function handleAdminLogInit(entries = []) {
setAdminLogs(entries);
setState((prev) => ({ ...prev, adminLogs: entries }));
}
function handleAdminLogEntry(entry) {
setAdminLogs((prev) => [...prev.slice(-199), entry]);
setState((prev) => ({ ...prev, adminLogs: [...prev.adminLogs.slice(-199), entry] }));
}
function handleLlmState(payload = null) {
const state = payload && typeof payload === 'object' ? payload : null;
const nextStatus =
state?.debug?.status && typeof state.debug.status === 'object' ? state.debug.status : null;
setLlmCommentaryState(state);
setLlmCommentaryStatus(nextStatus);
setState((prev) => ({
...prev,
llmCommentaryState: state,
llmCommentaryStatus: nextStatus,
}));
}
function handleAlertNew(payload = {}) {
setState((prev) => ({
...prev,
alerts: [
...prev.alerts.slice(-49),
{
...payload,
receivedAt: Date.now(),
},
],
}));
}
socket.on('session:sync', handleSession);
socket.on('log:init', handleLogInit);
socket.on('log:entry', handleLogEntry);
socket.on('adminlog:init', handleAdminLogInit);
socket.on('adminlog:entry', handleAdminLogEntry);
socket.on('llm:state', handleLlmState);
socket.on('alert:new', (payload = {}) => {
setAlerts((prev) => [
...prev.slice(-49),
{
...payload,
receivedAt: Date.now(),
},
]);
});
socket.on('alert:new', handleAlertNew);
return () => {
socket.off('session:sync', handleSession);
socket.off('log:init', handleLogInit);
@@ -121,9 +140,9 @@ export function SessionProvider({ children }) {
socket.off('adminlog:init', handleAdminLogInit);
socket.off('adminlog:entry', handleAdminLogEntry);
socket.off('llm:state', handleLlmState);
socket.off('alert:new');
socket.off('alert:new', handleAlertNew);
};
}, [socket]);
}, [setState, socket]);
const actions = useMemo(
() => ({
@@ -166,31 +185,69 @@ export function SessionProvider({ children }) {
llmControl: (action, controls = {}) =>
emitWithAck('llm:control', { controls: { action, ...controls } }),
pushAlert: (alert) =>
setAlerts((prev) => [
...prev.slice(-49),
setState((prev) => ({
...prev,
alerts: [
...prev.alerts.slice(-49),
{ ...alert, receivedAt: Date.now(), id: alert.id || Math.random().toString(36).slice(2) },
]),
],
})),
}),
[emitWithAck],
[emitWithAck, setState],
);
const value = useMemo(
const store = useMemo(
() => ({
connected,
session,
logs,
adminLogs,
llmCommentaryState,
llmCommentaryStatus,
alerts,
...actions,
getState,
subscribe,
actions,
}),
[actions, adminLogs, alerts, connected, llmCommentaryState, llmCommentaryStatus, logs, session],
[actions, getState, subscribe],
);
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
return <SessionContext.Provider value={store}>{children}</SessionContext.Provider>;
}
export function useSessionSelector(selector, equalityFn = Object.is) {
const store = useContext(SessionContext);
if (!store) {
throw new Error('useSessionSelector must be used within SessionProvider');
}
const selectorRef = useRef(selector);
selectorRef.current = selector;
const equalityRef = useRef(equalityFn);
equalityRef.current = equalityFn;
const [selected, setSelected] = useState(() => selector(store.getState()));
useEffect(() => {
setSelected((prev) => {
const next = selectorRef.current(store.getState());
return equalityRef.current(prev, next) ? prev : next;
});
return store.subscribe(
(state) => selectorRef.current(state),
(nextSelected) => {
setSelected((prev) => (equalityRef.current(prev, nextSelected) ? prev : nextSelected));
},
(a, b) => equalityRef.current(a, b),
);
}, [store]);
return selected;
}
export function useSessionActions() {
const store = useContext(SessionContext);
if (!store) {
throw new Error('useSessionActions must be used within SessionProvider');
}
return store.actions;
}
export function useSession() {
return useContext(SessionContext);
const state = useSessionSelector((value) => value);
const actions = useSessionActions();
return useMemo(() => ({ ...state, ...actions }), [actions, state]);
}
+30 -7
View File
@@ -13,7 +13,7 @@ import {
} from './constants.js';
import { canonicalizeKeyInput } from './keymapUtils.js';
import { useSettingsNamespace } from '../settings/index.js';
import { useSession } from '../context/SessionContext.jsx';
import { useSessionActions, useSessionSelector } from '../context/SessionContext.jsx';
import { HORN_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
import {
applyAuxOvercurrentScale,
@@ -71,7 +71,8 @@ export function ControlSystemProvider({ children }) {
typeof pageSettings?.driveMacroBackoffEnabled === 'boolean'
? pageSettings.driveMacroBackoffEnabled
: true;
const { session, homeAssistantSetState } = useSession();
const session = useSessionSelector((state) => state.session);
const { homeAssistantSetState } = useSessionActions();
const roverId = session?.assignment?.roverId ?? null;
const overcurrentLimiter = useOvercurrentLimiter(roverId);
const driveTransform = useCallback(
@@ -455,16 +456,38 @@ export function ControlSystemProvider({ children }) {
}
}, [dispatch, pipeline, state.horn?.heat, state.horn?.overheated, HORN_HEAT_UP_PER_SEC]);
const hornStateRef = useRef({
active: Boolean(state.horn?.active),
heat: state.horn?.heat ?? 0,
overheated: Boolean(state.horn?.overheated),
});
const stopHornRef = useRef(stopHorn);
useEffect(() => {
hornStateRef.current = {
active: Boolean(state.horn?.active),
heat: state.horn?.heat ?? 0,
overheated: Boolean(state.horn?.overheated),
};
}, [state.horn?.active, state.horn?.heat, state.horn?.overheated]);
useEffect(() => {
stopHornRef.current = stopHorn;
}, [stopHorn]);
const hornNeedsTick = Boolean(state.horn?.active) || (state.horn?.heat ?? 0) > 0 || Boolean(state.horn?.overheated);
useEffect(() => {
if (!hornNeedsTick) return undefined;
const tickMs = 100;
const interval = setInterval(() => {
const now = Date.now();
const last = hornHeatTickRef.current || now;
hornHeatTickRef.current = now;
const dt = Math.min(1000, Math.max(0, now - last)) / 1000;
const currentHeat = state.horn?.heat ?? 0;
const active = Boolean(state.horn?.active);
const overheated = Boolean(state.horn?.overheated);
const current = hornStateRef.current;
const currentHeat = current.heat ?? 0;
const active = Boolean(current.active);
const overheated = Boolean(current.overheated);
const delta = dt * (active ? HORN_HEAT_UP_PER_SEC : -HORN_HEAT_COOL_PER_SEC);
let nextHeat = Math.max(0, Math.min(1, currentHeat + delta));
let nextOverheated = overheated;
@@ -472,7 +495,7 @@ export function ControlSystemProvider({ children }) {
nextHeat = 1;
nextOverheated = true;
if (active) {
stopHorn();
stopHornRef.current();
}
} else if (nextOverheated && nextHeat <= HORN_HEAT_RESUME_THRESHOLD) {
nextOverheated = false;
@@ -482,7 +505,7 @@ export function ControlSystemProvider({ children }) {
}
}, tickMs);
return () => clearInterval(interval);
}, [dispatch, state.horn?.active, state.horn?.heat, state.horn?.overheated, stopHorn]);
}, [dispatch, hornNeedsTick, HORN_HEAT_COOL_PER_SEC, HORN_HEAT_RESUME_THRESHOLD, HORN_HEAT_UP_PER_SEC]);
const registerInputState = useCallback((source, data) => {
dispatch({ type: 'control/register-input-state', payload: { source, state: data } });
+2 -2
View File
@@ -1,6 +1,6 @@
import { useCallback, useMemo } from 'react';
import { useSocket } from '../context/SocketContext.jsx';
import { useSession } from '../context/SessionContext.jsx';
import { useSessionSelector } from '../context/SessionContext.jsx';
import {
AUX_LIMITS,
COMMAND_DELAY_MS,
@@ -14,7 +14,7 @@ import { bytesToBase64, clampRange, sleep } from './controlMath.js';
export function useCommandPipeline(options = {}) {
const { driveTransform, auxTransform } = options;
const socket = useSocket();
const { session } = useSession();
const session = useSessionSelector((state) => state.session);
const roverId = session?.assignment?.roverId;
const rosterEntry = useMemo(() => {
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useControlSystem } from '../ControlContext.jsx';
import { useChat } from '../../context/ChatContext.jsx';
import { useSession } from '../../context/SessionContext.jsx';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { normalizeKeymapEntries, tokensForEvent } from '../keymapUtils.js';
import { isKeyboardCaptureLocked } from './keyboardCaptureLock.js';
import { isTextInputElement } from './inputFocusUtils.js';
@@ -134,7 +134,8 @@ export default function KeyboardInputManager() {
sendSong,
},
} = useControlSystem();
const { session, homeAssistantSetState } = useSession();
const session = useSessionSelector((state) => state.session);
const { homeAssistantSetState } = useSessionActions();
const { focusChat, blurChat, isChatFocused } = useChat();
const { value: inputSettings } = useSettingsNamespace('inputs', INPUT_SETTINGS_DEFAULTS);
const keymap = useMemo(() => normalizeKeymapEntries(state.keymap), [state.keymap]);
+37
View File
@@ -4,6 +4,12 @@ import { getPadSignature } from './gamepadBindings.js';
const listeners = new Set();
let rafId = null;
let lastState = { pads: [], timestamp: 0 };
let hasDeviceListeners = false;
let deviceChangeHandler = null;
function hasConnectedPads() {
return readGamepads().some((pad) => pad?.connected !== false);
}
function readGamepads() {
if (typeof navigator === 'undefined' || !navigator.getGamepads) {
@@ -41,11 +47,17 @@ function updateState() {
function loop() {
updateState();
if (lastState.pads.length === 0) {
stopLoop();
return;
}
rafId = requestAnimationFrame(loop);
}
function startLoop() {
if (rafId) return;
updateState();
if (lastState.pads.length === 0) return;
rafId = requestAnimationFrame(loop);
}
@@ -57,12 +69,37 @@ function stopLoop() {
export function subscribeGamepadHub(listener) {
listeners.add(listener);
if (!hasDeviceListeners && typeof window !== 'undefined') {
const handleDeviceChange = () => {
updateState();
if (listeners.size === 0) return;
if (hasConnectedPads()) {
startLoop();
} else {
stopLoop();
}
};
window.addEventListener('gamepadconnected', handleDeviceChange);
window.addEventListener('gamepaddisconnected', handleDeviceChange);
deviceChangeHandler = handleDeviceChange;
hasDeviceListeners = true;
}
if (hasConnectedPads()) {
startLoop();
} else {
updateState();
}
listener(lastState);
return () => {
listeners.delete(listener);
if (listeners.size === 0) {
stopLoop();
if (hasDeviceListeners && typeof window !== 'undefined') {
window.removeEventListener('gamepadconnected', deviceChangeHandler);
window.removeEventListener('gamepaddisconnected', deviceChangeHandler);
deviceChangeHandler = null;
hasDeviceListeners = false;
}
}
};
}
+14 -3
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
import { useSession } from '../context/SessionContext.jsx';
import { useSessionSelector } from '../context/SessionContext.jsx';
export const OVERCURRENT_GROUPS = [
{ key: 'drive', motors: ['leftWheel', 'rightWheel'] },
@@ -27,7 +27,7 @@ function clampUnit(value) {
}
export function useOvercurrentLimiter(roverId, options = {}) {
const { session } = useSession();
const session = useSessionSelector((state) => state.session);
const frame = useTelemetryFrame(roverId);
const sensors = frame?.sensors || {};
const overcurrentFlags = sensors?.wheelOvercurrents || {};
@@ -47,7 +47,18 @@ export function useOvercurrentLimiter(roverId, options = {}) {
setCaps(createInitialCaps());
}, [roverId]);
const hasAnyOvercurrent = useMemo(
() => OVERCURRENT_GROUPS.some((group) => group.motors.some((motor) => Boolean(overcurrentFlags?.[motor]))),
[overcurrentFlags],
);
const needsRecoveryTick = useMemo(
() => Object.values(caps || {}).some((entry) => (Number.isFinite(entry?.cap) ? entry.cap : 1) < 0.999),
[caps],
);
const shouldTick = Boolean(roverId) && (hasAnyOvercurrent || needsRecoveryTick);
useEffect(() => {
if (!shouldTick) return undefined;
const interval = setInterval(() => {
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
const deltaMs = Math.max(0, now - lastTickRef.current);
@@ -79,7 +90,7 @@ export function useOvercurrentLimiter(roverId, options = {}) {
});
}, 100);
return () => clearInterval(interval);
}, [config.downRatePerSec, config.releaseDelaySec, config.upRatePerSec]);
}, [config.downRatePerSec, config.releaseDelaySec, config.upRatePerSec, roverId, shouldTick]);
const scales = useMemo(() => {
const perGroup = OVERCURRENT_GROUPS.reduce((acc, group) => {
+2 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react';
import { useSettingsNamespace } from '../settings/index.js';
import { useSession } from '../context/SessionContext.jsx';
import { useSessionActions } from '../context/SessionContext.jsx';
import wordListText from '../assets/wordlist.txt?raw';
function capitalize(word) {
@@ -19,7 +19,7 @@ function pickRandomWord() {
}
export default function useDefaultNickname() {
const { setNickname } = useSession();
const { setNickname } = useSessionActions();
const { value, status, save } = useSettingsNamespace('profile', { nickname: '' });
const attemptedRef = useRef(false);
+3 -2
View File
@@ -1,11 +1,12 @@
import { useCallback, useEffect, useRef } from 'react';
import { useSession } from '../context/SessionContext.jsx';
import { useSessionActions, useSessionSelector } from '../context/SessionContext.jsx';
import { useSocket } from '../context/SocketContext.jsx';
import { useSettingsNamespace } from '../settings/index.js';
export default function useUserIdentitySync() {
const socket = useSocket();
const { connected, identifySession } = useSession();
const connected = useSessionSelector((state) => state.connected);
const { identifySession } = useSessionActions();
const { value: identity, status: identityStatus, save: saveIdentity } = useSettingsNamespace('identity', {
cookieUserId: '',
});