From 95042c34d6ab34c688ff968d5e3d4e213badbbb3 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 20 Aug 2026 18:04:27 +0200 Subject: [PATCH 1/2] feat(now): draw Overview under the house from the box snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LAN page already fed from GET /api/status: per-driver planets, kWh today, self-powered. Now does the same, then the rest of that glance — compact price, the next plan step, today's totals, savings, fuse. Frozen fields stay the fallback. The new cards load after the first frame. --- src/lib/format/savings.test.ts | 32 ++ src/lib/format/savings.ts | 83 +++++ src/lib/sim/api.ts | 96 +++++- src/lib/sim/box.ts | 1 + src/lib/state/flow.test.ts | 125 ++++++- src/lib/state/flow.ts | 268 ++++++++++++++- src/lib/state/now-status.test.ts | 39 +++ src/lib/state/now-status.ts | 51 +++ src/vendor/ftw/ftw-energy-flow.d.ts | 9 + src/views/Now.svelte | 54 ++- src/views/Now.svelte.test.ts | 50 +++ src/views/NowOutlook.svelte | 515 ++++++++++++++++++++++++++++ tests/entry-bundle.test.ts | 10 + 13 files changed, 1318 insertions(+), 15 deletions(-) create mode 100644 src/lib/format/savings.test.ts create mode 100644 src/lib/format/savings.ts create mode 100644 src/lib/state/now-status.test.ts create mode 100644 src/lib/state/now-status.ts create mode 100644 src/views/NowOutlook.svelte diff --git a/src/lib/format/savings.test.ts b/src/lib/format/savings.test.ts new file mode 100644 index 0000000..91c601d --- /dev/null +++ b/src/lib/format/savings.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest' +import { buildSavingsPeriods, formatCompactMinor, toSavingsDay } from './savings' + +describe('buildSavingsPeriods', () => { + it('splits today, seven days and the box month the way the dashboard does', () => { + const days = [ + { day: '2026-07-01', savedOre: 100, resolution: 'slot' }, + { day: '2026-07-10', savedOre: 200, resolution: 'slot' }, + { day: '2026-07-14', savedOre: 50, resolution: 'no_prices' }, + { day: '2026-07-15', savedOre: 300, resolution: 'slot' }, + ] + const p = buildSavingsPeriods(days) + expect(p.today.savedMinor).toBe(300) + expect(p.today.available).toBe(true) + expect(p.week.savedMinor).toBe(600) + expect(p.week.complete).toBe(false) + expect(p.month.savedMinor).toBe(600) + }) + + it('ignores a row the box did not date', () => { + expect(toSavingsDay({ saved_ore: 10 })).toBeNull() + expect(toSavingsDay({ day: '2026-07-15', saved_ore: 12.4 })?.savedOre).toBe(12.4) + }) +}) + +describe('formatCompactMinor', () => { + it('matches the dashboard compact rounding', () => { + expect(formatCompactMinor(24700)).toBe('+247') + expect(formatCompactMinor(1234)).toBe('+12.3') + expect(formatCompactMinor(-80)).toBe('−0.80') + }) +}) diff --git a/src/lib/format/savings.ts b/src/lib/format/savings.ts new file mode 100644 index 0000000..dffad13 --- /dev/null +++ b/src/lib/format/savings.ts @@ -0,0 +1,83 @@ +/* Compact savings figures, from GET /api/savings/daily. + * + * The box page's compact card is a self-fetching web component. It talks + * HTTP to the box origin, which this app does not have, so the math is + * copied here and the fetch goes through the session. Same rounding, same + * periods, so a person comparing the two screens sees the same money. + */ + +export const SAVINGS_LOOKBACK_DAYS = 31 + +export interface SavingsDay { + day: string + savedOre: number + resolution: string +} + +export interface SavingsPeriod { + savedMinor: number + pricedDays: number + totalDays: number + available: boolean + complete: boolean +} + +export interface SavingsPeriods { + today: SavingsPeriod + week: SavingsPeriod + month: SavingsPeriod +} + +function finite(value: unknown): number { + const n = Number(value) + return Number.isFinite(n) ? n : 0 +} + +function summarize(rows: readonly SavingsDay[]): SavingsPeriod { + const priced = rows.filter((row) => row.resolution !== 'no_prices') + return { + savedMinor: priced.reduce((sum, row) => sum + row.savedOre, 0), + pricedDays: priced.length, + totalDays: rows.length, + available: priced.length > 0, + complete: rows.length > 0 && priced.length === rows.length, + } +} + +/** One row off the wire, unknown-tolerant. */ +export function toSavingsDay(row: { + day?: unknown + saved_ore?: unknown + resolution?: unknown +}): SavingsDay | null { + const day = typeof row.day === 'string' ? row.day : '' + if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) return null + return { + day, + savedOre: finite(row.saved_ore), + resolution: typeof row.resolution === 'string' ? row.resolution : 'slot', + } +} + +export function buildSavingsPeriods(days: readonly SavingsDay[]): SavingsPeriods { + const rows = [...days].sort((a, b) => a.day.localeCompare(b.day)) + const latest = rows[rows.length - 1] + const monthKey = latest ? latest.day.slice(0, 7) : '' + + return { + today: summarize(rows.slice(-1)), + week: summarize(rows.slice(-7)), + month: summarize(monthKey ? rows.filter((row) => row.day.startsWith(`${monthKey}-`)) : []), + } +} + +/** + * Minor units in, signed major units out. The currency sits once in the + * heading, which leaves room for the three values on a phone. + */ +export function formatCompactMinor(minor: number): string { + const major = finite(minor) / 100 + const absolute = Math.abs(major) + const digits = absolute >= 100 ? 0 : absolute >= 10 ? 1 : 2 + return (major >= 0 ? '+' : '−') + absolute.toFixed(digits) +} diff --git a/src/lib/sim/api.ts b/src/lib/sim/api.ts index ba483e5..1fb3237 100644 --- a/src/lib/sim/api.ts +++ b/src/lib/sim/api.ts @@ -27,7 +27,7 @@ * test here passed while a real box refused what the app had drawn. */ -import { DAY_ANCHOR_PERMILLE, sample, stepSoc, type HouseConfig } from './energy' +import { DAY_ANCHOR_PERMILLE, sample, stepSoc, type HouseConfig, type Reading } from './energy' import { OP_SET_MODE, ROLE_OWNER, ROLE_VIEWER, type Role } from '$lib/protocol/messages' import { roleHasScope } from '$lib/protocol/contract' import { wireBytes } from '$lib/protocol/frame' @@ -93,6 +93,7 @@ const ROUTES: Record = { // Reads the app makes, and one it never will. 'GET /api/status': { tier: 'read' }, 'GET /api/energy/daily': { tier: 'read' }, + 'GET /api/savings/daily': { tier: 'read' }, 'GET /api/app-link/devices': { tier: 'read' }, // A read that answers with a gzipped archive. Priced read because it hands // back nothing replayable — the session refuses it later, at the status @@ -235,6 +236,11 @@ export interface SimApiOptions { * loadpoints answer must describe the same household the stream does. */ loadpointState?: () => { holdW: number | null; boostActive: boolean } + /** + * The live sample the 1 Hz stream is already sending. Status must describe + * the same moment or the hero and the charger sheet disagree. + */ + liveReading?: () => Reading | null } const DAY_MS = 86_400_000 @@ -460,7 +466,9 @@ export class SimApi { ): ApiAnswer { const route = matched.pattern + if (route === 'GET /api/status') return this.#status() if (route === 'GET /api/energy/daily') return this.#energyDaily(req.query) + if (route === 'GET /api/savings/daily') return this.#savingsDaily(req.query) if (route === 'GET /api/loadpoints') return this.#loadpoints() if (route === 'GET /api/mpc/plan') return this.#mpcPlan() if (route === 'PUT /api/loadpoints/{id}/schedule') { @@ -538,6 +546,60 @@ export class SimApi { return json(404, { error: 'not found' }) } + /** + * The dashboard's own snapshot. Field names match handleStatus: snake_case, + * SoC as a 0–1 fraction, energy.today in watt-hours, one object per driver. + */ + #status(): ApiAnswer { + const now = this.#opts.now() + const live = this.#opts.liveReading?.() + const door = this.#opts.loadpointState?.() ?? { holdW: null, boostActive: false } + let r = live ?? sample(this.#opts.house, now, DAY_ANCHOR_PERMILLE, this.#opts.ceilingW) + if (!live && door.holdW !== null) { + r = { ...r, evW: door.holdW, gridW: r.gridW - r.evW + door.holdW } + } + + const todayMidnight = new Date(now).setHours(0, 0, 0, 0) + const today = this.#integrate(todayMidnight, now) + + const drivers: Record> = {} + if (this.#opts.house.pvPeakW > 0) { + drivers['sungrow'] = { status: 'ok', pv_w: r.pvW } + } + if (this.#opts.house.batteryCapacityWh > 0) { + drivers['lynx'] = { + status: 'ok', + bat_w: r.batteryW, + bat_soc: r.batterySocPermille / 1000, + } + } + drivers['easee'] = { status: 'ok', ev_w: r.evW } + + return json(200, { + grid_w: r.gridW, + pv_w: r.pvW, + bat_w: r.batteryW, + ev_w: r.evW, + load_w: r.loadW, + bat_soc: r.batterySocPermille / 1000, + fuse: { + max_amps: this.#opts.house.fuseA, + phases: this.#opts.house.phases, + voltage: 230, + }, + phase_amps: Array.from( + { length: this.#opts.house.phases }, + () => r.gridW / 230 / this.#opts.house.phases, + ), + phase_powers: Array.from( + { length: this.#opts.house.phases }, + () => r.gridW / this.#opts.house.phases, + ), + energy: { today }, + drivers, + }) + } + /** * The charger, from the same house the live view samples. * @@ -824,6 +886,38 @@ export class SimApi { return json(200, { days: out, tz: 'Local' }) } + /** + * Site savings vs a no-PV/no-battery baseline, as handleSavingsDaily + * answers them. Costs are in öre. The numbers are a shape, not a tariff + * model: enough for the compact card to have something true to print. + */ + #savingsDaily(query: Record): ApiAnswer { + let days = 7 + const asked = Number.parseInt(query['days'] ?? '', 10) + if (Number.isFinite(asked) && asked > 0) days = asked + if (days > 90) days = 90 + + const nowMs = this.#opts.now() + const todayMidnight = new Date(nowMs).setHours(0, 0, 0, 0) + const out = [] + for (let i = days - 1; i >= 0; i--) { + const dayStart = new Date(todayMidnight).setDate(new Date(todayMidnight).getDate() - i) + const dayEnd = Math.min(new Date(dayStart).setDate(new Date(dayStart).getDate() + 1), nowMs) + const e = this.#integrate(dayStart, dayEnd) + const baselineOre = Math.round((e.load_wh / 1000) * 150) + const actualOre = Math.round((e.import_wh / 1000) * 150 - (e.export_wh / 1000) * 60) + out.push({ + day: dayKey(dayStart), + ...e, + actual_cost_ore: actualOre, + baseline_cost_ore: baselineOre, + saved_ore: baselineOre - actualOre, + resolution: 'slot', + }) + } + return json(200, { days: out, tz: 'Local', value_scope: 'site_total' }) + } + #integrate(fromMs: number, toMs: number) { let importWh = 0 let exportWh = 0 diff --git a/src/lib/sim/box.ts b/src/lib/sim/box.ts index 367cd12..ba4a003 100644 --- a/src/lib/sim/box.ts +++ b/src/lib/sim/box.ts @@ -370,6 +370,7 @@ export class SimBox { holdW: this.#evHold?.powerW ?? null, boostActive: this.#evBoost !== null, }), + liveReading: () => this.#lastReading, }) } diff --git a/src/lib/state/flow.test.ts b/src/lib/state/flow.test.ts index 21790a1..320b479 100644 --- a/src/lib/state/flow.test.ts +++ b/src/lib/state/flow.test.ts @@ -1,5 +1,13 @@ import { describe, it, expect } from 'vitest' -import { flowReadings, loadpointChargeW, withLoadpointEv } from './flow' +import { + flowReadings, + flowReadingsFromStatus, + fmtKwhShort, + fuseView, + loadpointChargeW, + withLoadpointEv, + type SiteStatus, +} from './flow' import { FID } from '$lib/format/explanation' // The mapping between frozen fields and the vendored hero component. The @@ -155,3 +163,118 @@ describe('loadpointChargeW', () => { expect(loadpointChargeW([{ powerW: 11_400 }, { powerW: 20 }, { powerW: 0 }])).toBe(11_400) }) }) + +const STATUS: SiteStatus = { + grid_w: 500, + load_w: 970, + energy: { + today: { + import_wh: 5200, + export_wh: 12_400, + pv_wh: 18_100, + load_wh: 14_000, + bat_charged_wh: 4100, + bat_discharged_wh: 2800, + }, + }, + drivers: { + east: { status: 'ok', pv_w: -1800 }, + west: { status: 'ok', pv_w: -500 }, + lynx: { status: 'ok', bat_w: 1800, bat_soc: 0.687 }, + easee: { status: 'ok', ev_w: 7200 }, + dead: { status: 'offline', pv_w: -900 }, + }, +} + +describe('flowReadingsFromStatus', () => { + it('draws one planet per live driver, not one aggregate per corner', () => { + const r = flowReadingsFromStatus(STATUS) + expect(r.planets.map((p) => p.id).sort()).toEqual([ + 'bat-lynx', + 'ev-easee', + 'grid', + 'pv-east', + 'pv-west', + ]) + expect(r.planets.some((p) => p.id === 'pv-dead'), 'an offline inverter became a planet').toBe( + false, + ) + expect(r.load).toBeCloseTo(0.97) + }) + + it('keeps a faulted charger on the diagram, the way the dashboard does', () => { + const r = flowReadingsFromStatus({ + grid_w: 0, + load_w: 200, + drivers: { easee: { status: 'fault', ev_w: 11_400 } }, + }) + expect(r.planets.find((p) => p.id === 'ev-easee')?.kw).toBeCloseTo(11.4) + }) + + it('writes today onto the bubbles and the self-powered share', () => { + const r = flowReadingsFromStatus(STATUS) + const grid = r.planets.find((p) => p.id === 'grid')! + expect(grid.dailyKwhParts?.map((p) => p.text)).toEqual(['↓ 5.20', '↑ 12.4']) + const solar = r.planets.find((p) => p.id === 'pv-east')! + expect(solar.dailyKwh).toBe('18.1 kWh') + expect(solar.dailyScope).toBe('aggregate') + expect(solar.dailyAggregateMembers).toBe(3) + expect(r.selfPoweredPctToday).toBeCloseTo((1 - 5.2 / 14) * 100) + }) + + it('never hands the component a negative number to draw', () => { + const r = flowReadingsFromStatus({ + grid_w: -3400, + load_w: 900, + drivers: { + sungrow: { status: 'ok', pv_w: -2300 }, + lynx: { status: 'ok', bat_w: -2000, bat_soc: 0.4 }, + easee: { status: 'ok', ev_w: 0 }, + }, + }) + for (const p of r.planets) { + expect(p.kw, `${p.id} carried the wire's sign into the hero`).toBeGreaterThanOrEqual(0) + } + expect(r.planets.find((p) => p.id === 'grid')?.sub).toBe('exporting') + expect(r.planets.find((p) => p.id === 'bat-lynx')?.sub).toBe('discharging') + expect(r.planets.find((p) => p.id === 'bat-lynx')?.soc).toBe(40) + }) +}) + +describe('fmtKwhShort', () => { + it('matches the dashboard bubble rounding', () => { + expect(fmtKwhShort(5.2)).toBe('5.20') + expect(fmtKwhShort(12.4)).toBe('12.4') + expect(fmtKwhShort(100.6)).toBe('101') + }) +}) + +describe('fuseView', () => { + it('draws one bar per live phase', () => { + const v = fuseView({ + fuse: { max_amps: 20, phases: 3, voltage: 230 }, + phase_amps: [12, -3, 18], + phase_powers: [2700, -700, 4100], + }) + expect(v?.phases.map((p) => p.label)).toEqual(['L1', 'L2', 'L3']) + expect(v?.phases[2]?.pct).toBeCloseTo(90) + expect(v?.phases[1]?.exporting).toBe(true) + expect(v?.fallback).toBeNull() + }) + + it('falls back to throughput when the meter has no phases', () => { + const v = fuseView({ + grid_w: 6900, + pv_w: 0, + bat_w: 0, + fuse: { max_amps: 20, phases: 3, voltage: 230 }, + }) + expect(v?.phases).toEqual([]) + expect(v?.fallback?.amps).toBeCloseTo(10) + expect(v?.fallback?.pct).toBeCloseTo(50) + }) + + it('stays absent without a fuse rating', () => { + expect(fuseView({ grid_w: 1000 })).toBeNull() + }) +}) diff --git a/src/lib/state/flow.ts b/src/lib/state/flow.ts index ddd7555..66303d3 100644 --- a/src/lib/state/flow.ts +++ b/src/lib/state/flow.ts @@ -1,34 +1,84 @@ -/* From frozen fields to the energy-flow component's planets. +/* From a site snapshot to the energy-flow component's planets. * - * The box's own dashboard builds this list in web/app.js from /api/status; - * this is the same mapping fed from the wire's frozen fields instead. Roles, - * corners, colours and directions match the dashboard's choices exactly — - * "same components, same views" only holds if the caller speaks to the - * component the same way. + * The box's own dashboard builds this list in web/app.js from /api/status. + * Frozen fields on the 1 Hz stream are the fallback: five aggregates, no + * names, no kWh today. Same component, same corners and colours — "same + * views" only holds if the caller speaks to it the same way. * - * The component knows nothing about field ids or freshness. Everything it is + * The component knows nothing about field ids or HTTP. Everything it is * told is decided here, which is what makes this file worth testing. */ import { FID } from '$lib/format/explanation' import { FLOW_IDLE_W } from '$vendor/ftw/ftw-energy-flow.js' +export interface FlowDailyPart { + text: string + color: string + bold?: boolean +} + export interface FlowPlanet { id: string corner: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' title: string role: 'grid' | 'pv' | 'battery' | 'ev' + name?: string kw: number toHub: boolean color: string sub: string soc?: number | null + chargeLimit?: number | null + socStale?: boolean + socSource?: string | null clickable?: boolean + dailyKwh?: string | null + dailyKwhParts?: FlowDailyPart[] | null + dailyScope?: 'aggregate' + dailyAggregateMembers?: number } export interface FlowReadings { load: number planets: FlowPlanet[] + selfPoweredPctToday?: number | null +} + +/** The slice of GET /api/status this mapping reads. Unknown-tolerant. */ +export interface StatusDriver { + status?: unknown + not_running?: unknown + observe_only?: unknown + pv_w?: unknown + bat_w?: unknown + bat_soc?: unknown + ev_w?: unknown +} + +export interface StatusEnergyToday { + import_wh?: unknown + export_wh?: unknown + pv_wh?: unknown + load_wh?: unknown + bat_charged_wh?: unknown + bat_discharged_wh?: unknown +} + +export interface SiteStatus { + grid_w?: unknown + pv_w?: unknown + bat_w?: unknown + load_w?: unknown + energy?: { today?: StatusEnergyToday } + drivers?: Record + fuse?: { + max_amps?: unknown + phases?: unknown + voltage?: unknown + } + phase_amps?: unknown + phase_powers?: unknown } const idle = (w: number) => Math.abs(w) <= FLOW_IDLE_W @@ -155,3 +205,207 @@ export function withLoadpointEv( out.set(FID.LOAD_W, Math.max(0, load - evW)) return out } + +const num = (v: unknown): number | null => + typeof v === 'number' && Number.isFinite(v) ? v : null + +/** Same rule the dashboard uses: a faulted charger still has a planet. */ +function driverOnline(d: StatusDriver): boolean { + const status = typeof d.status === 'string' ? d.status : '' + return status !== 'offline' && status !== 'disabled' && d.not_running !== true +} + +/** + * Compact kWh for a bubble line. Copied from the dashboard's fmtKwhShort so + * a person comparing this screen with the box page sees the same figure. + */ +export interface FusePhase { + label: string + amps: number + watts: number + pct: number + exporting: boolean +} + +export interface FuseView { + maxAmps: number + phases: FusePhase[] + /** Used when the meter does not report per-phase amps. */ + fallback: { amps: number; pct: number } | null +} + +function ampList(v: unknown): number[] { + return Array.isArray(v) ? v.map((x) => num(x) ?? 0) : [] +} + +/** + * The live fuse reading the dashboard draws on Overview. + * + * Per-phase when the meter reports amps; otherwise one bar from grid, PV + * and battery throughput, the same fallback the box page uses. + */ +export function fuseView(status: SiteStatus): FuseView | null { + const fuse = status.fuse + if (!fuse) return null + const maxAmps = num(fuse.max_amps) + if (maxAmps === null || maxAmps <= 0) return null + const n = Math.max(1, Math.min(3, Math.round(num(fuse.phases) ?? 3))) + const voltage = num(fuse.voltage) ?? 230 + const phaseI = ampList(status.phase_amps) + const phaseW = ampList(status.phase_powers) + + if (phaseI.length > 0) { + const phases: FusePhase[] = [] + for (let i = 0; i < n; i++) { + const amps = phaseI[i] ?? 0 + const watts = phaseW[i] ?? 0 + phases.push({ + label: `L${i + 1}`, + amps, + watts, + pct: Math.min(100, (Math.abs(amps) / maxAmps) * 100), + exporting: amps < -0.1, + }) + } + return { maxAmps, phases, fallback: null } + } + + const gridW = Math.abs(num(status.grid_w) ?? 0) + const pvW = Math.abs(num(status.pv_w) ?? 0) + const batW = num(status.bat_w) ?? 0 + const discharge = batW < 0 ? -batW : 0 + const throughput = Math.max(gridW, pvW + discharge) + const amps = throughput / voltage / n + return { + maxAmps, + phases: [], + fallback: { amps, pct: Math.min(100, (amps / maxAmps) * 100) }, + } +} + +export function fmtKwhShort(kwh: number): string { + const v = Math.abs(kwh) + if (v >= 100) return kwh.toFixed(0) + if (v >= 10) return kwh.toFixed(1) + return kwh.toFixed(2) +} + +/** + * The dashboard's own planet list, from the same /api/status it polls. + * + * Per-driver bubbles, kWh today on each corner, self-powered share. This is + * why the box page looks finished and the frozen-field mapping does not: + * the component already knows how to draw all of it. + */ +export function flowReadingsFromStatus(status: SiteStatus): FlowReadings { + const planets: FlowPlanet[] = [] + const today = status.energy?.today ?? {} + const importKwh = (num(today.import_wh) ?? 0) / 1000 + const exportKwh = (num(today.export_wh) ?? 0) / 1000 + const pvKwhTotal = (num(today.pv_wh) ?? 0) / 1000 + const loadKwhTotal = (num(today.load_wh) ?? 0) / 1000 + const batChargedKwh = (num(today.bat_charged_wh) ?? 0) / 1000 + const batDischargedKwh = (num(today.bat_discharged_wh) ?? 0) / 1000 + + const pvDailyStr = `${fmtKwhShort(pvKwhTotal)} kWh` + const gridDailyParts: FlowDailyPart[] = [ + { text: `↓ ${fmtKwhShort(importKwh)}`, color: 'var(--red-e)', bold: true }, + { text: `↑ ${fmtKwhShort(exportKwh)}`, color: 'var(--green-e)', bold: true }, + ] + const batDailyParts: FlowDailyPart[] = [ + { text: `↑ ${fmtKwhShort(batChargedKwh)}`, color: 'var(--green-e)', bold: true }, + { text: `↓ ${fmtKwhShort(batDischargedKwh)}`, color: 'var(--red-e)', bold: true }, + ] + + const gridW = num(status.grid_w) + if (gridW === null) { + planets.push({ + id: 'grid', corner: 'bottom-left', title: 'GRID', role: 'grid', + kw: 0, toHub: true, color: 'var(--fg-muted)', sub: 'no data', clickable: false, + }) + } else { + const gIdle = idle(gridW) + planets.push({ + id: 'grid', corner: 'bottom-left', title: 'GRID', role: 'grid', + // Magnitude only: the sign is wire convention. Direction travels as + // toHub and as the sub line, never as a minus on the number. + kw: Math.abs(gridW) / 1000, toHub: gridW >= 0, + color: gIdle ? 'var(--fg-muted)' : gridW >= 0 ? 'var(--red-e)' : 'var(--green-e)', + sub: gIdle ? 'balanced' : gridW >= 0 ? 'importing' : 'exporting', + dailyKwhParts: gridDailyParts, + clickable: true, + }) + } + + const drivers = status.drivers ?? {} + const names = Object.keys(drivers) + let pvDailyMembers = 0 + let batDailyMembers = 0 + for (const name of names) { + const d = drivers[name] + if (!d) continue + if (d.pv_w != null) pvDailyMembers++ + if (d.bat_w != null) batDailyMembers++ + } + + for (const name of names) { + const d = drivers[name] + if (!d || !driverOnline(d)) continue + + const pvW = num(d.pv_w) + if (pvW !== null) { + const pvKw = -pvW / 1000 + const pvGen = !idle(pvW) + planets.push({ + id: `pv-${name}`, corner: 'top-left', title: 'SOLAR', role: 'pv', name, + kw: pvKw, toHub: true, + color: pvGen ? 'var(--amber)' : 'var(--fg-muted)', + sub: '', + dailyKwh: pvDailyStr, + dailyScope: 'aggregate', + dailyAggregateMembers: pvDailyMembers, + clickable: true, + }) + } + + const batW = num(d.bat_w) + if (batW !== null) { + const bIdle = idle(batW) + const soc = num(d.bat_soc) + planets.push({ + id: `bat-${name}`, corner: 'top-right', title: 'BATTERY', role: 'battery', name, + kw: Math.abs(batW) / 1000, toHub: batW < 0, + color: bIdle ? 'var(--cyan)' : batW >= 0 ? 'var(--green-e)' : 'var(--red-e)', + sub: d.observe_only === true ? 'observe only' : bIdle ? 'idle' : batW >= 0 ? 'charging' : 'discharging', + soc: soc === null ? null : Math.round(soc * 100), + dailyKwhParts: batDailyParts, + dailyScope: 'aggregate', + dailyAggregateMembers: batDailyMembers, + clickable: d.observe_only !== true, + }) + } + + const evW = num(d.ev_w) + if (evW !== null) { + const active = !idle(evW) + planets.push({ + id: `ev-${name}`, corner: 'bottom-right', title: 'EV CHARGER', role: 'ev', name, + kw: Math.abs(evW) / 1000, toHub: false, + color: active ? 'var(--green-e)' : 'var(--white-s)', + sub: active ? 'charging' : 'idle', + clickable: true, + }) + } + } + + let selfPoweredPctToday: number | null = null + if (loadKwhTotal > 0.001) { + selfPoweredPctToday = Math.max(0, Math.min(100, (1 - importKwh / loadKwhTotal) * 100)) + } + + return { + load: (num(status.load_w) ?? 0) / 1000, + planets, + selfPoweredPctToday, + } +} diff --git a/src/lib/state/now-status.test.ts b/src/lib/state/now-status.test.ts new file mode 100644 index 0000000..b872c60 --- /dev/null +++ b/src/lib/state/now-status.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { watchStatus } from './now-status' +import { SiteStore } from './site.svelte' +import { LoopbackCarrier } from '$lib/carrier/loopback' +import { SimBox } from '$lib/sim/box' + +const NOON = new Date(2026, 6, 15, 12, 0, 0).getTime() + +describe('watchStatus', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('reports the dashboard snapshot and can be stopped', async () => { + vi.useFakeTimers() + vi.setSystemTime(NOON) + + const box = new SimBox({ now: () => Date.now() }) + const site = new SiteStore('test') + site.connect(new LoopbackCarrier(box, { latencyMs: 5 })) + for (let i = 0; i < 100 && site.session.phase !== 'streaming'; i++) { + await vi.advanceTimersByTimeAsync(10) + } + expect(site.session.phase).toBe('streaming') + box.tick(1_000) + + const seen: unknown[] = [] + 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 + stop() + await vi.advanceTimersByTimeAsync(4_000) + expect(seen.length, 'a stopped watch kept asking').toBe(n) + }) +}) diff --git a/src/lib/state/now-status.ts b/src/lib/state/now-status.ts new file mode 100644 index 0000000..909d104 --- /dev/null +++ b/src/lib/state/now-status.ts @@ -0,0 +1,51 @@ +/* The dashboard's own live snapshot, fetched off the first frame. + * + * Frozen fields on the 1 Hz stream are five aggregates. GET /api/status is + * the document the box page already draws the hero from: per-driver + * planets, kWh today, the fuse. Same cadence as that page — two seconds — + * 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". + */ + +import { callBox } from './box-api' +import { CAP_API_PASSTHROUGH } from '$lib/protocol/contract' +import type { SiteStatus } from './flow' +import type { SiteStore } from './site.svelte' + +const PERIOD_MS = 2_000 + +function asStatus(wire: unknown): SiteStatus | null { + if (!wire || typeof wire !== 'object') return null + return wire as SiteStatus +} + +/** + * 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 { + let stopped = false + let timer: ReturnType | undefined + + const tick = async () => { + if (stopped) return + if (site.session.phase === 'streaming' && site.session.caps.has(CAP_API_PASSTHROUGH)) { + try { + const wire = await callBox(site, { method: 'GET', path: '/api/status' }) + const status = asStatus(wire) + if (!stopped && status) onStatus(status) + } catch { + // Keep the last snapshot. A failed ask is not "the house went idle". + } + } + if (!stopped) timer = setTimeout(() => void tick(), PERIOD_MS) + } + + void tick() + return () => { + stopped = true + clearTimeout(timer) + } +} diff --git a/src/vendor/ftw/ftw-energy-flow.d.ts b/src/vendor/ftw/ftw-energy-flow.d.ts index 9929cc5..1da8b07 100644 --- a/src/vendor/ftw/ftw-energy-flow.d.ts +++ b/src/vendor/ftw/ftw-energy-flow.d.ts @@ -6,6 +6,12 @@ export const FLOW_IDLE_W: number export const FLOW_IDLE_KW: number export function isIdleKw(kw: number): boolean +export interface FtwFlowDailyPart { + text: string + color: string + bold?: boolean +} + export interface FtwFlowPlanet { id: string corner: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' @@ -22,6 +28,9 @@ export interface FtwFlowPlanet { socSource?: string | null clickable?: boolean dailyKwh?: string | null + dailyKwhParts?: FtwFlowDailyPart[] | null + dailyScope?: 'aggregate' + dailyAggregateMembers?: number placeholder?: boolean } diff --git a/src/views/Now.svelte b/src/views/Now.svelte index 6217d46..c800804 100644 --- a/src/views/Now.svelte +++ b/src/views/Now.svelte @@ -12,7 +12,7 @@ import { untrack } from 'svelte' import '$vendor/ftw/ftw-energy-flow.js' import type { FtwEnergyFlowElement } from '$vendor/ftw/ftw-energy-flow.js' - import { flowReadings, withLoadpointEv } from '$lib/state/flow' + 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' @@ -83,6 +83,27 @@ } }) + // The box page's own snapshot. Same later-chunk rule as the charger + // 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) + $effect(() => { + if (!active) 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 + }) + }) + return () => { + cancelled = true + stop?.() + } + }) + const flowFields = $derived(withLoadpointEv(site.session.fields, evFromLp)) const headline = $derived( explain({ @@ -91,10 +112,13 @@ ceilingW: site.ceilingW, }).headline ) + const liveReadings = $derived( + status ? flowReadingsFromStatus(status) : flowReadings(flowFields) + ) let flow = $state(null) let lastFlow: FtwEnergyFlowElement | null = null - let lastFlowFields: ReadonlyMap | null = null + let lastReadings: typeof liveReadings | null = null // The component takes data by method, the way the dashboard feeds it. // An effect rather than an attribute because setReadings() is the @@ -103,11 +127,11 @@ // nobody can see — and because the effect reads the fields as they are // when `active` returns, coming back starts from the present. $effect(() => { - const fields = flowFields - if (!active || !flow || (flow === lastFlow && fields === lastFlowFields)) return - flow.setReadings(flowReadings(fields)) + const readings = liveReadings + if (!active || !flow || (flow === lastFlow && readings === lastReadings)) return + flow.setReadings(readings) lastFlow = flow - lastFlowFields = fields + lastReadings = readings }) /** The charger's sheet, opened by a tap on its bubble. Loaded on demand @@ -121,6 +145,20 @@ }) }) + /** Price, plan, today, fuse — the rest of Overview. Same later-chunk + * rule: none of it sits on the path to the first reading. */ + let Outlook = $state | null>(null) + $effect(() => { + if (!active || site.session.fields.size === 0 || Outlook) return + void import('./NowOutlook.svelte').then((m) => { + Outlook = m.default + }) + }) + /** The live-line sheet for one part of the house, or null. */ let liveRole = $state(null) @@ -259,6 +297,10 @@ > + {#if Outlook} + + {/if} + {#if evOpen && EvPanel} (evOpen = false)} /> {/if} diff --git a/src/views/Now.svelte.test.ts b/src/views/Now.svelte.test.ts index 283e9c5..1f3fb5d 100644 --- a/src/views/Now.svelte.test.ts +++ b/src/views/Now.svelte.test.ts @@ -155,4 +155,54 @@ describe('the Now screen', () => { await vi.advanceTimersByTimeAsync(100) expect(fed, 'a changed reading did not reach the diagram').toHaveBeenCalledTimes(1) }) + + it('draws kWh today on the hero once the dashboard snapshot arrives', async () => { + // Frozen fields are five aggregates. The box page is per-driver planets + // plus today's totals, from GET /api/status. This is the feed that makes + // those two screens the same diagram. + vi.useFakeTimers() + vi.setSystemTime(NOON) + + const box = new SimBox({ now: () => Date.now() }) + const site = new SiteStore('test') + site.connect(new LoopbackCarrier(box, { latencyMs: 0 })) + render(Now, { props: { site, active: true } }) + for (let i = 0; i < 100 && !flowEl(); i++) await vi.advanceTimersByTimeAsync(20) + expect(flowEl()).not.toBeNull() + box.tick(1_000) + + const fed = vi.spyOn(flowEl()!, 'setReadings') + await vi.advanceTimersByTimeAsync(2_200) + + const rich = fed.mock.calls + .map((c) => c[0] as { selfPoweredPctToday?: number | null; planets?: { id: string }[] }) + .find((r) => r.selfPoweredPctToday != null) + expect(rich, 'the dashboard snapshot never reached the hero').toBeTruthy() + expect(rich!.planets?.some((p) => p.id === 'pv-sungrow')).toBe(true) + }) + + it('draws price, the next plan step, today and the fuse under the house', async () => { + vi.useFakeTimers() + vi.setSystemTime(NOON) + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('no origin')) + + const box = new SimBox({ now: () => Date.now() }) + const site = new SiteStore('test') + site.connect(new LoopbackCarrier(box, { latencyMs: 0 })) + render(Now, { props: { site, active: true } }) + box.tick(1_000) + + const text = () => document.body.textContent ?? '' + for (let i = 0; i < 300 && !/\bFuse\b/.test(text()); i++) { + await vi.advanceTimersByTimeAsync(20) + } + + expect(text()).toMatch(/What FTW does next/) + expect(text()).toMatch(/\bToday\b/) + expect(text()).toMatch(/\bFuse\b/) + const chart = document.querySelector('ftw-price-chart') + expect(chart, 'the compact price chart never mounted').not.toBeNull() + expect(chart!.hasAttribute('fed'), 'the chart fetched /api/prices on this origin').toBe(true) + expect(chart!.hasAttribute('compact')).toBe(true) + }) }) diff --git a/src/views/NowOutlook.svelte b/src/views/NowOutlook.svelte new file mode 100644 index 0000000..ae53832 --- /dev/null +++ b/src/views/NowOutlook.svelte @@ -0,0 +1,515 @@ + + + +
+ {#if prices} +
+ {#await import('$vendor/ftw/ftw-price-chart.js') then _module} + + {:catch} +

The price chart didn't load — it will try again next time you open the app.

+ {/await} + {#if priceHole} +

Some hours are missing their price.

+ {:else if prices.stale} +

Tomorrow's rates aren't published yet.

+ {/if} +
+ {/if} + +
+
+
+

Automation

+

What FTW does next

+
+
+

{headline.text}

+ {#if nextChange} +

+ Next change {new Date(nextChange.startMs).toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + hour12: false, + })} +

+ {/if} + {#if currentSlot} +

{reasonText(currentSlot.reason)}.

+ {/if} + +
+
+ +{#if today} +
+
+
+

Since midnight

+

Today

+
+
+
+
+ Imported + {formatEnergy(today.importWh).text} + {formatEnergy(today.importWh).unit} +
+
+ Exported + {formatEnergy(today.exportWh).text} + {formatEnergy(today.exportWh).unit} +
+
+ Solar + {formatEnergy(today.pvWh).text} + {formatEnergy(today.pvWh).unit} +
+ {#if savings} +
+ Saved {currency} + = 0} + class:is-import={savings.today.savedMinor < 0} + >{formatCompactMinor(savings.today.savedMinor)} + {formatCompactMinor(savings.week.savedMinor)} this week +
+ {/if} +
+ +
+{/if} + +{#if fuse} +
+
+

Live safety

+

Fuse

+
+ {#if fuse.phases.length > 0} +
+ {#each fuse.phases as phase (phase.label)} +
+ {phase.label} + {phase.amps.toFixed(1)} A + +
+ {/each} +
+ {:else if fuse.fallback} +
+ {fuse.fallback.amps.toFixed(1)} A + + {fuse.maxAmps} A +
+ {/if} +
+{/if} + + diff --git a/tests/entry-bundle.test.ts b/tests/entry-bundle.test.ts index 618e3dd..790f4fc 100644 --- a/tests/entry-bundle.test.ts +++ b/tests/entry-bundle.test.ts @@ -131,6 +131,16 @@ describe('what a cold start downloads before it can paint', () => { expect(chunkWith(ENCODER)).toContain(ENCODER) }) + it('does not carry the Overview cards under Now', () => { + // Price, plan, today and the fuse are the rest of a glance, and they + // wait until the house has painted. A plain import from Now would put + // the price chart on every cold start. + expect(launchPath, 'Overview cards entered the static launch closure').not.toContain( + 'What FTW does next', + ) + expect(chunkWith('What FTW does next')).toContain('What FTW does next') + }) + it('does not carry the QR decoder', () => { // jsQR is 130 kB and runs when a camera is pointed at something. The // pairing screen imports it on demand and this is what keeps it there. From 84b30dc577ad45a3b2255af204cc04fa95854072 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 20 Aug 2026 20:02:20 +0200 Subject: [PATCH 2/2] fix(now): keep fuse warnings, battery sign, and honest savings Codex review on #53: an exporting phase on the fuse overrode warn/crit colours; two discharging packs summed as a charge; a day without prices printed as +0.00 saved. --- src/lib/format/savings.test.ts | 9 +++++++++ src/lib/state/flow.test.ts | 18 ++++++++++++++++-- src/lib/state/flow.ts | 6 +++++- src/views/NowOutlook.svelte | 22 +++++++++++++++------- 4 files changed, 45 insertions(+), 10 deletions(-) diff --git a/src/lib/format/savings.test.ts b/src/lib/format/savings.test.ts index 91c601d..cb5afcb 100644 --- a/src/lib/format/savings.test.ts +++ b/src/lib/format/savings.test.ts @@ -17,6 +17,15 @@ describe('buildSavingsPeriods', () => { expect(p.month.savedMinor).toBe(600) }) + it('marks today unavailable when the latest day has no prices', () => { + const p = buildSavingsPeriods([ + { day: '2026-07-14', savedOre: 200, resolution: 'slot' }, + { day: '2026-07-15', savedOre: 0, resolution: 'no_prices' }, + ]) + expect(p.today.available).toBe(false) + expect(p.week.available).toBe(true) + }) + it('ignores a row the box did not date', () => { expect(toSavingsDay({ saved_ore: 10 })).toBeNull() expect(toSavingsDay({ day: '2026-07-15', saved_ore: 12.4 })?.savedOre).toBe(12.4) diff --git a/src/lib/state/flow.test.ts b/src/lib/state/flow.test.ts index 320b479..57c2f59 100644 --- a/src/lib/state/flow.test.ts +++ b/src/lib/state/flow.test.ts @@ -222,7 +222,21 @@ describe('flowReadingsFromStatus', () => { expect(r.selfPoweredPctToday).toBeCloseTo((1 - 5.2 / 14) * 100) }) - it('never hands the component a negative number to draw', () => { + it('keeps battery sign so two discharging packs do not look like charging', () => { + const r = flowReadingsFromStatus({ + grid_w: 0, + load_w: 3500, + drivers: { + a: { status: 'ok', bat_w: -2000, bat_soc: 0.4 }, + b: { status: 'ok', bat_w: -1500, bat_soc: 0.5 }, + }, + }) + const bats = r.planets.filter((p) => p.role === 'battery') + expect(bats.reduce((sum, p) => sum + p.kw, 0)).toBeCloseTo(-3.5) + expect(bats.every((p) => p.sub === 'discharging')).toBe(true) + }) + + it('keeps grid, solar and the charger as magnitudes', () => { const r = flowReadingsFromStatus({ grid_w: -3400, load_w: 900, @@ -232,7 +246,7 @@ describe('flowReadingsFromStatus', () => { easee: { status: 'ok', ev_w: 0 }, }, }) - for (const p of r.planets) { + for (const p of r.planets.filter((x) => x.role !== 'battery')) { expect(p.kw, `${p.id} carried the wire's sign into the hero`).toBeGreaterThanOrEqual(0) } expect(r.planets.find((p) => p.id === 'grid')?.sub).toBe('exporting') diff --git a/src/lib/state/flow.ts b/src/lib/state/flow.ts index 66303d3..93d3881 100644 --- a/src/lib/state/flow.ts +++ b/src/lib/state/flow.ts @@ -374,7 +374,11 @@ export function flowReadingsFromStatus(status: SiteStatus): FlowReadings { const soc = num(d.bat_soc) planets.push({ id: `bat-${name}`, corner: 'top-right', title: 'BATTERY', role: 'battery', name, - kw: Math.abs(batW) / 1000, toHub: batW < 0, + // Signed, as the dashboard sends it: the hero folds several packs + // into one bubble by summing kw, then names the total from the + // sign. Magnitude here made two discharging packs look like a + // charge. + kw: batW / 1000, toHub: batW < 0, color: bIdle ? 'var(--cyan)' : batW >= 0 ? 'var(--green-e)' : 'var(--red-e)', sub: d.observe_only === true ? 'observe only' : bIdle ? 'idle' : batW >= 0 ? 'charging' : 'discharging', soc: soc === null ? null : Math.round(soc * 100), diff --git a/src/views/NowOutlook.svelte b/src/views/NowOutlook.svelte index ae53832..69f2107 100644 --- a/src/views/NowOutlook.svelte +++ b/src/views/NowOutlook.svelte @@ -245,12 +245,18 @@ {#if savings}
Saved {currency} - = 0} - class:is-import={savings.today.savedMinor < 0} - >{formatCompactMinor(savings.today.savedMinor)} - {formatCompactMinor(savings.week.savedMinor)} this week + {#if savings.today.available} + = 0} + class:is-import={savings.today.savedMinor < 0} + >{formatCompactMinor(savings.today.savedMinor)} + {:else} + + {/if} + {#if savings.week.available} + {formatCompactMinor(savings.week.savedMinor)} this week + {/if}
{/if} @@ -484,9 +490,11 @@ background: var(--energy-export); } + /* Direction first, load after: a phase that both exports and sits on + the fuse must still read as a warning, not as a calm storage colour. */ + .fill.is-out { background: var(--energy-storage); } .fill.warn { background: var(--energy-generation); } .fill.crit { background: var(--energy-import); } - .fill.is-out { background: var(--energy-storage); } .fallback { flex-direction: row;