diff --git a/README.md b/README.md
index 09cc7c0..0955342 100644
--- a/README.md
+++ b/README.md
@@ -32,7 +32,7 @@ Now you'll have `Float View` all the time, even offline!
## How do I use it?
1. Get on your board and record a ride with [Float Control] or [Floaty]
-2. Export your ride data (Float Control will put it in a `.csv.zip`, Floaty in a `.json`)
+2. Export your ride data (Float Control will put it in a `.csv.zip`, Floaty in a `.json` or `.csv`)
3. Load it up in (if it's zipped, unzip it first!)
4. Enjoy!
diff --git a/src/components/Picker.svelte b/src/components/Picker.svelte
index 01cbb5d..9d9331e 100644
--- a/src/components/Picker.svelte
+++ b/src/components/Picker.svelte
@@ -32,7 +32,10 @@
Float Control
an exported CSV file from VESC Tool
-
an exported JSON file from Floaty
+
+ an exported JSON or CSV file from
+ Floaty
+
... or drag and drop a supported file onto this window!
err instanceof ParseError);
for (const err of results.errors) {
if (err instanceof FloatControlLimitedError) {
banners.push({ text: err.message, kind: 'warning' });
@@ -147,12 +148,23 @@
if (err instanceof ParseError) {
console.error(err, err.cause);
- alert(
- `An error occurred when parsing ride, displayed data may be incomplete or incorrect! (${err.message})`,
- );
}
}
+ // Fatal: nothing usable to show — surface the error and return to the file picker.
+ if (results.data.length === 0 && parseErrors.length > 0) {
+ alert(`Could not load ride:\n\n${parseErrors.map((err) => err.message).join('\n')}`);
+ file = undefined;
+ source = DataSource.None;
+ return;
+ }
+
+ if (parseErrors.length > 0) {
+ alert(
+ `An error occurred when parsing ride, displayed data may be incomplete or incorrect! (${parseErrors.map((err) => err.message).join('; ')})`,
+ );
+ }
+
rows = results.data;
// initialize visibility and trimming to full range for the newly loaded ride
visibleFromMap = new Array(rows.length).fill(true);
@@ -160,7 +172,14 @@
trimEnd = rows.length ? rows.length - 1 : 0;
selectedIndex = 0;
- stats = computeStats(rows, pointsOfInterest);
+ stats = computeStats(rows, findPointsOfInterest(rows));
+ })
+ .catch((error) => {
+ clearTimeout(timer);
+ console.error(error);
+ alert(`Could not load ride:\n\n${error instanceof Error ? error.message : String(error)}`);
+ file = undefined;
+ source = DataSource.None;
})
.finally(() => (loading = false));
}
diff --git a/src/components/View.ts b/src/components/View.ts
index 011c0d8..5fdd535 100644
--- a/src/components/View.ts
+++ b/src/components/View.ts
@@ -112,7 +112,7 @@ export function computeStats(rows: RowWithIndex[], pois: PointOfInterest[]): Rid
highestFieldWeakeningCurrent,
highestTempMotor,
highestTempController,
- totalDistanceMeters: rows[rows.length - 1]!.distance,
+ totalDistanceMeters: rows.at(-1)?.distance ?? 0,
};
}
diff --git a/src/lib/parse/__fixtures__/floaty.csv b/src/lib/parse/__fixtures__/floaty.csv
new file mode 100644
index 0000000..ac924e6
--- /dev/null
+++ b/src/lib/parse/__fixtures__/floaty.csv
@@ -0,0 +1,4 @@
+timestamp,speed,dutyCycle,batteryVolts,batteryPercent,batteryCurrent,motorCurrent,motorTemp,controllerTemp,tripDistance,lifeDistance,remainingDistance,rollAngle,pitchAngle,truePitchAngle,inputTilt,throttle,ampHours,wattHours,state,switchState,setpointAdjustmentType,faultCode,adc1,adc2,sessionId,altitude,latitude,longitude,accuracy,gpsSpeed,gpsTimestamp
+105,0.4,0.03,81.9,0.92,0.2,9.9,18,18,0,592.211,0,-4,1,0,0,0,0,0,1,1,0,0,3.1,0.08,00000000-0000-0000-0000-000000000000,122.16287420969456,-1.0,1.5,4.55257009550728,0.23000000417232513,110.0522
+115,0.7,0.04,81.8,0.92,0.4,14,21,18,0.5,592.211,0,-4,0,-1,0,0,0,0,1,1,2,0,3.1,0.08,00000000-0000-0000-0000-000000000000,123.54580882564187,-1.1,1.6,4.552570096263577,0.769999980926508,120.0503
+125,,,,0.92,,,0,0,0,592.211,0,-4,0,-1,0,0,0,0,1,3,2,0,3.1,3.08,00000000-0000-0000-0000-000000000000,123.54580882564187,-1.1,1.6,4.552570096263577,0.769999980926508,120.0503
diff --git a/src/lib/parse/float-control.ts b/src/lib/parse/float-control.ts
index 7e1e477..f375a99 100644
--- a/src/lib/parse/float-control.ts
+++ b/src/lib/parse/float-control.ts
@@ -5,6 +5,13 @@ import { attachIndex } from '../misc';
import { RowKey, State, type Row, type RowWithIndex, Units } from './types';
import { FloatControlLimitedError, ParseError } from './errors';
+export function looksLikeFloatControlCsv(headerLine: string): boolean {
+ return headerLine
+ .split(',')
+ .map((header) => header.trim())
+ .some((header) => header in floatControlKeyMap);
+}
+
const transformHeader = (header: string) => {
const key = floatControlKeyMap[header as FloatControlRawHeader];
if (!key && !Object.values(RowKey).includes(header as RowKey)) {
diff --git a/src/lib/parse/floaty.test.ts b/src/lib/parse/floaty.test.ts
index 1128a00..96df0a8 100644
--- a/src/lib/parse/floaty.test.ts
+++ b/src/lib/parse/floaty.test.ts
@@ -1,7 +1,8 @@
import { describe, expect, test } from 'vitest';
-import { parseFloatyJson } from './floaty';
+import { parseFloatyCsv, parseFloatyJson } from './floaty';
import floatyJsonString from './__fixtures__/floaty.json?raw';
import floatyJson from './__fixtures__/floaty.json';
+import floatyCsv from './__fixtures__/floaty.csv?raw';
describe(parseFloatyJson.name, () => {
test('maps gps locations to logs', async () => {
@@ -57,3 +58,32 @@ describe(parseFloatyJson.name, () => {
expect(data[2]!.distance).toBe(data[1]!.distance);
});
});
+
+describe(parseFloatyCsv.name, () => {
+ test('maps floaty csv rows including inline gps', async () => {
+ const { data, units, source, errors } = await parseFloatyCsv(floatyCsv);
+ expect(errors).toEqual([]);
+ expect(source).toBe('floaty');
+ expect(units).toEqual('metric');
+ expect(data).toHaveLength(3);
+
+ expect(data[0]!.speed).toBe(0.4);
+ expect(data[0]!.duty).toBe(3);
+ expect(data[0]!.voltage).toBe(81.9);
+ expect(data[0]!.gps_latitude).toBe(-1.0);
+ expect(data[0]!.gps_longitude).toBe(1.5);
+ expect(data[0]!.time).toBe(0);
+ expect(data[1]!.time).toBe(0.01);
+ });
+
+ test('backfills empty csv cells like floaty json nulls', async () => {
+ const { data } = await parseFloatyCsv(floatyCsv);
+ expect(data[2]!.current_battery).toBe(data[1]!.current_battery);
+ expect(data[2]!.voltage).toBe(data[1]!.voltage);
+ expect(data[2]!.duty).toBe(data[1]!.duty);
+ expect(data[2]!.speed).toBe(data[1]!.speed);
+ expect(data[2]!.temp_mosfet).toBe(data[1]!.temp_mosfet);
+ expect(data[2]!.temp_motor).toBe(data[1]!.temp_motor);
+ expect(data[2]!.distance).toBe(data[1]!.distance);
+ });
+});
diff --git a/src/lib/parse/floaty.ts b/src/lib/parse/floaty.ts
index 248f906..f510ae4 100644
--- a/src/lib/parse/floaty.ts
+++ b/src/lib/parse/floaty.ts
@@ -1,61 +1,74 @@
+import csv from 'papaparse';
+
import { type ParseResult } from './index';
import { attachIndex } from '../misc';
import { FloatyJsonSchema, type ZFloatyJson, type ZLocation, type ZLog } from './floaty.types';
import { DataSource, stateCodeMap, Units, type Row } from './types';
import { ParseError } from './errors';
-function rowsFromFloatyJson(json: ZFloatyJson): Row[] {
- const rows: Row[] = [];
- // NOTE: sometimes Floaty doesn't record values, and seems to just put `null` (or 0) in its logs.
- // When it does, we backtrack until we find the last known value for it.
- const findValue = (index: number, key: keyof ZLog, floatyEmptyValue?: unknown): number => {
- const current = json.logs[index]![key];
- if (current !== null && (floatyEmptyValue === undefined || current !== floatyEmptyValue)) {
- return current;
- }
+/** Headers that uniquely identify a Floaty CSV export (vs Float Control / VESC Tool). */
+const FLOATY_CSV_MARKERS = ['dutyCycle', 'batteryVolts', 'tripDistance'] as const;
- let i = index - 1;
- while (i > 0) {
- const value = json.logs[i]![key];
- if (value !== null && (floatyEmptyValue === undefined || value !== floatyEmptyValue)) {
- return value;
- }
+export function looksLikeFloatyCsv(headerLine: string): boolean {
+ const headers = new Set(headerLine.split(',').map((header) => header.trim()));
+ return FLOATY_CSV_MARKERS.every((marker) => headers.has(marker));
+}
- i--;
+/**
+ * NOTE: sometimes Floaty doesn't record values, and seems to just put `null` (or 0) in its logs.
+ * When it does, we backtrack until we find the last known value for it.
+ */
+function findValue(logs: ZLog[], index: number, key: keyof ZLog, floatyEmptyValue?: unknown): number {
+ const current = logs[index]![key];
+ if (current !== null && (floatyEmptyValue === undefined || current !== floatyEmptyValue)) {
+ return current as number;
+ }
+
+ let i = index - 1;
+ while (i > 0) {
+ const value = logs[i]![key];
+ if (value !== null && (floatyEmptyValue === undefined || value !== floatyEmptyValue)) {
+ return value as number;
}
- return 0;
- };
+ i--;
+ }
- const map = (log: ZLog, location: ZLocation, index: number): Row => {
- const state_raw = findValue(index, 'state');
- return {
- adc1: findValue(index, 'adc1'),
- adc2: findValue(index, 'adc2'),
- ah: findValue(index, 'ampHours'),
- altitude: location.altitude,
- current_battery: findValue(index, 'batteryCurrent'),
- current_motor: findValue(index, 'motorCurrent'),
- distance: findValue(index, 'tripDistance', 0),
- duty: findValue(index, 'dutyCycle') * 100,
- gps_accuracy: location.accuracy,
- gps_latitude: location.latitude,
- gps_longitude: location.longitude,
- motor_fault: findValue(index, 'faultCode'),
- pitch: findValue(index, 'pitchAngle'),
- roll: findValue(index, 'rollAngle'),
- speed: findValue(index, 'speed'),
- state_raw,
- state: stateCodeMap[state_raw] ?? '??',
- temp_mosfet: findValue(index, 'controllerTemp', 0),
- temp_motor: findValue(index, 'motorTemp', 0),
- time: (log.timestamp - json.startTime) / 1000,
- true_pitch: findValue(index, 'truePitchAngle'),
- voltage: findValue(index, 'batteryVolts'),
- wh: findValue(index, 'wattHours'),
- };
+ return 0;
+}
+
+function mapFloatyLog(logs: ZLog[], location: ZLocation, index: number, startTime: number): Row {
+ const log = logs[index]!;
+ const state_raw = findValue(logs, index, 'state');
+ return {
+ adc1: findValue(logs, index, 'adc1'),
+ adc2: findValue(logs, index, 'adc2'),
+ ah: findValue(logs, index, 'ampHours'),
+ altitude: location.altitude,
+ current_battery: findValue(logs, index, 'batteryCurrent'),
+ current_motor: findValue(logs, index, 'motorCurrent'),
+ distance: findValue(logs, index, 'tripDistance', 0),
+ duty: findValue(logs, index, 'dutyCycle') * 100,
+ gps_accuracy: location.accuracy,
+ gps_latitude: location.latitude,
+ gps_longitude: location.longitude,
+ motor_fault: findValue(logs, index, 'faultCode'),
+ pitch: findValue(logs, index, 'pitchAngle'),
+ roll: findValue(logs, index, 'rollAngle'),
+ speed: findValue(logs, index, 'speed'),
+ state_raw,
+ state: stateCodeMap[state_raw] ?? '??',
+ temp_mosfet: findValue(logs, index, 'controllerTemp', 0),
+ temp_motor: findValue(logs, index, 'motorTemp', 0),
+ time: (log.timestamp - startTime) / 1000,
+ true_pitch: findValue(logs, index, 'truePitchAngle'),
+ voltage: findValue(logs, index, 'batteryVolts'),
+ wh: findValue(logs, index, 'wattHours'),
};
+}
+function rowsFromFloatyJson(json: ZFloatyJson): Row[] {
+ const rows: Row[] = [];
const { logs, locations } = json;
let locationIdx = 0;
for (let i = 0; i < logs.length; ++i) {
@@ -65,12 +78,123 @@ function rowsFromFloatyJson(json: ZFloatyJson): Row[] {
location = locations[++locationIdx]!;
}
- rows.push(map(log, location, i));
+ rows.push(mapFloatyLog(logs, location, i, json.startTime));
}
return rows;
}
+const optionalNumber = (value: string | undefined): number | null => {
+ if (value === undefined || value === '') {
+ return null;
+ }
+
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : null;
+};
+
+const requiredNumber = (value: string | undefined, fallback = 0): number => {
+ const parsed = optionalNumber(value);
+ return parsed === null ? fallback : parsed;
+};
+
+type FloatyCsvRow = Record;
+
+function csvRowToLog(row: FloatyCsvRow): ZLog {
+ return {
+ adc1: optionalNumber(row.adc1),
+ adc2: optionalNumber(row.adc2),
+ ampHours: optionalNumber(row.ampHours),
+ batteryCurrent: optionalNumber(row.batteryCurrent),
+ batteryPercent: optionalNumber(row.batteryPercent),
+ batteryVolts: optionalNumber(row.batteryVolts),
+ controllerTemp: optionalNumber(row.controllerTemp),
+ dutyCycle: optionalNumber(row.dutyCycle),
+ faultCode: optionalNumber(row.faultCode),
+ inputTilt: optionalNumber(row.inputTilt),
+ lifeDistance: optionalNumber(row.lifeDistance),
+ motorCurrent: optionalNumber(row.motorCurrent),
+ motorTemp: optionalNumber(row.motorTemp),
+ pitchAngle: optionalNumber(row.pitchAngle),
+ remainingDistance: optionalNumber(row.remainingDistance),
+ rollAngle: optionalNumber(row.rollAngle),
+ setpointAdjustmentType: optionalNumber(row.setpointAdjustmentType),
+ speed: optionalNumber(row.speed),
+ state: optionalNumber(row.state),
+ switchState: optionalNumber(row.switchState),
+ throttle: optionalNumber(row.throttle),
+ timestamp: requiredNumber(row.timestamp),
+ tripDistance: optionalNumber(row.tripDistance),
+ truePitchAngle: optionalNumber(row.truePitchAngle),
+ wattHours: optionalNumber(row.wattHours),
+ };
+}
+
+function csvRowToLocation(row: FloatyCsvRow): ZLocation {
+ return {
+ timestamp: requiredNumber(row.gpsTimestamp ?? row.timestamp),
+ altitude: requiredNumber(row.altitude),
+ latitude: requiredNumber(row.latitude),
+ longitude: requiredNumber(row.longitude),
+ accuracy: requiredNumber(row.accuracy),
+ speed: requiredNumber(row.gpsSpeed),
+ };
+}
+
+function rowsFromFloatyCsv(rawRows: FloatyCsvRow[]): Row[] {
+ const logs = rawRows.map(csvRowToLog);
+ const startTime = logs[0]?.timestamp ?? 0;
+ return rawRows.map((raw, index) => mapFloatyLog(logs, csvRowToLocation(raw), index, startTime));
+}
+
+export async function parseFloatyCsv(input: string | File): Promise {
+ const text = typeof input === 'string' ? input : await input.text();
+
+ return new Promise((resolve) => {
+ csv.parse(text, {
+ header: true,
+ skipEmptyLines: true,
+ complete: (results) => {
+ try {
+ if (results.data.length === 0) {
+ resolve({
+ source: DataSource.Floaty,
+ data: [],
+ units: Units.Metric,
+ errors: [new ParseError('Floaty CSV contained no rows!', results.errors)],
+ });
+ return;
+ }
+
+ resolve({
+ source: DataSource.Floaty,
+ data: attachIndex(rowsFromFloatyCsv(results.data)),
+ units: Units.Metric,
+ errors: results.errors.length
+ ? [new ParseError('Failed to parse Floaty CSV properly!', results.errors)]
+ : [],
+ });
+ } catch (error) {
+ resolve({
+ source: DataSource.Floaty,
+ data: [],
+ units: Units.Metric,
+ errors: [new ParseError('Failed to parse Floaty CSV!', error)],
+ });
+ }
+ },
+ error: (error: Error) => {
+ resolve({
+ source: DataSource.Floaty,
+ data: [],
+ units: Units.Metric,
+ errors: [new ParseError('Failed to parse Floaty CSV!', error)],
+ });
+ },
+ });
+ });
+}
+
export async function parseFloatyJson(input: string | File): Promise {
try {
const json = JSON.parse(typeof input === 'string' ? input : await input.text());
diff --git a/src/lib/parse/index.test.ts b/src/lib/parse/index.test.ts
index 1b1a5c8..0dcbbe3 100644
--- a/src/lib/parse/index.test.ts
+++ b/src/lib/parse/index.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, test } from 'vitest';
import fcMetricCsv from './__fixtures__/fc_metric.csv?raw';
+import floatyCsv from './__fixtures__/floaty.csv?raw';
import vescMetricCsv from './__fixtures__/vesc_metric.csv?raw';
import { parse } from './index';
@@ -28,4 +29,19 @@ describe(parse.name, () => {
const result = await parse(new MockFile([fcMetricCsv], 'fc.csv', { type: 'text/csv' }));
expect(result.source).toBe('float_control');
});
+
+ test('routes Floaty CSV to Floaty parser', async () => {
+ const result = await parse(new MockFile([floatyCsv], 'floaty.csv', { type: 'text/csv' }));
+ expect(result.source).toBe('floaty');
+ expect(result.errors).toEqual([]);
+ expect(result.data).toHaveLength(3);
+ });
+
+ test('rejects unrecognised CSV headers with a parse error', async () => {
+ const result = await parse(new MockFile(['foo,bar\n1,2\n'], 'unknown.csv', { type: 'text/csv' }));
+ expect(result.source).toBe('none');
+ expect(result.data).toEqual([]);
+ expect(result.errors).toHaveLength(1);
+ expect(result.errors[0]!.message).toMatch(/Unrecognised CSV headers/);
+ });
});
diff --git a/src/lib/parse/index.ts b/src/lib/parse/index.ts
index 4e47911..e93fdd4 100644
--- a/src/lib/parse/index.ts
+++ b/src/lib/parse/index.ts
@@ -1,7 +1,7 @@
import * as fflate from 'fflate';
-import { parseFloatControlCsv } from './float-control';
-import { parseFloatyJson } from './floaty';
+import { parseFloatControlCsv, looksLikeFloatControlCsv } from './float-control';
+import { looksLikeFloatyCsv, parseFloatyCsv, parseFloatyJson } from './floaty';
import { parseVescToolCsv } from './vesc-tool';
import { DataSource, Units, type RowWithIndex } from './types';
import { ParseError } from './errors';
@@ -56,18 +56,34 @@ export async function parse(file: File): Promise {
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.
+ // Heuristic: VESC Tool exports are semicolon-delimited, while Float Control / Floaty use commas.
if (semicolonCount > commaCount) {
return await parseVescToolCsv(text);
}
- const parsed = await parseFloatControlCsv(text);
+ if (looksLikeFloatyCsv(firstLine)) {
+ return await parseFloatyCsv(text);
+ }
+
+ if (looksLikeFloatControlCsv(firstLine)) {
+ const parsed = await parseFloatControlCsv(text);
+ return {
+ source: DataSource.FloatControl,
+ data: parsed.csv.data,
+ units: parsed.units,
+ errors: parsed.errors,
+ };
+ }
+
return {
- source: DataSource.FloatControl,
- data: parsed.csv.data,
- units: parsed.units,
- errors: parsed.errors,
+ source: DataSource.None,
+ data: [],
+ units: Units.Metric,
+ errors: [
+ new ParseError('Unrecognised CSV headers. Expected a Float Control, Floaty, or VESC Tool export.', {
+ header: firstLine,
+ }),
+ ],
};
}