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
5 changes: 5 additions & 0 deletions .changeset/car-sensor-soc-history.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": minor
---

Open Battery history on a car to see its saved sensor levels for 24 hours, 7 days or 30 days. Keep reported levels separate from charging estimates and show gaps and failed reads.
5 changes: 5 additions & 0 deletions .changeset/car-tap-feedback-session.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

Clear car-choice feedback when the plug-in changes, and ignore replies for an earlier plug-in.
63 changes: 63 additions & 0 deletions go/cmd/ftw/vehicle_soc_history_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package main

import (
"context"
"encoding/json"
"path/filepath"
"testing"
"time"

"github.com/srcfl/ftw/go/internal/state"
"github.com/srcfl/ftw/go/internal/telemetry"
)

// Follow the actual telemetry tick into SQLite, then reopen it. A delayed
// car report keeps its source clock; repeated or cached reports add no level.
func TestVehicleSensorSoCPersistsWithSourceTimeAcrossReopen(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
st, err := state.Open(path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = st.Close() })
if err := st.EnableHistoryAggregation(); err != nil {
t.Fatal(err)
}
tel := telemetry.NewStore()
tel.EnsureDriverHealth("car-sensor")
observed := time.Now().Add(-time.Minute).Truncate(time.Millisecond)
soc := 0.57
emit := func(fresh bool) {
payload, err := json.Marshal(map[string]any{"soc_fresh": fresh, "soc_observed_at_ms": observed.UnixMilli()})
if err != nil {
t.Fatal(err)
}
tel.Update("car-sensor", telemetry.DerVehicle, 0, &soc, payload)
}
emit(true)
emit(true) // same source observation, new poll
soc = 0.99
emit(false) // cached data is not a new sensor observation
if _, err := persistTelemetryTick(st, tel, tickPersistControl{}, time.Now().UnixMilli(), time.Minute, testEnergyIdentity(st)); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := st.FlushHistory(ctx); err != nil {
t.Fatal(err)
}
if err := st.Close(); err != nil {
t.Fatal(err)
}
st, err = state.Open(path)
if err != nil {
t.Fatal(err)
}
points, err := st.LoadSeriesBucketsOrRawContext(ctx, "car-sensor", "vehicle_soc", observed.Add(-time.Second).UnixMilli(), time.Now().UnixMilli(), 0)
if err != nil {
t.Fatal(err)
}
if len(points) != 1 || points[0].V != 0.57 || points[0].N != 1 || points[0].FirstMS != observed.UnixMilli() {
t.Fatalf("sensor level or source time changed across reopen: %+v", points)
}
}
234 changes: 234 additions & 0 deletions web/car-soc-history.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
// The car's reported levels already live in history.db as vehicle_soc.
// Keep these separate from the car runtime's energy-based level estimate.
const RANGES = ['24h', '7d', '30d'];

export function carLevelSources(car) {
return [...new Set((Array.isArray(car?.sources) ? car.sources : []).filter(x => typeof x === 'string' && x))];
}

export function reportedLevelPoints(points = [], now = Date.now()) {
return points.filter(p => p && Number.isFinite(p.ts) && p.ts > 0 && p.ts <= now &&
Number.isFinite(p.v) && p.v >= 0 && p.v <= 1 && Number.isSafeInteger(p.n) && p.n > 0)
.map(p => ({
at: p.ts,
first: Number.isFinite(p.first_ms) && p.first_ms > 0 ? p.first_ms : p.ts,
resolution: Math.max(0, p.resolution_ms || 0),
value: p.v * 100,
min: Number.isFinite(p.min) && p.min >= 0 && p.min <= 1 ? p.min * 100 : p.v * 100,
max: Number.isFinite(p.max) && p.max >= 0 && p.max <= 1 ? p.max * 100 : p.v * 100,
last: Number.isFinite(p.last) && p.last >= 0 && p.last <= 1 ? p.last * 100 : p.n === 1 ? p.v * 100 : null,
count: p.n,
})).sort((a, b) => a.at - b.at);
}

