From cec3408c636dcdba4ef1a498bfac47b258de1ba1 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 18 Oct 2026 16:03:21 +0200 Subject: [PATCH 1/3] feat(web): show saved sensor SoC history for each car --- .changeset/car-sensor-soc-history.md | 5 + go/cmd/ftw/vehicle_soc_history_test.go | 63 ++++++++ web/car-soc-history.js | 194 +++++++++++++++++++++++++ web/car-soc-history.test.mjs | 82 +++++++++++ web/cars.js | 12 +- web/cars.test.mjs | 17 ++- 6 files changed, 368 insertions(+), 5 deletions(-) create mode 100644 .changeset/car-sensor-soc-history.md create mode 100644 go/cmd/ftw/vehicle_soc_history_test.go create mode 100644 web/car-soc-history.js create mode 100644 web/car-soc-history.test.mjs diff --git a/.changeset/car-sensor-soc-history.md b/.changeset/car-sensor-soc-history.md new file mode 100644 index 000000000..a0da85947 --- /dev/null +++ b/.changeset/car-sensor-soc-history.md @@ -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. diff --git a/go/cmd/ftw/vehicle_soc_history_test.go b/go/cmd/ftw/vehicle_soc_history_test.go new file mode 100644 index 000000000..20d75a067 --- /dev/null +++ b/go/cmd/ftw/vehicle_soc_history_test.go @@ -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) + } +} diff --git a/web/car-soc-history.js b/web/car-soc-history.js new file mode 100644 index 000000000..bedc0d9ba --- /dev/null +++ b/web/car-soc-history.js @@ -0,0 +1,194 @@ +// 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); +} + +export async function readCarLevelHistory(car, range, { fetcher = fetch, signal, now = Date.now() } = {}) { + 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 response = await fetcher('/api/series?' + query, { signal, cache: 'no-store' }); + if (!response.ok) throw new Error('History read failed'); + const data = await response.json(); + 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 || err?.name === 'AbortError') throw err; + return { source, points: [], failed: true }; + } + })); +} + +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 } = {}) { + 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; + const timeout = setTimeout(() => controller?.signal === signal && controller.abort(), 30000); + buttons.forEach(([value, button]) => button.setAttribute('aria-pressed', String(value === range))); + content.replaceChildren(); node(doc, 'p', content, 'Loading saved levels…', 'soc-note'); + try { + 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'); + 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'); + } finally { clearTimeout(timeout); } + } + 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; +} diff --git a/web/car-soc-history.test.mjs b/web/car-soc-history.test.mjs new file mode 100644 index 000000000..7042acb4b --- /dev/null +++ b/web/car-soc-history.test.mjs @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { carLevelSources, pointDescription, readCarLevelHistory, reportedLevelPoints } from './car-soc-history.js'; + +const now = 1792330436520; +const point = { ts: now - 10000, first_ms: now - 9021, v: 0.57, last: 0.57, min: 0.57, max: 0.57, n: 1, resolution_ms: 10000 }; + +test('only linked sensor sources are used, once each', () => { + assert.deepEqual(carLevelSources({ sources: ['sensor-one', null, '', 'sensor-two', 'sensor-one'] }), ['sensor-one', 'sensor-two']); + for (const car of [null, {}, { sources: 'not-a-list' }]) assert.deepEqual(carLevelSources(car), []); +}); + +test('stored fractional SoC, including zero, is displayed as percent with its source time', () => { + const points = reportedLevelPoints([point, { ...point, ts: now - 20000, v: 0, last: 0, min: 0, max: 0 }], now); + assert.equal(points[0].value, 0); + assert.ok(Math.abs(points[1].value - 57) < 1e-10); + assert.equal(points[1].first, point.first_ms); + assert.equal(points[1].at, point.ts); + assert.match(pointDescription(points[1]), /^57\.0% · /); +}); + +test('missing, nonfinite, future and invalid reports never become zero or 100 percent', () => { + const bad = [null, { ...point, v: null }, { ...point, v: NaN }, { ...point, v: Infinity }, + { ...point, v: -0.1 }, { ...point, v: 57 }, { ...point, v: 1.1 }, + { ...point, ts: now + 1 }, { ...point, n: 0 }, { ...point, n: '1' }]; + assert.deepEqual(reportedLevelPoints(bad, now), []); +}); + +test('an aggregate retains its average, last reported value, envelope and count', () => { + const [p] = reportedLevelPoints([{ ...point, v: 0.55, last: 0.57, min: 0.53, max: 0.57, n: 3 }], now); + for (const [value, expected] of [[p.value, 55], [p.last, 57], [p.min, 53], [p.max, 57]]) assert.ok(Math.abs(value - expected) < 1e-10); + assert.equal(p.count, 3); + assert.match(pointDescription(p), /average · 53\.0–57\.0% · 3 reports/); +}); + +test('an aggregate without a last value never calls its average the last reported level', () => { + const [p] = reportedLevelPoints([{ ...point, n: 63, v: 0.548, last: undefined }], now); + assert.equal(p.last, null); + assert.match(pointDescription(p), /^54\.8% average/); +}); + +test('history reads use the linked source and vehicle_soc, with bounded points and no estimates', async () => { + const calls = []; + const car = { sources: ['sensor/&?'], soc_permille: 990, soc_estimate: true, current_soc: 0.99 }; + const result = await readCarLevelHistory(car, '7d', { now, fetcher: async (url, opts) => { + calls.push([url, opts]); return { ok: true, json: async () => ({ points: [point] }) }; + } }); + const url = new URL(calls[0][0], 'http://localhost'); + assert.equal(url.searchParams.get('driver'), 'sensor/&?'); + assert.equal(url.searchParams.get('metric'), 'vehicle_soc'); + assert.equal(url.searchParams.get('range'), '7d'); + assert.equal(url.searchParams.get('points'), '180'); + assert.equal(calls[0][1].cache, 'no-store'); + assert.ok(Math.abs(result[0].points[0].last - 57) < 1e-10); +}); + +test('a failed read and an empty period remain distinct, without discarding another source', async () => { + const result = await readCarLevelHistory({ sources: ['good', 'empty', 'failed'] }, '24h', { now, fetcher: async url => { + const source = new URL(url, 'http://localhost').searchParams.get('driver'); + return { ok: source !== 'failed', json: async () => ({ points: source === 'good' ? [point] : [] }) }; + } }); + assert.deepEqual(result.map(x => [x.source, x.failed, x.points.length]), [['good', false, 1], ['empty', false, 0], ['failed', true, 0]]); +}); + +test('a malformed body is a read failure, not an empty history', async () => { + const [result] = await readCarLevelHistory({ sources: ['sensor'] }, '24h', { fetcher: async () => ({ ok: true, json: async () => ({}) }) }); + assert.equal(result.failed, true); +}); + +test('closing or changing the range cancels in-flight reads', async () => { + const controller = new AbortController(); + const reading = readCarLevelHistory({ sources: ['sensor'] }, '24h', { signal: controller.signal, fetcher: (url, opts) => new Promise((resolve, reject) => { + opts.signal.addEventListener('abort', () => reject(new DOMException('Cancelled', 'AbortError'))); + }) }); + controller.abort(); + await assert.rejects(reading, { name: 'AbortError' }); +}); + +test('no linked source makes no history request and an unknown range is rejected', async () => { + assert.deepEqual(await readCarLevelHistory({ sources: [] }, '24h', { fetcher: () => { throw new Error('unexpected request'); } }), []); + await assert.rejects(readCarLevelHistory({ sources: ['sensor'] }, 'forever'), /Unknown history range/); +}); diff --git a/web/cars.js b/web/cars.js index 425fe181b..a75816bb2 100644 --- a/web/cars.js +++ b/web/cars.js @@ -6,6 +6,8 @@ // Names that come from outside, a car's name or a charger's id, only ever go // into the page as text. +import { carLevelSources, openCarLevelHistory } from './car-soc-history.js'; + const SOURCE_WORDS = { vehicle: 'From the car', manual: 'Set by hand', assumed: 'Assumed at plug-in' }; // How the box knows which car is on a charger (car_identified_by). @@ -223,7 +225,7 @@ export function carTiles(cars = [], loadpoints = [], sinceS = 0, chargersFresh = : lp ? `On ${lp.id}` : maybe.length ? `On ${maybe.map(o => o.id).join(' or ')}?` : 'Not plugged in'; const on = !!lp && !quiet; return { - id: car.id, name: car.name, where, + id: car.id, name: car.name, sources: carLevelSources(car), where, level: level.value, estimate: level.estimate, source: level.source, facts: facts.join(' · '), charger: on ? { id: lp.id, driver_name: lp.driver_name } : null, knownBy: on ? knownByText(lp, names) : '', }; @@ -232,7 +234,7 @@ export function carTiles(cars = [], loadpoints = [], sinceS = 0, chargersFresh = // createCarsCard fills the dashboard's card of cars, el, which the page // holds in its place in the overview. -export function createCarsCard(doc, el, { onOpenCharger } = {}) { +export function createCarsCard(doc, el, { onOpenCharger, onOpenHistory = car => openCarLevelHistory(doc, car) } = {}) { el.replaceChildren(); const head = node(doc, 'div', 'overview-card-head', el); const titles = node(doc, 'div', '', head); @@ -262,10 +264,12 @@ export function createCarsCard(doc, el, { onOpenCharger } = {}) { node(doc, 'div', 'car-tile-level' + (t.estimate ? ' is-estimate' : ''), tile, t.level); node(doc, 'div', 'car-tile-source', tile, t.source); node(doc, 'div', 'car-tile-facts', tile, t.facts); - if (!t.charger) return; const foot = node(doc, 'div', 'car-tile-foot', tile); + const history = node(doc, 'button', 'car-tile-open', foot, 'Battery history'); + history.type = 'button'; history.dataset.historyCar = t.id; + history.addEventListener('click', () => onOpenHistory(t)); + if (!t.charger || !onOpenCharger) return; node(doc, 'span', '', foot, t.knownBy); - if (!onOpenCharger) return; const b = node(doc, 'button', 'car-tile-open', foot, 'Check charging'); b.type = 'button'; b.dataset.openCar = t.id; diff --git a/web/cars.test.mjs b/web/cars.test.mjs index 7eb4fdb62..b7fb4d8ea 100644 --- a/web/cars.test.mjs +++ b/web/cars.test.mjs @@ -280,13 +280,28 @@ describe('the card of cars', () => { const card = createCarsCard(doc, el, { onOpenCharger: lp => opened.push(lp.driver_name) }); const lp = { id: 'garage', driver_name: 'easee', plugged_in: true, session: 's1', car_id: tesla.id, car_identified_by: 'tap' }; card.update({ cars: [tesla, volvo], loadpoints: [lp], fetchedAt: 1, now: 1 }); - const open = buttons(el); + const open = buttons(el).filter(b => b.textContent === 'Check charging'); assert.equal(open.length, 1); assert.equal(open[0].textContent, 'Check charging'); await open[0].click(); assert.deepEqual(opened, ['easee']); assert.match(el.textContent, /You chose it/); }); + + it('opens saved sensor history for an unplugged car without using its estimate', async () => { + const doc = fakeDoc(); + const el = doc.createElement('section'); + const opened = []; + const card = createCarsCard(doc, el, { onOpenHistory: car => opened.push(car) }); + card.update({ cars: [{ ...tesla, soc_permille: 790, soc_estimate: true, sources: ['sensor-one'] }], fetchedAt: 1, now: 1 }); + const history = buttons(el).find(b => b.textContent === 'Battery history'); + assert.ok(history); + await history.click(); + assert.equal(opened[0].id, tesla.id); + assert.deepEqual(opened[0].sources, ['sensor-one']); + assert.equal('soc_permille' in opened[0], false); + assert.equal('current_soc' in opened[0], false); + }); }); describe('the dashboard wiring', () => { From c4fcd83121f9675a87d75127b538e3d064e81353 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 18 Oct 2026 16:19:08 +0200 Subject: [PATCH 2/3] fix(web): distinguish hidden car sources and partial timeouts --- web/car-soc-history.js | 56 ++++++++++++++++++++++++++++++------ web/car-soc-history.test.mjs | 31 +++++++++++++++++++- 2 files changed, 78 insertions(+), 9 deletions(-) diff --git a/web/car-soc-history.js b/web/car-soc-history.js index bedc0d9ba..8898a6ef6 100644 --- a/web/car-soc-history.js +++ b/web/car-soc-history.js @@ -21,18 +21,43 @@ export function reportedLevelPoints(points = [], now = Date.now()) { })).sort((a, b) => a.at - b.at); } -export async function readCarLevelHistory(car, range, { fetcher = fetch, signal, now = Date.now() } = {}) { +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 response = await fetcher('/api/series?' + query, { signal, cache: 'no-store' }); - if (!response.ok) throw new Error('History read failed'); - const data = await response.json(); + 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 || err?.name === 'AbortError') throw err; + if (signal?.aborted) throw err; return { source, points: [], failed: true }; } })); @@ -127,7 +152,8 @@ function styles(doc) { `; } -export function openCarLevelHistory(doc, car, { fetcher = fetch } = {}) { +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); @@ -151,10 +177,24 @@ export function openCarLevelHistory(doc, car, { fetcher = fetch } = {}) { const current = ++sequence; controller?.abort(); controller = new AbortController(); const signal = controller.signal; - const timeout = setTimeout(() => controller?.signal === signal && controller.abort(), 30000); 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 }); @@ -182,7 +222,7 @@ export function openCarLevelHistory(doc, car, { fetcher = fetch } = {}) { if (closed || current !== sequence) return; content.replaceChildren(); node(doc, 'p', content, 'The box did not finish reading history. Try the range again.', 'soc-note'); - } finally { clearTimeout(timeout); } + } } dialog.addEventListener('close', () => { closed = true; controller?.abort(); dialog.remove(); diff --git a/web/car-soc-history.test.mjs b/web/car-soc-history.test.mjs index 7042acb4b..9f28f4ce7 100644 --- a/web/car-soc-history.test.mjs +++ b/web/car-soc-history.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { carLevelSources, pointDescription, readCarLevelHistory, reportedLevelPoints } from './car-soc-history.js'; +import { carLevelSources, pointDescription, readCarLevelHistory, readHistoryCar, reportedLevelPoints } from './car-soc-history.js'; const now = 1792330436520; const point = { ts: now - 10000, first_ms: now - 9021, v: 0.57, last: 0.57, min: 0.57, max: 0.57, n: 1, resolution_ms: 10000 }; @@ -80,3 +80,32 @@ test('no linked source makes no history request and an unknown range is rejected assert.deepEqual(await readCarLevelHistory({ sources: [] }, '24h', { fetcher: () => { throw new Error('unexpected request'); } }), []); await assert.rejects(readCarLevelHistory({ sources: ['sensor'] }, 'forever'), /Unknown history range/); }); + +test('a redacted source list asks for sign-in instead of reporting no sensor', async () => { + const car = { id: 'car-one' }; + const result = await readHistoryCar(car, { fetcher: async url => { + assert.equal(url, '/api/cars'); + return { ok: true, json: async () => ({ cars: [car], redacted: true }) }; + } }); + assert.equal(result.redacted, true); +}); + +test('after sign-in the protected list supplies sources; a known empty list stays empty', async () => { + for (const sources of [[], ['sensor']]) { + const result = await readHistoryCar({ id: 'car-one' }, { fetcher: async () => ({ ok: true, + json: async () => ({ cars: [{ id: 'car-one', sources }] }) }) }); + assert.equal(result.redacted, false); + assert.deepEqual(result.car.sources, sources); + } +}); + +test('a source deadline keeps another source’s completed history', async () => { + const result = await readCarLevelHistory({ sources: ['good', 'slow'] }, '24h', { now, timeoutMs: 10, + fetcher: async (url, { signal }) => { + if (url.includes('driver=good')) return { ok: true, json: async () => ({ points: [point] }) }; + return new Promise((resolve, reject) => signal.addEventListener('abort', () => reject(new DOMException('Timed out', 'AbortError')))); + } }); + assert.equal(result[0].failed, false); + assert.equal(result[0].points.length, 1); + assert.equal(result[1].failed, true); +}); From 513fc60d595da4ba8ab441123b7c79c95ea197cc Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 18 Oct 2026 17:19:50 +0200 Subject: [PATCH 3/3] fix(cars): keep tap feedback with its plug-in --- .changeset/car-tap-feedback-session.md | 5 +++++ web/cars.js | 11 +++++++++++ web/cars.test.mjs | 25 +++++++++++++++++++++++++ 3 files changed, 41 insertions(+) create mode 100644 .changeset/car-tap-feedback-session.md diff --git a/.changeset/car-tap-feedback-session.md b/.changeset/car-tap-feedback-session.md new file mode 100644 index 000000000..f53df2c6c --- /dev/null +++ b/.changeset/car-tap-feedback-session.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Clear car-choice feedback when the plug-in changes, and ignore replies for an earlier plug-in. diff --git a/web/cars.js b/web/cars.js index a75816bb2..f8d2382c1 100644 --- a/web/cars.js +++ b/web/cars.js @@ -165,8 +165,17 @@ export function createWhichCar(doc, { onTap }) { const note = node(doc, 'p', 'car-which-note', el); note.setAttribute('aria-live', 'polite'); let expanded = false, busy = false, last = null, shown = ''; + let plugIn = '', generation = 0; open.addEventListener('click', () => { expanded = !expanded; if (last) update(...last); }); function update(lp, cars, loadpoints, fresh) { + const nextPlugIn = JSON.stringify([lp.id, lp.session, !!lp.plugged_in]); + if (nextPlugIn !== plugIn) { + plugIn = nextPlugIn; + generation++; + expanded = false; + busy = false; + note.textContent = ''; + } last = [lp, cars, loadpoints, fresh]; const list = fresh ? choices(lp, cars, loadpoints) : []; const isAsking = asking(lp); @@ -190,8 +199,10 @@ export function createWhichCar(doc, { onTap }) { b.disabled = busy || current; if (current) b.setAttribute('aria-pressed', 'true'); b.addEventListener('click', async () => { + const requestGeneration = generation; busy = true; note.textContent = 'Saving…'; update(...last); const res = await onTap(lp, id).catch(() => ({ ok: false, text: 'The box did not answer. Try again.' })); + if (requestGeneration !== generation) return; busy = false; expanded = false; note.textContent = res.ok ? '' : res.text; update(...last); diff --git a/web/cars.test.mjs b/web/cars.test.mjs index b7fb4d8ea..b6baee77a 100644 --- a/web/cars.test.mjs +++ b/web/cars.test.mjs @@ -248,6 +248,31 @@ describe('a charger whose car the box cannot name asks', () => { assert.deepEqual(sent, [['/api/loadpoints/gar%20age/car', 'POST', { session: 's1', car_id: kia.id }]]); assert.deepEqual(res, { ok: false, text: 'That plug-in has ended.' }); }); + + it('keeps an old tap response out of a later plug-in', async () => { + let finish; + const which = createWhichCar(fakeDoc(), { onTap: () => new Promise(resolve => { finish = resolve; }) }); + which.update(askingLp, cars, [askingLp], true); + const pending = buttons(which.el).find(b => b.textContent === 'Model Y').click(); + assert.match(which.el.textContent, /Saving/); + const next = { ...askingLp, session: 'next-plug-in' }; + which.update(next, cars, [next], true); + assert.doesNotMatch(which.el.textContent, /Saving/); + assert.equal(buttons(which.el).find(b => b.textContent === 'Model Y').disabled, false); + finish({ ok: false, text: 'That plug-in has ended.' }); + await pending; + assert.doesNotMatch(which.el.textContent, /That plug-in has ended/); + }); + + it('clears a completed refusal when a different plug-in arrives', async () => { + const which = createWhichCar(fakeDoc(), { onTap: async () => ({ ok: false, text: 'That plug-in has ended.' }) }); + which.update(askingLp, cars, [askingLp], true); + await buttons(which.el).find(b => b.textContent === 'Model Y').click(); + assert.match(which.el.textContent, /That plug-in has ended/); + const next = { ...askingLp, session: 'next-plug-in' }; + which.update(next, cars, [next], true); + assert.doesNotMatch(which.el.textContent, /That plug-in has ended/); + }); }); describe('the card of cars', () => {