From fae175dd5d41a1ac73523e33d012c4cac2ac86b5 Mon Sep 17 00:00:00 2001 From: acheronfail Date: Tue, 21 Apr 2026 23:26:10 +0930 Subject: [PATCH] app: support logs from VESC Tool --- src/components/Details.svelte | 5 +- src/components/Footpads.svelte | 15 +- src/components/Picker.svelte | 1 + src/components/View.logic.test.ts | 77 ++++++++++ src/components/View.svelte | 19 ++- src/components/View.ts | 17 ++- .../parse/__fixtures__/vesc_fault_unknown.csv | 2 + src/lib/parse/__fixtures__/vesc_metric.csv | 4 + src/lib/parse/index.test.ts | 31 ++++ src/lib/parse/index.ts | 14 +- src/lib/parse/types.ts | 1 + src/lib/parse/vesc-tool.test.ts | 46 ++++++ src/lib/parse/vesc-tool.ts | 142 ++++++++++++++++++ 13 files changed, 361 insertions(+), 13 deletions(-) create mode 100644 src/components/View.logic.test.ts create mode 100644 src/lib/parse/__fixtures__/vesc_fault_unknown.csv create mode 100644 src/lib/parse/__fixtures__/vesc_metric.csv create mode 100644 src/lib/parse/index.test.ts create mode 100644 src/lib/parse/vesc-tool.test.ts create mode 100644 src/lib/parse/vesc-tool.ts diff --git a/src/components/Details.svelte b/src/components/Details.svelte index 2c894bc..faa2f4b 100644 --- a/src/components/Details.svelte +++ b/src/components/Details.svelte @@ -7,6 +7,7 @@ stats: RideStats; batterySpecs: ZBatterySpecs; units: Units; + hasAdcTelemetry: boolean; } @@ -22,7 +23,7 @@ import { formatFloat, formatInt } from '../lib/misc'; import { globalState } from '../lib/global.svelte'; - let { data = empty, stats, batterySpecs, units }: Props = $props(); + let { data = empty, stats, batterySpecs, units, hasAdcTelemetry }: Props = $props(); let showStats = $state(false); @@ -76,7 +77,7 @@ ]} /> {:else} - + {/if} diff --git a/src/components/Footpads.svelte b/src/components/Footpads.svelte index b89c5e3..5f46e36 100644 --- a/src/components/Footpads.svelte +++ b/src/components/Footpads.svelte @@ -2,13 +2,14 @@ import type { Row } from '../lib/parse/types'; import List from './List.svelte'; - let { data }: { data: Row } = $props(); + let { data, hasAdcTelemetry }: { data: Row; hasAdcTelemetry: boolean } = $props(); let goingSlow = $derived(data.speed < 2); let adc1Enabled = $derived(data.adc1 > 2); let adc2Enabled = $derived(data.adc2 > 2); const ACTIVE_COLOUR = '#0ea5e9'; const INACTIVE_COLOUR = '#991b1b'; + const UNKNOWN_COLOUR = '#475569'; @@ -18,11 +19,11 @@ d="M 61.135201,5.4592 H 38.864798 A 33.405601,13.634527 0 0 0 5.4591996,19.09373 V 74.5408 H 94.5408 V 19.09373 A 33.405601,13.634527 0 0 0 61.135201,5.4592 m -11.135196,8.180715 v 52.720169" /> @@ -32,13 +33,13 @@ items={[ { label: 'ADC1', - value: data.adc1.toFixed(2), - color: adc1Enabled ? 'yellowgreen' : goingSlow ? 'grey' : 'red', + value: hasAdcTelemetry ? data.adc1.toFixed(2) : 'unknown', + color: hasAdcTelemetry ? (adc1Enabled ? 'yellowgreen' : goingSlow ? 'grey' : 'red') : 'grey', }, { label: 'ADC2', - value: data.adc2.toFixed(2), - color: adc2Enabled ? 'yellowgreen' : goingSlow ? 'grey' : 'red', + value: hasAdcTelemetry ? data.adc2.toFixed(2) : 'unknown', + color: hasAdcTelemetry ? (adc2Enabled ? 'yellowgreen' : goingSlow ? 'grey' : 'red') : 'grey', }, ]} /> diff --git a/src/components/Picker.svelte b/src/components/Picker.svelte index 8c2bd94..01cbb5d 100644 --- a/src/components/Picker.svelte +++ b/src/components/Picker.svelte @@ -31,6 +31,7 @@ an exported CSV or ZIP file from Float Control +
  • an exported CSV file from VESC Tool
  • an exported JSON file from Floaty
  • ... or drag and drop a supported file onto this window!
  • diff --git a/src/components/View.logic.test.ts b/src/components/View.logic.test.ts new file mode 100644 index 0000000..61da3e6 --- /dev/null +++ b/src/components/View.logic.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from 'vitest'; +import { DataSource, State, type RowWithIndex } from '../lib/parse/types'; +import { extractGpsInformation, findPointsOfInterest } from './View'; + +const makeRow = (overrides: Partial = {}): RowWithIndex => ({ + index: 0, + adc1: 0, + adc2: 0, + ah: 0, + altitude: 0, + current_battery: 0, + current_motor: 0, + distance: 0, + duty: 0, + gps_accuracy: 0, + gps_latitude: 0, + gps_longitude: 0, + motor_fault: 0, + pitch: 0, + roll: 0, + speed: 0, + state: State.Riding, + state_raw: 0, + temp_mosfet: 0, + temp_motor: 0, + time: 0, + true_pitch: 0, + voltage: 0, + wh: 0, + ...overrides, +}); + +describe(extractGpsInformation.name, () => { + test('replaces leading (0,0) gps points with first non-zero coordinate for all sources', () => { + const rows: RowWithIndex[] = [ + makeRow({ index: 0, time: 0, gps_latitude: 0, gps_longitude: 0 }), + makeRow({ index: 1, time: 1, gps_latitude: 0, gps_longitude: 0 }), + makeRow({ index: 2, time: 2, gps_latitude: -37.81, gps_longitude: 144.96 }), + makeRow({ index: 3, time: 3, gps_latitude: -37.82, gps_longitude: 144.97 }), + ]; + + const sources = [DataSource.FloatControl, DataSource.Floaty, DataSource.VescTool]; + for (const source of sources) { + const { gpsPoints } = extractGpsInformation(rows, source); + + expect(gpsPoints[0]).toEqual([-37.81, 144.96]); + expect(gpsPoints[1]).toEqual([-37.81, 144.96]); + expect(gpsPoints[2]).toEqual([-37.81, 144.96]); + expect(gpsPoints[3]).toEqual([-37.82, 144.97]); + } + }); +}); + +describe(findPointsOfInterest.name, () => { + test('does not infer footpad faults when ADC telemetry is absent', () => { + const rows: RowWithIndex[] = [ + makeRow({ index: 0, speed: 10, adc1: 0, adc2: 0 }), + makeRow({ index: 1, speed: 15, adc1: 0, adc2: 0 }), + ]; + + const points = findPointsOfInterest(rows); + expect(points).toEqual([]); + }); + + test('still infers footpad faults when ADC telemetry is present', () => { + const rows: RowWithIndex[] = [ + makeRow({ index: 0, speed: 10, adc1: 0, adc2: 3 }), + makeRow({ index: 1, speed: 10, adc1: 0, adc2: 1 }), + ]; + + const points = findPointsOfInterest(rows); + expect(points).toEqual([ + { index: 0, state: State.Custom_OneFootpadAtSpeed }, + { index: 1, state: State.Custom_NoFootpadsAtSpeed }, + ]); + }); +}); diff --git a/src/components/View.svelte b/src/components/View.svelte index 04af3ea..abafdcb 100644 --- a/src/components/View.svelte +++ b/src/components/View.svelte @@ -12,7 +12,14 @@ import Button from './Button.svelte'; import { parse, supportedMimeTypes } from '../lib/parse'; import { globalState } from '../lib/global.svelte'; - import { computeStats, extractGpsInformation, findPointsOfInterest, type Banner, type RideStats } from './View'; + import { + computeStats, + extractGpsInformation, + findPointsOfInterest, + hasAdcTelemetry, + type Banner, + type RideStats, + } from './View'; import { type ChartKey, Charts } from './Chart'; import { riderSvg } from './Map'; import PickerFull from './PickerFull.svelte'; @@ -62,6 +69,8 @@ let visible = $state([]); /** filtered visible rows */ let visibleRows = $derived(rows.filter((_, i) => visible[i])); + /** whether this ride appears to include footpad ADC telemetry */ + let adcsEnabled = $derived(hasAdcTelemetry(rows)); /** indices of gaps between non-contiguous ranges in `visibleRows`; used for rendering vertical lines in charts */ let gapIndices = $derived.by(() => { let gaps: number[] = []; @@ -294,7 +303,13 @@ wide:[grid-column:span_2] wide:[grid-row:unset]" class:details-swapped={swapMapAndDetails} > -
    +
    {#each settings.charts as key, index} diff --git a/src/components/View.ts b/src/components/View.ts index fb22715..011c0d8 100644 --- a/src/components/View.ts +++ b/src/components/View.ts @@ -153,6 +153,16 @@ export function extractGpsInformation(rows: RowWithIndex[], source: DataSource) } } + // Some logs begin before GNSS has a fix, producing leading (0, 0) values. + // Replace only the leading invalid points with the first non-zero coordinate + const firstGoodPointIndex = gpsPoints.findIndex(([lat, lon]) => lat !== 0 || lon !== 0); + if (firstGoodPointIndex > 0) { + const firstGoodPoint = gpsPoints[firstGoodPointIndex]!; + for (let i = 0; i < firstGoodPointIndex; ++i) { + gpsPoints[i] = firstGoodPoint; + } + } + // When Float Control starts recording a ride, it appears that the first few data points // have incorrect GPS data. If it's the start of the ride, it's (0, 0), but if it's a resumed // ride, then it seems to be the last known point from the paused ride. @@ -178,8 +188,13 @@ export function extractGpsInformation(rows: RowWithIndex[], source: DataSource) return { gpsPoints, gpsGaps }; } +export function hasAdcTelemetry(rows: RowWithIndex[]): boolean { + return rows.some((row) => row.adc1 !== 0 || row.adc2 !== 0); +} + export function findPointsOfInterest(rows: RowWithIndex[]): PointOfInterest[] { const points: PointOfInterest[] = []; + const adcFaultsEnabled = hasAdcTelemetry(rows); for (let i = 0; i < rows.length; i++) { const row = rows[i]!; @@ -191,7 +206,7 @@ export function findPointsOfInterest(rows: RowWithIndex[]): PointOfInterest[] { } // custom footpad faults - if (row.speed > 2) { + if (adcFaultsEnabled && row.speed > 2) { const combinedAdcVoltage = row.adc1 + row.adc2; if (combinedAdcVoltage < 2) { states.push(State.Custom_NoFootpadsAtSpeed); diff --git a/src/lib/parse/__fixtures__/vesc_fault_unknown.csv b/src/lib/parse/__fixtures__/vesc_fault_unknown.csv new file mode 100644 index 0000000..a982e53 --- /dev/null +++ b/src/lib/parse/__fixtures__/vesc_fault_unknown.csv @@ -0,0 +1,2 @@ +ms_today;input_voltage;temp_mos_max;temp_motor;current_motor;current_in;erpm;duty_cycle;amp_hours_used;watt_hours_used;tacho_meters;fault_code;speed_meters_per_sec;roll;pitch;gnss_lat;gnss_lon;gnss_alt;gnss_hAcc; +2000;80.0;19.1;27.0;14;11;1300;0.45;0.7000;60.0;2000;99;1.5;0.2;0.1;41.0;-76.0;150;1.2; diff --git a/src/lib/parse/__fixtures__/vesc_metric.csv b/src/lib/parse/__fixtures__/vesc_metric.csv new file mode 100644 index 0000000..1e99c4c --- /dev/null +++ b/src/lib/parse/__fixtures__/vesc_metric.csv @@ -0,0 +1,4 @@ +ms_today;input_voltage;temp_mos_max;temp_motor;current_motor;current_in;erpm;duty_cycle;amp_hours_used;watt_hours_used;tacho_meters;fault_code;speed_meters_per_sec;roll;pitch;gnss_lat;gnss_lon;gnss_alt;gnss_hAcc; +1000;81.5;18.8;26.5;10;9;1200;0.50;0.6502;51.836;1710.23;0;2.0;0.1;0.2;42.1;-77.1;200;2.5; +1100;81.4;18.9;26.7;12;10;1202;0.52;0.6504;52.0;1710.33;3;2.2;0.3;0.4;42.3;-77.3;202;2.7; +1300;81.3;19.0;26.8;13;10.5;1203;0.53;0.6505;52.1;1710.40;0;2.3;0.4;0.5;42.4;-77.4;203;2.8; diff --git a/src/lib/parse/index.test.ts b/src/lib/parse/index.test.ts new file mode 100644 index 0000000..1b1a5c8 --- /dev/null +++ b/src/lib/parse/index.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from 'vitest'; + +import fcMetricCsv from './__fixtures__/fc_metric.csv?raw'; +import vescMetricCsv from './__fixtures__/vesc_metric.csv?raw'; +import { parse } from './index'; + +class MockFile extends File { + constructor( + private fileParts: BlobPart[], + fileName: string, + options?: FilePropertyBag, + ) { + super(fileParts, fileName, options); + } + + async text() { + return this.fileParts.map((part) => part.toString()).join(''); + } +} + +describe(parse.name, () => { + test('routes semicolon-delimited CSV to VESC Tool parser', async () => { + const result = await parse(new MockFile([vescMetricCsv], 'vesc.csv', { type: 'text/csv' })); + expect(result.source).toBe('vesc_tool'); + }); + + test('routes comma-delimited CSV to Float Control parser', async () => { + const result = await parse(new MockFile([fcMetricCsv], 'fc.csv', { type: 'text/csv' })); + expect(result.source).toBe('float_control'); + }); +}); diff --git a/src/lib/parse/index.ts b/src/lib/parse/index.ts index d9dcf40..4e47911 100644 --- a/src/lib/parse/index.ts +++ b/src/lib/parse/index.ts @@ -2,6 +2,7 @@ import * as fflate from 'fflate'; import { parseFloatControlCsv } from './float-control'; import { parseFloatyJson } from './floaty'; +import { parseVescToolCsv } from './vesc-tool'; import { DataSource, Units, type RowWithIndex } from './types'; import { ParseError } from './errors'; @@ -50,7 +51,18 @@ export async function parse(file: File): Promise { } if (file.type === SupportedMimeTypes.Csv || lowerName.endsWith('.csv')) { - const parsed = await parseFloatControlCsv(file); + const text = await file.text(); + const firstLine = text.split(/\r?\n/, 1)[0] ?? ''; + const semicolonCount = (firstLine.match(/;/g) ?? []).length; + const commaCount = (firstLine.match(/,/g) ?? []).length; + + // Heuristic: VESC Tool exports are semicolon-delimited, while Float Control uses commas. + // If this does not look like VESC Tool, we fall back to Float Control parsing. + if (semicolonCount > commaCount) { + return await parseVescToolCsv(text); + } + + const parsed = await parseFloatControlCsv(text); return { source: DataSource.FloatControl, data: parsed.csv.data, diff --git a/src/lib/parse/types.ts b/src/lib/parse/types.ts index ef7db45..07d5bbf 100644 --- a/src/lib/parse/types.ts +++ b/src/lib/parse/types.ts @@ -2,6 +2,7 @@ export enum DataSource { None = 'none', FloatControl = 'float_control', Floaty = 'floaty', + VescTool = 'vesc_tool', } export enum Units { diff --git a/src/lib/parse/vesc-tool.test.ts b/src/lib/parse/vesc-tool.test.ts new file mode 100644 index 0000000..0cdc61d --- /dev/null +++ b/src/lib/parse/vesc-tool.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from 'vitest'; + +import csvMetric from './__fixtures__/vesc_metric.csv?raw'; +import csvUnknownFault from './__fixtures__/vesc_fault_unknown.csv?raw'; +import { parseVescToolCsv } from './vesc-tool'; + +describe(parseVescToolCsv.name, () => { + test('maps VESC CSV fields', async () => { + const parsed = await parseVescToolCsv(csvMetric); + + expect(parsed.source).toBe('vesc_tool'); + expect(parsed.units).toBe('metric'); + expect(parsed.errors).toEqual([]); + + const first = parsed.data[0]!; + expect(first.time).toBe(0); + expect(first.voltage).toBe(81.5); + expect(first.temp_mosfet).toBe(18.8); + expect(first.temp_motor).toBe(26.5); + expect(first.current_motor).toBe(10); + expect(first.current_battery).toBe(9); + expect(first.duty).toBe(50); + expect(first.speed).toBeCloseTo(7.2, 6); + expect(first.distance).toBeCloseTo(1.71023, 6); + expect(first.state).toBe('riding'); + expect(first.state_raw).toBe(0); + expect(parsed.data).toHaveLength(3); + + const second = parsed.data[1]!; + expect(second.time).toBeCloseTo(0.1, 8); + expect(second.state_raw).toBe(3); + expect(second.state).toBe('wheelslip'); + + const third = parsed.data[2]!; + expect(third.time).toBeCloseTo(0.3, 8); + }); + + test('keeps unknown fault codes as explicit state labels', async () => { + const parsed = await parseVescToolCsv(csvUnknownFault); + + expect(parsed.errors).toEqual([]); + expect(parsed.data).toHaveLength(1); + expect(parsed.data[0]!.state_raw).toBe(99); + expect(parsed.data[0]!.state).toBe('fault 99'); + }); +}); diff --git a/src/lib/parse/vesc-tool.ts b/src/lib/parse/vesc-tool.ts new file mode 100644 index 0000000..6a28338 --- /dev/null +++ b/src/lib/parse/vesc-tool.ts @@ -0,0 +1,142 @@ +import csv, { type ParseResult as CsvParseResult } from 'papaparse'; + +import type { ParseResult } from './index'; +import { ParseError } from './errors'; +import { attachIndex } from '../misc'; +import { DataSource, RowKey, State, stateCodeMap, Units, type Row } from './types'; + +const headerMap: Record = { + amp_hours_used: RowKey.Ah, + current_in: RowKey.CurrentBattery, + current_motor: RowKey.CurrentMotor, + duty_cycle: RowKey.Duty, + erpm: RowKey.Erpm, + fault_code: RowKey.StateRaw, + gnss_alt: RowKey.Altitude, + gnss_hAcc: RowKey.GpsAccuracy, + gnss_lat: RowKey.GpsLatitude, + gnss_lon: RowKey.GpsLongitude, + input_voltage: RowKey.Voltage, + ms_today: RowKey.Time, + pitch: RowKey.Pitch, + roll: RowKey.Roll, + speed_meters_per_sec: RowKey.Speed, + tacho_meters: RowKey.Distance, + temp_mos_max: RowKey.TempMosfet, + temp_motor: RowKey.TempMotor, + watt_hours_used: RowKey.Wh, +}; + +const parseFloatValue = (input: string): number => { + const trimmed = input.trim(); + if (trimmed === '') return 0; + + const float = parseFloat(trimmed); + if (Number.isNaN(float)) { + console.warn(`Failed to parse VESC Tool CSV! Expected a number, but got: '${input}'`); + return 0; + } + + return float; +}; + +const transformHeader = (header: string): string => { + const trimmed = header.trim(); + return headerMap[trimmed] ?? trimmed; +}; + +const transform = (value: string, column: C): Row[C] => { + const parsed = parseFloatValue(value); + + switch (column) { + case RowKey.Duty: + // VESC Tool logs duty cycle as a fraction (0..1), while the app expects percent. + return (parsed * 100) as Row[C]; + case RowKey.Speed: + // VESC Tool speed is meters per second, convert to km/h for metric parity. + return (parsed * 3.6) as Row[C]; + case RowKey.Distance: + // VESC Tool tacho distance is in meters, convert to kilometers. + return (parsed / 1000) as Row[C]; + case RowKey.Time: + // VESC Tool timestamp is milliseconds. + return (parsed / 1000) as Row[C]; + default: + return parsed as Row[C]; + } +}; + +function normalizeRow(row: Partial): Row { + const stateRaw = row.state_raw ?? 0; + const state = stateRaw === 0 ? State.Riding : (stateCodeMap[stateRaw] ?? `fault ${stateRaw}`); + + return { + [RowKey.Adc1]: row.adc1 ?? 0, + [RowKey.Adc2]: row.adc2 ?? 0, + [RowKey.Ah]: row.ah ?? 0, + [RowKey.Altitude]: row.altitude ?? 0, + [RowKey.CurrentBattery]: row.current_battery ?? 0, + [RowKey.CurrentMotor]: row.current_motor ?? 0, + [RowKey.Distance]: row.distance ?? 0, + [RowKey.Duty]: row.duty ?? 0, + [RowKey.Erpm]: row.erpm, + [RowKey.GpsAccuracy]: row.gps_accuracy ?? 0, + [RowKey.GpsLatitude]: row.gps_latitude ?? 0, + [RowKey.GpsLongitude]: row.gps_longitude ?? 0, + [RowKey.MotorFault]: row.motor_fault ?? stateRaw, + [RowKey.Pitch]: row.pitch ?? 0, + [RowKey.Roll]: row.roll ?? 0, + [RowKey.Speed]: row.speed ?? 0, + [RowKey.State]: state, + [RowKey.StateRaw]: stateRaw, + [RowKey.TempMosfet]: row.temp_mosfet ?? 0, + [RowKey.TempMotor]: row.temp_motor ?? 0, + [RowKey.Time]: row.time ?? 0, + [RowKey.TruePitch]: row.true_pitch ?? row.pitch ?? 0, + [RowKey.Voltage]: row.voltage ?? 0, + [RowKey.Wh]: row.wh ?? 0, + }; +} + +export async function parseVescToolCsv(input: string | File): Promise { + try { + const text = typeof input === 'string' ? input : await input.text(); + + return await new Promise((resolve) => { + csv.parse(text, { + delimiter: ';', + header: true, + skipEmptyLines: true, + transformHeader, + transform, + complete: (results: CsvParseResult) => { + const rows = results.data.map((row) => normalizeRow(row)); + + const startTime = rows[0]?.time ?? 0; + for (const row of rows) { + row.time -= startTime; + } + + const errors: Error[] = []; + if (results.errors.length > 0) { + errors.push(new ParseError('Failed to parse VESC Tool CSV properly!', results.errors)); + } + + resolve({ + source: DataSource.VescTool, + data: attachIndex(rows), + units: Units.Metric, + errors, + }); + }, + }); + }); + } catch (error) { + return { + source: DataSource.VescTool, + data: [], + units: Units.Metric, + errors: [new ParseError('Failed to parse VESC Tool CSV!', error)], + }; + } +}