Add telemetry CSV export. Closes #334.

This commit is contained in:
Jack Kingsman
2026-07-21 12:06:52 -07:00
parent 6f743446b0
commit 9fc343c4fd
2 changed files with 232 additions and 0 deletions
@@ -9,6 +9,7 @@ import {
ResponsiveContainer,
Brush,
} from 'recharts';
import { Download } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '../ui/button';
import { Separator } from '../ui/separator';
@@ -128,6 +129,108 @@ function cleanNumber(value: number): string {
return `${Number(value.toFixed(4))}`;
}
// --- CSV export ---
/** Strip float representation noise without clamping small magnitudes the way
* a fixed decimal count would (`toFixed(4)` would flatten 0.000012 to 0). */
function csvNumber(value: number): string {
if (Number.isInteger(value)) return `${value}`;
return `${Number(value.toPrecision(12))}`;
}
function escapeCsvValue(value: string): string {
return /[",\r\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
}
const pad2 = (n: number) => String(n).padStart(2, '0');
/** Local-time ISO 8601 with offset, so a sample's wall-clock time survives the
* trip into a spreadsheet without the reader having to know our timezone. */
export function toLocalIsoString(date: Date): string {
const offsetMin = -date.getTimezoneOffset();
const sign = offsetMin >= 0 ? '+' : '-';
const abs = Math.abs(offsetMin);
return (
`${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}` +
`T${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}` +
`${sign}${pad2(Math.floor(abs / 60))}:${pad2(abs % 60)}`
);
}
/** `<repeaterName>_data_YYYYMMDD_HHMMSS.csv`, reduced to filesystem-safe
* characters. Falls back to "repeater" when a name sanitizes away entirely. */
export function telemetryCsvFilename(repeaterName: string, at: Date): string {
const safeName =
repeaterName
.replace(/[^a-zA-Z0-9_-]+/g, '_')
.replace(/_{2,}/g, '_')
.replace(/^_|_$/g, '') || 'repeater';
const stamp =
`${at.getFullYear()}${pad2(at.getMonth() + 1)}${pad2(at.getDate())}` +
`_${pad2(at.getHours())}${pad2(at.getMinutes())}${pad2(at.getSeconds())}`;
return `${safeName}_data_${stamp}.csv`;
}
interface CsvColumn {
key: string;
header: string;
}
/** Columns mirror the flattened point shape built by `chartData` below, so every
* metric the pane can plot — builtins, their derived series, and each
* discovered LPP sensor — gets a column. Keep the two in step. */
function buildCsvColumns(lppMetrics: { key: string; config: MetricConfig }[]): CsvColumn[] {
const withUnit = (label: string, unit: string) => (unit ? `${label} (${unit})` : label);
return [
{ key: 'timestamp_iso', header: 'Timestamp (ISO 8601)' },
{ key: 'timestamp', header: 'Unix Timestamp' },
{ key: 'battery_volts', header: withUnit('Voltage', BUILTIN_METRIC_CONFIG.battery_volts.unit) },
{
key: 'noise_floor_dbm',
header: withUnit('Noise Floor', BUILTIN_METRIC_CONFIG.noise_floor_dbm.unit),
},
{ key: 'packets_received', header: 'Packets Received' },
{ key: 'packets_sent', header: 'Packets Sent' },
{ key: 'packets_received_delta', header: 'Packets Received Delta' },
{ key: 'packets_sent_delta', header: 'Packets Sent Delta' },
{ key: 'recv_errors', header: 'RX Errors' },
{ key: 'recv_error_pct', header: 'RX Error Rate (%)' },
{
key: 'uptime_seconds',
header: withUnit('Uptime', BUILTIN_METRIC_CONFIG.uptime_seconds.unit),
},
...lppMetrics.map((m) => ({
key: m.key,
// Unit already reflects the active distance-unit preference, as the chart does.
header: withUnit(m.config.label, m.config.unit),
})),
];
}
/** Serialize chart points to CSV. Missing samples become empty cells rather
* than zeros, so gaps stay distinguishable from real readings. */
export function buildTelemetryCsv(
rows: Array<Record<string, number | undefined>>,
columns: CsvColumn[]
): string {
const lines = [columns.map((c) => escapeCsvValue(c.header)).join(',')];
for (const row of rows) {
lines.push(
columns
.map((c) => {
if (c.key === 'timestamp_iso') {
const ts = row.timestamp;
return ts == null ? '' : escapeCsvValue(toLocalIsoString(new Date(ts * 1000)));
}
const v = row[c.key];
return typeof v === 'number' && Number.isFinite(v) ? csvNumber(v) : '';
})
.join(',')
);
}
return lines.join('\r\n');
}
interface TelemetryHistoryPaneProps {
entries: TelemetryHistoryEntry[];
publicKey: string;
@@ -427,6 +530,26 @@ export function TelemetryHistoryPane({
}
};
const repeaterName = useMemo(
() => contacts.find((c) => c.public_key === publicKey)?.name ?? publicKey.slice(0, 12),
[contacts, publicKey]
);
// Exports the full stored history, not the brushed viewport — the button is
// about archiving the data, while the brush is a chart-reading aid.
const handleDownloadCsv = () => {
const csv = buildTelemetryCsv(chartData, buildCsvColumns(lppMetrics));
// Excel only detects UTF-8 in a CSV via the BOM, and LPP units include
// non-ASCII characters such as "°C".
const blob = new Blob(['\ufeff', csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = telemetryCsvFilename(repeaterName, new Date());
a.click();
URL.revokeObjectURL(url);
};
const trackedNames = useMemo(() => {
if (!slotsFull) return [];
return trackedTelemetryRepeaters.map((key) => {
@@ -444,6 +567,17 @@ export function TelemetryHistoryPane({
<span className="text-[0.625rem] text-muted-foreground">{entries.length} samples</span>
)}
</div>
{entries.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={handleDownloadCsv}
title="Download all telemetry history as CSV"
>
<Download className="h-3.5 w-3.5 mr-1.5" aria-hidden="true" />
Download CSV
</Button>
)}
</div>
<div className="p-3">
{/* Explanation + tracking toggle */}
@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest';
import {
buildTelemetryCsv,
telemetryCsvFilename,
toLocalIsoString,
} from '../components/repeater/RepeaterTelemetryHistoryPane';
const COLUMNS = [
{ key: 'timestamp_iso', header: 'Timestamp (ISO 8601)' },
{ key: 'timestamp', header: 'Unix Timestamp' },
{ key: 'battery_volts', header: 'Voltage (V)' },
{ key: 'lpp_temperature_ch1', header: 'Temperature Ch1 (°C)' },
];
describe('telemetryCsvFilename', () => {
const at = new Date(2026, 6, 21, 9, 5, 3); // 2026-07-21 09:05:03 local
it('formats as <name>_data_YYYYMMDD_HHMMSS.csv with zero padding', () => {
expect(telemetryCsvFilename('BaseCamp', at)).toBe('BaseCamp_data_20260721_090503.csv');
});
it('reduces unsafe characters to single underscores', () => {
expect(telemetryCsvFilename('Hill/Top Repeater #2!', at)).toBe(
'Hill_Top_Repeater_2_data_20260721_090503.csv'
);
});
it('falls back to "repeater" when the name sanitizes away entirely', () => {
expect(telemetryCsvFilename('///', at)).toBe('repeater_data_20260721_090503.csv');
expect(telemetryCsvFilename('', at)).toBe('repeater_data_20260721_090503.csv');
});
});
describe('toLocalIsoString', () => {
it('emits local wall-clock time with an explicit UTC offset', () => {
const value = toLocalIsoString(new Date(2026, 0, 2, 3, 4, 5));
// Offset varies with the runner's zone, so assert shape plus local fields.
expect(value).toMatch(/^2026-01-02T03:04:05[+-]\d{2}:\d{2}$/);
});
});
describe('buildTelemetryCsv', () => {
it('writes a header row and one CRLF-terminated row per sample', () => {
const csv = buildTelemetryCsv(
[
{ timestamp: 1700000000, battery_volts: 4.05, lpp_temperature_ch1: 21.5 },
{ timestamp: 1700000600, battery_volts: 4.04, lpp_temperature_ch1: 21.6 },
],
COLUMNS
);
const lines = csv.split('\r\n');
expect(lines).toHaveLength(3);
expect(lines[0]).toBe('Timestamp (ISO 8601),Unix Timestamp,Voltage (V),Temperature Ch1 (°C)');
expect(lines[1]).toContain('1700000000,4.05,21.5');
expect(lines[2]).toContain('1700000600,4.04,21.6');
});
it('leaves gaps empty rather than filling them with zeros', () => {
const csv = buildTelemetryCsv([{ timestamp: 1700000000, battery_volts: undefined }], COLUMNS);
const cells = csv.split('\r\n')[1].split(',');
expect(cells[2]).toBe(''); // battery_volts
expect(cells[3]).toBe(''); // absent LPP sensor
});
it('strips floating-point noise without flattening small magnitudes', () => {
// 0.1 + 0.2 === 0.30000000000000004 — real IEEE-754 noise rather than a
// literal, which the linter rejects for losing precision at parse time.
const noisy = 0.1 + 0.2;
expect(`${noisy}`).toBe('0.30000000000000004');
const csv = buildTelemetryCsv(
[{ timestamp: 1, battery_volts: noisy, lpp_temperature_ch1: 0.000012 }],
COLUMNS
);
const cells = csv.split('\r\n')[1].split(',');
expect(cells[2]).toBe('0.3');
expect(cells[3]).toBe('0.000012');
});
it('quotes headers containing commas or quotes', () => {
const csv = buildTelemetryCsv(
[],
[{ key: 'x', header: 'Odd, "Name"' }] // sensor labels are user-influenced
);
expect(csv).toBe('"Odd, ""Name"""');
});
it('renders the ISO column from the sample timestamp', () => {
const csv = buildTelemetryCsv([{ timestamp: 1700000000 }], COLUMNS);
expect(csv.split('\r\n')[1].split(',')[0]).toBe(toLocalIsoString(new Date(1700000000 * 1000)));
});
});