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