mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
258 lines
10 KiB
React
258 lines
10 KiB
React
// Telemetry Panel
|
|
// Purpose: Defines the Telemetry Panel module and the local helpers/components used in this file.
|
|
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
|
import { useMemo } from 'react';
|
|
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
|
import { useTelemetryFrame } from '../../context/TelemetryContext.jsx';
|
|
import { useDockIr } from '../../hooks/useDockIr.js';
|
|
|
|
function formatMetric(value, fallback = '--') {
|
|
if (value == null || value === '') return fallback;
|
|
return value;
|
|
}
|
|
|
|
export default function TelemetryPanel() {
|
|
const connected = useSessionSelector((state) => state.connected);
|
|
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
|
const activeDriverId = useSessionSelector((state) => {
|
|
const id = state.session?.assignment?.roverId;
|
|
return id ? state.session?.activeDrivers?.[id] || null : null;
|
|
});
|
|
const selfSocketId = useSessionSelector((state) => state.session?.socketId || null);
|
|
const users = useSessionSelector((state) => state.session?.users ?? []);
|
|
const frame = useTelemetryFrame(roverId);
|
|
const sensors = frame?.sensors || {};
|
|
const dockIr = useDockIr(sensors);
|
|
const voltage = sensors.voltageMv != null ? `${(sensors.voltageMv / 1000).toFixed(2)} V` : null;
|
|
const current = sensors.currentMa != null ? `${sensors.currentMa} mA` : null;
|
|
const batteryTemp = sensors.batteryTemperatureC != null ? `${sensors.batteryTemperatureC} °C` : null;
|
|
const charge = sensors.batteryChargeMah;
|
|
const capacity = sensors.batteryCapacityMah;
|
|
const updated = frame?.receivedAt ? new Date(frame.receivedAt).toLocaleTimeString() : null;
|
|
const rawSnippet = frame?.raw ? frame.raw : null;
|
|
const driverLabel = useMemo(() => {
|
|
if (!roverId) return 'n/a';
|
|
if (!activeDriverId) return 'Available';
|
|
if (activeDriverId === selfSocketId) return 'You';
|
|
const user = users.find((entry) => entry.socketId === activeDriverId);
|
|
return user?.nickname || activeDriverId.slice(0, 6);
|
|
}, [activeDriverId, roverId, selfSocketId, users]);
|
|
|
|
return (
|
|
<section className="panel-section space-y-0.5 text-base text-slate-100">
|
|
{/* <div className="text-sm text-slate-400">
|
|
<span>{connected ? 'online' : 'offline'}</span>
|
|
<span> · role {session?.role || 'unknown'}</span>
|
|
<span> · mode {session?.mode || '--'}</span>
|
|
{updated && <span> · sensors {updated}</span>}
|
|
<span> · driver {driverLabel}</span>
|
|
</div> */}
|
|
{!roverId ? (
|
|
<p className="text-sm text-slate-500">Assign a rover to view sensors.</p>
|
|
) : !frame ? (
|
|
<p className="text-sm text-slate-500">Waiting for sensor frames…</p>
|
|
) : (
|
|
<>
|
|
<TelemetrySummary sensors={sensors} voltage={voltage} current={current} batteryTemp={batteryTemp} charge={charge} capacity={capacity} />
|
|
<SensorDetails sensors={sensors} dockState={dockIr} />
|
|
</>
|
|
)}
|
|
{rawSnippet && (
|
|
<pre className="surface whitespace-pre-wrap break-words text-xs text-lime-300">{rawSnippet}</pre>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function TelemetrySummary({ sensors, voltage, current, batteryTemp, charge, capacity }) {
|
|
const chargePct = charge != null && capacity ? `${Math.round((charge / capacity) * 100)}` : '--';
|
|
const oiMode = sensors?.oiMode?.label || '--';
|
|
const docked = sensors?.chargingSources?.homeBase ? 'Yes' : 'No';
|
|
const charging = sensors?.chargingState?.label || '--';
|
|
|
|
return (
|
|
<div className="grid grid-cols-2 gap-0.5 md:grid-cols-3">
|
|
<Metric label="Voltage" value={voltage ?? '--'} />
|
|
<Metric label="Current" value={current ?? '--'} />
|
|
<Metric label="Battery temp" value={batteryTemp ?? '--'} />
|
|
<Metric label="Charge" value={charge != null ? `${charge} mAh` : '--'} />
|
|
<Metric label="Capacity" value={capacity != null ? `${capacity} mAh` : '--'} />
|
|
<Metric label="Charge" value={chargePct} />
|
|
<Metric label="OI mode" value={oiMode} />
|
|
<Metric label="Docked" value={docked} />
|
|
<Metric label="Charging state" value={charging} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Metric({ label, value }) {
|
|
return (
|
|
<div className="surface flex items-center justify-between gap-0.5 px-1 py-0.5 text-sm">
|
|
<span className="text-slate-400">{label}</span>
|
|
<span className="text-slate-100">{value}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SensorDetails({ sensors, dockState }) {
|
|
const bumps = sensors?.bumpsAndWheelDrops || {};
|
|
const over = sensors?.wheelOvercurrents || {};
|
|
|
|
const lightSignals = [
|
|
{ label: 'Left', value: sensors?.lightBumpLeftSignal },
|
|
{ label: 'Front Left', value: sensors?.lightBumpFrontLeftSignal },
|
|
{ label: 'Center Left', value: sensors?.lightBumpCenterLeftSignal },
|
|
{ label: 'Center Right', value: sensors?.lightBumpCenterRightSignal },
|
|
{ label: 'Front Right', value: sensors?.lightBumpFrontRightSignal },
|
|
{ label: 'Right', value: sensors?.lightBumpRightSignal },
|
|
];
|
|
|
|
const cliffSignals = [
|
|
{ label: 'Left', value: sensors?.cliffLeftSignal, active: sensors?.cliffLeft },
|
|
{ label: 'Front Left', value: sensors?.cliffFrontLeftSignal, active: sensors?.cliffFrontLeft },
|
|
{ label: 'Front Right', value: sensors?.cliffFrontRightSignal, active: sensors?.cliffFrontRight },
|
|
{ label: 'Right', value: sensors?.cliffRightSignal, active: sensors?.cliffRight },
|
|
];
|
|
|
|
const currents = [
|
|
{ label: 'Wheel L', value: sensors?.wheelLeftCurrentMa, over: over.leftWheel },
|
|
{ label: 'Wheel R', value: sensors?.wheelRightCurrentMa, over: over.rightWheel },
|
|
{ label: 'Side brush', value: sensors?.sideBrushCurrentMa, over: over.sideBrush },
|
|
{ label: 'Main brush', value: sensors?.mainBrushCurrentMa, over: over.mainBrush },
|
|
];
|
|
|
|
return (
|
|
<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">
|
|
<ValueRow label="Bump L" value={<Pill active={bumps.bumpLeft} />} />
|
|
<ValueRow label="Bump R" value={<Pill active={bumps.bumpRight} />} />
|
|
<ValueRow label="Wheel drop L" value={<Pill active={bumps.wheelDropLeft} tone="amber" />} />
|
|
<ValueRow label="Wheel drop R" value={<Pill active={bumps.wheelDropRight} tone="amber" />} />
|
|
</DetailCard>
|
|
|
|
<DetailCard title="Light bumps (raw)">
|
|
{lightSignals.map((entry) => (
|
|
<ValueRow key={entry.label} label={entry.label} value={formatNumber(entry.value)} />
|
|
))}
|
|
</DetailCard>
|
|
|
|
<DetailCard title="Cliff sensors">
|
|
{cliffSignals.map((entry) => (
|
|
<ValueRow key={entry.label} label={entry.label} value={`${formatNumber(entry.value)} · ${entry.active ? 'bool: true' : 'bool: false'}`} />
|
|
))}
|
|
</DetailCard>
|
|
|
|
<DetailCard title="Currents">
|
|
{currents.map((entry) => (
|
|
<ValueRow
|
|
key={entry.label}
|
|
label={entry.label}
|
|
value={
|
|
<span className="flex items-center gap-0.5">
|
|
<span>{formatNumber(entry.value, 'mA')}</span>
|
|
{entry.over ? <Pill active tone="red" label="over" /> : null}
|
|
</span>
|
|
}
|
|
/>
|
|
))}
|
|
</DetailCard>
|
|
|
|
<DetailCard title="Dirt detect">
|
|
<ValueRow label="Left" value={formatNumber(sensors?.dirtDetectLeft)} />
|
|
<ValueRow label="Right" value={formatNumber(sensors?.dirtDetect)} />
|
|
</DetailCard>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DetailCard({ title, children }) {
|
|
return (
|
|
<div className="surface space-y-0.5 p-1 text-sm">
|
|
<div className="text-[0.8rem] uppercase tracking-wide text-slate-400">{title}</div>
|
|
<div className="space-y-0.5">{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ValueRow({ label, value }) {
|
|
return (
|
|
<div className="flex items-center justify-between gap-0.5">
|
|
<span className="text-slate-300">{label}</span>
|
|
<span className="text-slate-100">{value}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Pill({ active, tone = 'green', label }) {
|
|
const base = 'rounded px-1.5 py-0.5 text-[0.7rem] font-semibold';
|
|
const colors =
|
|
tone === 'red'
|
|
? active
|
|
? 'bg-red-600 text-white'
|
|
: 'bg-slate-700 text-slate-300'
|
|
: tone === 'amber'
|
|
? active
|
|
? 'bg-amber-500 text-slate-900'
|
|
: 'bg-slate-700 text-slate-300'
|
|
: active
|
|
? 'bg-emerald-600 text-white'
|
|
: 'bg-slate-700 text-slate-300';
|
|
return <span className={`${base} ${colors}`}>{label || (active ? 'true' : 'false')}</span>;
|
|
}
|
|
|
|
function formatNumber(value, unit) {
|
|
if (value == null || Number.isNaN(value)) return '--';
|
|
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>
|
|
);
|
|
}
|