async function readJSON(url, { fetcher, signal, timeoutMs = 30000 }) {
signal?.throwIfAborted();
const controller = new AbortController();
const abort = () => controller.abort();
signal?.addEventListener('abort', abort, { once: true });
const timer = setTimeout(abort, timeoutMs);
try {
const response = await fetcher(url, { signal: controller.signal, cache: 'no-store' });
if (!response.ok) throw new Error('History read failed');
return await response.json();
} finally {
clearTimeout(timer);
signal?.removeEventListener('abort', abort);
}
}

// Empty sources can mean the viewer's car list hid them. Ask the box before
// saying that the car has none; an authorized empty list is different.
export async function readHistoryCar(car, { fetcher = fetch, signal } = {}) {
if (carLevelSources(car).length) return { car, redacted: false };
const data = await readJSON('/api/cars', { fetcher, signal });
if (data.redacted) return { car, redacted: true };
const found = data.cars?.find(c => c.id === car.id);
if (!found) throw new Error('Car no longer available');
return { car: { ...car, sources: carLevelSources(found) }, redacted: false };
}

export async function readCarLevelHistory(car, range, { fetcher = fetch, signal, now = Date.now(), timeoutMs = 30000 } = {}) {
if (!RANGES.includes(range)) throw new Error('Unknown history range');
return Promise.all(carLevelSources(car).map(async source => {
try {
const query = new URLSearchParams({ driver: source, metric: 'vehicle_soc', range, points: '180' });
const data = await readJSON('/api/series?' + query, { fetcher, signal, timeoutMs });
if (!Array.isArray(data.points)) throw new Error('History points missing');
return { source, points: reportedLevelPoints(data.points, now), failed: false };
} catch (err) {
if (signal?.aborted) throw err;
return { source, points: [], failed: true };
Comment on lines +59 to +61

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep completed source histories when another request times out

For a car with multiple sensor sources, if one request exceeds the dialog's 30-second deadline, the shared controller aborts it and this branch rethrows; consequently Promise.all rejects and the outer dialog replaces every section with one global failure, discarding histories that other sources already returned successfully. Treat a deadline abort as a failed result for that source while retaining rethrows for dialog-close/range-change cancellation, so failures remain distinct and successful evidence remains visible.

AGENTS.md reference: AGENTS.md:L22-L23

Useful? React with 👍 / 👎.

}
}));
}

function node(doc, tag, parent, text, className) {
const el = doc.createElement(tag);
if (text != null) el.textContent = text;
if (className) el.className = className;
if (parent) parent.appendChild(el);
return el;
}

function clock(ms) {
return new Date(ms).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}

export function pointDescription(p) {
if (p.count === 1) return `${p.last.toFixed(1)}% · ${clock(p.first)}`;
return `${p.value.toFixed(1)}% average · ${p.min.toFixed(1)}–${p.max.toFixed(1)}% · ${p.count} reports · ${clock(p.at)}`;
}

