mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-17 01:50:47 -04:00
slopodometering
This commit is contained in:
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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-sHjOwTuH.js"></script>
|
<script type="module" crossorigin src="/assets/index-C0TEdOkf.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-BvKlDyS5.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-C3R_zn0v.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
import { useSocket } from '../../context/SocketContext.jsx';
|
import { useSocket } from '../../context/SocketContext.jsx';
|
||||||
|
import useFleetReport from '../../hooks/useFleetReport.js';
|
||||||
|
import { isFeatureEnabled } from '../../lib/features.js';
|
||||||
import CardFrame from '../CardFrame/index.jsx';
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
|
|
||||||
function mapByRoverId(entries = []) {
|
function mapByRoverId(entries = []) {
|
||||||
@@ -41,6 +43,22 @@ function formatAge(timestamp, now) {
|
|||||||
return `${Math.round(ageMs / (60 * 1000))}m ago`;
|
return `${Math.round(ageMs / (60 * 1000))}m ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSpeed(mmPerSecond) {
|
||||||
|
const value = Number(mmPerSecond);
|
||||||
|
if (!Number.isFinite(value)) return '--';
|
||||||
|
return `${(value / 1000).toFixed(2)} m/s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHealth(percent) {
|
||||||
|
const value = Number(percent);
|
||||||
|
return Number.isFinite(value) ? `${value.toFixed(0)}%` : '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEfficiency(whPerKm) {
|
||||||
|
const value = Number(whPerKm);
|
||||||
|
return Number.isFinite(value) ? `${value.toFixed(1)} Wh/km` : '--';
|
||||||
|
}
|
||||||
|
|
||||||
function roverLabel(rover) {
|
function roverLabel(rover) {
|
||||||
return rover?.name || rover?.id || 'Unknown rover';
|
return rover?.name || rover?.id || 'Unknown rover';
|
||||||
}
|
}
|
||||||
@@ -50,6 +68,7 @@ export default function OdometerPanel() {
|
|||||||
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||||
const roster = useSessionSelector((state) => state.session?.roster ?? []);
|
const roster = useSessionSelector((state) => state.session?.roster ?? []);
|
||||||
const sessionOdometers = useSessionSelector((state) => state.session?.odometers ?? []);
|
const sessionOdometers = useSessionSelector((state) => state.session?.odometers ?? []);
|
||||||
|
const fleetReportsEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'fleetReports'));
|
||||||
const sessionOdometerMap = useMemo(() => mapByRoverId(sessionOdometers), [sessionOdometers]);
|
const sessionOdometerMap = useMemo(() => mapByRoverId(sessionOdometers), [sessionOdometers]);
|
||||||
const [liveOdometerMap, setLiveOdometerMap] = useState({});
|
const [liveOdometerMap, setLiveOdometerMap] = useState({});
|
||||||
const [now, setNow] = useState(() => Date.now());
|
const [now, setNow] = useState(() => Date.now());
|
||||||
@@ -97,36 +116,86 @@ export default function OdometerPanel() {
|
|||||||
})),
|
})),
|
||||||
[odometerMap, roster],
|
[odometerMap, roster],
|
||||||
);
|
);
|
||||||
|
const visibleRoverIds = useMemo(() => roster.map((rover) => String(rover.id)), [roster]);
|
||||||
|
|
||||||
const primaryRoverId = assignedRoverId || visibleRows[0]?.rover?.id || null;
|
/*
|
||||||
|
The emphasized summary represents "your rover", so it must follow the
|
||||||
|
actual assignment rather than silently promoting the first visible rover.
|
||||||
|
Unassigned users still retain the compact all-rover list below.
|
||||||
|
*/
|
||||||
|
const primaryRoverId = assignedRoverId || null;
|
||||||
const primaryRover = visibleRows.find((entry) => String(entry.rover?.id) === String(primaryRoverId))?.rover || null;
|
const primaryRover = visibleRows.find((entry) => String(entry.rover?.id) === String(primaryRoverId))?.rover || null;
|
||||||
const primary = primaryRoverId ? odometerMap[String(primaryRoverId)] || null : null;
|
const primary = primaryRoverId ? odometerMap[String(primaryRoverId)] || null : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardFrame title="Rover odometer" clipOverflow={false} bodyClassName="space-y-0.5 text-base text-slate-100">
|
<CardFrame title="Rover odometer" clipOverflow={false} bodyClassName="space-y-0.5 text-base text-slate-100">
|
||||||
{!primaryRoverId ? (
|
{fleetReportsEnabled ? (
|
||||||
<p className="text-sm text-slate-500">No rover odometer data yet.</p>
|
<OdometerReportContent
|
||||||
|
now={now}
|
||||||
|
primary={primary}
|
||||||
|
primaryRover={primaryRover}
|
||||||
|
primaryRoverId={primaryRoverId}
|
||||||
|
roverIds={visibleRoverIds}
|
||||||
|
rows={visibleRows}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<OdometerContent
|
||||||
<OdometerSummary odometer={primary} rover={primaryRover} now={now} />
|
now={now}
|
||||||
<RoverOdometerList rows={visibleRows} primaryRoverId={primaryRoverId} />
|
primary={primary}
|
||||||
</>
|
primaryRover={primaryRover}
|
||||||
|
primaryRoverId={primaryRoverId}
|
||||||
|
rows={visibleRows}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</CardFrame>
|
</CardFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function OdometerSummary({ odometer, rover, now }) {
|
function OdometerReportContent({ roverIds, ...props }) {
|
||||||
const status = odometer?.status || 'waiting';
|
// A lazy state initializer establishes one stable 24-hour query boundary;
|
||||||
const ignoredSamples = Number(odometer?.ignoredSamples) || 0;
|
// ordinary odometer re-renders must not trigger fresh historical requests.
|
||||||
|
const [rangeEnd] = useState(() => Date.now());
|
||||||
|
const { report } = useFleetReport({
|
||||||
|
since: rangeEnd - 24 * 60 * 60 * 1000,
|
||||||
|
until: rangeEnd,
|
||||||
|
compact: true,
|
||||||
|
includeEvents: false,
|
||||||
|
roverIds,
|
||||||
|
});
|
||||||
|
const reportByRoverId = useMemo(
|
||||||
|
() => Object.fromEntries((report?.rovers || []).map((rover) => [String(rover.roverId), rover])),
|
||||||
|
[report],
|
||||||
|
);
|
||||||
|
return <OdometerContent {...props} reportByRoverId={reportByRoverId} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function OdometerContent({ now, primary, primaryRover, primaryRoverId, rows, reportByRoverId = {} }) {
|
||||||
|
const primaryReport = primaryRoverId ? reportByRoverId[String(primaryRoverId)] : null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{primaryRoverId ? (
|
||||||
|
<OdometerSummary odometer={primary} rover={primaryRover} report={primaryReport} now={now} />
|
||||||
|
) : (
|
||||||
|
<p className="surface px-1 py-0.5 text-sm text-slate-500">You do not currently have a rover.</p>
|
||||||
|
)}
|
||||||
|
<RoverOdometerList rows={rows} primaryRoverId={primaryRoverId} reportByRoverId={reportByRoverId} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OdometerSummary({ odometer, rover, report, now }) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<div className="text-sm font-semibold text-slate-200">{roverLabel(rover)}</div>
|
<div className="flex items-center justify-between gap-1 text-sm">
|
||||||
<div className="grid grid-cols-2 gap-0.5 md:grid-cols-3">
|
<span className="font-semibold text-slate-200">{roverLabel(rover)}</span>
|
||||||
|
<span className="text-xs text-slate-500">{formatAge(odometer?.updatedAt, now)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-0.5 md:grid-cols-5">
|
||||||
<Metric label="Total distance" value={formatDistance(odometer?.totalMm)} />
|
<Metric label="Total distance" value={formatDistance(odometer?.totalMm)} />
|
||||||
<Metric label="Last update" value={formatAge(odometer?.updatedAt, now)} />
|
<Metric label="Session distance" value={formatDistance(odometer?.sessionMm)} />
|
||||||
<Metric label="Status" value={status} />
|
<Metric label="Current speed" value={formatSpeed(odometer?.wheelSpeedsMmPerSecond?.center)} />
|
||||||
{ignoredSamples > 0 ? <Metric label="Ignored samples" value={String(ignoredSamples)} /> : null}
|
<Metric label="Battery health" value={formatHealth(report?.batteryHealth?.capacityRetentionPercent)} />
|
||||||
|
<Metric label="Efficiency" value={formatEfficiency(report?.overallWhPerKm)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -141,12 +210,13 @@ function Metric({ label, value }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RoverOdometerList({ rows, primaryRoverId }) {
|
function RoverOdometerList({ rows, primaryRoverId, reportByRoverId }) {
|
||||||
if (!rows.length) return null;
|
if (!rows.length) return null;
|
||||||
return (
|
return (
|
||||||
<DetailCard title="All rovers">
|
<DetailCard title="All rovers">
|
||||||
{rows.map(({ rover, odometer }) => {
|
{rows.map(({ rover, odometer }) => {
|
||||||
const active = String(rover?.id) === String(primaryRoverId);
|
const active = String(rover?.id) === String(primaryRoverId);
|
||||||
|
const report = reportByRoverId[String(rover?.id)];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={rover?.id}
|
key={rover?.id}
|
||||||
@@ -156,7 +226,8 @@ function RoverOdometerList({ rows, primaryRoverId }) {
|
|||||||
>
|
>
|
||||||
<span className="min-w-0 truncate">{roverLabel(rover)}</span>
|
<span className="min-w-0 truncate">{roverLabel(rover)}</span>
|
||||||
<span className="shrink-0 text-right text-slate-100">{formatDistance(odometer?.totalMm)}</span>
|
<span className="shrink-0 text-right text-slate-100">{formatDistance(odometer?.totalMm)}</span>
|
||||||
<span className="shrink-0 text-right text-slate-400">{odometer?.status || 'waiting'}</span>
|
<span className="shrink-0 text-right text-slate-300">{formatHealth(report?.batteryHealth?.capacityRetentionPercent)}</span>
|
||||||
|
<span className="shrink-0 text-right text-slate-400">{formatEfficiency(report?.overallWhPerKm)}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -172,12 +243,3 @@ function DetailCard({ title, 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="min-w-0 truncate text-right text-slate-100">{value ?? '--'}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user