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';
@@ -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)],
+ };
+ }
+}