function plot(doc, host, points, since, until) {
const ns = 'http://www.w3.org/2000/svg';
const svg = doc.createElementNS(ns, 'svg');
svg.setAttribute('viewBox', '0 0 640 210');
svg.setAttribute('role', 'group');
svg.setAttribute('aria-label', 'Reported car battery levels, from 0 to 100 percent');
const shape = (tag, attrs, text) => {
const el = doc.createElementNS(ns, tag);
for (const [name, value] of Object.entries(attrs)) el.setAttribute(name, String(value));
if (text != null) el.textContent = text;
svg.appendChild(el);
return el;
};
const x = ms => 42 + Math.max(0, Math.min(1, (ms - since) / (until - since))) * 580;
const y = pct => 174 - pct * 1.5;
for (const pct of [0, 50, 100]) {
shape('line', { x1: 42, x2: 622, y1: y(pct), y2: y(pct), class: 'soc-grid' });
shape('text', { x: 32, y: y(pct) + 4, 'text-anchor': 'end', class: 'soc-axis' }, `${pct}%`);
}
const selected = node(doc, 'p', null, 'Select a point to read its saved level and time.', 'soc-note');
selected.setAttribute('role', 'status');
// Dots are recorded reports. No line fills the time between sparse reports.
for (const p of points) {
if (p.min !== p.max) shape('line', { x1: x(p.at), x2: x(p.at), y1: y(p.min), y2: y(p.max), class: 'soc-envelope' });
const dot = shape('circle', { cx: x(p.count === 1 ? p.first : p.at), cy: y(p.value), r: 3.5, class: 'soc-dot', tabindex: 0 });
const desc = pointDescription(p);
dot.setAttribute('aria-label', desc);
dot.setAttribute('role', 'button');
for (const event of ['pointerenter', 'focus', 'click']) dot.addEventListener(event, () => { selected.textContent = desc; });
dot.addEventListener('keydown', event => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault(); selected.textContent = desc;
});
const title = doc.createElementNS(ns, 'title'); title.textContent = desc; dot.appendChild(title);
}
shape('text', { x: 42, y: 200, class: 'soc-axis' }, clock(since));
shape('text', { x: 622, y: 200, 'text-anchor': 'end', class: 'soc-axis' }, clock(until));
svg.addEventListener('pointerdown', event => {
const bounds = svg.getBoundingClientRect();
if (!bounds.width || !points.length) return;
const atX = (event.clientX - bounds.left) / bounds.width * 640;
const nearest = points.reduce((best, p) => Math.abs(x(p.at) - atX) < Math.abs(x(best.at) - atX) ? p : best);
selected.textContent = pointDescription(nearest);
});
host.appendChild(svg);
host.appendChild(selected);
}

function styles(doc) {
if (doc.getElementById('car-soc-history-styles')) return;
const el = node(doc, 'style', doc.head);
el.id = 'car-soc-history-styles';
el.textContent = `
.car-soc-history{box-sizing:border-box;width:min(720px,94vw);max-height:90dvh;margin:auto;padding:0;border:1px solid var(--line);border-radius:12px;background:var(--ink-raised);color:var(--fg);overflow:auto}
.car-soc-history::backdrop{background:rgb(0 0 0 / .65)}
.car-soc-history header{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:18px;border-bottom:1px solid var(--line)}
.car-soc-history h2{font-size:1.1rem;margin:0}.car-soc-history h3{font-size:.95rem;margin:0}
.car-soc-history button{font:inherit;color:var(--fg);border:1px solid var(--line);border-radius:8px;padding:7px 12px;background:transparent;cursor:pointer}
.car-soc-history button[aria-pressed=true]{background:var(--accent-e);color:#0a0a0a}
.car-soc-history button:focus-visible{outline:2px solid var(--accent-e);outline-offset:3px}
.car-soc-history .soc-body{padding:18px}.car-soc-history .soc-ranges{display:flex;gap:8px;margin-bottom:14px}
.car-soc-history .soc-note{font-size:.82rem;color:var(--fg-muted);line-height:1.5;margin:10px 0}
.car-soc-history .soc-source{border-top:1px solid var(--line);padding-top:14px;margin-top:14px}
.car-soc-history .soc-last{font:1.3rem var(--mono);margin:8px 0}
.car-soc-history svg{display:block;width:100%;height:auto;min-height:130px;overflow:visible}
.car-soc-history .soc-grid{stroke:var(--line)}.car-soc-history .soc-axis{font:11px var(--mono);fill:var(--fg-muted)}
.car-soc-history .soc-dot{fill:var(--cyan)}.car-soc-history .soc-dot:focus{stroke:var(--fg);stroke-width:2}
.car-soc-history .soc-envelope{stroke:var(--cyan);stroke-width:2}
@media(max-width:480px){.car-soc-history .soc-axis{font-size:24px}}
`;
}

