mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
pass 1 (broken)
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -11,7 +11,7 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||||
<title>Multi Roomba Rover</title>
|
<title>Multi Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-BhTaDZYU.js"></script>
|
<script type="module" crossorigin src="/assets/index-CduNi74O.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CGvUXLso.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CGvUXLso.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+3
-2
@@ -93,6 +93,7 @@ function MobileFeatureTabs({
|
|||||||
roomPanelId,
|
roomPanelId,
|
||||||
showTelemetry = true,
|
showTelemetry = true,
|
||||||
}) {
|
}) {
|
||||||
|
const [activeTab, setActiveTab] = useState('chat');
|
||||||
const session = useSessionSelector((state) => state.session);
|
const session = useSessionSelector((state) => state.session);
|
||||||
const { state: controlState } = useControlSystem();
|
const { state: controlState } = useControlSystem();
|
||||||
const { value: vipAudio } = useSettingsNamespace('vipAudio', { openMicEnabled: false, pttMode: 'live' });
|
const { value: vipAudio } = useSettingsNamespace('vipAudio', { openMicEnabled: false, pttMode: 'live' });
|
||||||
@@ -116,7 +117,7 @@ function MobileFeatureTabs({
|
|||||||
);
|
);
|
||||||
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'}>
|
||||||
@@ -141,7 +142,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">
|
||||||
|
|||||||
@@ -2,14 +2,14 @@
|
|||||||
// 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 session = useSessionSelector((state) => state.session);
|
||||||
const goalText = session?.globalObjective?.text ? String(session.globalObjective.text).trim() : '';
|
const goalText = session?.globalObjective?.text ? String(session.globalObjective.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);
|
||||||
|
|||||||
@@ -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>{' '}
|
||||||
|
|||||||
@@ -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,7 +35,7 @@ export default function RawUserPilePanel({
|
|||||||
fillHeight = false,
|
fillHeight = false,
|
||||||
compact = false,
|
compact = false,
|
||||||
}) {
|
}) {
|
||||||
const { session } = useSession();
|
const session = useSessionSelector((state) => state.session);
|
||||||
const canSetNickname = session?.role !== 'spectator';
|
const canSetNickname = session?.role !== 'spectator';
|
||||||
const users = session?.users ?? [];
|
const users = session?.users ?? [];
|
||||||
const selfId = session?.socketId || null;
|
const selfId = session?.socketId || null;
|
||||||
|
|||||||
@@ -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,6 +116,7 @@ function DriveDockPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
|
export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
|
||||||
|
const [activeTab, setActiveTab] = useState('telemetry');
|
||||||
const session = useSessionSelector((state) => state.session);
|
const session = useSessionSelector((state) => state.session);
|
||||||
const { state: controlState } = useControlSystem();
|
const { state: controlState } = useControlSystem();
|
||||||
const { value: vipAudio } = useSettingsNamespace('vipAudio', { openMicEnabled: false, pttMode: 'live' });
|
const { value: vipAudio } = useSettingsNamespace('vipAudio', { openMicEnabled: false, pttMode: 'live' });
|
||||||
@@ -138,7 +140,7 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
|
|||||||
);
|
);
|
||||||
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'}>
|
||||||
@@ -179,7 +181,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,7 +32,7 @@ export default function RoomCameraPanel({
|
|||||||
hideHeader = false,
|
hideHeader = false,
|
||||||
panelId = null,
|
panelId = null,
|
||||||
}) {
|
}) {
|
||||||
const { session } = useSession();
|
const session = useSessionSelector((state) => state.session);
|
||||||
const cameras = 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', {});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -100,10 +100,18 @@ export default function VideoTile({
|
|||||||
.map(([key]) => key);
|
.map(([key]) => key);
|
||||||
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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user