mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-31 05:53:04 +02:00
Add telemetry CSV export. Closes #334.
This commit is contained in:
@@ -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)));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user