Add GPS trip reports and route maps

This commit is contained in:
2026-08-01 00:56:19 +02:00
parent cb0ac06091
commit e501895e56
12 changed files with 817 additions and 21 deletions
+1 -1
View File
@@ -168,7 +168,7 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
reply.header("X-Frame-Options", "DENY");
reply.header(
"Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'"
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://*.tile.openstreetmap.org; connect-src 'self'"
);
});
+46
View File
@@ -101,6 +101,52 @@ function migrate(database: CompanionDatabase): void {
network_type TEXT
);
CREATE TABLE IF NOT EXISTS trips (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL REFERENCES mobile_devices(id) ON DELETE CASCADE,
started_at INTEGER NOT NULL,
ended_at INTEGER,
last_moving_at INTEGER NOT NULL,
start_latitude REAL NOT NULL,
start_longitude REAL NOT NULL,
end_latitude REAL NOT NULL,
end_longitude REAL NOT NULL,
distance_km REAL NOT NULL DEFAULT 0,
moving_seconds REAL NOT NULL DEFAULT 0,
point_count INTEGER NOT NULL DEFAULT 0,
speed_sum REAL NOT NULL DEFAULT 0,
speed_count INTEGER NOT NULL DEFAULT 0,
peak_speed_kph REAL,
peak_speed_at INTEGER,
peak_speed_latitude REAL,
peak_speed_longitude REAL,
vehicle_battery_start REAL,
vehicle_battery_end REAL,
vehicle_consumption_start_kwh REAL,
vehicle_consumption_end_kwh REAL,
phone_battery_start REAL,
phone_battery_end REAL
);
CREATE INDEX IF NOT EXISTS trips_started_idx ON trips(started_at DESC);
CREATE UNIQUE INDEX IF NOT EXISTS trips_active_device_idx ON trips(device_id) WHERE ended_at IS NULL;
CREATE TABLE IF NOT EXISTS trip_points (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
captured_at INTEGER NOT NULL,
latitude REAL NOT NULL,
longitude REAL NOT NULL,
accuracy_meters REAL NOT NULL,
altitude_meters REAL,
speed_kph REAL,
bearing_degrees REAL,
vehicle_battery_percent REAL,
UNIQUE(trip_id, captured_at)
);
CREATE INDEX IF NOT EXISTS trip_points_trip_time_idx ON trip_points(trip_id, captured_at);
CREATE TABLE IF NOT EXISTS car_telemetry_samples (
recorded_at INTEGER PRIMARY KEY,
speed_kph REAL,
+46 -14
View File
@@ -8,6 +8,7 @@ import type { CarTelemetryRecorder } from "./car-telemetry.js";
import type { CompanionDatabase } from "./database.js";
import { hashToken, randomToken } from "./security.js";
import { collectSystemStatus } from "./status.js";
import { TripStore } from "./trips.js";
const pairingSchema = z.object({
code: z.string().regex(/^\d{6}$/),
@@ -71,6 +72,8 @@ export function registerMobileRoutes(
carTelemetry: CarTelemetryRecorder,
requireWebUser: (request: FastifyRequest, reply: FastifyReply) => boolean
): void {
const trips = new TripStore(database);
app.post("/api/mobile/pairing", async (request, reply) => {
if (!requireWebUser(request, reply)) return;
const code = String(randomInt(100_000, 1_000_000));
@@ -94,6 +97,22 @@ export function registerMobileRoutes(
return { devices };
});
app.get("/api/trips", async (request, reply) => {
if (!requireWebUser(request, reply)) return;
return trips.list();
});
app.get<{ Params: { id: string } }>("/api/trips/:id", async (request, reply) => {
if (!requireWebUser(request, reply)) return;
const id = Number(request.params.id);
if (!Number.isInteger(id) || id < 1) {
return reply.code(400).send({ error: { code: "INVALID_TRIP", message: "That trip is invalid" } });
}
const trip = trips.detail(id);
if (!trip) return reply.code(404).send({ error: { code: "TRIP_NOT_FOUND", message: "Trip not found" } });
return trip;
});
app.get("/api/mobile/bluetooth", async (request, reply) => {
if (!requireWebUser(request, reply)) return;
return readBluetoothStatus();
@@ -170,20 +189,33 @@ export function registerMobileRoutes(
const parsed = locationSchema.safeParse(request.body);
if (!parsed.success) return reply.code(400).send({ error: { code: "INVALID_PHONE_LOCATION", message: "The phone location was invalid" } });
const value = parsed.data;
database.prepare(
`INSERT INTO mobile_phone_state
(device_id, received_at, captured_at, latitude, longitude, accuracy_meters, altitude_meters,
speed_kph, bearing_degrees, battery_percent, network_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(device_id) DO UPDATE SET
received_at = excluded.received_at, captured_at = excluded.captured_at,
latitude = excluded.latitude, longitude = excluded.longitude, accuracy_meters = excluded.accuracy_meters,
altitude_meters = excluded.altitude_meters, speed_kph = excluded.speed_kph,
bearing_degrees = excluded.bearing_degrees, battery_percent = excluded.battery_percent,
network_type = excluded.network_type`
).run(device.id, new Date().toISOString(), value.capturedAt, value.latitude, value.longitude, value.accuracyMeters,
value.altitudeMeters ?? null, value.speedKph ?? null, value.bearingDegrees ?? null,
value.batteryPercent ?? null, value.networkType ?? null);
return { accepted: true };
const trip = database.transaction(() => {
database.prepare(
`INSERT INTO mobile_phone_state
(device_id, received_at, captured_at, latitude, longitude, accuracy_meters, altitude_meters,
speed_kph, bearing_degrees, battery_percent, network_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(device_id) DO UPDATE SET
received_at = excluded.received_at, captured_at = excluded.captured_at,
latitude = excluded.latitude, longitude = excluded.longitude, accuracy_meters = excluded.accuracy_meters,
altitude_meters = excluded.altitude_meters, speed_kph = excluded.speed_kph,
bearing_degrees = excluded.bearing_degrees, battery_percent = excluded.battery_percent,
network_type = excluded.network_type`
).run(device.id, new Date().toISOString(), value.capturedAt, value.latitude, value.longitude, value.accuracyMeters,
value.altitudeMeters ?? null, value.speedKph ?? null, value.bearingDegrees ?? null,
value.batteryPercent ?? null, value.networkType ?? null);
return trips.record({
deviceId: device.id,
capturedAt: value.capturedAt,
latitude: value.latitude,
longitude: value.longitude,
accuracyMeters: value.accuracyMeters,
altitudeMeters: value.altitudeMeters ?? null,
speedKph: value.speedKph ?? null,
bearingDegrees: value.bearingDegrees ?? null,
phoneBatteryPercent: value.batteryPercent ?? null
});
})();
return { accepted: true, trip };
});
app.post<{ Params: { id: string } }>("/internal/mobile/shortcuts/:id/launch", async (request, reply) => {
+264
View File
@@ -0,0 +1,264 @@
import type { CompanionDatabase } from "./database.js";
const START_SPEED_KPH = 5;
const STOP_TIMEOUT_MS = 3 * 60_000;
const GAP_TIMEOUT_MS = 10 * 60_000;
const MAX_LOCATION_ACCURACY_METERS = 100;
const CAR_SAMPLE_WINDOW_MS = 30_000;
export type LocationSample = {
deviceId: string;
capturedAt: string;
latitude: number;
longitude: number;
accuracyMeters: number;
altitudeMeters?: number | null;
speedKph?: number | null;
bearingDegrees?: number | null;
phoneBatteryPercent?: number | null;
};
type CarSample = {
speed_kph: number | null;
battery_percent: number | null;
total_consumption_kwh: number | null;
};
type ActiveTrip = {
id: number;
started_at: number;
last_moving_at: number;
end_latitude: number;
end_longitude: number;
};
type PreviousPoint = {
captured_at: number;
latitude: number;
longitude: number;
speed_kph: number | null;
};
function finite(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function haversineKm(a: { latitude: number; longitude: number }, b: { latitude: number; longitude: number }): number {
const radians = Math.PI / 180;
const lat1 = a.latitude * radians;
const lat2 = b.latitude * radians;
const deltaLat = (b.latitude - a.latitude) * radians;
const deltaLon = (b.longitude - a.longitude) * radians;
const value = Math.sin(deltaLat / 2) ** 2
+ Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLon / 2) ** 2;
return 6_371 * 2 * Math.atan2(Math.sqrt(value), Math.sqrt(1 - value));
}
export class TripStore {
constructor(private readonly database: CompanionDatabase) {}
record(sample: LocationSample): { tripId: number | null; active: boolean } {
const capturedAt = Date.parse(sample.capturedAt);
if (!Number.isFinite(capturedAt) || sample.accuracyMeters > MAX_LOCATION_ACCURACY_METERS) {
return { tripId: null, active: false };
}
const car = this.database.prepare(
`SELECT speed_kph, battery_percent, total_consumption_kwh
FROM car_telemetry_samples WHERE recorded_at BETWEEN ? AND ?
ORDER BY ABS(recorded_at - ?) LIMIT 1`
).get(capturedAt - CAR_SAMPLE_WINDOW_MS, capturedAt + CAR_SAMPLE_WINDOW_MS, capturedAt) as CarSample | undefined;
const speedKph = finite(car?.speed_kph) ?? finite(sample.speedKph);
const moving = (speedKph ?? 0) >= START_SPEED_KPH;
let active = this.activeTrip(sample.deviceId);
if (active && capturedAt <= active.started_at) return { tripId: active.id, active: true };
if (active && capturedAt - active.last_moving_at >= GAP_TIMEOUT_MS) {
this.finish(active.id, active.last_moving_at, active.end_latitude, active.end_longitude);
active = undefined;
}
if (!active && !moving) return { tripId: null, active: false };
if (!active) {
const result = this.database.prepare(
`INSERT INTO trips
(device_id, started_at, last_moving_at, start_latitude, start_longitude,
end_latitude, end_longitude, peak_speed_kph, peak_speed_at,
peak_speed_latitude, peak_speed_longitude, vehicle_battery_start,
vehicle_battery_end, vehicle_consumption_start_kwh, vehicle_consumption_end_kwh,
phone_battery_start, phone_battery_end)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
sample.deviceId, capturedAt, capturedAt, sample.latitude, sample.longitude,
sample.latitude, sample.longitude, speedKph, capturedAt, sample.latitude, sample.longitude,
finite(car?.battery_percent), finite(car?.battery_percent), finite(car?.total_consumption_kwh),
finite(car?.total_consumption_kwh), finite(sample.phoneBatteryPercent), finite(sample.phoneBatteryPercent)
);
active = {
id: Number(result.lastInsertRowid),
started_at: capturedAt,
last_moving_at: capturedAt,
end_latitude: sample.latitude,
end_longitude: sample.longitude
};
}
const previous = this.database.prepare(
`SELECT captured_at, latitude, longitude, speed_kph FROM trip_points
WHERE trip_id = ? ORDER BY captured_at DESC LIMIT 1`
).get(active.id) as PreviousPoint | undefined;
const elapsedSeconds = previous ? Math.max(0, (capturedAt - previous.captured_at) / 1_000) : 0;
const distanceKm = previous && elapsedSeconds <= GAP_TIMEOUT_MS / 1_000
? haversineKm(previous, sample)
: 0;
const plausibleDistanceKm = Math.max(0.25, elapsedSeconds / 3_600 * 250);
const segmentMoving = moving || (previous?.speed_kph ?? 0) >= START_SPEED_KPH;
const acceptedDistanceKm = segmentMoving && distanceKm <= plausibleDistanceKm ? distanceKm : 0;
const movingSeconds = segmentMoving && previous ? Math.min(elapsedSeconds, 30) : 0;
const transaction = this.database.transaction(() => {
const inserted = this.database.prepare(
`INSERT OR IGNORE INTO trip_points
(trip_id, captured_at, latitude, longitude, accuracy_meters, altitude_meters,
speed_kph, bearing_degrees, vehicle_battery_percent)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
active!.id, capturedAt, sample.latitude, sample.longitude, sample.accuracyMeters,
sample.altitudeMeters ?? null, speedKph, sample.bearingDegrees ?? null,
finite(car?.battery_percent)
);
if (inserted.changes === 0) return;
this.database.prepare(
`UPDATE trips SET
last_moving_at = CASE WHEN ? THEN ? ELSE last_moving_at END,
end_latitude = CASE WHEN ? THEN ? ELSE end_latitude END,
end_longitude = CASE WHEN ? THEN ? ELSE end_longitude END,
distance_km = distance_km + ?, moving_seconds = moving_seconds + ?,
point_count = point_count + 1,
speed_sum = speed_sum + ?, speed_count = speed_count + ?,
peak_speed_at = CASE WHEN ? IS NOT NULL AND (peak_speed_kph IS NULL OR ? > peak_speed_kph) THEN ? ELSE peak_speed_at END,
peak_speed_latitude = CASE WHEN ? IS NOT NULL AND (peak_speed_kph IS NULL OR ? > peak_speed_kph) THEN ? ELSE peak_speed_latitude END,
peak_speed_longitude = CASE WHEN ? IS NOT NULL AND (peak_speed_kph IS NULL OR ? > peak_speed_kph) THEN ? ELSE peak_speed_longitude END,
peak_speed_kph = CASE WHEN ? IS NOT NULL AND (peak_speed_kph IS NULL OR ? > peak_speed_kph) THEN ? ELSE peak_speed_kph END,
vehicle_battery_end = COALESCE(?, vehicle_battery_end),
vehicle_consumption_end_kwh = COALESCE(?, vehicle_consumption_end_kwh),
phone_battery_end = COALESCE(?, phone_battery_end)
WHERE id = ?`
).run(
moving ? 1 : 0, capturedAt, moving ? 1 : 0, sample.latitude, moving ? 1 : 0, sample.longitude,
acceptedDistanceKm, movingSeconds, moving && speedKph !== null ? speedKph : 0, moving && speedKph !== null ? 1 : 0,
speedKph, speedKph, capturedAt,
speedKph, speedKph, sample.latitude,
speedKph, speedKph, sample.longitude,
speedKph, speedKph, speedKph,
finite(car?.battery_percent), finite(car?.total_consumption_kwh), finite(sample.phoneBatteryPercent), active!.id
);
});
transaction();
if (!moving && capturedAt - active.last_moving_at >= STOP_TIMEOUT_MS) {
this.finish(active.id, active.last_moving_at, active.end_latitude, active.end_longitude);
return { tripId: active.id, active: false };
}
return { tripId: active.id, active: true };
}
list() {
this.finalizeStale();
const rows = this.database.prepare(
`SELECT trips.*, mobile_devices.name AS device_name FROM trips
JOIN mobile_devices ON mobile_devices.id = trips.device_id
ORDER BY started_at DESC`
).all() as Record<string, unknown>[];
return { trips: rows.map((row) => this.summary(row)) };
}
detail(id: number) {
this.finalizeStale();
const row = this.database.prepare(
`SELECT trips.*, mobile_devices.name AS device_name FROM trips
JOIN mobile_devices ON mobile_devices.id = trips.device_id WHERE trips.id = ?`
).get(id) as Record<string, unknown> | undefined;
if (!row) return null;
const endedAt = finite(row.ended_at);
const points = this.database.prepare(
`SELECT captured_at AS capturedAt, latitude, longitude, accuracy_meters AS accuracyMeters,
altitude_meters AS altitudeMeters, speed_kph AS speedKph, bearing_degrees AS bearingDegrees,
vehicle_battery_percent AS vehicleBatteryPercent
FROM trip_points WHERE trip_id = ? AND captured_at <= ? ORDER BY captured_at`
).all(id, endedAt ?? Date.now());
return {
...this.summary(row),
points,
pins: [
{ type: "start", latitude: Number(row.start_latitude), longitude: Number(row.start_longitude), capturedAt: Number(row.started_at) },
finite(row.peak_speed_latitude) === null ? null : {
type: "peak-speed", latitude: Number(row.peak_speed_latitude), longitude: Number(row.peak_speed_longitude),
capturedAt: finite(row.peak_speed_at), speedKph: finite(row.peak_speed_kph)
},
{ type: "end", latitude: Number(row.end_latitude), longitude: Number(row.end_longitude), capturedAt: endedAt }
].filter(Boolean)
};
}
private activeTrip(deviceId: string): ActiveTrip | undefined {
return this.database.prepare(
`SELECT id, started_at, last_moving_at, end_latitude, end_longitude
FROM trips WHERE device_id = ? AND ended_at IS NULL`
).get(deviceId) as ActiveTrip | undefined;
}
private finish(id: number, endedAt: number, latitude: number, longitude: number): void {
this.database.transaction(() => {
this.database.prepare("DELETE FROM trip_points WHERE trip_id = ? AND captured_at > ?").run(id, endedAt);
this.database.prepare(
`UPDATE trips SET ended_at = ?, end_latitude = ?, end_longitude = ?,
point_count = (SELECT COUNT(*) FROM trip_points WHERE trip_id = ?)
WHERE id = ? AND ended_at IS NULL`
).run(endedAt, latitude, longitude, id, id);
})();
}
private finalizeStale(): void {
const cutoff = Date.now() - STOP_TIMEOUT_MS;
const stale = this.database.prepare(
`SELECT id, last_moving_at, end_latitude, end_longitude FROM trips
WHERE ended_at IS NULL AND last_moving_at < ?`
).all(cutoff) as Array<{ id: number; last_moving_at: number; end_latitude: number; end_longitude: number }>;
for (const trip of stale) this.finish(trip.id, trip.last_moving_at, trip.end_latitude, trip.end_longitude);
}
private summary(row: Record<string, unknown>) {
const startedAt = Number(row.started_at);
const endedAt = finite(row.ended_at);
const vehicleStart = finite(row.vehicle_battery_start);
const vehicleEnd = finite(row.vehicle_battery_end);
const consumptionStart = finite(row.vehicle_consumption_start_kwh);
const consumptionEnd = finite(row.vehicle_consumption_end_kwh);
const speedCount = Number(row.speed_count ?? 0);
return {
id: Number(row.id),
deviceId: String(row.device_id),
deviceName: String(row.device_name),
startedAt,
endedAt,
active: endedAt === null,
durationSeconds: Math.max(0, ((endedAt ?? Date.now()) - startedAt) / 1_000),
movingSeconds: Number(row.moving_seconds ?? 0),
distanceKm: Number(row.distance_km ?? 0),
pointCount: Number(row.point_count ?? 0),
averageSpeedKph: speedCount > 0 ? Number(row.speed_sum) / speedCount : null,
peakSpeedKph: finite(row.peak_speed_kph),
vehicleBatteryStartPercent: vehicleStart,
vehicleBatteryEndPercent: vehicleEnd,
batteryConsumedPercent: vehicleStart !== null && vehicleEnd !== null ? Math.max(0, vehicleStart - vehicleEnd) : null,
energyConsumedKwh: consumptionStart !== null && consumptionEnd !== null
? Math.max(0, consumptionEnd - consumptionStart)
: null,
phoneBatteryStartPercent: finite(row.phone_battery_start),
phoneBatteryEndPercent: finite(row.phone_battery_end),
start: { latitude: Number(row.start_latitude), longitude: Number(row.start_longitude) },
end: { latitude: Number(row.end_latitude), longitude: Number(row.end_longitude) }
};
}
}