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