From d5bc3a8a20ffaea650c6e2bb95e30c26c3887c2f Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 7 Sep 2026 19:33:42 +0200 Subject: [PATCH 1/2] fix(now): expire stale status and use live telemetry --- .changeset/now-status-freshness.md | 5 +++ src/lib/state/now-ev-overlay.test.ts | 22 ++++++++++ src/lib/state/now-ev-overlay.ts | 2 +- src/lib/state/now-status.test.ts | 47 ++++++++++++++++++--- src/lib/state/now-status.ts | 44 +++++++++++++++++--- src/views/Now.svelte | 23 +++++++++-- src/views/Now.svelte.test.ts | 62 +++++++++++++++++++++++++++- src/views/NowOutlook.svelte | 20 +++++++-- 8 files changed, 204 insertions(+), 21 deletions(-) create mode 100644 .changeset/now-status-freshness.md diff --git a/.changeset/now-status-freshness.md b/.changeset/now-status-freshness.md new file mode 100644 index 0000000..d13373a --- /dev/null +++ b/.changeset/now-status-freshness.md @@ -0,0 +1,5 @@ +--- +"ftw-webapp": patch +--- + +Use fresh telemetry totals when Now's device details stop updating. Date retained energy and fuse readings, and restore details when the box answers again. diff --git a/src/lib/state/now-ev-overlay.test.ts b/src/lib/state/now-ev-overlay.test.ts index 63050f0..46154e8 100644 --- a/src/lib/state/now-ev-overlay.test.ts +++ b/src/lib/state/now-ev-overlay.test.ts @@ -9,6 +9,7 @@ const EVENING = Date.UTC(2026, 6, 15, 18, 30, 0) describe('watchLoadpointCharge', () => { afterEach(() => { vi.useRealTimers() + vi.restoreAllMocks() }) it('reports charger watts from the box and can be stopped', async () => { @@ -32,4 +33,25 @@ describe('watchLoadpointCharge', () => { await vi.advanceTimersByTimeAsync(6_000) expect(seen.length, 'a stopped watch kept asking').toBe(n) }) + + it('removes an old charger overlay so it cannot override fresh telemetry', async () => { + vi.useFakeTimers() + vi.setSystemTime(EVENING) + const box = new SimBox({ now: () => Date.now() }) + const site = new SiteStore('test') + site.connect(new LoopbackCarrier(box, { latencyMs: 0 })) + await vi.advanceTimersByTimeAsync(100) + const seen: number[] = [] + const stop = watchLoadpointCharge(site, watts => seen.push(watts)) + await vi.advanceTimersByTimeAsync(100) + expect(seen.at(-1)).toBeGreaterThan(7000) + const serve = box.api.serve.bind(box.api) + vi.spyOn(box.api, 'serve').mockImplementation(req => req.path === '/api/loadpoints' + ? { status: 503, contentType: 'application/json', body: new TextEncoder().encode('{}') } + : serve(req)) + await vi.advanceTimersByTimeAsync(5_100) + expect(seen.at(-1)).toBe(0) + stop() + site.destroy() + }) }) diff --git a/src/lib/state/now-ev-overlay.ts b/src/lib/state/now-ev-overlay.ts index 271ead1..2026151 100644 --- a/src/lib/state/now-ev-overlay.ts +++ b/src/lib/state/now-ev-overlay.ts @@ -15,5 +15,5 @@ import { loadpointChargeW } from './flow' import type { SiteStore } from './site.svelte' export function watchLoadpointCharge(site: SiteStore, onWatts: (w: number) => void): () => void { - return watchCharging(site, snapshot => { if (snapshot.fresh) onWatts(loadpointChargeW(snapshot.points)) }) + return watchCharging(site, snapshot => onWatts(snapshot.fresh ? loadpointChargeW(snapshot.points) : 0)) } diff --git a/src/lib/state/now-status.test.ts b/src/lib/state/now-status.test.ts index b872c60..552507c 100644 --- a/src/lib/state/now-status.test.ts +++ b/src/lib/state/now-status.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from 'vitest' -import { watchStatus } from './now-status' +import { watchStatus, STATUS_MAX_AGE_MS, type StatusSnapshot } from './now-status' import { SiteStore } from './site.svelte' import { LoopbackCarrier } from '$lib/carrier/loopback' import { SimBox } from '$lib/sim/box' @@ -9,6 +9,7 @@ const NOON = new Date(2026, 6, 15, 12, 0, 0).getTime() describe('watchStatus', () => { afterEach(() => { vi.useRealTimers() + vi.restoreAllMocks() }) it('reports the dashboard snapshot and can be stopped', async () => { @@ -24,16 +25,52 @@ describe('watchStatus', () => { expect(site.session.phase).toBe('streaming') box.tick(1_000) - const seen: unknown[] = [] + const seen: StatusSnapshot[] = [] const stop = watchStatus(site, (status) => seen.push(status)) await vi.advanceTimersByTimeAsync(200) expect(seen.length, 'no status arrived').toBeGreaterThan(0) - const first = seen[0] as { drivers?: Record } - expect(first.drivers?.['sungrow']?.pv_w, 'solar missing from the snapshot').toBeDefined() - const n = seen.length + const first = seen[0]! + expect(first.status?.drivers?.['sungrow']?.pv_w, 'solar missing from the snapshot').toBeDefined() + expect(first.fresh).toBe(true) + expect(first.receivedAt).toBeGreaterThanOrEqual(NOON) stop() + expect(seen.at(-1)?.fresh).toBe(false) + const n = seen.length await vi.advanceTimersByTimeAsync(4_000) expect(seen.length, 'a stopped watch kept asking').toBe(n) + site.destroy() + }) + + it('expires the last reply while a later request is pending and ignores a reply after stop', async () => { + vi.useFakeTimers() + vi.setSystemTime(NOON) + const box = new SimBox({ now: () => Date.now() }) + const site = new SiteStore('test') + site.connect(new LoopbackCarrier(box, { latencyMs: 0 })) + await vi.advanceTimersByTimeAsync(100) + const seen: StatusSnapshot[] = [] + const stop = watchStatus(site, snapshot => seen.push(snapshot)) + await vi.advanceTimersByTimeAsync(100) + const first = seen.at(-1)! + expect(first.fresh).toBe(true) + + const api = site.api.bind(site) + let finish: (() => void) | undefined + vi.spyOn(site, 'api').mockImplementation(req => req.path === '/api/status' + ? new Promise(resolve => { finish = () => resolve({ status: 200, headers: {}, body: new TextEncoder().encode('{"grid_w":123}') }) }) + : api(req)) + await vi.advanceTimersByTimeAsync(2_100) + expect(finish).toBeDefined() + // A backwards clock change must not extend the life of a reading. + vi.setSystemTime(NOON - 3_600_000) + await vi.advanceTimersByTimeAsync(STATUS_MAX_AGE_MS) + expect(seen.at(-1)).toEqual({ ...first, fresh: false }) + stop() + const n = seen.length + finish!() + await vi.advanceTimersByTimeAsync(100) + expect(seen).toHaveLength(n) + site.destroy() }) }) diff --git a/src/lib/state/now-status.ts b/src/lib/state/now-status.ts index 909d104..2210a7f 100644 --- a/src/lib/state/now-status.ts +++ b/src/lib/state/now-status.ts @@ -6,7 +6,7 @@ * and the same rule as the charger overlay: callBox stays out of the * launch chunk. * - * A failed ask keeps the last snapshot. A 404 is not "the house went away". + * A failed or overdue ask marks the retained snapshot as old. */ import { callBox } from './box-api' @@ -15,6 +15,13 @@ import type { SiteStatus } from './flow' import type { SiteStore } from './site.svelte' const PERIOD_MS = 2_000 +export const STATUS_MAX_AGE_MS = 15_000 + +export interface StatusSnapshot { + status: SiteStatus | null + receivedAt: number | null + fresh: boolean +} function asStatus(wire: unknown): SiteStatus | null { if (!wire || typeof wire !== 'object') return null @@ -25,27 +32,52 @@ function asStatus(wire: unknown): SiteStatus | null { * Poll /api/status while the session is live. Calls `onStatus` with each * snapshot that looks like one. Returns a stop function. */ -export function watchStatus(site: SiteStore, onStatus: (status: SiteStatus) => void): () => void { +export function watchStatus(site: SiteStore, onStatus: (snapshot: StatusSnapshot) => void): () => void { let stopped = false let timer: ReturnType | undefined + let expiry: ReturnType | undefined + let snapshot: StatusSnapshot = { status: null, receivedAt: null, fresh: false } + + const expire = () => { + if (stopped) return + snapshot = { ...snapshot, fresh: false } + onStatus(snapshot) + } const tick = async () => { if (stopped) return if (site.session.phase === 'streaming' && site.session.caps.has(CAP_API_PASSTHROUGH)) { try { + const startedAt = performance.now() const wire = await callBox(site, { method: 'GET', path: '/api/status' }) const status = asStatus(wire) - if (!stopped && status) onStatus(status) + if (stopped) return + if (!status) throw new Error('Missing status') + const age = performance.now() - startedAt + snapshot = { + status, + receivedAt: Date.now(), + fresh: age < STATUS_MAX_AGE_MS && site.session.phase === 'streaming', + } + clearTimeout(expiry) + onStatus(snapshot) + // Expire even if the next request remains pending. Timers use elapsed + // time; changing the phone's wall clock must not extend freshness. + expiry = setTimeout(expire, Math.max(0, STATUS_MAX_AGE_MS - age)) } catch { - // Keep the last snapshot. A failed ask is not "the house went idle". + clearTimeout(expiry) + expire() } - } - if (!stopped) timer = setTimeout(() => void tick(), PERIOD_MS) + } else expire() + if (stopped) return + timer = setTimeout(() => void tick(), PERIOD_MS) } void tick() return () => { + expire() stopped = true clearTimeout(timer) + clearTimeout(expiry) } } diff --git a/src/views/Now.svelte b/src/views/Now.svelte index 864431a..469716c 100644 --- a/src/views/Now.svelte +++ b/src/views/Now.svelte @@ -14,6 +14,7 @@ import type { FtwEnergyFlowElement } from '$vendor/ftw/ftw-energy-flow.js' import { flowReadings, flowReadingsFromStatus, withLoadpointEv, type SiteStatus } from '$lib/state/flow' import { explain } from '$lib/format/explanation' + import { CAP_API_PASSTHROUGH } from '$lib/protocol/contract' import LivePanel, { type LiveRole } from './LivePanel.svelte' import type { SiteStore } from '$lib/state/site.svelte' import type { Component } from 'svelte' @@ -86,15 +87,23 @@ // overlay: callBox is not on the path to the first frame. Frozen fields // keep drawing until one lands, and after a drop. let status = $state(null) + let statusFresh = $state(false) + let statusReceivedAt = $state(null) + const watchStatusNow = $derived(active && site.documentVisible && + site.session.phase === 'streaming' && site.session.caps.has(CAP_API_PASSTHROUGH)) $effect(() => { - if (!active) return + if (!watchStatusNow) return const s = untrack(() => site) let stop: (() => void) | undefined let cancelled = false void import('$lib/state/now-status').then((m) => { if (cancelled) return stop = m.watchStatus(s, (next) => { - status = next + if (next.status) { + status = next.status + statusReceivedAt = next.receivedAt + } + statusFresh = next.fresh }) }) return () => { @@ -102,6 +111,7 @@ stop?.() } }) + const statusLive = $derived(statusFresh && live && watchStatusNow) const flowFields = $derived(withLoadpointEv(site.session.fields, evFromLp)) const headline = $derived( @@ -112,7 +122,7 @@ }).headline ) const liveReadings = $derived( - status ? flowReadingsFromStatus(status) : flowReadings(flowFields) + status && (statusLive || !live) ? flowReadingsFromStatus(status) : flowReadings(flowFields) ) let flow = $state(null) @@ -178,6 +188,8 @@ let Outlook = $state | null>(null) $effect(() => { @@ -335,10 +347,13 @@ moving particle claims power is flowing at this very moment. --> + {#if status && !statusLive} +

Device details are out of date.{live ? ' Showing live totals.' : ''}

+ {/if} {#if Outlook} - + {/if} {#if evOpen && EvPanel} diff --git a/src/views/Now.svelte.test.ts b/src/views/Now.svelte.test.ts index 7281d1c..07d0ca6 100644 --- a/src/views/Now.svelte.test.ts +++ b/src/views/Now.svelte.test.ts @@ -18,6 +18,7 @@ import { LoopbackCarrier } from '$lib/carrier/loopback' import { SimBox } from '$lib/sim/box' import type { FtwEnergyFlowElement } from '$vendor/ftw/ftw-energy-flow.js' import { decodeFrame } from '$lib/protocol/frame' +import { flowReadings, type FlowReadings } from '$lib/state/flow' /** Fixed so the simulated house is the same every run. */ const NOON = new Date(2026, 6, 15, 12, 0, 0).getTime() @@ -126,6 +127,11 @@ describe('the Now screen', () => { vi.setSystemTime(NOON) const box = new SimBox({ now: () => Date.now() }) + // Exercise the telemetry path; the richer status has its own cadence. + const serve = box.api.serve.bind(box.api) + vi.spyOn(box.api, 'serve').mockImplementation(req => req.path === '/api/status' + ? { status: 503, contentType: 'application/json', body: new TextEncoder().encode('{}') } + : serve(req)) const types: string[] = [] box.onFrame((frame) => types.push(decodeFrame(frame).envelope.t)) const site = new SiteStore('test') @@ -147,7 +153,10 @@ describe('the Now screen', () => { box.tick(0) await vi.advanceTimersByTimeAsync(100) - expect(types, 'the simulator sent changed readings, so this is not a tick').toEqual(['tick']) + // Plan, price and API replies may finish loading in parallel with Now. + // Only these three message types can replace the telemetry being tested. + const telemetry = types.filter(t => t === 'snap' || t === 'delta' || t === 'tick') + expect(telemetry, 'the simulator sent changed readings, so this is not a tick').toEqual(['tick']) expect(fed, 'an unchanged 1 Hz tick rebuilt the full shadow DOM').not.toHaveBeenCalled() vi.setSystemTime(NOON + 3_600_000) @@ -181,6 +190,57 @@ describe('the Now screen', () => { expect(rich!.planets?.some((p) => p.id === 'pv-sungrow')).toBe(true) }) + it('uses fresh telemetry after a status failure and restores details when status recovers', async () => { + vi.useFakeTimers() + vi.setSystemTime(NOON) + const box = new SimBox({ now: () => Date.now() }) + const serve = box.api.serve.bind(box.api) + let failStatus = false + vi.spyOn(box.api, 'serve').mockImplementation(req => { + if (req.path === '/api/status' && failStatus) { + return { status: 503, contentType: 'application/json', body: new TextEncoder().encode('{}') } + } + return serve(req) + }) + const site = new SiteStore('test') + site.connect(new LoopbackCarrier(box, { latencyMs: 0 })) + const view = render(Now, { props: { site } }) + for (let i = 0; i < 300 && !document.querySelector('.card.fuse'); i++) { + box.tick(20) + await vi.advanceTimersByTimeAsync(20) + } + expect(document.querySelector('.card.fuse')?.textContent).toContain('Live safety') + const fed = vi.spyOn(flowEl()!, 'setReadings') + + failStatus = true + for (let i = 0; i < 4; i++) { + box.tick(1_000) + await vi.advanceTimersByTimeAsync(1_000) + } + expect(site.srcState).toBe('live') + const fallback = fed.mock.lastCall?.[0] as FlowReadings + expect(fallback, 'stale status still supplied the diagram').toEqual(flowReadings(site.session.fields)) + expect(flowEl()!.hasAttribute('static'), 'fresh telemetry should keep moving').toBe(false) + expect(document.body.textContent).toContain('Device details are out of date. Showing live totals.') + expect(document.querySelector('.card.fuse')?.textContent).not.toContain('Live safety') + expect(document.querySelector('.card.fuse time')?.getAttribute('datetime')).toBeTruthy() + expect(document.querySelector('#today-title')?.textContent).toBe('Last totals') + + failStatus = false + for (let i = 0; i < 4; i++) { + box.tick(1_000) + await vi.advanceTimersByTimeAsync(1_000) + } + const recovered = fed.mock.lastCall?.[0] as FlowReadings + expect(recovered.planets.some(p => p.id === 'pv-sungrow')).toBe(true) + expect(recovered.selfPoweredPctToday).not.toBeNull() + expect(document.body.textContent).not.toContain('Device details are out of date') + expect(document.querySelector('.card.fuse')?.textContent).toContain('Live safety') + expect(document.querySelector('#today-title')?.textContent).toBe('Today') + view.unmount() + site.destroy() + }) + it('draws price, the next plan step, today and the fuse under the house', async () => { vi.useFakeTimers() vi.setSystemTime(NOON) diff --git a/src/views/NowOutlook.svelte b/src/views/NowOutlook.svelte index bd92566..1301d0d 100644 --- a/src/views/NowOutlook.svelte +++ b/src/views/NowOutlook.svelte @@ -28,10 +28,16 @@ interface Props { site: SiteStore status: SiteStatus | null + statusFresh?: boolean + statusReceivedAt?: number | null active?: boolean } - let { site, status, active = true }: Props = $props() + let { site, status, statusFresh = false, statusReceivedAt = null, active = true }: Props = $props() + const statusTime = $derived(statusReceivedAt === null ? null : new Date(statusReceivedAt)) + const statusStamp = $derived(statusTime?.toLocaleString(undefined, { + month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', + })) const CAP_PRICE_SPOT = 'price.spot' const PRICE_HORIZON_MS = 48 * 3_600_000 @@ -214,10 +220,13 @@
-

Since midnight

-

Today

+

{statusFresh ? 'Since midnight' : 'Last known'}

+

{statusFresh ? 'Today' : 'Last totals'}

+ {#if !statusFresh && statusTime} +

Energy totals last updated .

+ {/if}
Imported @@ -261,9 +270,12 @@ {#if fuse}
-

Live safety

+

{statusFresh ? 'Live safety' : 'Last known'}

Fuse

+ {#if !statusFresh && statusTime} +

Fuse readings last updated .

+ {/if} {#if fuse.phases.length > 0}
{#each fuse.phases as phase (phase.label)} From bf27cc06c32ee8494e69ec73ab6d081aaf5df58f Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 7 Sep 2026 20:24:37 +0200 Subject: [PATCH 2/2] fix(now): clear retained daily share on status fallback --- src/views/Now.svelte | 4 +++- src/views/Now.svelte.test.ts | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/views/Now.svelte b/src/views/Now.svelte index 469716c..57a45be 100644 --- a/src/views/Now.svelte +++ b/src/views/Now.svelte @@ -138,7 +138,9 @@ $effect(() => { const readings = liveReadings if (!active || !flow || (flow === lastFlow && readings === lastReadings)) return - flow.setReadings(readings) + // The component retains an omitted daily share. Clear it when only + // telemetry totals remain, so old status details cannot survive fallback. + flow.setReadings({ ...readings, selfPoweredPctToday: readings.selfPoweredPctToday ?? null }) lastFlow = flow lastReadings = readings }) diff --git a/src/views/Now.svelte.test.ts b/src/views/Now.svelte.test.ts index 07d0ca6..48e0250 100644 --- a/src/views/Now.svelte.test.ts +++ b/src/views/Now.svelte.test.ts @@ -218,8 +218,11 @@ describe('the Now screen', () => { await vi.advanceTimersByTimeAsync(1_000) } expect(site.srcState).toBe('live') + expect(flowEl()!.shadowRoot!.textContent, 'old daily share remained in the live diagram').not.toContain('SELF-POWERED TODAY') const fallback = fed.mock.lastCall?.[0] as FlowReadings - expect(fallback, 'stale status still supplied the diagram').toEqual(flowReadings(site.session.fields)) + expect(fallback, 'stale status still supplied the diagram').toEqual({ + ...flowReadings(site.session.fields), selfPoweredPctToday: null, + }) expect(flowEl()!.hasAttribute('static'), 'fresh telemetry should keep moving').toBe(false) expect(document.body.textContent).toContain('Device details are out of date. Showing live totals.') expect(document.querySelector('.card.fuse')?.textContent).not.toContain('Live safety') @@ -234,6 +237,7 @@ describe('the Now screen', () => { const recovered = fed.mock.lastCall?.[0] as FlowReadings expect(recovered.planets.some(p => p.id === 'pv-sungrow')).toBe(true) expect(recovered.selfPoweredPctToday).not.toBeNull() + expect(flowEl()!.shadowRoot!.textContent).toContain('SELF-POWERED TODAY') expect(document.body.textContent).not.toContain('Device details are out of date') expect(document.querySelector('.card.fuse')?.textContent).toContain('Live safety') expect(document.querySelector('#today-title')?.textContent).toBe('Today')