improvements and bug fixes

This commit is contained in:
legop3
2026-06-29 20:19:24 -04:00
parent 02df826eb4
commit 79be3e1583
28 changed files with 164 additions and 233 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -78,8 +78,8 @@
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script> <script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script> <script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
<title>Roomba Rover</title> <title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-BmIqHXVL.js"></script> <script type="module" crossorigin src="/assets/index-B5yBezZ5.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DtN3aedy.css"> <link rel="stylesheet" crossorigin href="/assets/index-JqEX_oga.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+1 -1
View File
@@ -6,7 +6,7 @@ const io = require('../../globals/io');
const { getRole, roleEvents } = require('../roleService'); const { getRole, roleEvents } = require('../roleService');
const { getSocketIp } = require('../../helpers/ipResolver'); const { getSocketIp } = require('../../helpers/ipResolver');
const ADMIN_ROLES = new Set(['admin', 'lockdown', 'lockdown-admin']); const ADMIN_ROLES = new Set(['admin', 'lockdown']);
const MAX_HISTORY = 200; const MAX_HISTORY = 200;
const history = []; const history = [];
@@ -19,7 +19,7 @@ function formatWebhookUsername(payload) {
const name = payload.nickname || payload.socketId?.slice(0, 6) || 'unknown'; const name = payload.nickname || payload.socketId?.slice(0, 6) || 'unknown';
const botTag = payload.bot ? ' [BOT]' : ''; const botTag = payload.bot ? ' [BOT]' : '';
const spectatorTag = payload.role === 'spectator' && !payload.bot ? ' [SPECTATOR]' : ''; const spectatorTag = payload.role === 'spectator' && !payload.bot ? ' [SPECTATOR]' : '';
const adminTag = payload.role === 'admin' || payload.role === 'lockdown' || payload.role === 'lockdown-admin' ? ' [Rover Admin]' : ''; const adminTag = payload.role === 'admin' || payload.role === 'lockdown' ? ' [Rover Admin]' : '';
if (payload.fromDiscord) { if (payload.fromDiscord) {
const origin = payload.discordGuildName ? ` (From: ${payload.discordGuildName})` : ''; const origin = payload.discordGuildName ? ` (From: ${payload.discordGuildName})` : '';
return `${name}${origin}${botTag}${spectatorTag}${adminTag}`; return `${name}${origin}${botTag}${spectatorTag}${adminTag}`;
@@ -1,5 +1,5 @@
// Identity Admin Service // Identity Admin Service
// Purpose: Exposes lockdown-admin socket operations for inspecting and editing the central identity database. // Purpose: Exposes lockdown admin socket operations for inspecting and editing the central identity database.
// Scope: Owns transport-level authorization and delegates every database mutation to identityService. // Scope: Owns transport-level authorization and delegates every database mutation to identityService.
const io = require('../../globals/io'); const io = require('../../globals/io');
const logger = require('../../globals/logger').child('identityAdminService'); const logger = require('../../globals/logger').child('identityAdminService');
@@ -19,7 +19,7 @@ const MAX_FEATURE_STATE_JSON_BYTES = 64 * 1024;
function isLockdownAdminSocket(socket) { function isLockdownAdminSocket(socket) {
const role = getRole(socket); const role = getRole(socket);
return role === 'lockdown' || role === 'lockdown-admin'; return role === 'lockdown';
} }
function requireLockdownAdmin(socket) { function requireLockdownAdmin(socket) {
@@ -2,7 +2,7 @@
// Purpose: Provides pure helpers for admin state projection, role checks, and structured error normalization. // Purpose: Provides pure helpers for admin state projection, role checks, and structured error normalization.
// Scope: Keeps runtime behavior unchanged by extracting deterministic helper logic from index orchestration. // Scope: Keeps runtime behavior unchanged by extracting deterministic helper logic from index orchestration.
function isAdminRole(role) { function isAdminRole(role) {
return role === 'admin' || role === 'lockdown' || role === 'lockdown-admin'; return role === 'admin' || role === 'lockdown';
} }
function buildAdminState(status, runHistory) { function buildAdminState(status, runHistory) {
@@ -1,5 +1,5 @@
function isAdminRole(role) { function isAdminRole(role) {
return role === 'admin' || role === 'lockdown' || role === 'lockdown-admin'; return role === 'admin' || role === 'lockdown';
} }
function buildAdminState(status, runHistory) { function buildAdminState(status, runHistory) {
@@ -39,7 +39,6 @@ function createSidebarRenderer({ execFileAsync, ensureDir }) {
switch (String(role)) { switch (String(role)) {
case 'admin': case 'admin':
case 'lockdown': case 'lockdown':
case 'lockdown-admin':
return '#FCD34D'; return '#FCD34D';
case 'spectator': case 'spectator':
return '#94A3B8'; return '#94A3B8';
@@ -8,7 +8,7 @@ const { isAdmin, isLockdownAdmin, getRole } = require('../roleService');
const SUBSCRIBE_LIMIT = 50; const SUBSCRIBE_LIMIT = 50;
const SUBSCRIBE_WINDOW_MS = 10000; const SUBSCRIBE_WINDOW_MS = 10000;
const STREAM_INTERVAL_MS = 1000; const STREAM_INTERVAL_MS = 2000;
function passesMode(socket) { function passesMode(socket) {
const mode = getMode(); const mode = getMode();
@@ -43,7 +43,7 @@ function normalizeKnownIps(raw = []) {
} }
function isAdminRole(role) { function isAdminRole(role) {
return role === 'admin' || role === 'lockdown' || role === 'lockdown-admin'; return role === 'admin' || role === 'lockdown';
} }
function parseDeterrenceSelector(selector) { function parseDeterrenceSelector(selector) {
@@ -42,7 +42,7 @@ function emitChange(reason, payload = {}) {
} }
function isAdminRole(role) { function isAdminRole(role) {
return role === 'admin' || role === 'lockdown' || role === 'lockdown-admin'; return role === 'admin' || role === 'lockdown';
} }
function refreshSocketIdentityFlags(socket) { function refreshSocketIdentityFlags(socket) {
+3 -7
View File
@@ -1,10 +1,6 @@
1. fix rover request spam queue cheat 1. maybe add better camera control? a complete rework or maybe just a precision mode
2. remove dockIR stuff from webui 2. improve spectator page, options on what to see and what not to see
3. maybe add better camera control? a complete rework or maybe just a precision mode 3. fix this:
4. improve spectator page, options on what to see and what not to see
5. make it so the room camera panel stops when its out of view for bandwidth
6. clean up lockdown-admin or statements everywhere
7. fix this:
`Jun 18 15:14:18 roombaserver.local node[216731]: /home/daniel/MultiRoombaRover/server/src/services/roverManager/socketHandlers.js:92 `Jun 18 15:14:18 roombaserver.local node[216731]: /home/daniel/MultiRoombaRover/server/src/services/roverManager/socketHandlers.js:92
Jun 18 15:14:18 roombaserver.local node[216731]: cb({ error: err.message }); Jun 18 15:14:18 roombaserver.local node[216731]: cb({ error: err.message });
Jun 18 15:14:18 roombaserver.local node[216731]: ^ Jun 18 15:14:18 roombaserver.local node[216731]: ^
@@ -61,9 +61,8 @@ export default function AdminPanelContent() {
const isAdmin = const isAdmin =
session?.role === 'admin' || session?.role === 'admin' ||
session?.role === 'lockdown' || session?.role === 'lockdown';
session?.role === 'lockdown-admin'; const isLockdownAdmin = session?.role === 'lockdown';
const isLockdownAdmin = session?.role === 'lockdown' || session?.role === 'lockdown-admin';
const currentMode = session?.mode ?? 'open'; const currentMode = session?.mode ?? 'open';
@@ -10,7 +10,6 @@ function roleColors(role) {
switch (role) { switch (role) {
case 'admin': case 'admin':
case 'lockdown': case 'lockdown':
case 'lockdown-admin':
return 'text-amber-300'; return 'text-amber-300';
case 'spectator': case 'spectator':
return 'text-slate-400'; return 'text-slate-400';
@@ -151,7 +150,7 @@ function chatRowClass(message, variant = 'message') {
return 'flex flex-col gap-0.5 rounded-md border border-emerald-500/40 bg-emerald-950 px-0.5 py-0.5 text-sm text-neutral-100'; return 'flex flex-col gap-0.5 rounded-md border border-emerald-500/40 bg-emerald-950 px-0.5 py-0.5 text-sm text-neutral-100';
} }
const isAdmin = const isAdmin =
message.role === 'admin' || message.role === 'lockdown' || message.role === 'lockdown-admin'; message.role === 'admin' || message.role === 'lockdown';
return `flex flex-col gap-0.5 rounded-md bg-neutral-800 px-0.5 py-0.5 text-sm text-neutral-100 ${ return `flex flex-col gap-0.5 rounded-md bg-neutral-800 px-0.5 py-0.5 text-sm text-neutral-100 ${
isAdmin isAdmin
? 'border border-amber-400/30' ? 'border border-amber-400/30'
@@ -8,8 +8,8 @@ import { useSharedClock } from '../../hooks/useSharedClock.js';
import SocialButton from '../SocialButton/index.jsx'; import SocialButton from '../SocialButton/index.jsx';
import ChatPanel from '../ChatPanel/index.jsx'; import ChatPanel from '../ChatPanel/index.jsx';
const PRIVILEGED_ROLES = new Set(['admin', 'lockdown', 'lockdown-admin']); const PRIVILEGED_ROLES = new Set(['admin', 'lockdown']);
const LOCKDOWN_ROLES = new Set(['lockdown', 'lockdown-admin']); const LOCKDOWN_ROLES = new Set(['lockdown']);
const RESTRICTED_MODES = new Set(['admin', 'lockdown']); const RESTRICTED_MODES = new Set(['admin', 'lockdown']);
function getModeDetails(mode = 'admin') { function getModeDetails(mode = 'admin') {
@@ -11,7 +11,6 @@ function roleColors(role) {
switch (role) { switch (role) {
case 'admin': case 'admin':
case 'lockdown': case 'lockdown':
case 'lockdown-admin':
return 'text-amber-300'; return 'text-amber-300';
case 'spectator': case 'spectator':
return 'text-slate-400'; return 'text-slate-400';
+102 -13
View File
@@ -1,7 +1,7 @@
// Room Camera Panel // Room Camera Panel
// 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, useMemo, useRef, useState } from 'react';
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 { useRoomCameraSnapshots } from '../../hooks/useRoomCameraSnapshots.js'; import { useRoomCameraSnapshots } from '../../hooks/useRoomCameraSnapshots.js';
@@ -18,6 +18,7 @@ function EmptyState() {
} }
const ORIENTATIONS = ['horizontal', 'vertical']; const ORIENTATIONS = ['horizontal', 'vertical'];
const CAMERA_VISIBILITY_ROOT_MARGIN = '0px';
function normalizeOrientation(value, fallback) { function normalizeOrientation(value, fallback) {
if (ORIENTATIONS.includes(value)) { if (ORIENTATIONS.includes(value)) {
@@ -26,6 +27,82 @@ function normalizeOrientation(value, fallback) {
return fallback; return fallback;
} }
function useCameraPanelSubscriptionGate() {
const panelRef = useRef(null);
const [isPanelVisible, setIsPanelVisible] = useState(false);
useEffect(() => {
/*
Browser visibility can only be measured after React has mounted a real DOM
node. Starting closed avoids a short subscribe/unsubscribe burst for room
camera panels that mount below the fold.
*/
const panel = panelRef.current;
if (!panel || typeof window === 'undefined' || typeof document === 'undefined') {
return undefined;
}
let intersectsViewport = true;
const publishVisibility = () => {
/*
Server upload bandwidth is only saved when the socket subscription is
actually disabled. Combining viewport intersection with page visibility
means a scrolled-away panel and a hidden browser tab both stop receiving
room-camera frame uploads from socketGateway.js.
*/
setIsPanelVisible(intersectsViewport && document.visibilityState !== 'hidden');
};
const handlePageVisibilityChange = () => {
/*
IntersectionObserver is about layout visibility, while the Page
Visibility API is about whether the tab itself can be seen. The latter
matters here because a background tab can still keep a mounted panel and
socket alive unless we explicitly close the subscription gate.
*/
publishVisibility();
};
document.addEventListener('visibilitychange', handlePageVisibilityChange);
if (typeof IntersectionObserver !== 'function') {
/*
Without IntersectionObserver we cannot cheaply know whether the panel is
clipped by a scroll container. Fall back to page visibility so browsers
without the observer still behave correctly, just without scroll-based
bandwidth savings.
*/
publishVisibility();
return () => {
document.removeEventListener('visibilitychange', handlePageVisibilityChange);
};
}
const observer = new IntersectionObserver(
([entry]) => {
/*
A zero root margin keeps the bandwidth gate strict: the browser only
subscribes once the panel intersects the viewport. If the first frame
feels too delayed in real use, this constant can be widened later.
*/
intersectsViewport = Boolean(entry?.isIntersecting);
publishVisibility();
},
{ root: null, rootMargin: CAMERA_VISIBILITY_ROOT_MARGIN, threshold: 0 },
);
observer.observe(panel);
return () => {
observer.disconnect();
document.removeEventListener('visibilitychange', handlePageVisibilityChange);
};
}, []);
return { panelRef, isPanelVisible };
}
export default function RoomCameraPanel({ export default function RoomCameraPanel({
defaultOrientation = 'horizontal', defaultOrientation = 'horizontal',
orientation: forcedOrientation, orientation: forcedOrientation,
@@ -34,7 +111,12 @@ export default function RoomCameraPanel({
panelId = null, panelId = null,
}) { }) {
const cameras = useSessionSelector((state) => state.session?.roomCameras || []); const cameras = useSessionSelector((state) => state.session?.roomCameras || []);
const feedMap = useRoomCameraSnapshots(cameras.map((camera) => ({ id: camera.id }))); const cameraIds = useMemo(
() => cameras.map((camera) => camera.id),
[cameras],
);
const { panelRef, isPanelVisible } = useCameraPanelSubscriptionGate();
const feedMap = useRoomCameraSnapshots(cameraIds, { enabled: isPanelVisible });
const { value: orientationSettings, save: saveOrientationSettings } = useSettingsNamespace('roomCameraPanels', {}); const { value: orientationSettings, save: saveOrientationSettings } = useSettingsNamespace('roomCameraPanels', {});
const [orientation, setOrientation] = useState(() => const [orientation, setOrientation] = useState(() =>
normalizeOrientation( normalizeOrientation(
@@ -42,17 +124,19 @@ export default function RoomCameraPanel({
'horizontal', 'horizontal',
), ),
); );
const storedOrientation = panelId ? orientationSettings?.[panelId] : null;
useEffect(() => {
if (!panelId) return;
const stored = orientationSettings?.[panelId];
if (!stored) return;
setOrientation(normalizeOrientation(stored, 'horizontal'));
// only respond to changes for this panel id
}, [panelId, orientationSettings?.[panelId]]);
const effectiveOrientation = forcedOrientation const effectiveOrientation = forcedOrientation
? normalizeOrientation(forcedOrientation, 'horizontal') ? normalizeOrientation(forcedOrientation, 'horizontal')
: orientation; : normalizeOrientation(
/*
Settings are external state, so derive from them during render instead
of mirroring them into local state from an effect. Local state remains
useful as the immediate value after clicking the layout toggle, while
stored settings win once the settings provider has loaded or saved.
*/
storedOrientation || orientation,
'horizontal',
);
const containerClass = const containerClass =
effectiveOrientation === 'vertical' ? 'flex flex-col gap-0.5' : 'grid gap-0.5 md:grid-cols-2'; effectiveOrientation === 'vertical' ? 'flex flex-col gap-0.5' : 'grid gap-0.5 md:grid-cols-2';
const showLayoutToggle = !hideLayoutToggle && !forcedOrientation && cameras.length > 0; const showLayoutToggle = !hideLayoutToggle && !forcedOrientation && cameras.length > 0;
@@ -64,7 +148,11 @@ export default function RoomCameraPanel({
}; };
if (cameras.length === 0) { if (cameras.length === 0) {
return <EmptyState />; return (
<div ref={panelRef}>
<EmptyState />
</div>
);
} }
const actions = showLayoutToggle ? ( const actions = showLayoutToggle ? (
@@ -86,9 +174,9 @@ export default function RoomCameraPanel({
) : null; ) : null;
return ( return (
<div ref={panelRef}>
<CardFrame <CardFrame
title="Room cameras" title="Room cameras"
actions={actions} actions={actions}
hideHeader={hideHeader} hideHeader={hideHeader}
bodyClassName="space-y-0.5 text-base" bodyClassName="space-y-0.5 text-base"
@@ -108,5 +196,6 @@ export default function RoomCameraPanel({
})} })}
</div> </div>
</CardFrame> </CardFrame>
</div>
); );
} }
@@ -29,7 +29,6 @@ function roleColors(role) {
switch (role) { switch (role) {
case 'admin': case 'admin':
case 'lockdown': case 'lockdown':
case 'lockdown-admin':
return 'text-amber-300'; return 'text-amber-300';
case 'spectator': case 'spectator':
return 'text-slate-400'; return 'text-slate-400';
@@ -66,7 +65,7 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
const canRequest = useMemo(() => role && role !== 'spectator', [role]); const canRequest = useMemo(() => role && role !== 'spectator', [role]);
const adminCapable = useMemo( const adminCapable = useMemo(
() => role === 'admin' || role === 'lockdown' || role === 'lockdown-admin', () => role === 'admin' || role === 'lockdown',
[role], [role],
); );
const hasDeadlines = useMemo( const hasDeadlines = useMemo(
+2 -55
View File
@@ -4,14 +4,12 @@
import { useSessionSelector } from '../../context/SessionContext.jsx'; import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useVisualTelemetrySelector } from '../../context/TelemetryContext.jsx'; import { useVisualTelemetrySelector } from '../../context/TelemetryContext.jsx';
import { selectFrameForDisplay } from '../../context/telemetryViews.js'; import { selectFrameForDisplay } from '../../context/telemetryViews.js';
import { useDockIr } from '../../hooks/useDockIr.js';
import CardFrame from '../CardFrame/index.jsx'; import CardFrame from '../CardFrame/index.jsx';
export default function TelemetryPanel() { export default function TelemetryPanel() {
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null); const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const frame = useVisualTelemetrySelector(roverId, selectFrameForDisplay); const frame = useVisualTelemetrySelector(roverId, selectFrameForDisplay);
const sensors = frame?.sensors || {}; const sensors = frame?.sensors || {};
const dockIr = useDockIr(sensors);
const voltage = sensors.voltageMv != null ? `${(sensors.voltageMv / 1000).toFixed(2)} V` : null; const voltage = sensors.voltageMv != null ? `${(sensors.voltageMv / 1000).toFixed(2)} V` : null;
const current = sensors.currentMa != null ? `${sensors.currentMa} mA` : null; const current = sensors.currentMa != null ? `${sensors.currentMa} mA` : null;
const batteryTemp = sensors.batteryTemperatureC != null ? `${sensors.batteryTemperatureC} °C` : null; const batteryTemp = sensors.batteryTemperatureC != null ? `${sensors.batteryTemperatureC} °C` : null;
@@ -35,7 +33,7 @@ export default function TelemetryPanel() {
) : ( ) : (
<> <>
<TelemetrySummary sensors={sensors} voltage={voltage} current={current} batteryTemp={batteryTemp} charge={charge} capacity={capacity} /> <TelemetrySummary sensors={sensors} voltage={voltage} current={current} batteryTemp={batteryTemp} charge={charge} capacity={capacity} />
<SensorDetails sensors={sensors} dockState={dockIr} /> <SensorDetails sensors={sensors} />
</> </>
)} )}
{rawSnippet && ( {rawSnippet && (
@@ -75,7 +73,7 @@ function Metric({ label, value }) {
); );
} }
function SensorDetails({ sensors, dockState }) { function SensorDetails({ sensors }) {
const bumps = sensors?.bumpsAndWheelDrops || {}; const bumps = sensors?.bumpsAndWheelDrops || {};
const over = sensors?.wheelOvercurrents || {}; const over = sensors?.wheelOvercurrents || {};
@@ -104,11 +102,6 @@ function SensorDetails({ sensors, dockState }) {
return ( return (
<div className="grid gap-0.5 md:grid-cols-2"> <div className="grid gap-0.5 md:grid-cols-2">
<DetailCard title="Dock IR">
<DockMiniStatus sensors={sensors} />
<DockDebug state={dockState} />
</DetailCard>
<DetailCard title="Bumps & drops"> <DetailCard title="Bumps & drops">
<ValueRow label="Bump L" value={<Pill active={bumps.bumpLeft} />} /> <ValueRow label="Bump L" value={<Pill active={bumps.bumpLeft} />} />
<ValueRow label="Bump R" value={<Pill active={bumps.bumpRight} />} /> <ValueRow label="Bump R" value={<Pill active={bumps.bumpRight} />} />
@@ -190,49 +183,3 @@ function formatNumber(value, unit) {
if (value == null || Number.isNaN(value)) return '--'; if (value == null || Number.isNaN(value)) return '--';
return unit ? `${value} ${unit}` : value; return unit ? `${value} ${unit}` : value;
} }
function DockMiniStatus({ sensors }) {
const left = sensors?.infraredCharacterLeft;
const right = sensors?.infraredCharacterRight;
const omni = sensors?.infraredCharacterOmni;
const badge = (code) => (
<span className={`rounded px-1 py-0.25 text-[0.7rem] font-semibold ${code ? 'bg-emerald-600 text-white' : 'bg-slate-700 text-slate-300'}`}>
{code || '--'}
</span>
);
return (
<div className="flex items-center justify-between gap-0.5 text-xs text-slate-200">
<div className="flex items-center gap-0.5">
<span className="text-slate-400">L</span>
{badge(left)}
</div>
<div className="flex items-center gap-0.5">
<span className="text-slate-400">O</span>
{badge(omni)}
</div>
<div className="flex items-center gap-0.5">
<span className="text-slate-400">R</span>
{badge(right)}
</div>
</div>
);
}
function DockDebug({ state }) {
if (!state) return null;
const label = (entry) => {
const parts = [];
if (entry?.red) parts.push('R');
if (entry?.green) parts.push('G');
if (entry?.force) parts.push('F');
return parts.length ? parts.join('') : 'none';
};
const age = (entry) => (entry?.age != null ? `${entry.age}ms` : '--');
return (
<div className="text-[0.7rem] text-slate-400">
<div>{`Left: ${state.left.code ?? '--'} (${label(state.left)}) · age ${age(state.left)}`}</div>
<div>{`Omni: ${state.omni.code ?? '--'} (${label(state.omni)}) · age ${age(state.omni)}`}</div>
<div>{`Right: ${state.right.code ?? '--'} (${label(state.right)}) · age ${age(state.right)}`}</div>
</div>
);
}
+2 -3
View File
@@ -30,7 +30,6 @@ function roleColors(role) {
switch (role) { switch (role) {
case 'admin': case 'admin':
case 'lockdown': case 'lockdown':
case 'lockdown-admin':
return 'text-amber-300'; return 'text-amber-300';
case 'spectator': case 'spectator':
return 'text-slate-400'; return 'text-slate-400';
@@ -120,7 +119,7 @@ export default function UserListPanel({
) : ( ) : (
sorted.map((user) => { sorted.map((user) => {
const isAdmin = const isAdmin =
user.role === 'admin' || user.role === 'lockdown' || user.role === 'lockdown-admin'; user.role === 'admin' || user.role === 'lockdown';
return ( return (
<div <div
key={user.socketId} key={user.socketId}
@@ -229,7 +228,7 @@ export default function UserListPanel({
const isNext = Boolean(nextId && socketId === nextId && !isCurrent); const isNext = Boolean(nextId && socketId === nextId && !isCurrent);
const isSelf = Boolean(selfId && socketId === selfId); const isSelf = Boolean(selfId && socketId === selfId);
const isAdmin = const isAdmin =
user.role === 'admin' || user.role === 'lockdown' || user.role === 'lockdown-admin'; user.role === 'admin' || user.role === 'lockdown';
const highlightClass = isCurrent const highlightClass = isCurrent
? 'bg-sky-600 text-white ring-2 ring-amber-300 animate-pulse' ? 'bg-sky-600 text-white ring-2 ring-amber-300 animate-pulse'
: isNext : isNext
+1 -1
View File
@@ -130,7 +130,7 @@ export function useOvercurrentLimiter(roverId, options = {}) {
return { motors, groups }; return { motors, groups };
}, [overcurrentFlags]); }, [overcurrentFlags]);
const adminImmune = role === 'admin' || role === 'lockdown' || role === 'lockdown-admin'; const adminImmune = role === 'admin' || role === 'lockdown';
return useMemo( return useMemo(
() => ({ () => ({
+2 -2
View File
@@ -1,5 +1,5 @@
// Database Admin App // Database Admin App
// Purpose: Provides the dedicated /database route for lockdown-admin identity database management. // Purpose: Provides the dedicated /database route for lockdown admin identity database management.
// Scope: Handles route-level identity sync, access gating, and composition of the self-contained database panel. // Scope: Handles route-level identity sync, access gating, and composition of the self-contained database panel.
import AuthPanel from '../components/AuthPanel/index.jsx'; import AuthPanel from '../components/AuthPanel/index.jsx';
import CardFrame from '../components/CardFrame/index.jsx'; import CardFrame from '../components/CardFrame/index.jsx';
@@ -10,7 +10,7 @@ import { pageBackgroundClass } from '../themeFlags.js';
import IdentityDatabasePanel from './IdentityDatabasePanel.jsx'; import IdentityDatabasePanel from './IdentityDatabasePanel.jsx';
function isLockdownAdminRole(role) { function isLockdownAdminRole(role) {
return role === 'lockdown' || role === 'lockdown-admin'; return role === 'lockdown';
} }
export default function DatabaseAdminApp() { export default function DatabaseAdminApp() {
+1 -1
View File
@@ -1,5 +1,5 @@
// Identity Database Panel // Identity Database Panel
// Purpose: Implements the lockdown-admin identity database editor UI for the /database route. // Purpose: Implements the lockdown admin identity database editor UI for the /database route.
// Scope: Keeps list, detail, signal, status, feature-state, and raw JSON editing local to this feature. // Scope: Keeps list, detail, signal, status, feature-state, and raw JSON editing local to this feature.
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import CardFrame from '../components/CardFrame/index.jsx'; import CardFrame from '../components/CardFrame/index.jsx';
-95
View File
@@ -1,95 +0,0 @@
// Hook: useDockIr
// Purpose: Tracks dock IR telemetry/status values for docking-related UI indicators. Scope: Converts telemetry feed updates into component-friendly reactive state.
import { useEffect, useMemo, useState } from 'react';
const HOLD_MS = 650;
const DOCK_CODES = {
161: { red: true }, // Red buoy
164: { green: true }, // Green buoy
165: { force: true }, // Force field only
168: { red: true, green: true }, // Red + green
169: { red: true, force: true }, // Red + force
172: { green: true, force: true }, // Green + force
173: { red: true, green: true, force: true }, // Red + green + force
};
function decodeDockBits(code) {
if (typeof code !== 'number' || code <= 0) return null;
const bits = DOCK_CODES[code];
if (!bits) return null;
return { code, ...bits };
}
/**
* Returns a smoothed view of dock IR signals. Keeps the last seen code alive for HOLD_MS to mask blinks.
*/
export function useDockIr(sensors, options = {}) {
const holdMs = options.holdMs || HOLD_MS;
const [state, setState] = useState({
left: null,
right: null,
omni: null,
});
const [tick, setTick] = useState(0);
useEffect(() => {
const id = setInterval(() => setTick(Date.now()), Math.max(holdMs / 2, 100));
return () => clearInterval(id);
}, [holdMs]);
useEffect(() => {
const now = Date.now();
const left = decodeDockBits(sensors?.infraredCharacterLeft);
const right = decodeDockBits(sensors?.infraredCharacterRight);
const omni = decodeDockBits(sensors?.infraredCharacterOmni);
if (!left && !right && !omni) {
return;
}
setState((prev) => ({
left: left ? { ...left, ts: now } : prev.left,
right: right ? { ...right, ts: now } : prev.right,
omni: omni ? { ...omni, ts: now } : prev.omni,
}));
}, [sensors?.infraredCharacterLeft, sensors?.infraredCharacterRight, sensors?.infraredCharacterOmni]);
return useMemo(() => {
const now = Date.now();
const withWindow = (entry) => {
if (!entry) return { active: false, red: false, green: false, force: false, age: null, code: null };
const age = now - entry.ts;
const active = age <= holdMs;
return {
active,
age,
code: entry.code,
red: Boolean(entry.red && active),
green: Boolean(entry.green && active),
force: Boolean(entry.force && active),
};
};
const left = withWindow(state.left);
const right = withWindow(state.right);
const omni = withWindow(state.omni);
const forceDetected = left.force || right.force || omni.force;
const visible = left.active || right.active || omni.active;
const bias =
left.active && !right.active
? 'right'
: right.active && !left.active
? 'left'
: 'center';
const balance = (right.active ? 1 : 0) - (left.active ? 1 : 0); // positive means steer left-to-right
return {
visible,
forceDetected,
left,
right,
omni,
bias, // left/right/center for steering cue
balance: Math.max(-1, Math.min(1, balance)),
};
}, [state.left, state.right, state.omni, holdMs, tick]);
}