export function openCarLevelHistory(doc, car, { fetcher = fetch,
requestLogin = () => doc.defaultView?.FTWLanAuth?.ensure?.() || Promise.resolve(false) } = {}) {
styles(doc);
const dialog = node(doc, 'dialog', doc.body, null, 'car-soc-history');
const head = node(doc, 'header', dialog);
const title = node(doc, 'h2', head, `${car.name || 'Car'} · battery history`);
title.id = 'car-level-history-title';
dialog.setAttribute('aria-labelledby', title.id);
const close = node(doc, 'button', head, 'Close'); close.type = 'button';
close.addEventListener('click', () => dialog.close());
const body = node(doc, 'div', dialog, null, 'soc-body');
const ranges = node(doc, 'div', body, null, 'soc-ranges');
ranges.setAttribute('role', 'group'); ranges.setAttribute('aria-label', 'History range');
node(doc, 'p', body, 'Reported sensor levels saved by the box. Charging estimates and levels set by hand are not plotted.', 'soc-note');
const content = node(doc, 'div', body); content.setAttribute('aria-live', 'polite');
let range = '24h', controller = null, sequence = 0, closed = false;
const buttons = RANGES.map(value => {
const button = node(doc, 'button', ranges, value === '24h' ? '24 hours' : value === '7d' ? '7 days' : '30 days');
button.type = 'button'; button.addEventListener('click', () => { range = value; load(); });
return [value, button];
});
async function load() {
const current = ++sequence;
controller?.abort(); controller = new AbortController();
const signal = controller.signal;
buttons.forEach(([value, button]) => button.setAttribute('aria-pressed', String(value === range)));
content.replaceChildren(); node(doc, 'p', content, 'Loading saved levels…', 'soc-note');
try {
const resolved = await readHistoryCar(car, { fetcher, signal });
if (closed || current !== sequence) return;
if (resolved.redacted) {
content.replaceChildren();
node(doc, 'p', content, 'Sign in to read this car’s sensor history.', 'soc-note');
const login = node(doc, 'button', content, 'Sign in'); login.type = 'button';
login.addEventListener('click', async () => {
// The house-password form must be above the page, not behind a
// native modal. Reopen history after it has finished.
dialog.close();
if (await requestLogin()) openCarLevelHistory(doc, car, { fetcher, requestLogin });
});
return;
}
car = resolved.car;
const until = Date.now();
const since = until - (range === '24h' ? 1 : range === '7d' ? 7 : 30) * 86400000;
const sources = await readCarLevelHistory(car, range, { fetcher, signal, now: until });
if (closed || current !== sequence) return;
content.replaceChildren();
if (!sources.length) node(doc, 'p', content, 'No sensor source is available for this car on this device.', 'soc-note');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prompt for authentication before declaring no sensor source

When LAN authentication is enabled and the device has not signed in, GET /api/cars deliberately returns HTTP 200 with redacted: true and omits sources; because the LAN auth wrapper prompts only on 401 responses, opening Battery history reaches this branch and falsely reports that no sensor source exists, with no way to authenticate from the flow. The dialog should recognize the redacted car or obtain the protected source list after prompting and refetching.

AGENTS.md reference: AGENTS.md:L22-L23

Useful? React with 👍 / 👎.

for (const [index, source] of sources.entries()) {
const section = node(doc, 'section', content, null, 'soc-source');
node(doc, 'h3', section, sources.length === 1 ? 'Sensor reports' : `Sensor ${index + 1}`);
if (source.failed) {
node(doc, 'p', section, 'The box could not read this history. Try the range again.', 'soc-note');
} else if (!source.points.length) {
node(doc, 'p', section, 'No saved sensor reports in this period.', 'soc-note');
} else {
const last = source.points.at(-1);
node(doc, 'p', section, last.last == null
? `${last.value.toFixed(1)}% · latest interval average`
: `${last.last.toFixed(1)}% · last saved level`, 'soc-last');
node(doc, 'p', section, pointDescription(last), 'soc-note');
plot(doc, section, source.points, since, until);
}
}
node(doc, 'p', content, 'Each dot is a saved report or the average of reports in one interval. Vertical bars show the reported range. Time between reports is not filled.', 'soc-note');
} catch (err) {
if (closed || current !== sequence) return;
content.replaceChildren();
node(doc, 'p', content, 'The box did not finish reading history. Try the range again.', 'soc-note');
}
}
dialog.addEventListener('close', () => {
closed = true; controller?.abort(); dialog.remove();
[...doc.querySelectorAll('[data-history-car]')].find(button => button.dataset.historyCar === car.id)?.focus();
});
dialog.addEventListener('click', event => { if (event.target === dialog) dialog.close(); });
dialog.showModal(); load();
return dialog;
}
Loading
Loading