Merge pull request #5 from legop3/webuiopt

Webuiopt
This commit is contained in:
legop3
2026-05-03 22:07:43 -04:00
committed by GitHub
40 changed files with 470 additions and 361 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -10,8 +10,8 @@
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-BhTaDZYU.js"></script>
<title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-BVMv97rc.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CGvUXLso.css">
</head>
<body>
+13 -9
View File
@@ -93,30 +93,34 @@ function MobileFeatureTabs({
roomPanelId,
showTelemetry = true,
}) {
const session = useSessionSelector((state) => state.session);
const [activeTab, setActiveTab] = useState('chat');
const isVerified = useSessionSelector((state) => Boolean(state.session?.isVerified));
const ownRoverId = useSessionSelector((state) => String(state.session?.assignment?.roverId || '').trim());
const ownAudioForward = useSessionSelector((state) => {
const roverId = String(state.session?.assignment?.roverId || '').trim();
return roverId ? state.session?.audioForward?.[roverId] || null : null;
});
const { state: controlState } = useControlSystem();
const { value: vipAudio } = useSettingsNamespace('vipAudio', { openMicEnabled: false, pttMode: 'live' });
const vipDotClass = session?.isVerified ? 'bg-emerald-400' : 'bg-amber-400';
const ownRoverId = String(session?.assignment?.roverId || '').trim();
const ownAudioForward = ownRoverId ? session?.audioForward?.[ownRoverId] : null;
const vipDotClass = isVerified ? 'bg-emerald-400' : 'bg-amber-400';
const pttActive = Boolean(controlState?.mic?.pttActive);
const openMicEnabled = Boolean(vipAudio?.openMicEnabled);
const pttMode = vipAudio?.pttMode === 'clip' ? 'clip' : 'live';
const vipMicActive = Boolean(
ownRoverId &&
session?.isVerified &&
isVerified &&
(pttMode === 'clip' ? pttActive : (openMicEnabled || pttActive)),
);
const vipClipPlaying = Boolean(
ownRoverId &&
session?.isVerified &&
isVerified &&
pttMode === 'clip' &&
ownAudioForward?.source === 'upload' &&
ownAudioForward?.state === 'playing',
);
return (
<section className="panel text-base">
<Tabs defaultTab="chat">
<Tabs defaultTab="chat" currentTab={activeTab} onTabChange={setActiveTab}>
<TabList>
<Tab id="chat">Chat</Tab>
<Tab id="vip" highlight={vipClipPlaying ? 'green' : vipMicActive ? 'pink' : 'none'}>
@@ -125,7 +129,7 @@ function MobileFeatureTabs({
<span
className={`inline-block h-1.5 w-1.5 rounded-full ${vipDotClass}`}
aria-hidden="true"
title={session?.isVerified ? 'Verified' : 'Not verified'}
title={isVerified ? 'Verified' : 'Not verified'}
/>
</span>
</Tab>
@@ -141,7 +145,7 @@ function MobileFeatureTabs({
</div>
</TabPanel>
<TabPanel id="vip" keepMounted>
<VipPanel />
<VipPanel isActive={activeTab === 'vip'} />
</TabPanel>
<TabPanel id="roomcontrols">
<div className="space-y-0.5">
+3 -3
View File
@@ -20,7 +20,7 @@ function buildKey(alert) {
export default function AlertFeed({ scale = 1 }) {
const alerts = useSessionSelector((state) => state.alerts);
const session = useSessionSelector((state) => state.session);
const buttonBoxButtons = useSessionSelector((state) => state.session?.buttonBox?.buttons ?? []);
const { pushAlert } = useSessionActions();
const socket = useSocket();
const [now, setNow] = useState(() => Date.now());
@@ -29,7 +29,7 @@ export default function AlertFeed({ scale = 1 }) {
function onButtonIncrement(payload = {}) {
const buttonId = Number(payload.buttonId);
if (!Number.isFinite(buttonId) || buttonId < 1 || buttonId > 4) return;
const buttons = Array.isArray(session?.buttonBox?.buttons) ? session.buttonBox.buttons : [];
const buttons = Array.isArray(buttonBoxButtons) ? buttonBoxButtons : [];
const button = buttons.find((entry) => Number(entry?.id) === buttonId) || {};
const count = Number.isFinite(payload.count) ? payload.count : Number(button.count) || 0;
const goal = Number.isFinite(button.goal) ? button.goal : 0;
@@ -49,7 +49,7 @@ export default function AlertFeed({ scale = 1 }) {
return () => {
socket.off('buttonBox:increment', onButtonIncrement);
};
}, [pushAlert, session?.buttonBox?.buttons, socket]);
}, [pushAlert, buttonBoxButtons, socket]);
const latest = useMemo(() => alerts.slice(-12).map((alert) => ({ alert, key: buildKey(alert) })), [alerts]);
+4 -1
View File
@@ -1,6 +1,7 @@
// Battery Bar
// Purpose: Defines the Battery Bar module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import React from 'react';
import { WARN_DISPLAY_PERCENT } from '../../lib/battery.js';
const WARN_FLASH_MS = 1600;
@@ -10,7 +11,7 @@ function classNames(...values) {
return values.filter(Boolean).join(' ');
}
export default function BatteryBar({
function BatteryBar({
visual,
orientation = 'horizontal',
variant = 'inline',
@@ -155,3 +156,5 @@ export default function BatteryBar({
</div>
);
}
export default React.memo(BatteryBar);
@@ -2,7 +2,7 @@
// Purpose: Defines the Button Box Panel module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useEffect, useMemo, useRef, useState } from 'react';
import { useSession } from '../../context/SessionContext.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useSocket } from '../../context/SocketContext.jsx';
import { useSettingsNamespace } from '../../settings/index.js';
import { AUDIO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
@@ -18,7 +18,7 @@ const BUTTON_TONES = {
};
export default function ButtonBoxPanel() {
const { session } = useSession();
const buttonBoxButtons = useSessionSelector((state) => state.session?.buttonBox?.buttons ?? []);
const socket = useSocket();
const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
const masterVolume = Number.isFinite(audioSettings?.masterVolume)
@@ -29,7 +29,7 @@ export default function ButtonBoxPanel() {
: AUDIO_SETTINGS_DEFAULTS.alertVolume;
const effectiveAlertVolume = Math.max(0, Math.min(1, masterVolume * alertVolume));
const buttons = useMemo(() => {
const list = Array.isArray(session?.buttonBox?.buttons) ? session.buttonBox.buttons : [];
const list = Array.isArray(buttonBoxButtons) ? buttonBoxButtons : [];
if (list.length === 4) return list;
return [1, 2, 3, 4].map((id) => list.find((entry) => Number(entry?.id) === id) || {
id,
@@ -40,7 +40,7 @@ export default function ButtonBoxPanel() {
rewardNumber: null,
lastRewardAt: null,
});
}, [session?.buttonBox?.buttons]);
}, [buttonBoxButtons]);
const [incFlash, setIncFlash] = useState({});
const [rewardFlash, setRewardFlash] = useState({});
+6 -5
View File
@@ -17,7 +17,9 @@ export default function ChatPanel({
fillHeight = false,
allowSpectatorInput = false,
}) {
const session = useSessionSelector((state) => state.session);
const role = useSessionSelector((state) => state.session?.role || null);
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
const roster = useSessionSelector((state) => state.session?.roster ?? []);
const {
messages,
typing,
@@ -38,13 +40,12 @@ export default function ChatPanel({
const [engine, setEngine] = useState(() => ttsSettings?.engine || 'flite');
const [voice, setVoice] = useState(() => ttsSettings?.voice || 'rms');
const [pitch, setPitch] = useState(() => (Number.isFinite(ttsSettings?.pitch) ? ttsSettings.pitch : 50));
const canChat = session?.role !== 'spectator' || allowSpectatorInput;
const canChat = role !== 'spectator' || allowSpectatorInput;
const listRef = useRef(null);
const currentRoverId = session?.assignment?.roverId || null;
const rover = useMemo(
() => session?.roster?.find((entry) => String(entry.id) === String(currentRoverId)) || null,
[currentRoverId, session?.roster],
() => roster.find((entry) => String(entry.id) === String(currentRoverId)) || null,
[currentRoverId, roster],
);
const ttsSupported = Boolean(rover?.audio?.ttsEnabled);
+18 -15
View File
@@ -10,7 +10,12 @@ import { useControlSystem } from '../../controls/index.js';
import VideoTile from '../VideoTile/index.jsx';
export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
const session = useSessionSelector((state) => state.session);
const mode = useSessionSelector((state) => state.session?.mode || null);
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const roster = useSessionSelector((state) => state.session?.roster ?? []);
const turnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
const socketId = useSessionSelector((state) => state.session?.socketId || null);
const activeDrivers = useSessionSelector((state) => state.session?.activeDrivers ?? {});
const {
state: { song, lastControlIntentAt },
overcurrentLimiter,
@@ -20,19 +25,17 @@ export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
const [turnCueStartAt, setTurnCueStartAt] = useState(null);
const lastTurnRef = useRef({ active: false, roverId: null });
useEffect(() => {
if (session?.mode !== 'turns') {
if (mode !== 'turns') {
return undefined;
}
const timer = setInterval(() => setNow(Date.now()), 250);
return () => clearInterval(timer);
}, [session?.mode]);
const roverId = session?.assignment?.roverId;
}, [mode]);
const rosterEntry =
roverId && session?.roster ? session.roster.find((item) => String(item.id) === String(roverId)) : null;
roverId && roster ? roster.find((item) => String(item.id) === String(roverId)) : null;
const hasAudio = Boolean(rosterEntry?.media?.audioPublishUrl);
const turnInfo = roverId ? session?.turnQueues?.[roverId] : null;
const socketId = session?.socketId || null;
const activeDriverId = roverId ? session?.activeDrivers?.[roverId] : null;
const turnInfo = roverId ? turnQueues?.[roverId] : null;
const activeDriverId = roverId ? activeDrivers?.[roverId] : null;
const isActiveDriver = Boolean(socketId && activeDriverId === socketId);
const nextDriverId = useMemo(() => {
const queue = turnInfo?.queue || [];
@@ -49,8 +52,8 @@ export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
const msUntilTurn = deadline ? deadline - now : null;
const msUntilIdleSkip = idleDeadline ? idleDeadline - now : null;
const isPreSwitchWindow =
session?.mode === 'turns' && isNextDriver && msUntilTurn != null && msUntilTurn <= 5000 && msUntilTurn > 0;
const shouldShowVideo = session?.mode !== 'turns' || isActiveDriver || isPreSwitchWindow;
mode === 'turns' && isNextDriver && msUntilTurn != null && msUntilTurn <= 5000 && msUntilTurn > 0;
const shouldShowVideo = mode !== 'turns' || isActiveDriver || isPreSwitchWindow;
const turnSeconds =
msUntilTurn != null && Number.isFinite(msUntilTurn) ? Math.max(0, Math.ceil(msUntilTurn / 1000)) : null;
const idleSkipSeconds =
@@ -75,20 +78,20 @@ export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
const audioInfo = roverId && hasAudio ? sources[`${roverId}-audio`] : null;
const snapshotFeeds = useRoverSnapshots(roverId ? [roverId] : [], {
enabled: Boolean(roverId),
version: session?.mode,
version: mode,
});
const snapshotFeed = roverId ? snapshotFeeds[roverId] || null : null;
const frame = useTelemetryFrame(roverId);
const batteryRecord =
roverId && session?.roster
? session.roster.find((item) => String(item.id) === String(roverId))
roverId && roster
? roster.find((item) => String(item.id) === String(roverId))
: null;
const batteryConfig = batteryRecord?.battery ?? null;
const roverLabel = batteryRecord?.name || (roverId ? `Rover ${roverId}` : '');
useEffect(() => {
if (session?.mode !== 'turns') {
if (mode !== 'turns') {
setTurnCueVisible(false);
setTurnCueStartAt(null);
lastTurnRef.current = { active: false, roverId: null };
@@ -105,7 +108,7 @@ export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
setTurnCueStartAt(null);
}
lastTurnRef.current = { active: isActiveDriver, roverId };
}, [isActiveDriver, roverId, session?.mode]);
}, [isActiveDriver, roverId, mode]);
useEffect(() => {
if (!turnCueVisible || !turnCueStartAt) return;
@@ -2,15 +2,17 @@
// Purpose: Defines the Global Objective Banner module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useEffect, useMemo, useRef, useState } from 'react';
import { useSession } from '../../context/SessionContext.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx';
const MOBILE_DISMISS_MS = 10000;
const MAX_FONT_PX = 28;
const MIN_FONT_PX = 14;
export default function GlobalObjectiveBanner({ layout = 'desktop', className = '', dismissable = true }) {
const { session } = useSession();
const goalText = session?.globalObjective?.text ? String(session.globalObjective.text).trim() : '';
const goalText = useSessionSelector((state) => {
const text = state.session?.globalObjective?.text;
return text ? String(text).trim() : '';
});
const isMobile = layout === 'mobile-portrait' || layout === 'mobile-landscape' || layout === 'mobile';
const [visible, setVisible] = useState(false);
const [fontSize, setFontSize] = useState(MAX_FONT_PX);
@@ -243,10 +243,9 @@ export default function HomeAssistantControls() {
const {
state: { keymap },
} = useControlSystem();
const session = useSessionSelector((state) => state.session);
const ha = useSessionSelector((state) => state.session?.homeAssistant || null);
const { homeAssistantToggle, homeAssistantSetLightColor, homeAssistantSetLightWhite } =
useSessionActions();
const ha = session?.homeAssistant;
const entities = useMemo(() => ha?.entities || [], [ha?.entities]);
const lightPolicy = ha?.lightPolicy || null;
const controlsLocked = Boolean(lightPolicy?.locked || lightPolicy?.lockedOn);
+3 -4
View File
@@ -2,9 +2,11 @@
// Purpose: Defines the Log Panel module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useMemo } from 'react';
export default function LogPanel() {
const logs = useSessionSelector((state) => state.logs);
const rendered = useMemo(() => logs.slice().reverse(), [logs]);
return (
<div className="panel-section space-y-0.5 text-base">
<div className="flex items-center justify-between text-sm text-slate-400">
@@ -15,10 +17,7 @@ export default function LogPanel() {
{logs.length === 0 ? (
<p>No logs yet.</p>
) : (
logs
.slice()
.reverse()
.map((entry) => (
rendered.map((entry) => (
<div key={entry.id} className="surface">
<span className="text-amber-400">{entry.timestamp}</span>{' '}
<span className="text-lime-400">[{entry.level}]</span>{' '}
+11 -11
View File
@@ -28,19 +28,19 @@ function getModeDetails(mode = 'admin') {
}
export default function ModeGateOverlay() {
const session = useSessionSelector((state) => state.session);
const mode = session?.mode;
const role = session?.role;
const reason = session?.adminReason?.text || '';
const reasonUpdatedAt = session?.adminReason?.updatedAt || null;
const timezone = session?.timezone || 'UTC';
const discordUrl =
session?.socials?.find((entry) => {
const mode = useSessionSelector((state) => state.session?.mode || null);
const role = useSessionSelector((state) => state.session?.role || null);
const reason = useSessionSelector((state) => state.session?.adminReason?.text || '');
const reasonUpdatedAt = useSessionSelector((state) => state.session?.adminReason?.updatedAt || null);
const timezone = useSessionSelector((state) => state.session?.timezone || 'UTC');
const discordUrl = useSessionSelector((state) => {
const socials = state.session?.socials || [];
const fromSocials = socials.find((entry) => {
const key = String(entry?.id || entry?.label || '').toLowerCase();
return key === 'discord';
})?.url ||
session?.discord?.invite ||
null;
})?.url;
return fromSocials || state.session?.discord?.invite || null;
});
const restricted = RESTRICTED_MODES.has(mode);
const privileged = mode === 'lockdown' ? LOCKDOWN_ROLES.has(role) : PRIVILEGED_ROLES.has(role);
const [now, setNow] = useState(() => new Date());
+4 -3
View File
@@ -2,16 +2,17 @@
// Purpose: Defines the Nickname Form module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useEffect, useState } from 'react';
import { useSession } from '../../context/SessionContext.jsx';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../settings/index.js';
export default function NicknameForm({ compact = false }) {
const { session, setNickname } = useSession();
const role = useSessionSelector((state) => state.session?.role || null);
const { setNickname } = useSessionActions();
const { value, save } = useSettingsNamespace('profile', { nickname: '' });
const [nicknameInput, setNicknameInput] = useState(value.nickname || '');
const [saving, setSaving] = useState(false);
const canSetNickname = session?.role !== 'spectator';
const canSetNickname = role !== 'spectator';
useEffect(() => {
setNicknameInput(value.nickname || '');
@@ -2,7 +2,7 @@
// Purpose: Defines the Raw User Pile Panel module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useMemo } from 'react';
import { useSession } from '../../context/SessionContext.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import NicknameForm from '../NicknameForm/index.jsx';
import SocialButtonsGrid from '../SocialButtonsGrid/index.jsx';
@@ -35,10 +35,10 @@ export default function RawUserPilePanel({
fillHeight = false,
compact = false,
}) {
const { session } = useSession();
const canSetNickname = session?.role !== 'spectator';
const users = session?.users ?? [];
const selfId = session?.socketId || null;
const role = useSessionSelector((state) => state.session?.role || null);
const users = useSessionSelector((state) => state.session?.users ?? []);
const selfId = useSessionSelector((state) => state.session?.socketId || null);
const canSetNickname = role !== 'spectator';
const sorted = useMemo(
() =>
@@ -2,7 +2,7 @@
// Purpose: Defines the Replay Sources Panel module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useEffect, useMemo, useState } from 'react';
import { useSession } from '../../context/SessionContext.jsx';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../settings/index.js';
import { roverNameChromeStyle } from '../../lib/roverColor.js';
@@ -22,8 +22,15 @@ function normalizeSources(list = []) {
}
export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHeight = false }) {
const { session, triggerReplay } = useSession();
const sources = normalizeSources(session?.replaySources || []);
const replaySources = useSessionSelector((state) => state.session?.replaySources ?? []);
const mode = useSessionSelector((state) => state.session?.mode || null);
const assignmentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const users = useSessionSelector((state) => state.session?.users ?? []);
const selfSocketId = useSessionSelector((state) => state.session?.socketId || null);
const roster = useSessionSelector((state) => state.session?.roster ?? []);
const replayState = useSessionSelector((state) => state.session?.replay || null);
const { triggerReplay } = useSessionActions();
const sources = normalizeSources(replaySources || []);
const { value: settings, save: saveSettings } = useSettingsNamespace('replaySources', {});
const [selected, setSelected] = useState([]);
const [busy, setBusy] = useState(false);
@@ -32,29 +39,28 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
const [title, setTitle] = useState('');
const [titleDirty, setTitleDirty] = useState(false);
const [includeSidebar, setIncludeSidebar] = useState(true);
const replayState = session?.replay || null;
const [remainingMs, setRemainingMs] = useState(0);
const defaults = useMemo(() => {
const roverId = session?.assignment?.roverId;
const roverId = assignmentRoverId;
if (roverId) {
return [`rover:${roverId}`];
}
return [];
}, [session?.assignment?.roverId]);
}, [assignmentRoverId]);
const defaultTitle = useMemo(() => {
const self = Array.isArray(session?.users)
? session.users.find((entry) => entry?.socketId === session?.socketId)
const self = Array.isArray(users)
? users.find((entry) => entry?.socketId === selfSocketId)
: null;
const nickname = (self?.nickname || 'Someone').trim() || 'Someone';
const roverId = session?.assignment?.roverId || null;
const roverId = assignmentRoverId || null;
const roverName =
roverId && Array.isArray(session?.roster)
? session.roster.find((entry) => String(entry?.id) === String(roverId))?.name || roverId
roverId && Array.isArray(roster)
? roster.find((entry) => String(entry?.id) === String(roverId))?.name || roverId
: 'a rover';
return `${nickname} driving ${roverName}`;
}, [session?.users, session?.socketId, session?.assignment?.roverId, session?.roster]);
}, [users, selfSocketId, assignmentRoverId, roster]);
useEffect(() => {
const saved = settings?.[panelId];
@@ -106,7 +112,7 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
return () => clearInterval(interval);
}, [replayState?.lastTriggeredAt, replayState?.cooldownMs, replayState?.remainingMs]);
const replayDisabled = busy || session?.mode === 'lockdown' || remainingMs > 0 || !selected.length;
const replayDisabled = busy || mode === 'lockdown' || remainingMs > 0 || !selected.length;
const toggleKey = (key) => {
setSelected((prev) => {
+14 -9
View File
@@ -23,6 +23,7 @@ import VipPanel from '../VipPanel/index.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../settings/index.js';
import ButtonBoxPanel from '../ButtonBoxPanel/index.jsx';
import { useState } from 'react';
function TopDownMapPanel() {
const {
@@ -115,30 +116,34 @@ function DriveDockPanel() {
}
export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
const session = useSessionSelector((state) => state.session);
const [activeTab, setActiveTab] = useState('telemetry');
const isVerified = useSessionSelector((state) => Boolean(state.session?.isVerified));
const ownRoverId = useSessionSelector((state) => String(state.session?.assignment?.roverId || '').trim());
const ownAudioForward = useSessionSelector((state) => {
const roverId = String(state.session?.assignment?.roverId || '').trim();
return roverId ? state.session?.audioForward?.[roverId] || null : null;
});
const { state: controlState } = useControlSystem();
const { value: vipAudio } = useSettingsNamespace('vipAudio', { openMicEnabled: false, pttMode: 'live' });
const vipDotClass = session?.isVerified ? 'bg-emerald-400' : 'bg-red-600';
const ownRoverId = String(session?.assignment?.roverId || '').trim();
const ownAudioForward = ownRoverId ? session?.audioForward?.[ownRoverId] : null;
const vipDotClass = isVerified ? 'bg-emerald-400' : 'bg-red-600';
const pttActive = Boolean(controlState?.mic?.pttActive);
const openMicEnabled = Boolean(vipAudio?.openMicEnabled);
const pttMode = vipAudio?.pttMode === 'clip' ? 'clip' : 'live';
const vipMicActive = Boolean(
ownRoverId &&
session?.isVerified &&
isVerified &&
(pttMode === 'clip' ? pttActive : (openMicEnabled || pttActive)),
);
const vipClipPlaying = Boolean(
ownRoverId &&
session?.isVerified &&
isVerified &&
pttMode === 'clip' &&
ownAudioForward?.source === 'upload' &&
ownAudioForward?.state === 'playing',
);
return (
<section className="panel text-base">
<Tabs defaultTab="telemetry">
<Tabs defaultTab="telemetry" currentTab={activeTab} onTabChange={setActiveTab}>
<TabList>
<Tab id="telemetry">Controls</Tab>
<Tab id="vip" highlight={vipClipPlaying ? 'green' : vipMicActive ? 'pink' : 'none'}>
@@ -147,7 +152,7 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
<span
className={`inline-block h-3 w-3 rounded-full ${vipDotClass}`}
aria-hidden="true"
title={session?.isVerified ? 'Verified' : 'Not verified'}
title={isVerified ? 'Verified' : 'Not verified'}
/>
</span>
</Tab>
@@ -179,7 +184,7 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
</div>
</TabPanel>
<TabPanel id="vip" keepMounted>
<VipPanel />
<VipPanel isActive={activeTab === 'vip'} />
</TabPanel>
<TabPanel id="help">
<HelpPanel layout={layout} onOpenOverlay={onOpenHelpOverlay} />
@@ -2,7 +2,7 @@
// Purpose: Defines the Room Camera Panel module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useEffect, useState } from 'react';
import { useSession } from '../../context/SessionContext.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../settings/index.js';
import { useRoomCameraSnapshots } from '../../hooks/useRoomCameraSnapshots.js';
import RoomCameraFeed from '../RoomCameraFeed/index.jsx';
@@ -32,8 +32,7 @@ export default function RoomCameraPanel({
hideHeader = false,
panelId = null,
}) {
const { session } = useSession();
const cameras = session?.roomCameras || [];
const cameras = useSessionSelector((state) => state.session?.roomCameras || []);
const feedMap = useRoomCameraSnapshots(cameras.map((camera) => ({ id: camera.id })));
const { value: orientationSettings, save: saveOrientationSettings } = useSettingsNamespace('roomCameraPanels', {});
const [orientation, setOrientation] = useState(() =>
@@ -45,23 +45,20 @@ function formatLabel(user, selfId) {
}
export default function RoverQueuesPanel({ title = 'Rovers' }) {
const session = useSessionSelector((state) => state.session);
const role = useSessionSelector((state) => state.session?.role || null);
const roster = useSessionSelector((state) => state.session?.roster ?? []);
const turnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
const users = useSessionSelector((state) => state.session?.users ?? []);
const selfId = useSessionSelector((state) => state.session?.socketId || null);
const { requestControl } = useSessionActions();
const [pending, setPending] = useState({});
const [now, setNow] = useState(() => Date.now());
const canRequest = useMemo(() => session?.role && session.role !== 'spectator', [session?.role]);
const canRequest = useMemo(() => role && role !== 'spectator', [role]);
const adminCapable = useMemo(
() =>
session?.role === 'admin' ||
session?.role === 'lockdown' ||
session?.role === 'lockdown-admin',
[session?.role],
() => role === 'admin' || role === 'lockdown' || role === 'lockdown-admin',
[role],
);
const roster = session?.roster ?? [];
const turnQueues = session?.turnQueues ?? {};
const users = session?.users ?? [];
const selfId = session?.socketId || null;
const hasDeadlines = useMemo(
() => Object.values(turnQueues || {}).some((info) => info?.deadline || info?.idleDeadline),
[turnQueues],
@@ -1,20 +1,20 @@
// Social Buttons Grid
// Purpose: Defines the Social Buttons Grid module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useSession } from '../../context/SessionContext.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import SocialButton from '../SocialButton/index.jsx';
function normalizeSocials(session) {
const configured = Array.isArray(session?.socials) ? session.socials : null;
function normalizeSocials({ socials, discordInvite, kofiLink }) {
const configured = Array.isArray(socials) ? socials : null;
if (configured && configured.length) {
return configured;
}
const fallback = [];
if (session?.discord?.invite) {
fallback.push({ id: 'discord', label: 'Discord', url: session.discord.invite });
if (discordInvite) {
fallback.push({ id: 'discord', label: 'Discord', url: discordInvite });
}
if (session?.kofi?.link) {
fallback.push({ id: 'kofi', label: 'Ko-fi', url: session.kofi.link });
if (kofiLink) {
fallback.push({ id: 'kofi', label: 'Ko-fi', url: kofiLink });
}
return fallback;
}
@@ -28,8 +28,12 @@ function normalizeEntry(entry) {
}
export default function SocialButtonsGrid({ className = '' }) {
const { session } = useSession();
const socials = normalizeSocials(session)
const socialsInput = useSessionSelector((state) => ({
socials: state.session?.socials ?? [],
discordInvite: state.session?.discord?.invite || null,
kofiLink: state.session?.kofi?.link || null,
}));
const socials = normalizeSocials(socialsInput)
.map(normalizeEntry)
.filter(Boolean)
.slice(0, 4);
+10 -6
View File
@@ -13,8 +13,13 @@ function formatMetric(value, fallback = '--') {
export default function TelemetryPanel() {
const connected = useSessionSelector((state) => state.connected);
const session = useSessionSelector((state) => state.session);
const roverId = session?.assignment?.roverId;
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const activeDriverId = useSessionSelector((state) => {
const id = state.session?.assignment?.roverId;
return id ? state.session?.activeDrivers?.[id] || null : null;
});
const selfSocketId = useSessionSelector((state) => state.session?.socketId || null);
const users = useSessionSelector((state) => state.session?.users ?? []);
const frame = useTelemetryFrame(roverId);
const sensors = frame?.sensors || {};
const dockIr = useDockIr(sensors);
@@ -25,14 +30,13 @@ export default function TelemetryPanel() {
const capacity = sensors.batteryCapacityMah;
const updated = frame?.receivedAt ? new Date(frame.receivedAt).toLocaleTimeString() : null;
const rawSnippet = frame?.raw ? frame.raw : null;
const activeDriverId = roverId ? session?.activeDrivers?.[roverId] : null;
const driverLabel = useMemo(() => {
if (!roverId) return 'n/a';
if (!activeDriverId) return 'Available';
if (activeDriverId === session?.socketId) return 'You';
const user = (session?.users || []).find((entry) => entry.socketId === activeDriverId);
if (activeDriverId === selfSocketId) return 'You';
const user = users.find((entry) => entry.socketId === activeDriverId);
return user?.nickname || activeDriverId.slice(0, 6);
}, [activeDriverId, roverId, session?.socketId, session?.users]);
}, [activeDriverId, roverId, selfSocketId, users]);
return (
<section className="panel-section space-y-0.5 text-base text-slate-100">
@@ -13,7 +13,7 @@ import {
cliffColor,
} from './visuals.jsx';
export default function TopDownMapContent({ sensors = {}, variant = 'full', size: overrideSize, overlay = false }) {
function TopDownMapContent({ sensors = {}, variant = 'full', size: overrideSize, overlay = false }) {
const size = overrideSize || (variant === 'mini' ? 190 : 260);
const center = size / 2;
const offsetY = size * 0.07;
@@ -147,3 +147,5 @@ export default function TopDownMapContent({ sensors = {}, variant = 'full', size
</div>
);
}
export default React.memo(TopDownMapContent);
@@ -1,7 +1,7 @@
// Turn Alert Listener
// Purpose: Defines the Turn Alert Listener module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useEffect, useMemo, useRef } from 'react';
import { useEffect, useRef } from 'react';
import turnSound from '../../assets/turn_alert.mp3';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../settings/index.js';
@@ -24,7 +24,9 @@ function useAudio(src, volume = 1) {
}
export default function TurnAlertListener() {
const session = useSessionSelector((state) => state.session);
const socketId = useSessionSelector((state) => state.session?.socketId || null);
const assignments = useSessionSelector((state) => state.session?.turnQueues || {});
const roster = useSessionSelector((state) => state.session?.roster || []);
const { pushAlert } = useSessionActions();
const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
const masterVolume = Number.isFinite(audioSettings?.masterVolume) ? audioSettings.masterVolume : AUDIO_SETTINGS_DEFAULTS.masterVolume;
@@ -33,9 +35,6 @@ export default function TurnAlertListener() {
const playSound = useAudio(turnSound, effectiveAlertVolume);
const seenRoversRef = useRef(new Set());
const assignments = useMemo(() => session?.turnQueues || {}, [session?.turnQueues]);
const socketId = session?.socketId || null;
useEffect(() => {
if (!socketId) return;
const newlyMine = [];
@@ -53,7 +52,7 @@ export default function TurnAlertListener() {
});
if (newlyMine.length === 0) return;
newlyMine.forEach((roverId) => {
const roverName = session?.roster?.find((r) => String(r.id) === String(roverId))?.name || roverId;
const roverName = roster.find((r) => String(r.id) === String(roverId))?.name || roverId;
pushAlert({
title: 'Your turn!',
message: `You now control ${roverName}.`,
@@ -61,7 +60,7 @@ export default function TurnAlertListener() {
});
});
playSound();
}, [assignments, playSound, pushAlert, session?.roster, socketId]);
}, [assignments, playSound, pushAlert, roster, socketId]);
return null;
}
+9 -8
View File
@@ -2,7 +2,7 @@
// Purpose: Defines the User List Panel module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useEffect, useMemo, useState, useCallback } from 'react';
import { useSession } from '../../context/SessionContext.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import NicknameForm from '../NicknameForm/index.jsx';
import SocialButtonsGrid from '../SocialButtonsGrid/index.jsx';
import { roverBadgeStyle, roverNameChromeStyle } from '../../lib/roverColor.js';
@@ -55,13 +55,14 @@ export default function UserListPanel({
compact = false,
showBothTurnsAndUsers = false,
}) {
const { session } = useSession();
const canSetNickname = session?.role !== 'spectator';
const users = session?.users ?? [];
const selfId = session?.socketId || null;
const isTurnsMode = session?.mode === 'turns';
const turnQueues = session?.turnQueues || {};
const roster = session?.roster || [];
const role = useSessionSelector((state) => state.session?.role || null);
const users = useSessionSelector((state) => state.session?.users ?? []);
const selfId = useSessionSelector((state) => state.session?.socketId || null);
const mode = useSessionSelector((state) => state.session?.mode || null);
const turnQueues = useSessionSelector((state) => state.session?.turnQueues || {});
const roster = useSessionSelector((state) => state.session?.roster || []);
const canSetNickname = role !== 'spectator';
const isTurnsMode = mode === 'turns';
const [turnView, setTurnView] = useState('queues');
useEffect(() => {
@@ -1,23 +1,24 @@
// Hud Chat Input
// Purpose: Defines the Hud Chat Input module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useMemo, useState } from 'react';
import { memo, useMemo, useState } from 'react';
import { useChat } from '../../context/ChatContext.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../settings/index.js';
export default function HudChatInput({ compact = false }) {
const session = useSessionSelector((state) => state.session);
function HudChatInput({ compact = false }) {
const role = useSessionSelector((state) => state.session?.role || null);
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
const roverRoster = useSessionSelector((state) => state.session?.roster || []);
const { sendMessage, onInputFocus, onInputBlur, blurChat, registerInputRef, setTypingActive } = useChat();
const { value: ttsSettings } = useSettingsNamespace('tts', { engine: 'flite', voice: 'rms', pitch: 50 });
const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false);
const canChat = session?.role !== 'spectator';
const hideHudChat = session?.role === 'spectator';
const currentRoverId = session?.assignment?.roverId || null;
const canChat = role !== 'spectator';
const hideHudChat = role === 'spectator';
const rover = useMemo(
() => session?.roster?.find((entry) => String(entry.id) === String(currentRoverId)) || null,
[currentRoverId, session?.roster],
() => roverRoster.find((entry) => String(entry.id) === String(currentRoverId)) || null,
[currentRoverId, roverRoster],
);
const ttsSupported = Boolean(rover?.audio?.ttsEnabled);
const ttsPayload = useMemo(() => {
@@ -98,3 +99,5 @@ export default function HudChatInput({ compact = false }) {
</form>
);
}
export default memo(HudChatInput);
@@ -5,7 +5,7 @@ import React from 'react';
import TopDownMap from '../TopDownMap/index.jsx';
import { roverNameChromeStyle } from '../../lib/roverColor.js';
export default function HudOverlay({
function HudOverlay({
sensors,
label,
roverColor = null,
@@ -174,3 +174,5 @@ export default function HudOverlay({
</div>
);
}
export default React.memo(HudOverlay);
@@ -3,7 +3,7 @@
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import React from 'react';
export default function LightBumpBars({ sensors }) {
function LightBumpBars({ sensors }) {
const values = [
sensors?.lightBumpLeftSignal,
sensors?.lightBumpFrontLeftSignal,
@@ -47,3 +47,5 @@ export default function LightBumpBars({ sensors }) {
</div>
);
}
export default React.memo(LightBumpBars);
@@ -3,7 +3,7 @@
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import React from 'react';
export default function LowBatteryOverlay({ battery, compact = false }) {
function LowBatteryOverlay({ battery, compact = false }) {
if (!battery?.available) return null;
if (!battery.warnActive && !battery.urgentActive) return null;
@@ -24,3 +24,5 @@ export default function LowBatteryOverlay({ battery, compact = false }) {
</div>
);
}
export default React.memo(LowBatteryOverlay);
@@ -4,7 +4,7 @@
import React from 'react';
import { OVERCURRENT_LABELS } from './constants.js';
export default function OvercurrentOverlay({ motors, fill = 0, compact = false }) {
function OvercurrentOverlay({ motors, fill = 0, compact = false }) {
if (!motors?.length) return null;
const safeLabels = motors.map((name) => OVERCURRENT_LABELS[name] || name);
const containerClass = compact ? 'w-[12rem] h-[3.5rem]' : 'w-[20rem] h-[7rem]';
@@ -29,3 +29,5 @@ export default function OvercurrentOverlay({ motors, fill = 0, compact = false }
</div>
);
}
export default React.memo(OvercurrentOverlay);
+47 -28
View File
@@ -48,7 +48,15 @@ export default function VideoTile({
isActiveDriver = false,
idleSkipSeconds = null,
}) {
const session = useSessionSelector((state) => state.session);
const discordUrl = useSessionSelector((state) => {
const socials = state.session?.socials || [];
const socialUrl =
socials.find((entry) => {
const key = String(entry?.id || entry?.label || '').toLowerCase();
return key === 'discord';
})?.url || null;
return socialUrl || state.session?.discord?.invite || null;
});
const videoRef = useRef(null);
const audioRef = useRef(null);
const restartTimer = useRef(null);
@@ -92,18 +100,29 @@ export default function VideoTile({
const showHudMap = hudForceMap ? true : mobileHud ? true : showHudMapDesktop;
const batteryVisual = buildBatteryVisual({ charge: batteryCharge, config: batteryConfig });
const wheelOvercurrents = sensors?.wheelOvercurrents || null;
const overcurrentMotors =
wheelOvercurrents == null
? []
: Object.entries(wheelOvercurrents)
.filter(([, active]) => Boolean(active))
.map(([key]) => key);
const overcurrentMotors = useMemo(
() =>
wheelOvercurrents == null
? []
: Object.entries(wheelOvercurrents)
.filter(([, active]) => Boolean(active))
.map(([key]) => key),
[wheelOvercurrents],
);
const limiterCaps = overcurrentLimiter?.caps || null;
const limiterGroups = overcurrentLimiter?.overcurrent?.groups || null;
const debugAudio =
typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('debugAudio');
const debugHud =
typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('debugHud');
const debugFlags = useMemo(() => {
if (typeof window === 'undefined') {
return { debugAudio: false, debugHud: false };
}
const params = new URLSearchParams(window.location.search);
return {
debugAudio: params.has('debugAudio'),
debugHud: params.has('debugHud'),
};
}, []);
const debugAudio = debugFlags.debugAudio;
const debugHud = debugFlags.debugHud;
const limiterFill = useMemo(() => {
if (!limiterCaps) return null;
const driveCap = Number.isFinite(limiterCaps?.drive?.cap) ? limiterCaps.drive.cap : 1;
@@ -111,9 +130,15 @@ export default function VideoTile({
return Math.max(0, Math.min(1, 1 - Math.min(driveCap, auxCap)));
}, [limiterCaps]);
const limiterActive = Boolean(overcurrentLimiter?.isActive);
const overlayMotors = overcurrentMotors.length ? overcurrentMotors : limiterActive ? ['limiter'] : [];
const overlayFill = limiterFill ?? (overcurrentMotors.length ? 1 : 0);
const overlayVisible = Boolean(overlayMotors.length);
const overlayState = useMemo(() => {
const motors = overcurrentMotors.length ? overcurrentMotors : limiterActive ? ['limiter'] : [];
const fill = limiterFill ?? (overcurrentMotors.length ? 1 : 0);
return {
motors,
fill,
visible: Boolean(motors.length),
};
}, [overcurrentMotors, limiterActive, limiterFill]);
const mainBrushActive = Boolean(
(Number(sensors?.mainBrushCurrentMa) || 0) > BRUSH_CURRENT_THRESHOLD_MA ||
sensors?.wheelOvercurrents?.mainBrush,
@@ -188,26 +213,18 @@ export default function VideoTile({
},
[debugAudio, label],
);
const discordUrl =
session?.socials?.find((entry) => {
const key = String(entry?.id || entry?.label || '').toLowerCase();
return key === 'discord';
})?.url ||
session?.discord?.invite ||
null;
useEffect(() => {
if (!debugHud) return;
console.log('[OvercurrentHUD]', {
overlayVisible,
overlayMotors,
overlayFill,
overlayVisible: overlayState.visible,
overlayMotors: overlayState.motors,
overlayFill: overlayState.fill,
limiterActive,
limiterCaps,
limiterGroups,
wheelOvercurrents,
});
}, [debugHud, overlayFill, overlayMotors, overlayVisible, limiterActive, limiterCaps, limiterGroups, wheelOvercurrents]);
}, [debugHud, overlayState, limiterActive, limiterCaps, limiterGroups, wheelOvercurrents]);
useEffect(() => {
logAudio('settings/update');
@@ -623,10 +640,12 @@ export default function VideoTile({
{!noHud ? <HudChatInput compact={mobileHud} /> : null}
{!noHud && debugHud ? (
<div className="pointer-events-none absolute left-1 top-1 z-40 rounded bg-black/80 px-1 py-0.5 text-[0.6rem] text-lime-200">
{`OC vis:${overlayVisible ? 1 : 0} motors:${overlayMotors.length} fill:${Math.round(overlayFill * 100)}%`}
{`OC vis:${overlayState.visible ? 1 : 0} motors:${overlayState.motors.length} fill:${Math.round(
overlayState.fill * 100,
)}%`}
</div>
) : null}
{!noHud ? <OvercurrentOverlay motors={overlayMotors} fill={overlayFill} compact={mobileHud} /> : null}
{!noHud ? <OvercurrentOverlay motors={overlayState.motors} fill={overlayState.fill} compact={mobileHud} /> : null}
{!noHud ? <LowBatteryOverlay battery={batteryVisual} compact={mobileHud} /> : null}
{!noHud && showVerticalBattery && batteryVisual.available ? (
<div className="pointer-events-none absolute right-1 top-1/2 flex h-[70%] -translate-y-1/2 flex-col items-center justify-center rounded bg-black/60 px-0.5 pb-1 pt-1">
+6 -5
View File
@@ -2,7 +2,7 @@
// Purpose: Defines the Vip Panel module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useMemo, useState } from 'react';
import { useSession } from '../../context/SessionContext.jsx';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../settings/index.js';
import { COOKIE_KEY_REGEX, flowWrapClass } from '../vip/constants.js';
import VipAudioUploadCard from '../vip/VipAudioUploadCard/index.jsx';
@@ -12,10 +12,10 @@ import VipPrivateRoverAccessCard from '../vip/VipPrivateRoverAccessCard.jsx';
import VipNeatoCard from '../vip/VipNeatoCard.jsx';
import VipLiftCard from '../vip/VipLiftCard.jsx';
export default function VipPanel() {
export default function VipPanel({ isActive = true }) {
const session = useSessionSelector((state) => state.session);
const neatoLidar = useSessionSelector((state) => state.neatoLidar);
const {
session,
neatoLidar,
identifySession,
requestVerification,
requestPrivateRoverAccess,
@@ -31,7 +31,7 @@ export default function VipPanel() {
neatoPowerCycle,
liftUp,
liftDown,
} = useSession();
} = useSessionActions();
const { value: identity, save: saveIdentity } = useSettingsNamespace('identity', { cookieUserId: '' });
const { value: profile } = useSettingsNamespace('profile', { nickname: '' });
@@ -80,6 +80,7 @@ export default function VipPanel() {
<VipNeatoCard
neato={session?.neato || null}
lidar={neatoLidar}
lidarActive={isActive}
onStart={neatoStart}
onSendHome={neatoSendHome}
onLocate={neatoLocate}
+8 -3
View File
@@ -1,7 +1,7 @@
// Vip Neato Card
// Purpose: Defines the Vip Neato Card module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
function normalizeState(value) {
return String(value || '').trim();
@@ -114,6 +114,7 @@ function buildLidarRenderPoints(points = []) {
export default function VipNeatoCard({
neato,
lidar,
lidarActive = true,
onStart,
onSendHome,
onLocate,
@@ -140,7 +141,10 @@ export default function VipNeatoCard({
const robotError = normalizeState(neato?.telemetry?.robotError) || '--';
const robotAlert = normalizeState(neato?.telemetry?.robotAlert) || '--';
const lidarPoints = Array.isArray(lidar?.points) ? lidar.points : [];
const lidarRenderPoints = buildLidarRenderPoints(lidarPoints);
const lidarRenderPoints = useMemo(
() => (lidarActive ? buildLidarRenderPoints(lidarPoints) : []),
[lidarActive, lidarPoints],
);
const lidarStatus = normalizeState(lidar?.status) || '--';
const lidarDebug = lidar?.debug && typeof lidar.debug === 'object' ? lidar.debug : null;
const lidarReason = normalizeState(lidarDebug?.reason) || '--';
@@ -180,13 +184,14 @@ export default function VipNeatoCard({
const primaryState = docked ? 'Docked' : uiStateLabel !== '--' ? uiStateLabel : 'Away from dock';
useEffect(() => {
if (!lidarActive) return undefined;
if (!lidar || !Array.isArray(lidar.points)) return undefined;
setLidarFlash(true);
const timer = setTimeout(() => {
setLidarFlash(false);
}, 120);
return () => clearTimeout(timer);
}, [lidar]);
}, [lidar, lidarActive]);
return (
<section className={`surface text-sm text-slate-200 ${wrapClass}`}>
+69 -12
View File
@@ -2,27 +2,75 @@
// Purpose: Maintains shared telemetry snapshots and rover status streams for UI consumers. Scope: Subscribes to telemetry events and exposes normalized read APIs to components.
/* eslint-disable react-refresh/only-export-components */
import { createContext, useContext, useMemo, useState, useEffect } from 'react';
import { createContext, useContext, useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
import { useSocket } from './SocketContext.jsx';
const TelemetryContext = createContext({ frames: {} });
const EMPTY_FRAMES = Object.freeze({});
const EMPTY_FRAME = null;
const TelemetryContext = createContext(null);
export function TelemetryProvider({ children }) {
const socket = useSocket();
const [frames, setFrames] = useState({});
const framesRef = useRef({});
const roverSubscribersRef = useRef(new Map());
const allSubscribersRef = useRef(new Set());
const notifyRover = (roverId) => {
const listeners = roverSubscribersRef.current.get(roverId);
if (listeners) {
listeners.forEach((listener) => listener());
}
allSubscribersRef.current.forEach((listener) => listener());
};
const store = useMemo(
() => ({
getFrames: () => framesRef.current,
getFrame: (roverId) => {
if (!roverId) return EMPTY_FRAME;
return framesRef.current[roverId] ?? EMPTY_FRAME;
},
subscribeAll: (listener) => {
allSubscribersRef.current.add(listener);
return () => {
allSubscribersRef.current.delete(listener);
};
},
subscribeRover: (roverId, listener) => {
if (!roverId) return () => {};
let listeners = roverSubscribersRef.current.get(roverId);
if (!listeners) {
listeners = new Set();
roverSubscribersRef.current.set(roverId, listeners);
}
listeners.add(listener);
return () => {
const current = roverSubscribersRef.current.get(roverId);
if (!current) return;
current.delete(listener);
if (!current.size) {
roverSubscribersRef.current.delete(roverId);
}
};
},
}),
[],
);
useEffect(() => {
function handleSensorFrame({ roverId, sensors = {}, frame = {} }) {
if (!roverId) return;
setFrames((prev) => ({
...prev,
framesRef.current = {
...framesRef.current,
[roverId]: {
roverId,
sensors,
raw: frame?.data || null,
receivedAt: Date.now(),
},
}));
};
notifyRover(roverId);
}
socket.on('sensorFrame', handleSensorFrame);
@@ -31,16 +79,25 @@ export function TelemetryProvider({ children }) {
};
}, [socket]);
const value = useMemo(() => ({ frames }), [frames]);
return <TelemetryContext.Provider value={value}>{children}</TelemetryContext.Provider>;
return <TelemetryContext.Provider value={store}>{children}</TelemetryContext.Provider>;
}
export function useTelemetryFrames() {
return useContext(TelemetryContext).frames;
const store = useContext(TelemetryContext);
if (!store) {
throw new Error('useTelemetryFrames must be used within TelemetryProvider');
}
return useSyncExternalStore(store.subscribeAll, store.getFrames, () => EMPTY_FRAMES);
}
export function useTelemetryFrame(roverId) {
const frames = useTelemetryFrames();
if (!roverId) return null;
return frames[roverId] ?? null;
const store = useContext(TelemetryContext);
if (!store) {
throw new Error('useTelemetryFrame must be used within TelemetryProvider');
}
return useSyncExternalStore(
(listener) => store.subscribeRover(roverId, listener),
() => store.getFrame(roverId),
() => EMPTY_FRAME,
);
}
+10 -10
View File
@@ -73,9 +73,9 @@ export function ControlSystemProvider({ children }) {
typeof pageSettings?.driveMacroBackoffEnabled === 'boolean'
? pageSettings.driveMacroBackoffEnabled
: true;
const session = useSessionSelector((state) => state.session);
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const homeAssistantEntities = useSessionSelector((state) => state.session?.homeAssistant?.entities ?? []);
const { homeAssistantSetState } = useSessionActions();
const roverId = session?.assignment?.roverId ?? null;
const overcurrentLimiter = useOvercurrentLimiter(roverId);
const driveTransform = useCallback(
(speeds) => applyDriveOvercurrentScale(speeds, overcurrentLimiter.scales, overcurrentLimiter.adminImmune),
@@ -88,7 +88,7 @@ export function ControlSystemProvider({ children }) {
const pipeline = useCommandPipeline({ driveTransform, auxTransform });
const turnOnAllLights = useCallback(() => {
const entities = session?.homeAssistant?.entities || [];
const entities = homeAssistantEntities || [];
const targets = entities.filter(
(ent) =>
(ent.type === 'light' || ent.type === 'switch') &&
@@ -105,7 +105,7 @@ export function ControlSystemProvider({ children }) {
});
// Give the loop a chance; clear pending after issuing commands.
pendingLightsRef.current = false;
}, [session?.homeAssistant?.entities, homeAssistantSetState]);
}, [homeAssistantEntities, homeAssistantSetState]);
useEffect(() => {
dispatch({ type: 'control/set-rover', payload: pipeline.roverId });
@@ -114,18 +114,18 @@ export function ControlSystemProvider({ children }) {
useEffect(() => {
const prevMode = prevModeRef.current;
prevModeRef.current = state.mode;
if (prevMode !== 'drive' && state.mode === 'drive' && session?.homeAssistant?.entities) {
if (prevMode !== 'drive' && state.mode === 'drive' && homeAssistantEntities?.length) {
turnOnAllLights();
} else if (prevMode !== 'drive' && state.mode === 'drive') {
pendingLightsRef.current = true;
}
}, [state.mode, session?.homeAssistant?.entities, turnOnAllLights]);
}, [state.mode, homeAssistantEntities, turnOnAllLights]);
useEffect(() => {
if (pendingLightsRef.current && session?.homeAssistant?.entities) {
if (pendingLightsRef.current && homeAssistantEntities?.length) {
turnOnAllLights();
}
}, [session?.homeAssistant?.entities, turnOnAllLights]);
}, [homeAssistantEntities, turnOnAllLights]);
useEffect(() => {
const mergedKeymap = { ...DEFAULT_KEYMAP, ...(controlSettings?.keymap || {}) };
@@ -308,7 +308,7 @@ export function ControlSystemProvider({ children }) {
let macroToRun = macro;
if (macroId === 'drive-sequence') {
turnOnAllLights();
if (!session?.homeAssistant?.entities) {
if (!homeAssistantEntities?.length) {
pendingLightsRef.current = true;
}
recordControlIntent();
@@ -327,7 +327,7 @@ export function ControlSystemProvider({ children }) {
driveMacroBackoffEnabled,
pipeline,
recordControlIntent,
session?.homeAssistant?.entities,
homeAssistantEntities,
state.macros,
turnOnAllLights,
],
+5 -5
View File
@@ -16,13 +16,13 @@ import { bytesToBase64, clampRange, sleep } from './controlMath.js';
export function useCommandPipeline(options = {}) {
const { driveTransform, auxTransform } = options;
const socket = useSocket();
const session = useSessionSelector((state) => state.session);
const roverId = session?.assignment?.roverId;
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const roster = useSessionSelector((state) => state.session?.roster ?? []);
const rosterEntry = useMemo(() => {
if (!roverId || !Array.isArray(session?.roster)) return null;
return session.roster.find((entry) => String(entry.id) === String(roverId)) || null;
}, [roverId, session?.roster]);
if (!roverId || !Array.isArray(roster)) return null;
return roster.find((entry) => String(entry.id) === String(roverId)) || null;
}, [roverId, roster]);
const servoConfig = useMemo(() => {
if (!rosterEntry?.cameraServo || !rosterEntry.cameraServo.enabled) return null;
@@ -136,7 +136,7 @@ export default function KeyboardInputManager() {
sendSong,
},
} = useControlSystem();
const session = useSessionSelector((state) => state.session);
const homeAssistant = useSessionSelector((state) => state.session?.homeAssistant || null);
const { homeAssistantSetState } = useSessionActions();
const { focusChat, blurChat, isChatFocused } = useChat();
const { value: inputSettings } = useSettingsNamespace('inputs', INPUT_SETTINGS_DEFAULTS);
@@ -318,7 +318,7 @@ export default function KeyboardInputManager() {
const triggerHomeAssistantCycle = useCallback(
(targetState) => {
const ha = session?.homeAssistant;
const ha = homeAssistant;
if (!ha?.enabled || !ha?.connected) return;
if (ha?.lightPolicy?.locked || ha?.lightPolicy?.lockedOn) return;
const entities = ha.entities || [];
@@ -346,7 +346,7 @@ export default function KeyboardInputManager() {
}
}
},
[homeAssistantSetState, session?.homeAssistant],
[homeAssistantSetState, homeAssistant],
);
useEffect(() => {
+2 -5
View File
@@ -29,7 +29,7 @@ function clampUnit(value) {
}
export function useOvercurrentLimiter(roverId, options = {}) {
const session = useSessionSelector((state) => state.session);
const role = useSessionSelector((state) => state.session?.role || null);
const frame = useTelemetryFrame(roverId);
const sensors = frame?.sensors || {};
const overcurrentFlags = sensors?.wheelOvercurrents || {};
@@ -131,10 +131,7 @@ export function useOvercurrentLimiter(roverId, options = {}) {
return { motors, groups };
}, [overcurrentFlags]);
const adminImmune =
session?.role === 'admin' ||
session?.role === 'lockdown' ||
session?.role === 'lockdown-admin';
const adminImmune = role === 'admin' || role === 'lockdown' || role === 'lockdown-admin';
return useMemo(
() => ({
-14
View File
@@ -53,20 +53,6 @@ export function useDockIr(sensors, options = {}) {
}));
}, [sensors?.infraredCharacterLeft, sensors?.infraredCharacterRight, sensors?.infraredCharacterOmni]);
useEffect(() => {
// Debug: log when new codes appear
const { left, right, omni } = state;
const haveAny = left?.ts || right?.ts || omni?.ts;
if (!haveAny) return;
const stamp = new Date().toISOString();
// eslint-disable-next-line no-console
console.debug('[DockIR]', stamp, {
left: left?.code ?? 0,
omni: omni?.code ?? 0,
right: right?.code ?? 0,
});
}, [state.left?.code, state.right?.code, state.omni?.code]);
return useMemo(() => {
const now = Date.now();
const withWindow = (entry) => {
+4 -2
View File
@@ -16,6 +16,8 @@ export function useRoomCameraSnapshots(sourceList = [], options = {}) {
const idsRef = useRef([]);
const [connectionNonce, setConnectionNonce] = useState(0);
const statsRef = useRef(new Map());
const debugSnapshots =
typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('debugSnapshots');
useEffect(() => {
if (!socket) return undefined;
@@ -61,7 +63,7 @@ export function useRoomCameraSnapshots(sourceList = [], options = {}) {
totalBytes: prevStats.totalBytes + sizeBytes,
lastLogAt: prevStats.lastLogAt,
};
if (!nextStats.lastLogAt || now - nextStats.lastLogAt >= 10000) {
if (debugSnapshots && (!nextStats.lastLogAt || now - nextStats.lastLogAt >= 10000)) {
const avgBytes = nextStats.count ? nextStats.totalBytes / nextStats.count : 0;
console.log(
'[roomCamera]',
@@ -120,7 +122,7 @@ export function useRoomCameraSnapshots(sourceList = [], options = {}) {
objectUrls.current.forEach((url) => URL.revokeObjectURL(url));
objectUrls.current.clear();
};
}, [socket, idsKey, enabled, connectionNonce]);
}, [socket, idsKey, enabled, connectionNonce, debugSnapshots]);
return feeds;
}
+4 -2
View File
@@ -19,6 +19,8 @@ export function useRoverSnapshots(sourceList = [], options = {}) {
const idsRef = useRef([]);
const [connectionNonce, setConnectionNonce] = useState(0);
const statsRef = useRef(new Map());
const debugSnapshots =
typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('debugSnapshots');
useEffect(() => {
if (!socket) return undefined;
@@ -64,7 +66,7 @@ export function useRoverSnapshots(sourceList = [], options = {}) {
totalBytes: prevStats.totalBytes + sizeBytes,
lastLogAt: prevStats.lastLogAt,
};
if (!nextStats.lastLogAt || now - nextStats.lastLogAt >= 10000) {
if (debugSnapshots && (!nextStats.lastLogAt || now - nextStats.lastLogAt >= 10000)) {
const avgBytes = nextStats.count ? nextStats.totalBytes / nextStats.count : 0;
console.log(
'[roverSnapshot]',
@@ -123,7 +125,7 @@ export function useRoverSnapshots(sourceList = [], options = {}) {
objectUrls.current.forEach((url) => URL.revokeObjectURL(url));
objectUrls.current.clear();
};
}, [socket, idsKey, enabled, connectionNonce]);
}, [socket, idsKey, enabled, connectionNonce, debugSnapshots]);
return feeds;
}