feat: add direct car telemetry management
This commit is contained in:
@@ -169,10 +169,15 @@ describe("ADB dashboard operations", () => {
|
||||
await expect(launchAdbTarget(enabledConfig, { type: "component", value: "com.example/.Main;reboot" }, execute)).rejects.toThrow("INVALID_ADB_COMPONENT");
|
||||
});
|
||||
|
||||
it("parses the verified MG4 live properties and marks SAIC-only data unavailable", async () => {
|
||||
const execute: AdbExecutor = async (_executable, arguments_) => arguments_.includes("content")
|
||||
? { stdout: "Result: Bundle[{status=ok, sampledAt=1785540000000, soc=73.5, rangeKm=286, speedKph=18.0, batteryVolts=397.4, totalConsumptionKwh=6.2, chargingStatus=0}]", stderr: "" }
|
||||
: { stdout: "[arcsoft.avm.mCurCarSpeed]: [17]\n[arcsoft.avm.mCurCarGear]: [4]\n[arcsoft.avm.mCurCarWheelAngle]: [-12.5]\n", stderr: "" };
|
||||
it("reads vehicle data through a pushed ADB probe without an installed companion app", async () => {
|
||||
const calls: string[][] = [];
|
||||
const execute: AdbExecutor = async (_executable, arguments_) => {
|
||||
calls.push([...arguments_]);
|
||||
if (arguments_.includes("app_process")) {
|
||||
return { stdout: '{"status":"ok","soc":73.5,"rangeKm":286,"speedKph":18,"batteryVolts":397.4,"totalConsumptionKwh":6.2,"chargingStatus":0}\n', stderr: "" };
|
||||
}
|
||||
return { stdout: "[arcsoft.avm.mCurCarSpeed]: [17]\n[arcsoft.avm.mCurCarGear]: [4]\n[arcsoft.avm.mCurCarWheelAngle]: [-12.5]\n", stderr: "" };
|
||||
};
|
||||
await expect(collectCarTelemetry(enabledConfig, execute)).resolves.toMatchObject({
|
||||
available: true,
|
||||
speedKph: { available: true, value: 18 },
|
||||
@@ -184,6 +189,23 @@ describe("ADB dashboard operations", () => {
|
||||
totalConsumptionKwh: { available: true, value: 6.2 },
|
||||
chargingStatus: { available: true, value: 0 }
|
||||
});
|
||||
expect(calls.some((call) => call[2] === "push" && call.at(-1) === "/data/local/tmp/pi-car-telemetry-probe.dex")).toBe(true);
|
||||
expect(calls.some((call) => call.includes("app_process"))).toBe(true);
|
||||
expect(calls.flat()).not.toContain("content://cloud.molberg.mgutility.vehicle");
|
||||
|
||||
await collectCarTelemetry(enabledConfig, execute);
|
||||
expect(calls.filter((call) => call[2] === "push")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps direct properties available when the factory vehicle service cannot be sampled", async () => {
|
||||
const execute: AdbExecutor = async (_executable, arguments_) => {
|
||||
if (arguments_.includes("app_process")) throw new Error("service unavailable");
|
||||
return { stdout: "[arcsoft.avm.mCurCarSpeed]: [17]\n[arcsoft.avm.mCurCarGear]: [4]\n[arcsoft.avm.mCurCarWheelAngle]: [-12.5]\n", stderr: "" };
|
||||
};
|
||||
await expect(collectCarTelemetry(enabledConfig, execute)).resolves.toMatchObject({
|
||||
speedKph: { available: true, value: 17 },
|
||||
batteryPercent: { available: false, reason: expect.stringContaining("factory vehicle service over ADB") }
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts only ZIP-formatted APK payloads and removes the temporary file", async () => {
|
||||
|
||||
@@ -259,6 +259,12 @@ describe("authentication boundary", () => {
|
||||
expect((await app.inject({ method: "GET", url: "/api/car/statistics" })).statusCode).toBe(401);
|
||||
|
||||
const token = await csrf(app);
|
||||
expect((await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/car/history/clear",
|
||||
headers: mutationHeaders(token),
|
||||
payload: { mode: "history" }
|
||||
})).statusCode).toBe(401);
|
||||
await setup(app, token);
|
||||
const login = await app.inject({
|
||||
method: "POST",
|
||||
@@ -276,6 +282,33 @@ describe("authentication boundary", () => {
|
||||
const invalidPeriod = await app.inject({ method: "GET", url: "/api/car/history?period=forever", headers: { cookie } });
|
||||
expect(invalidPeriod.statusCode).toBe(400);
|
||||
|
||||
const invalidClear = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/car/history/clear",
|
||||
headers,
|
||||
payload: { mode: "recent" }
|
||||
});
|
||||
expect(invalidClear.statusCode).toBe(400);
|
||||
expect(invalidClear.json()).toMatchObject({ error: { code: "INVALID_CLEAR_TELEMETRY_MODE" } });
|
||||
|
||||
const clearHistory = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/car/history/clear",
|
||||
headers,
|
||||
payload: { mode: "history" }
|
||||
});
|
||||
expect(clearHistory.statusCode).toBe(200);
|
||||
expect(clearHistory.json()).toEqual({ mode: "history", deletedSamples: 0, deletedRollups: 0 });
|
||||
|
||||
const clearAll = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/car/history/clear",
|
||||
headers,
|
||||
payload: { mode: "all" }
|
||||
});
|
||||
expect(clearAll.statusCode).toBe(200);
|
||||
expect(clearAll.json()).toEqual({ mode: "all", deletedSamples: 0, deletedRollups: 0 });
|
||||
|
||||
const invalid = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/adb/shortcuts",
|
||||
|
||||
@@ -78,4 +78,58 @@ describe("car telemetry history", () => {
|
||||
expect(store.history("24h").points).toHaveLength(1);
|
||||
database.close();
|
||||
});
|
||||
|
||||
it("clears history independently from all-time statistics", () => {
|
||||
const database = openDatabase(":memory:");
|
||||
const store = new CarTelemetryStore(database);
|
||||
const startedAt = Date.now() - 10 * 60_000;
|
||||
|
||||
store.record(sample(startedAt, { speed: 20, soc: 80, range: 320, charging: 0 }));
|
||||
for (let index = 1; index <= 40; index += 1) {
|
||||
store.record(sample(startedAt + index * 10_000, {
|
||||
speed: 110,
|
||||
soc: 80 - index * 0.15,
|
||||
range: 320 - index,
|
||||
charging: 0
|
||||
}));
|
||||
}
|
||||
|
||||
const before = store.overview();
|
||||
expect(before.lifetime.trackedDistanceKm).toBeGreaterThan(10);
|
||||
expect(before.lifetime.ownRangeEstimateKm).not.toBeNull();
|
||||
|
||||
expect(store.clear("history")).toMatchObject({ mode: "history", deletedSamples: 41 });
|
||||
const kept = store.overview();
|
||||
expect(store.history("24h").points).toEqual([]);
|
||||
expect(kept.storage.rawSamples).toBe(0);
|
||||
expect(kept.storage.hourlyRollups).toBe(0);
|
||||
expect(kept.records).toEqual(before.records);
|
||||
expect(kept.lifetime).toEqual(before.lifetime);
|
||||
|
||||
store.record(sample(Date.now(), { speed: 30, soc: 70, range: 260, charging: 0 }));
|
||||
expect(store.clear("all")).toMatchObject({ mode: "all", deletedSamples: 1 });
|
||||
const reset = store.overview();
|
||||
expect(store.history("all").points).toEqual([]);
|
||||
expect(reset.records).toEqual({
|
||||
peakSpeedKph: null,
|
||||
peakSpeedAt: null,
|
||||
peakChargingPowerKw: null,
|
||||
peakChargingPowerAt: null,
|
||||
maxObservedRangeKm: null,
|
||||
maxObservedRangeAt: null
|
||||
});
|
||||
expect(reset.lifetime).toMatchObject({
|
||||
firstSampleAt: null,
|
||||
lastSampleAt: null,
|
||||
sampleCount: 0,
|
||||
trackedDistanceKm: 0,
|
||||
drivingSeconds: 0,
|
||||
chargingSeconds: 0,
|
||||
chargeSessions: 0,
|
||||
socUsedPercent: 0,
|
||||
averageMovingSpeedKph: null,
|
||||
ownRangeEstimateKm: null
|
||||
});
|
||||
database.close();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user