Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,13 +199,16 @@ Configure notification providers and alert preferences under **Settings → Noti
Bring your history across from another app under **Settings → Import Data**: choose **New import**,
pick the source, add its export files, and check the preview before importing.

| Source | Getting the export | Files |
| --------- | ------------------------------------------------------------------------ | ----------------- |
| ElectraFi | **Settings → Account → Advanced → Download Data**, then **Download All** | `.csv`, `.csv.gz` |
| Source | Getting the export | Files |
| ------------- | ------------------------------------------------------------------------------------------------------------------- | ----------------- |
| Rivian Roamer | **Account** (from the menu by your name) → **Data → Download a copy**; request it, then **Download ZIP** once ready | one `.zip` |
| ElectraFi | **Settings → Account → Advanced → Download Data**, then **Download All** | `.csv`, `.csv.gz` |

- Imports add drives, charging sessions, and vehicle history.
- A Rivian Roamer copy is matched to your Rivian by VIN, and brings each charge's curve and energy
breakdown.
- Anything already recorded stays as it is, and an existing charge only gets what it's missing.
- **Undo** removes what an import added.
- **Undo** removes what an import added, apart from drivers and saved places, which stay.

### TeslaMate

Expand Down
26 changes: 26 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,13 @@
"pino-pretty": "^13.2.0",
"postgres": "^3.4.9",
"ws": "^8.22.0",
"yauzl": "^3.4.0",
"zod": "^4.6.7"
},
"devDependencies": {
"@types/node": "^22.20.4",
"@types/ws": "^8.18.2",
"@types/yauzl": "^3.4.0",
"drizzle-kit": "^0.31.11",
"tsx": "^4.23.15",
"typescript": "^6.0.3",
Expand Down
33 changes: 28 additions & 5 deletions server/src/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,9 +388,9 @@ export interface ChargingSessionDto {
socLimit: number | null;
/**
* live = recorded by RivianMate; rivian = imported from Rivian's history;
* electrafi = imported from an ElectraFi export; teslamate = read from TeslaMate.
* electrafi, roamer = imported from that app's export; teslamate = read from TeslaMate.
*/
source: 'live' | 'rivian' | 'live+rivian' | 'electrafi' | 'teslamate';
source: 'live' | 'rivian' | 'live+rivian' | 'electrafi' | 'roamer' | 'teslamate';
vendor: string | null;
city: string | null;
isPublic: boolean | null;
Expand Down Expand Up @@ -585,17 +585,25 @@ export interface ImportCounts {
sessionsEnriched: number;
locationPoints: number;
stateChanges: number;
/** Saved places added; absent on imports from before places were. */
places?: number;
}

/** An ElectraFi upload waiting to be imported. */
/** The kinds of export that can be imported. */
export type ImportFormat = 'electrafi' | 'roamer';

/** An upload waiting to be imported. */
export interface ImportDraftDto {
id: string;
format: ImportFormat;
files: string[];
/** The vehicles the export has data for, matched by VIN; null when it doesn't say. */
vehicleIds: string[] | null;
}

export interface ImportSelection {
vehicleId: string;
/** IANA time zone the export's local times are in. */
/** IANA time zone the dates are in, and an ElectraFi export's local times. */
timeZone: string;
/** Both included, as YYYY-MM-DD in `timeZone`. */
from: string;
Expand All @@ -619,11 +627,20 @@ export interface ImportDrivePreview {
/** Saved locations it starts and ends at. */
startPlace: string | null;
endPlace: string | null;
/** Who the export says drove. */
driver: string | null;
}

/** What a charge can add to a session that's missing it. */
export type ImportSessionAddition =
'chargingTime' | 'batteryLevels' | 'energy' | 'range' | 'power' | 'location' | 'curve';
| 'chargingTime'
| 'batteryLevels'
| 'energy'
| 'breakdown'
| 'range'
| 'power'
| 'location'
| 'curve';

export interface ImportChargePreview {
startedAt: string;
Expand Down Expand Up @@ -655,6 +672,11 @@ export interface ImportPreviewDto {
sessionsEnriched: number;
readingsNew: number;
readingsRecorded: number;
/** Past what live recording last reported, so left for it. */
readingsNewer: number;
/** Names of the saved places it adds, and how many are saved already. */
placesNew: string[];
placesRecorded: number;
warnings: ('odometer' | 'future')[];
/** Everything starting in the chosen dates, in time order. */
drives: ImportDrivePreview[];
Expand All @@ -664,6 +686,7 @@ export interface ImportPreviewDto {
export interface ImportBatchDto {
id: number;
vehicleId: string;
format: ImportFormat;
files: string[];
timeZone: string;
from: string;
Expand Down
4 changes: 2 additions & 2 deletions server/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { RivianApi } from './rivian/client.js';
import type { RivianGovernor } from './rivian/governor.js';
import type { RivianTokens } from './rivian/types.js';
import type { PlaceSearch } from './services/drive-places.js';
import type { ElectraFiImports } from './services/electrafi/imports.js';
import type { Imports } from './services/imports/imports.js';
import type { LiveBus } from './services/live-bus.js';
import type { LoginThrottle } from './services/login-throttle.js';
import type { NotificationService } from './services/notifications.js';
Expand Down Expand Up @@ -46,7 +46,7 @@ export interface AppContext {
/** Vehicles read from a TeslaMate database, when one is set up. */
teslamate: TeslaMateSource;
/** Uploads and runs ElectraFi imports. */
imports: ElectraFiImports;
imports: Imports;
/** Shared throttle/cooldown/counters for all Rivian traffic. */
governor: RivianGovernor;
/** Address search for placing saved locations. */
Expand Down
25 changes: 19 additions & 6 deletions server/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export const importBatches = pgTable('import_batches', {
.notNull()
.references(() => vehicles.id),
/** What kind of export it came from. */
format: text('format', { enum: ['electrafi'] }).notNull(),
format: text('format', { enum: ['electrafi', 'roamer'] }).notNull(),
files: jsonb('files').$type<string[]>().notNull(),
timeZone: text('time_zone').notNull(),
/** The dates chosen, in `time_zone`, both included. */
Expand Down Expand Up @@ -274,8 +274,11 @@ export const chargingSessions = pgTable(
lat: doublePrecision('lat'),
lon: doublePrecision('lon'),
rawFinal: jsonb('raw_final'),
/** live = recorded by RivianMate; rivian = imported; live+rivian = both; electrafi = from an ElectraFi export. */
source: text('source', { enum: ['live', 'rivian', 'live+rivian', 'electrafi'] })
/**
* live = recorded by RivianMate; rivian = imported; live+rivian = both;
* electrafi, roamer = from that app's export.
*/
source: text('source', { enum: ['live', 'rivian', 'live+rivian', 'electrafi', 'roamer'] })
.notNull()
.default('live'),
rivianTransactionId: text('rivian_transaction_id'),
Expand Down Expand Up @@ -328,11 +331,21 @@ export const chargingCurvePoints = pgTable(
* feed's chart;
* push_live = the push feed's current reading; forecast = the vehicle's
* projection to the limit for a charge in progress (replaced by each
* graph, never a reading); electrafi = from an ElectraFi export; legacy =
* recorded before sources were kept apart.
* graph, never a reading); roamer = the push feed's readings as Roamer
* kept them; electrafi = from an ElectraFi export; legacy = recorded
* before sources were kept apart.
*/
source: text('source', {
enum: ['observed', 'graph', 'push_chart', 'push_live', 'forecast', 'electrafi', 'legacy'],
enum: [
'observed',
'graph',
'push_chart',
'push_live',
'forecast',
'roamer',
'electrafi',
'legacy',
],
})
.notNull()
.default('legacy'),
Expand Down
4 changes: 2 additions & 2 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { seedAppPassword } from './routes/auth.js';
import { getLookupSettings } from './routes/settings.js';
import { DrivePlaces, nominatimGeocoder, nominatimSearch } from './services/drive-places.js';
import { DriveWeather, openMeteoTemperatures } from './services/drive-weather.js';
import { ElectraFiImports } from './services/electrafi/imports.js';
import { Imports } from './services/imports/imports.js';
import { LiveBus } from './services/live-bus.js';
import { NotificationService } from './services/notifications.js';
import { LoginThrottle } from './services/login-throttle.js';
Expand Down Expand Up @@ -104,7 +104,7 @@ async function main(): Promise<void> {
);
notifications.start(bus);

const imports = new ElectraFiImports(db, historyKeys, logger.child({ component: 'import' }), () =>
const imports = new Imports(db, historyKeys, logger.child({ component: 'import' }), () =>
monitor.queueDriveLookups(),
);
await imports.recover();
Expand Down
9 changes: 5 additions & 4 deletions server/src/routes/imports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { FastifyInstance } from 'fastify';
import type { Readable } from 'node:stream';
import { z } from 'zod';
import type { AppContext } from '../context.js';
import { ImportError } from '../services/electrafi/imports.js';
import { ImportError } from '../services/imports/format.js';

const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
const selectionSchema = z.object({
Expand All @@ -14,7 +14,8 @@ const selectionSchema = z.object({
});
const uploadQuery = z.object({
draft: z.string().min(1).max(64).optional(),
name: z.string().max(1000).default('export.csv'),
name: z.string().max(1000).default('export'),
format: z.enum(['electrafi', 'roamer']).default('electrafi'),
});

export async function importRoutes(app: FastifyInstance, ctx: AppContext): Promise<void> {
Expand All @@ -38,11 +39,11 @@ export async function importRoutes(app: FastifyInstance, ctx: AppContext): Promi
app.get('/api/imports', async () => ctx.imports.list());

app.post('/api/imports/files', async (request) => {
const { draft, name } = uploadQuery.parse(request.query);
const { draft, name, format } = uploadQuery.parse(request.query);
if (request.headers['content-type'] !== 'application/octet-stream') {
throw new ImportError('Upload the file as a binary body.', 415);
}
return ctx.imports.addFile(draft, name, request.body as Readable);
return ctx.imports.addFile(draft, name, request.body as Readable, format);
});

app.delete<{ Params: { id: string } }>('/api/imports/drafts/:id', async (request, reply) => {
Expand Down
13 changes: 11 additions & 2 deletions server/src/services/charging-curve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@
import type { LiveSessionData } from '../rivian/types.js';

export type CurveSource =
'observed' | 'graph' | 'push_chart' | 'push_live' | 'forecast' | 'electrafi' | 'legacy';
| 'observed'
| 'graph'
| 'push_chart'
| 'push_live'
| 'forecast'
| 'roamer'
| 'electrafi'
| 'legacy';

export interface CurvePoint {
source: CurveSource;
Expand All @@ -14,14 +21,16 @@ export interface CurvePoint {
/**
* Recorded series, most trusted first: Parallax's charging graph is the
* vehicle's own record; the push feed's chart is Rivian's; its current
* readings are snapshots; an ElectraFi export is another app's samples. Rows
* readings are snapshots, which a Roamer export keeps too; an ElectraFi
* export is another app's samples. Rows
* from before sources were kept apart come last. The forecast is never a
* recorded series.
*/
const SOURCE_ORDER: readonly CurveSource[] = [
'graph',
'push_chart',
'push_live',
'roamer',
'electrafi',
'legacy',
];
Expand Down
4 changes: 2 additions & 2 deletions server/src/services/drive-places.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { and, desc, eq, isNotNull, isNull } from 'drizzle-orm';
import { and, desc, eq, inArray, isNotNull, isNull } from 'drizzle-orm';
import { nearbySpot, wallboxSpots } from './locations.js';
import type { Db } from '../db/client.js';
import { type Logger, silentLogger } from '../logger.js';
Expand Down Expand Up @@ -192,7 +192,7 @@ export class DrivePlaces {
.from(chargingSessions)
.where(
and(
eq(chargingSessions.source, 'electrafi'),
inArray(chargingSessions.source, ['electrafi', 'roamer']),
isNull(chargingSessions.placesCheckedAt),
isNull(chargingSessions.vendor),
isNull(chargingSessions.city),
Expand Down
22 changes: 22 additions & 0 deletions server/src/services/drivers.db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,28 @@ describe.skipIf(!url)('Drivers with Postgres', () => {
{ profileId: BEN, reportedName: 'Ben', lastSeenAt: at(2) },
]);
});

it('takes over a driver an import made from the name, with its drives', async () => {
const [imported] = await handle.db
.insert(drivers)
.values({ vehicleId: 'v1', reportedName: 'Ben V.', name: 'Benny' })
.returning({ id: drivers.id });
const id = await drive(0);
await handle.db
.update(drives)
.set({ driverId: imported!.id, driverSource: 'profile' })
.where(eq(drives.id, id));

await service.noteProfile('v1', BEN, at(10));
service.noteName('v1', ' ben v. ');
await service.flushNames();

const rows = await handle.db
.select({ id: drivers.id, profileId: drivers.profileId, name: drivers.name })
.from(drivers);
expect(rows).toEqual([{ id: await driverId(BEN), profileId: BEN, name: 'Benny' }]);
expect(await driveDriver(id)).toEqual({ driverId: await driverId(BEN), source: 'profile' });
});
});

async function waitFor(check: () => Promise<boolean>, timeoutMs = 5000): Promise<void> {
Expand Down
Loading
Loading