mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
npm run build
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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
@@ -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';
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 || '';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|
||||||
|
|||||||
@@ -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 || {};
|
||||||
|
|||||||
@@ -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,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('');
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
|||||||
@@ -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]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 } });
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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: '',
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user