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