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/now-status-freshness.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 22 additions & 0 deletions src/lib/state/now-ev-overlay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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()
})
})
2 changes: 1 addition & 1 deletion src/lib/state/now-ev-overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
47 changes: 42 additions & 5 deletions src/lib/state/now-status.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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 () => {
Expand All @@ -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<string, { pv_w?: number }> }
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()
})
})
44 changes: 38 additions & 6 deletions src/lib/state/now-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand All @@ -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<typeof setTimeout> | undefined
let expiry: ReturnType<typeof setTimeout> | 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<unknown>(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)
}
}
27 changes: 22 additions & 5 deletions src/views/Now.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -86,22 +87,31 @@
// 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<SiteStatus | null>(null)
let statusFresh = $state(false)
let statusReceivedAt = $state<number | null>(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 () => {
cancelled = true
stop?.()
}
})
const statusLive = $derived(statusFresh && live && watchStatusNow)

const flowFields = $derived(withLoadpointEv(site.session.fields, evFromLp))
const headline = $derived(
Expand All @@ -112,7 +122,7 @@
}).headline
)
const liveReadings = $derived(
status ? flowReadingsFromStatus(status) : flowReadings(flowFields)
status && (statusLive || !live) ? flowReadingsFromStatus(status) : flowReadings(flowFields)
)

let flow = $state<FtwEnergyFlowElement | null>(null)
Expand All @@ -128,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
})
Expand Down Expand Up @@ -178,6 +190,8 @@
let Outlook = $state<Component<{
site: SiteStore
status: SiteStatus | null
statusFresh: boolean
statusReceivedAt: number | null
active?: boolean
}> | null>(null)
$effect(() => {
Expand Down Expand Up @@ -335,10 +349,13 @@
moving particle claims power is flowing at this very moment. -->
<ftw-energy-flow bind:this={flow} embedded static={live && active ? undefined : true}
></ftw-energy-flow>
{#if status && !statusLive}
<p class="note" role="status">Device details are out of date.{live ? ' Showing live totals.' : ''}</p>
{/if}
</div>

{#if Outlook}
<Outlook {site} {status} {active} />
<Outlook {site} {status} {active} statusFresh={statusLive} {statusReceivedAt} />
{/if}

{#if evOpen && EvPanel}
Expand Down
66 changes: 65 additions & 1 deletion src/views/Now.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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')
Expand All @@ -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)
Expand Down Expand Up @@ -181,6 +190,61 @@ 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')
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), 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')
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(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')
view.unmount()
site.destroy()
})

it('draws price, the next plan step, today and the fuse under the house', async () => {
vi.useFakeTimers()
vi.setSystemTime(NOON)
Expand Down
Loading