From 9fbdddeea13ce738080cdcb3e5ab33629c632d72 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 29 Sep 2026 10:01:27 +0200 Subject: [PATCH 01/11] feat(control): show limits and measured response in Now and charging Signed-off-by: Fredrik Ahlgren --- .changeset/control-results.md | 5 + src/lib/format/control-feedback.d.mts | 16 +++ src/lib/format/control-feedback.mjs | 124 ++++++++++++++++++++++ src/lib/format/ev.ts | 5 + src/lib/state/flow.ts | 1 + src/lib/ui/ControlFeedback.svelte | 48 +++++++++ src/lib/ui/ControlFeedback.svelte.test.ts | 24 +++++ src/views/EvPanel.svelte | 2 + src/views/Now.svelte | 3 + 9 files changed, 228 insertions(+) create mode 100644 .changeset/control-results.md create mode 100644 src/lib/format/control-feedback.d.mts create mode 100644 src/lib/format/control-feedback.mjs create mode 100644 src/lib/ui/ControlFeedback.svelte create mode 100644 src/lib/ui/ControlFeedback.svelte.test.ts diff --git a/.changeset/control-results.md b/.changeset/control-results.md new file mode 100644 index 0000000..52d628a --- /dev/null +++ b/.changeset/control-results.md @@ -0,0 +1,5 @@ +--- +"ftw-webapp": patch +--- + +Show control limits and measured response in Now and the charging panel. Distinguish command acknowledgement, device power and independent site-meter confirmation. Keep missing, stale and uncertain evidence explicit. diff --git a/src/lib/format/control-feedback.d.mts b/src/lib/format/control-feedback.d.mts new file mode 100644 index 0000000..1397f11 --- /dev/null +++ b/src/lib/format/control-feedback.d.mts @@ -0,0 +1,16 @@ +export interface ControlFeedback { + driver: string; kind?: string; mode?: string; state?: string; reason: string; severity?: string; + site_meter?: string; site_before_w?: number | null; site_after_w?: number | null; site_before_at_ms?: number; site_after_at_ms?: number; + verification_tier?: number | null; site_confirmation?: string; site_delta_w?: number | null; device_delta_w?: number | null; + tolerance_w?: number | null; response?: string; requested_w?: number | null; sent_w?: number | null; readback_w?: number | null; + actual_w?: number | null; requested_a?: number | null; offered_a?: number | null; device_limit_a?: number | null; + device_reason?: string; observed_at_ms?: number; verified_at_ms?: number; +} +export function feedbackRows(value: unknown): ControlFeedback[]; +export function feedbackText(row: ControlFeedback, live?: boolean): {title:string;detail:string;action:string}; +export function feedbackPower(value: unknown, kind?: string): string; +export function feedbackValues(row: ControlFeedback, live?: boolean): [string,string][]; +export function feedbackProof(row: ControlFeedback, live?: boolean): string; +export function renderFeedback(root: HTMLElement | null, value: unknown, live?: boolean): void; + +export function feedbackSite(row: ControlFeedback, live?: boolean): string; diff --git a/src/lib/format/control-feedback.mjs b/src/lib/format/control-feedback.mjs new file mode 100644 index 0000000..1436fab --- /dev/null +++ b/src/lib/format/control-feedback.mjs @@ -0,0 +1,124 @@ +// Presentation only. Core owns evidence, freshness, limits and response checks. +const messages = { + device_limit: ['Charger limit', 'The charger’s own current limit is below this request.', 'Check the charger’s current limit in its app or settings.'], + offered_current_lower: ['Charger offers less current', 'The charger reports a lower current offer than FTW requested.', 'Check its load balancing and current limits. The reason is not confirmed.'], + setpoint_changed: ['Setpoint does not match', 'The device reports a different setpoint from the command FTW sent.', 'Check for another controller or a device mode that changes the setpoint. FTW cannot tell which caused this.'], + power_differs: ['Power does not follow the command', 'Fresh measurements differ from the command after the response wait.', 'The device has not reported a confirmed cause. Check its app or status display.'], + command_failed: ['Command failed', 'The driver could not complete this command. The device may still have received part of it.', 'Check the device connection and status. FTW attempts to restore its safe default.'], + command_unconfirmed: ['Command result unknown', 'FTW stopped waiting before it could confirm the call’s result.', 'The command may have reached the device. Check fresh power readings before trying again.'], + default_failed: ['Safe default not confirmed', 'FTW has blocked further control while it retries the device’s safe default.', 'Check the device connection and its own status.'], + telemetry_stale: ['Waiting for fresh readings', 'FTW cannot verify the effect with the readings available.', 'Check the device connection. This view updates when fresh readings arrive.'], + site_meter_stale: ['Waiting for the site meter', 'FTW has stopped dispatch because site power readings are missing or too old.', 'Check the site meter connection. Dispatch resumes when readings recover.'], + site_phase_currents_stale: ['Waiting for phase currents', 'FTW has stopped dispatch because it lacks fresh current readings for the main-fuse check.', 'Check the site meter connection and its phase readings. Dispatch resumes when readings recover.'], + device_fault: ['Device cannot follow commands', 'The device or its driver reports a fault that blocks control.', 'Check the reported reason and the device’s status.'], + fuse_limit: ['Limited by the main fuse', 'FTW reduced the request to keep site current within its limit.', 'The rate can rise when other household demand falls.'], + fuse_cooldown: ['Waiting after a fuse limit', 'FTW is waiting before restoring the charging rate.', 'Charging resumes when the fuse protection allows it.'], + charger_limit: ['Limited by FTW’s charger setting', 'FTW reduced the request to the configured charging limit.', 'Check the charger limit in FTW settings.'], + core_limit: ['Adjusted by FTW', 'FTW changed the target through its safety or ramp limits.', 'The control tick does not report which limit applied.'], + observe_only: ['Monitoring only', 'FTW reads this device but does not send commands to it.', 'Enable control in the device settings if you want FTW to control it.'], + disabled: ['Device disabled', 'FTW does not control this device while it is disabled.', 'Check its device settings.'], + device_control: ['Device controls itself', 'FTW returned the device to its own control.', 'The earlier FTW command no longer describes its target.'], + not_connected: ['No car connected', 'The charger reports that no car is connected.', 'Connect a car to charge.'], + waiting_response: ['Waiting for measured response', 'FTW is checking fresh power readings against the command.', 'Starting and changing phases can take time. A driver reply alone does not confirm the effect.'], + response_unknown: ['Effect not verified', 'FTW does not have enough evidence to compare the command with measured power.', 'A successful driver call alone does not confirm the effect.'], + no_command: ['No command recorded yet', 'FTW has no command result for this device since it started.', 'Fresh measurements will show the effect once control runs.'], + power_observed: ['Device follows the command', 'The device’s own power readings follow the command within the response tolerance.', 'Independent confirmation is a separate step. A charging goal needs its own completion evidence.'], + idle: ['No power requested', 'FTW currently requests no charge or discharge.', 'The active mode decides when the next request is needed.'], + no_plan_budget: ['Waiting for the plan', 'The plan assigns no charging energy to this interval.', 'Check the next charging window, or choose Charge now.'], + pv_surplus_pause: ['Waiting for spare solar', 'There is not enough spare solar to start charging.', 'Charging resumes when the solar rule allows it.'], + pv_surplus: ['Using spare solar', 'FTW is matching charging to the available solar surplus.', 'The rate changes with household demand and solar output.'], + plan: ['Following the plan', 'FTW is using the charging plan for this interval.', 'Power readings show whether the charger follows the request.'], + manual_hold: ['Manual request', 'FTW is using your manual charging request.', 'Power readings show whether the charger follows it.'], + wake_kick: ['Starting the charger', 'FTW is trying to wake the connected charger.', 'Waiting for fresh power readings.'], +}; + +export function feedbackRows(value) { + if (!Array.isArray(value)) return []; + return value.filter(row => row && typeof row === 'object' && typeof row.driver === 'string' && typeof row.reason === 'string'); +} + +export function feedbackText(row, live = true) { + if (!live) return {title:'Last known control status', detail:'The box’s current control result is unavailable.', action:'Waiting for a fresh status report.'}; + const words = messages[row.reason] || ['Effect not verified', 'This box reports a control state this app does not recognise.', 'Check the device status.']; + return {title:words[0], detail:words[1], action:words[2]}; +} +const number = value => typeof value === 'number' && Number.isFinite(value); +export function feedbackPower(value, kind) { + if (!number(value)) return 'Unknown'; + if (Math.abs(value) < 1) return '0 W'; + const size = Math.abs(value) >= 1000 ? `${(Math.abs(value)/1000).toFixed(1)} kW` : `${Math.round(Math.abs(value))} W`; + return `${size} ${kind === 'pv' ? 'generation' : value < 0 ? 'discharge' : 'charge'}`; +} +export function feedbackValues(row, live = true) { + const values = [ + ['Requested', feedbackPower(row.requested_w, row.kind)], + ['Sent to driver', feedbackPower(row.sent_w, row.kind)], + ['Device setpoint', live ? feedbackPower(row.readback_w, row.kind) : 'Not current'], + ['Measured', live ? feedbackPower(row.actual_w, row.kind) : 'Not current'], + ]; + if (number(row.tolerance_w)) values.push(['Response tolerance', `${Math.round(row.tolerance_w)} W`]); + if (number(row.requested_a)) values.push(['Requested current', `${row.requested_a.toFixed(1)} A`]); + if (number(row.offered_a)) values.push(['Charger offer', live ? `${row.offered_a.toFixed(1)} A` : 'Not current']); + if (number(row.device_limit_a)) values.push(['Charger limit', live ? `${row.device_limit_a.toFixed(1)} A` : 'Not current']); + const grid = value => `${(Math.abs(value)/1000).toFixed(2)} kW ${value < 0 ? 'export' : 'import'}`; + if (number(row.site_before_w)) values.push(['Site before command', live ? grid(row.site_before_w) : 'Not current']); + if (number(row.site_after_w)) values.push(['Site after command', live ? grid(row.site_after_w) : 'Not current']); + if (number(row.device_delta_w)) values.push(['Device change', live ? `${(Math.abs(row.device_delta_w)/1000).toFixed(2)} kW ${row.device_delta_w < 0 ? "less" : "more"} site demand` : 'Not current']); + if (number(row.site_delta_w)) values.push(['Site change', live ? `${(Math.abs(row.site_delta_w)/1000).toFixed(2)} kW ${row.site_delta_w < 0 ? 'less' : 'more'} import` : 'Not current']); + return values; +} +export function feedbackProof(row, live = true) { + if (!live) return 'Current effect unknown'; + if (row.verification_tier === 2) return 'Tier 2 · Confirmed at site meter'; + if (row.verification_tier === 1) return 'Tier 1 · Device reports the expected power'; + if (row.verification_tier === 0) return 'Tier 0 · Driver accepted the command'; + return 'No command acknowledgement'; +} +export function feedbackSite(row, live = true) { + if (!live) return 'Waiting for fresh measurements.'; + const text = { + confirmed: 'A separate site meter shows the matching power change while other monitored flows stayed steady.', + independent_source_unknown: 'Independent confirmation needs a separate, identified meter. These sources do not establish that.', + no_site_meter: 'No site meter is available for independent confirmation.', + no_baseline: 'No steady reading before the command is available for a site comparison.', + waiting_for_meter: 'Waiting for a fresh, stable site-meter window.', + readings_not_aligned: 'Device and site readings are too far apart in time to compare.', + flows_changing: 'Power is changing during the comparison. Independent confirmation remains uncertain.', + no_clear_change: 'The change is too small to distinguish from other site activity.', + other_flows_changed: 'Other equipment changed or its readings are missing. FTW cannot isolate this response.', + site_change_differs: 'The site-meter change does not match. Another household load may have changed; the cause is not confirmed.', + device_response_unconfirmed: 'Independent confirmation waits for the device’s measured response.', + }; + return text[row.site_confirmation] || 'Independent confirmation is not available.'; +} + +// On-box renderer. The app uses the same presentation functions in Svelte. +export function renderFeedback(root, value, live = true) { + if (!root) return; + const rows = feedbackRows(value); + root.hidden = rows.length === 0; + // Preserve an open evidence table across the two-second status refresh. + const open = new Set(Array.from(root.querySelectorAll('details[open]')).map(el => el.dataset.device)); + root.replaceChildren(); + if (!rows.length) return; + const el = (tag, text, parent, cls) => { const node=document.createElement(tag); if (text) node.textContent=text; if(cls) node.className=cls; parent.appendChild(node); return node; }; + el('h2','Are you in control?',root); + for (const row of rows) { + const text=feedbackText(row,live); + const card=el('article','',root,'control-result' + (live && row.severity === 'warning' ? ' needs-attention' : '') + (!live ? ' not-current' : '')); + el('div',`${row.driver} · ${row.kind || 'device'}`,card,'control-device'); + el('h3',text.title,card); + el('p',text.detail,card); + if (live && row.device_reason) el('p',`Device reports: ${row.device_reason}`,card,'device-reason'); + el('p',text.action,card,'control-action'); + const proof=el('p',feedbackProof(row,live),card,'control-proof'); proof.dataset.tier=live ? String(row.verification_tier) : ''; + el('p',feedbackSite(row,live),card,'control-action'); + const details=el('details','',card); details.dataset.device=`${row.driver}:${row.kind}`; details.open=open.has(details.dataset.device); + el('summary','Request and measurements',details); + const dl=el('dl','',details); + for (const [label,value] of feedbackValues(row,live)) {el('dt',label,dl);el('dd',value,dl);} + if (live && row.site_meter && number(row.site_after_at_ms)) el('p',`Site meter: ${row.site_meter} · ${new Date(row.site_after_at_ms).toLocaleTimeString()}`,details,'control-time'); + if (number(row.observed_at_ms)) el('p',`Last reading: ${new Date(row.observed_at_ms).toLocaleTimeString()}`,details,'control-time'); + } +} +if (typeof window !== 'undefined') window.FTWControlFeedback = {render:renderFeedback,text:feedbackText}; diff --git a/src/lib/format/ev.ts b/src/lib/format/ev.ts index 55ae0b9..6ac5c54 100644 --- a/src/lib/format/ev.ts +++ b/src/lib/format/ev.ts @@ -11,9 +11,11 @@ */ import { formatPower } from './power' +import { feedbackRows, feedbackText, type ControlFeedback } from './control-feedback.mjs' /** The box's own field names for one charger, as `/api/loadpoints` serves them. */ export interface WireLoadpoint { + control_feedback?: unknown id?: unknown driver_name?: unknown plugged_in?: unknown @@ -81,6 +83,7 @@ export interface WireManualStatus { } export interface Loadpoint { + controlFeedback?: ControlFeedback id: string /** Whether a cable is in. False means the rest is about an empty bay. */ pluggedIn: boolean @@ -185,6 +188,7 @@ export function toLoadpoint(w: WireLoadpoint): Loadpoint { manualSaveError: w.manual_save_error === true, ...(w.manual ? { manual: w.manual } : {}), ...(w.charger ? { charger: w.charger } : {}), + ...(feedbackRows([w.control_feedback])[0] ? {controlFeedback: feedbackRows([w.control_feedback])[0]!} : {}), commandedW: num(w.commanded_w), commandedReason: typeof w.commanded_reason === 'string' ? w.commanded_reason : '', commandedKnown: w.commanded_known === true, @@ -282,6 +286,7 @@ export function evStatusSentence(lp: Loadpoint, canControl = true): string { if (lp.charger && lp.charger.available !== true) return lp.charger.known ? 'Charger status is out of date. FTW cannot confirm whether the car is charging.' : 'Waiting for the charger’s first status report.' if (!lp.pluggedIn) return 'Not plugged in' + if (lp.controlFeedback?.severity === "warning") { const f = feedbackText(lp.controlFeedback); return `${f.detail} ${f.action}` } if (lp.manualActive) return manualStatusSentence(lp) if (lp.powerW >= 100) { const p = formatPower(lp.powerW) diff --git a/src/lib/state/flow.ts b/src/lib/state/flow.ts index eed7772..aa599c2 100644 --- a/src/lib/state/flow.ts +++ b/src/lib/state/flow.ts @@ -66,6 +66,7 @@ export interface StatusEnergyToday { } export interface SiteStatus { + control_feedback?: unknown grid_w?: unknown pv_w?: unknown bat_w?: unknown diff --git a/src/lib/ui/ControlFeedback.svelte b/src/lib/ui/ControlFeedback.svelte new file mode 100644 index 0000000..856e853 --- /dev/null +++ b/src/lib/ui/ControlFeedback.svelte @@ -0,0 +1,48 @@ + + +{#if rows.length} +
+

Are you in control?

+ {#each rows as row (`${row.driver}:${row.kind}`)} + {@const text = feedbackText(row, live)} +
+
{row.driver} · {row.kind ?? 'device'}
+

{text.title}

+

{text.detail}

+ {#if live && row.device_reason}

Device reports: {row.device_reason}

{/if} +

{text.action}

+
{feedbackProof(row, live)}
+

{feedbackSite(row, live)}

+
+ Request and measurements +
{#each feedbackValues(row, live) as [label, value]}
{label}
{value}
{/each}
+ {#if live && row.site_meter && row.site_after_at_ms}

Site meter: {row.site_meter} · {new Date(row.site_after_at_ms).toLocaleTimeString()}

{/if} + {#if row.observed_at_ms}

Last device reading: {new Date(row.observed_at_ms).toLocaleTimeString()}

{/if} +
+
+ {/each} +
+{/if} + + diff --git a/src/lib/ui/ControlFeedback.svelte.test.ts b/src/lib/ui/ControlFeedback.svelte.test.ts new file mode 100644 index 0000000..f0dff04 --- /dev/null +++ b/src/lib/ui/ControlFeedback.svelte.test.ts @@ -0,0 +1,24 @@ +// @vitest-environment jsdom +import {render,screen,cleanup} from '@testing-library/svelte' +import {afterEach,expect,it} from 'vitest' +import ControlFeedback from './ControlFeedback.svelte' +afterEach(cleanup) +it('shows device limits even while charging and keeps proof levels distinct',()=>{ + render(ControlFeedback,{value:[{driver:'Garage',kind:'ev',reason:'device_limit',severity:'warning',verification_tier:0,device_limit_a:8,requested_a:16,actual_w:5500,site_confirmation:'device_response_unconfirmed'}]}) + expect(screen.getByText('Charger limit',{selector:'h3'})).toBeTruthy() + expect(screen.getByText('8.0 A')).toBeTruthy() + expect(screen.getByText(/Tier 0/)).toBeTruthy() + expect(screen.queryByText(/Tier 2/)).toBeNull() +}) +it('withdraws independent confirmation when status is stale',()=>{ + render(ControlFeedback,{live:false,value:[{driver:'Battery',kind:'battery',reason:'power_observed',verification_tier:2,actual_w:1000}]}) + expect(screen.queryByText(/Tier 2/)).toBeNull() + expect(screen.getByText('Current effect unknown')).toBeTruthy() + expect(screen.queryByText('1.0 kW charge')).toBeNull() +}) +it('renders device reasons as text',()=>{ + render(ControlFeedback,{value:[{driver:'',reason:'device_fault',device_reason:''}]}) + expect(document.querySelector('script')).toBeNull() + expect(document.querySelector('img')).toBeNull() + expect(screen.getByText('Device reports: ')).toBeTruthy() +}) diff --git a/src/views/EvPanel.svelte b/src/views/EvPanel.svelte index 5cd8f62..b2d9204 100644 --- a/src/views/EvPanel.svelte +++ b/src/views/EvPanel.svelte @@ -10,6 +10,7 @@ the box decides; the panel repaints from what the box then reports. --> @@ -9,6 +9,7 @@

Are you in control?

{#each rows as row (`${row.driver}:${row.kind}`)} {@const text = feedbackText(row, live)} + {@const curve = feedbackCurve(row,live)}
{row.driver} · {row.kind ?? 'device'}

{text.title}

@@ -20,6 +21,14 @@
Request and measurements
{#each feedbackValues(row, live) as [label, value]}
{label}
{value}
{/each}
+ {#if curve} + + + + + +

Device: solid · Adjusted site: dashed · ±{curve.scale} · {curve.duration}

+ {/if} {#if live && row.site_meter && row.site_after_at_ms}

Site meter: {row.site_meter} · {new Date(row.site_after_at_ms).toLocaleTimeString()}

{/if} {#if row.observed_at_ms}

Last device reading: {new Date(row.observed_at_ms).toLocaleTimeString()}

{/if}
@@ -45,4 +54,9 @@ dl { display: grid; grid-template-columns: 1fr 1fr; gap: .4rem 1rem; font-size: .85rem; } dt { color: var(--fg-dim); } dd { margin: 0; text-align: right; } + svg { width: 100%; max-height: 160px; } + polyline { fill: none; } + .zero { stroke: var(--line); stroke-width: 1; } + .device-line { stroke: var(--energy-storage); stroke-width: 3; } + .site-line { stroke: var(--energy-export); stroke-width: 2; stroke-dasharray: 5 4; } diff --git a/src/lib/ui/ControlFeedback.svelte.test.ts b/src/lib/ui/ControlFeedback.svelte.test.ts index f0dff04..d0a040f 100644 --- a/src/lib/ui/ControlFeedback.svelte.test.ts +++ b/src/lib/ui/ControlFeedback.svelte.test.ts @@ -22,3 +22,12 @@ it('renders device reasons as text',()=>{ expect(document.querySelector('img')).toBeNull() expect(screen.getByText('Device reports: ')).toBeTruthy() }) +it('shows the measured comparison curve and removes it when status expires',async()=>{ + const value=[{driver:'Battery',reason:'power_observed',verification_tier:2,site_evidence:{samples:3,window_s:10,unexplained_change_w:10,trace:[0,1,2].map(i=>({at_ms:1000+i*5000,device_change_w:i*500,adjusted_site_change_w:i*500+10}))}}] + const view=render(ControlFeedback,{value}) + expect(screen.getByRole('img',{name:/Measured changes over 10 seconds/})).toBeTruthy() + expect(screen.getByText('Unexplained change')).toBeTruthy() + await view.rerender({value,live:false}) + expect(screen.queryByRole('img')).toBeNull() + expect(screen.queryByText(/Tier 2/)).toBeNull() +}) From 96e6102c0a707f4ac34ffedc652779382586930a Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 29 Sep 2026 13:38:19 +0200 Subject: [PATCH 03/11] fix(control): distinguish measurement sources from active control Signed-off-by: Fredrik Ahlgren --- src/lib/format/control-feedback.mjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/lib/format/control-feedback.mjs b/src/lib/format/control-feedback.mjs index 727ffb7..d495116 100644 --- a/src/lib/format/control-feedback.mjs +++ b/src/lib/format/control-feedback.mjs @@ -1,5 +1,6 @@ // Presentation only. Core owns evidence, freshness, limits and response checks. const messages = { + solar_below_ceiling: ['Solar is below the requested ceiling', 'The measured output respects the ceiling, but available sunshine may already limit it.', 'FTW cannot yet confirm that curtailment caused the lower output.'], device_limit: ['Charger limit', 'The charger’s own current limit is below this request.', 'Check the charger’s current limit in its app or settings.'], offered_current_lower: ['Charger offers less current', 'The charger reports a lower current offer than FTW requested.', 'Check its load balancing and current limits. The reason is not confirmed.'], setpoint_changed: ['Setpoint does not match', 'The device reports a different setpoint from the command FTW sent.', 'Check for another controller or a device mode that changes the setpoint. FTW cannot tell which caused this.'], @@ -79,6 +80,7 @@ export function feedbackValues(row, live = true) { } export function feedbackProof(row, live = true) { if (!live) return 'Current effect unknown'; + if (['observe_only','disabled','device_control'].includes(row.reason)) return 'No active FTW power command'; if (row.verification_tier === 2) return 'Tier 2 · Confirmed at site meter'; if (row.verification_tier === 1) return 'Tier 1 · Device reports the expected power'; if (row.verification_tier === 0) return 'Tier 0 · Driver accepted the command'; @@ -86,6 +88,7 @@ export function feedbackProof(row, live = true) { } export function feedbackSite(row, live = true) { if (!live) return 'Waiting for fresh measurements.'; + if (['observe_only','disabled','device_control'].includes(row.reason)) return 'Measurements remain context for the site; this device has no current control verdict.'; const text = { confirmed: 'A separate site meter follows the device’s change across several samples, after accounting for other measured flows.', independent_source_unknown: 'Independent confirmation needs a separate, identified meter. These sources do not establish that.', From a6ca1142f77cb7fc8766e0e8a187d2325f22127a Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 29 Sep 2026 19:35:27 +0200 Subject: [PATCH 04/11] Show measured timing in milliseconds and name missing sources Signed-off-by: Fredrik Ahlgren --- src/lib/format/control-feedback.d.mts | 4 ++-- src/lib/format/control-feedback.mjs | 8 +++++++- src/lib/format/control-feedback.test.ts | 18 ++++++++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 src/lib/format/control-feedback.test.ts diff --git a/src/lib/format/control-feedback.d.mts b/src/lib/format/control-feedback.d.mts index a7b6986..372b0e0 100644 --- a/src/lib/format/control-feedback.d.mts +++ b/src/lib/format/control-feedback.d.mts @@ -1,8 +1,8 @@ export interface ControlFeedback { - site_evidence?: { other_change_w?: number|null; adjusted_site_change_w?: number|null; unexplained_change_w?: number|null; tolerance_w?: number; samples?: number; window_s?: number; max_skew_ms?: number; trace?: {at_ms:number;device_change_w:number;adjusted_site_change_w:number}[] }; + site_evidence?: { other_change_w?: number|null; adjusted_site_change_w?: number|null; unexplained_change_w?: number|null; tolerance_w?: number; samples?: number; window_s?: number; max_skew_ms?: number|null; trace?: {at_ms:number;device_change_w:number;adjusted_site_change_w:number}[] }; driver: string; kind?: string; mode?: string; state?: string; reason: string; severity?: string; site_meter?: string; site_before_w?: number | null; site_after_w?: number | null; site_before_at_ms?: number; site_after_at_ms?: number; - verification_tier?: number | null; site_confirmation?: string; site_delta_w?: number | null; device_delta_w?: number | null; + verification_tier?: number | null; site_confirmation?: string; site_source_issue?: string; site_delta_w?: number | null; device_delta_w?: number | null; tolerance_w?: number | null; response?: string; requested_w?: number | null; sent_w?: number | null; readback_w?: number | null; actual_w?: number | null; requested_a?: number | null; offered_a?: number | null; device_limit_a?: number | null; device_reason?: string; observed_at_ms?: number; verified_at_ms?: number; diff --git a/src/lib/format/control-feedback.mjs b/src/lib/format/control-feedback.mjs index d495116..684ed9a 100644 --- a/src/lib/format/control-feedback.mjs +++ b/src/lib/format/control-feedback.mjs @@ -74,7 +74,7 @@ export function feedbackValues(row, live = true) { } if (number(e.samples) && e.samples > 0) values.push(['Comparison',`${e.samples} samples across ${Math.round(e.window_s)} s`]); if (number(e.tolerance_w) && e.tolerance_w > 0) values.push(['Site tolerance',`${Math.round(e.tolerance_w)} W`]); - if (number(e.max_skew_ms)) values.push(['Largest time gap',`${(e.max_skew_ms/1000).toFixed(1)} s`]); + if (e.samples > 0 && number(e.max_skew_ms)) values.push(['Largest time gap',`${Math.round(e.max_skew_ms)} ms`]); } return values; } @@ -105,6 +105,12 @@ export function feedbackSite(row, live = true) { site_change_differs: 'The site-meter change does not match. Another household load may have changed; the cause is not confirmed.', device_response_unconfirmed: 'Independent confirmation waits for the device’s measured response.', }; + if (row.site_confirmation === 'measurement_sources_unclear' && typeof row.site_source_issue === 'string') { + const [reason, driver, kind] = row.site_source_issue.split(':'); + if (driver && kind && reason === 'missing_fresh_power') return `Fresh power readings from ${driver} (${kind}) are missing. Independent confirmation waits for these measurements.`; + if (driver && kind && reason === 'offline') return `${driver} (${kind}) is offline. Independent confirmation waits for fresh measurements.`; + if (driver && kind && reason === 'duplicate') return `${driver} (${kind}) overlaps another measured flow. Check the measurement sources.`; + } return text[row.site_confirmation] || 'Independent confirmation is not available.'; } diff --git a/src/lib/format/control-feedback.test.ts b/src/lib/format/control-feedback.test.ts new file mode 100644 index 0000000..c2ec1e4 --- /dev/null +++ b/src/lib/format/control-feedback.test.ts @@ -0,0 +1,18 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { feedbackValues, feedbackSite } from './control-feedback.mjs'; + +test('comparison time gaps use milliseconds and require measured evidence',()=>{ + const gap=(e: {samples:number;max_skew_ms:number|null})=>feedbackValues({driver:'hybrid',reason:'power_observed',site_evidence:e}).find(v=>v[0]==='Largest time gap'); + assert.equal(gap({samples:0,max_skew_ms:0}),undefined); + assert.equal(gap({samples:3,max_skew_ms:null}),undefined); + assert.deepEqual(gap({samples:3,max_skew_ms:0}),['Largest time gap','0 ms']); + assert.deepEqual(gap({samples:3,max_skew_ms:37}),['Largest time gap','37 ms']); + assert.deepEqual(gap({samples:3,max_skew_ms:1250}),['Largest time gap','1250 ms']); +}); + +test('a missing measurement names the source holding back site confirmation',()=>{ + const row={driver:'hybrid',reason:'power_observed',site_confirmation:'measurement_sources_unclear',site_source_issue:'missing_fresh_power:easee:ev'}; + assert.match(feedbackSite(row),/Fresh power readings from easee \(ev\) are missing/); + assert.doesNotMatch(feedbackSite(row,false),/Fresh power readings/); +}); From a5569bf15ba555fb86da10f946555bb6ff9bde04 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 29 Sep 2026 20:13:03 +0200 Subject: [PATCH 05/11] Show per-device proof status and verification-loss alarms Signed-off-by: Fredrik Ahlgren --- src/lib/format/control-feedback.d.mts | 6 ++++-- src/lib/format/control-feedback.mjs | 22 ++++++++++++++++++++-- src/lib/format/control-feedback.test.ts | 12 +++++++++++- src/lib/ui/ControlFeedback.svelte | 19 +++++++++++++++++-- src/lib/ui/ControlFeedback.svelte.test.ts | 23 +++++++++++++++++++++-- 5 files changed, 73 insertions(+), 9 deletions(-) diff --git a/src/lib/format/control-feedback.d.mts b/src/lib/format/control-feedback.d.mts index 372b0e0..ac44e95 100644 --- a/src/lib/format/control-feedback.d.mts +++ b/src/lib/format/control-feedback.d.mts @@ -1,8 +1,8 @@ export interface ControlFeedback { - site_evidence?: { other_change_w?: number|null; adjusted_site_change_w?: number|null; unexplained_change_w?: number|null; tolerance_w?: number; samples?: number; window_s?: number; max_skew_ms?: number|null; trace?: {at_ms:number;device_change_w:number;adjusted_site_change_w:number}[] }; + site_evidence?: { unmeasured_flows?: string[]; other_change_w?: number|null; adjusted_site_change_w?: number|null; unexplained_change_w?: number|null; tolerance_w?: number; samples?: number; window_s?: number; max_skew_ms?: number|null; trace?: {at_ms:number;device_change_w:number;adjusted_site_change_w:number}[] }; driver: string; kind?: string; mode?: string; state?: string; reason: string; severity?: string; site_meter?: string; site_before_w?: number | null; site_after_w?: number | null; site_before_at_ms?: number; site_after_at_ms?: number; - verification_tier?: number | null; site_confirmation?: string; site_source_issue?: string; site_delta_w?: number | null; device_delta_w?: number | null; + verification_lost?: boolean; verification_tier?: number | null; site_confirmation?: string; site_source_issue?: string; site_delta_w?: number | null; device_delta_w?: number | null; tolerance_w?: number | null; response?: string; requested_w?: number | null; sent_w?: number | null; readback_w?: number | null; actual_w?: number | null; requested_a?: number | null; offered_a?: number | null; device_limit_a?: number | null; device_reason?: string; observed_at_ms?: number; verified_at_ms?: number; @@ -16,3 +16,5 @@ export function renderFeedback(root: HTMLElement | null, value: unknown, live?: export function feedbackSite(row: ControlFeedback, live?: boolean): string; export function feedbackCurve(row: ControlFeedback, live?: boolean): {device:string;site:string;label:string;scale:string;duration:string}|null; + +export function feedbackStatus(row: ControlFeedback, live?: boolean): {label:string;tone:string}; diff --git a/src/lib/format/control-feedback.mjs b/src/lib/format/control-feedback.mjs index 684ed9a..6d025b2 100644 --- a/src/lib/format/control-feedback.mjs +++ b/src/lib/format/control-feedback.mjs @@ -41,7 +41,18 @@ export function feedbackRows(value) { export function feedbackText(row, live = true) { if (!live) return {title:'Last known control status', detail:'The box’s current control result is unavailable.', action:'Waiting for a fresh status report.'}; const words = messages[row.reason] || ['Effect not verified', 'This box reports a control state this app does not recognise.', 'Check the device status.']; - return {title:words[0], detail:words[1], action:words[2]}; + return {title:row.verification_lost ? 'Alarm · Measured control lost' : words[0], detail:words[1], action:words[2]}; +} +// A per-device status, never a site-wide tier or a verdict inferred by the UI. +export function feedbackStatus(row, live = true) { + if (!live) return {label:'No live proof',tone:'unknown'}; + if (row.verification_lost) return {label:row.verification_tier === 0 ? 'Alarm · Tier 0' : 'Alarm · No proof',tone:'alarm'}; + if (['observe_only','disabled','device_control','not_connected','no_command'].includes(row.reason)) return {label:'No active command',tone:'unknown'}; + if (row.verification_tier === 2) return {label:'Tier 2 · Site confirmed',tone:'confirmed'}; + if (row.verification_tier === 1) return {label:'Tier 1 · Device measured',tone:'measured'}; + if (row.severity === 'warning') return {label:row.verification_tier === 0 ? 'Alarm · Tier 0' : 'Alarm · Unconfirmed',tone:'alarm'}; + if (row.verification_tier === 0) return {label:'Tier 0 · Waiting',tone:'waiting'}; + return {label:'Waiting for acknowledgement',tone:'waiting'}; } const number = value => typeof value === 'number' && Number.isFinite(value); export function feedbackPower(value, kind) { @@ -74,6 +85,7 @@ export function feedbackValues(row, live = true) { } if (number(e.samples) && e.samples > 0) values.push(['Comparison',`${e.samples} samples across ${Math.round(e.window_s)} s`]); if (number(e.tolerance_w) && e.tolerance_w > 0) values.push(['Site tolerance',`${Math.round(e.tolerance_w)} W`]); + if (Array.isArray(e.unmeasured_flows) && e.unmeasured_flows.length) values.push(['Included in background', e.unmeasured_flows.filter(v=>typeof v === 'string').join(', ')]); if (e.samples > 0 && number(e.max_skew_ms)) values.push(['Largest time gap',`${Math.round(e.max_skew_ms)} ms`]); } return values; @@ -136,9 +148,15 @@ export function renderFeedback(root, value, live = true) { if (!rows.length) return; const el = (tag, text, parent, cls) => { const node=document.createElement(tag); if (text) node.textContent=text; if(cls) node.className=cls; parent.appendChild(node); return node; }; el('h2','Are you in control?',root); + const statuses=el('ul','',root,'control-status-list'); statuses.setAttribute('aria-label','Control status by device'); + for (const row of rows) { + const status=feedbackStatus(row,live), item=el('li','',statuses); + el('span',`${row.driver} · ${row.kind || 'device'}`,item); + const badge=el('strong',status.label,item,'control-status'); badge.dataset.tone=status.tone; + } for (const row of rows) { const text=feedbackText(row,live); - const card=el('article','',root,'control-result' + (live && row.severity === 'warning' ? ' needs-attention' : '') + (!live ? ' not-current' : '')); + const card=el('article','',root,'control-result' + (live && row.severity === 'warning' ? ' needs-attention' : '') + (!live ? ' not-current' : '') + (live && row.verification_lost ? ' control-alarm' : '')); el('div',`${row.driver} · ${row.kind || 'device'}`,card,'control-device'); el('h3',text.title,card); el('p',text.detail,card); diff --git a/src/lib/format/control-feedback.test.ts b/src/lib/format/control-feedback.test.ts index c2ec1e4..24bb1dd 100644 --- a/src/lib/format/control-feedback.test.ts +++ b/src/lib/format/control-feedback.test.ts @@ -1,6 +1,6 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; -import { feedbackValues, feedbackSite } from './control-feedback.mjs'; +import { feedbackStatus, feedbackValues, feedbackSite } from './control-feedback.mjs'; test('comparison time gaps use milliseconds and require measured evidence',()=>{ const gap=(e: {samples:number;max_skew_ms:number|null})=>feedbackValues({driver:'hybrid',reason:'power_observed',site_evidence:e}).find(v=>v[0]==='Largest time gap'); @@ -16,3 +16,13 @@ test('a missing measurement names the source holding back site confirmation',()= assert.match(feedbackSite(row),/Fresh power readings from easee \(ev\) are missing/); assert.doesNotMatch(feedbackSite(row,false),/Fresh power readings/); }); + +test('each device has its own status and unknown background is not a veto',()=>{ + const row={driver:'battery',reason:'power_observed',verification_tier:2,site_confirmation:'confirmed',site_evidence:{unmeasured_flows:['offline-ev:ev']}}; + assert.equal(feedbackStatus(row).tone,'confirmed'); + assert.match(feedbackSite(row),/separate site meter/); + assert.deepEqual(feedbackValues(row).find(v=>v[0]==='Included in background'),['Included in background','offline-ev:ev']); + assert.equal(feedbackStatus({driver:'ev',reason:'waiting_response',verification_tier:0}).tone,'waiting'); + assert.equal(feedbackStatus({driver:'ev',reason:'telemetry_stale',verification_tier:0,verification_lost:true}).tone,'alarm'); + assert.equal(feedbackStatus(row,false).tone,'unknown'); +}); diff --git a/src/lib/ui/ControlFeedback.svelte b/src/lib/ui/ControlFeedback.svelte index 733efe6..1fa671c 100644 --- a/src/lib/ui/ControlFeedback.svelte +++ b/src/lib/ui/ControlFeedback.svelte @@ -1,5 +1,5 @@ @@ -7,10 +7,16 @@ {#if rows.length}

Are you in control?

+
    + {#each rows as row (`${row.driver}:${row.kind}`)} + {@const status = feedbackStatus(row,live)} +
  • {row.driver} · {row.kind ?? 'device'}{status.label}
  • + {/each} +
{#each rows as row (`${row.driver}:${row.kind}`)} {@const text = feedbackText(row, live)} {@const curve = feedbackCurve(row,live)} -
+
{row.driver} · {row.kind ?? 'device'}

{text.title}

{text.detail}

@@ -59,4 +65,13 @@ .zero { stroke: var(--line); stroke-width: 1; } .device-line { stroke: var(--energy-storage); stroke-width: 3; } .site-line { stroke: var(--energy-export); stroke-width: 2; stroke-dasharray: 5 4; } + .control-status-list { display: flex; flex-wrap: wrap; gap: .6rem; list-style: none; padding: 0; margin: 0 0 1rem; } + .control-status-list li { display: flex; flex-wrap: wrap; align-items: center; gap: .6rem; padding: .65rem .8rem; border: 1px solid var(--line); border-radius: 8px; font-size: .85rem; } + .control-status-list strong { font-size: .8rem; } + [data-tone="confirmed"] { color: var(--energy-export); } + [data-tone="measured"] { color: var(--energy-storage); } + [data-tone="waiting"] { color: var(--energy-generation); } + [data-tone="unknown"] { color: var(--fg-dim); } + [data-tone="alarm"], .control-alarm h3 { color: var(--energy-import); } + article.control-alarm { border-inline-start: 4px solid var(--energy-import); } diff --git a/src/lib/ui/ControlFeedback.svelte.test.ts b/src/lib/ui/ControlFeedback.svelte.test.ts index d0a040f..d5a5812 100644 --- a/src/lib/ui/ControlFeedback.svelte.test.ts +++ b/src/lib/ui/ControlFeedback.svelte.test.ts @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import {render,screen,cleanup} from '@testing-library/svelte' +import {render,screen,cleanup,within} from '@testing-library/svelte' import {afterEach,expect,it} from 'vitest' import ControlFeedback from './ControlFeedback.svelte' afterEach(cleanup) @@ -7,7 +7,7 @@ it('shows device limits even while charging and keeps proof levels distinct',()= render(ControlFeedback,{value:[{driver:'Garage',kind:'ev',reason:'device_limit',severity:'warning',verification_tier:0,device_limit_a:8,requested_a:16,actual_w:5500,site_confirmation:'device_response_unconfirmed'}]}) expect(screen.getByText('Charger limit',{selector:'h3'})).toBeTruthy() expect(screen.getByText('8.0 A')).toBeTruthy() - expect(screen.getByText(/Tier 0/)).toBeTruthy() + expect(screen.getByText('Alarm · Tier 0')).toBeTruthy() expect(screen.queryByText(/Tier 2/)).toBeNull() }) it('withdraws independent confirmation when status is stale',()=>{ @@ -31,3 +31,22 @@ it('shows the measured comparison curve and removes it when status expires',asyn expect(screen.queryByRole('img')).toBeNull() expect(screen.queryByText(/Tier 2/)).toBeNull() }) + +it('shows independent overview tiers and a loss alarm without lowering another device',async()=>{ + const value=[ + {driver:'Sungrow',kind:'battery',reason:'power_observed',verification_tier:2,site_confirmation:'confirmed',site_evidence:{unmeasured_flows:['Easee:ev']}}, + {driver:'Easee',kind:'ev',reason:'telemetry_stale',severity:'warning',verification_tier:0,verification_lost:true}, + ] + const view=render(ControlFeedback,{value}) + const list=within(screen.getByRole('list',{name:'Control status by device'})) + expect(list.getByText('Tier 2 · Site confirmed')).toBeTruthy() + expect(list.getByText('Alarm · Tier 0')).toBeTruthy() + expect(screen.getByRole('heading',{name:'Alarm · Measured control lost'})).toBeTruthy() + expect(screen.getByText('Included in background')).toBeTruthy() + await view.rerender({value:[value[0],{...value[1],reason:'power_observed',severity:'info',verification_tier:1,verification_lost:false}]}) + expect(screen.queryByText('Alarm · Tier 0')).toBeNull() + expect(list.getByText('Tier 1 · Device measured')).toBeTruthy() + await view.rerender({value,live:false}) + expect(screen.queryByText('Alarm · Tier 0')).toBeNull() + expect(screen.queryByText('Tier 2 · Site confirmed')).toBeNull() +}) From 481172aa7b1ae9449558c321f8b353cb80785029 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 29 Sep 2026 21:05:05 +0200 Subject: [PATCH 06/11] Keep per-device control proof in overview bubbles and a detail dialog Signed-off-by: Fredrik Ahlgren --- src/lib/format/control-feedback.d.mts | 5 + src/lib/format/control-feedback.mjs | 48 ++++- src/lib/state/flow.ts | 7 +- src/lib/ui/ControlEvidencePanel.svelte | 29 +++ src/lib/ui/ControlFeedback.svelte | 4 +- src/vendor/ftw/digests.json | 2 +- src/vendor/ftw/ftw-energy-flow.d.ts | 1 + src/vendor/ftw/ftw-energy-flow.js | 201 ++++++++++++++------- src/views/Now.control-proof.svelte.test.ts | 50 +++++ src/views/Now.svelte | 30 ++- tests/control-proof-bubbles.test.ts | 20 ++ 11 files changed, 311 insertions(+), 86 deletions(-) create mode 100644 src/lib/ui/ControlEvidencePanel.svelte create mode 100644 src/views/Now.control-proof.svelte.test.ts create mode 100644 tests/control-proof-bubbles.test.ts diff --git a/src/lib/format/control-feedback.d.mts b/src/lib/format/control-feedback.d.mts index ac44e95..7e92686 100644 --- a/src/lib/format/control-feedback.d.mts +++ b/src/lib/format/control-feedback.d.mts @@ -18,3 +18,8 @@ export function feedbackSite(row: ControlFeedback, live?: boolean): string; export function feedbackCurve(row: ControlFeedback, live?: boolean): {device:string;site:string;label:string;scale:string;duration:string}|null; export function feedbackStatus(row: ControlFeedback, live?: boolean): {label:string;tone:string}; + +export interface ControlProof { label:string; detail:string; tone:string } +export interface ProofPlanetScope { role?:string; name?:string; id?:string } +export function feedbackForPlanet(value:unknown, planet?:ProofPlanetScope): ControlFeedback[]; +export function withControlProof(planets:T[], value:unknown, live?:boolean): (T & {controlProof?:ControlProof})[]; diff --git a/src/lib/format/control-feedback.mjs b/src/lib/format/control-feedback.mjs index 6d025b2..98ce9b2 100644 --- a/src/lib/format/control-feedback.mjs +++ b/src/lib/format/control-feedback.mjs @@ -54,6 +54,42 @@ export function feedbackStatus(row, live = true) { if (row.verification_tier === 0) return {label:'Tier 0 · Waiting',tone:'waiting'}; return {label:'Waiting for acknowledgement',tone:'waiting'}; } +// Both views use the same per-function identity, including combined bubbles. +export function feedbackForPlanet(value, planet = {}) { + const name = planet.id?.startsWith('agg-') ? '' : planet.name; + return feedbackRows(value).filter(row => (!name || row.driver === name) && + (!planet.role || row.kind === planet.role || planet.role === 'ev' && row.kind === 'v2x_charger')); +} + +export function withControlProof(planets, value, live = true) { + const result = planets.map(p => ({...p})); + const corners = {battery:'top-right', ev:'bottom-right', pv:'top-left', v2x_charger:'bottom-right'}; + for (const row of feedbackRows(value)) { + if (!corners[row.kind]) continue; + const role = row.kind === 'v2x_charger' ? 'ev' : row.kind; + let planet = result.find(p => p.name === row.driver && p.role === role); + if (!planet) { + // A failed device must remain visible next to a healthy sibling. + planet = {id:`proof-${row.kind}-${row.driver}`, name:row.driver, role, + corner:corners[row.kind], title:role === 'battery' ? 'BATTERY' : role === 'pv' ? 'SOLAR' : 'EV CHARGER', + kw:0, toHub:false, color:'var(--fg-muted)', sub:'no data', placeholder:true}; + result.push(planet); + } + if (live && row.actual_w == null && ['telemetry_stale','device_fault'].includes(row.reason)) { + planet.placeholder = true; planet.kw = 0; planet.sub = 'no data'; planet.color = 'var(--fg-muted)'; + } + const status = feedbackStatus(row,live); + const label = !live ? 'No live proof' : status.tone === 'alarm' ? + (row.verification_tier === 0 ? '⚠ Tier 0' : '⚠ No proof') : + [0,1,2].includes(row.verification_tier) ? `Tier ${row.verification_tier}` : + status.label === 'No active command' ? 'No command' : 'Waiting'; + const proof = {...status, label, detail:status.label}; + const order = {alarm:0, waiting:1, unknown:2, measured:3, confirmed:4}; + if (!planet.controlProof || order[status.tone] < order[planet.controlProof.tone]) planet.controlProof = proof; + planet.clickable = true; + } + return result; +} const number = value => typeof value === 'number' && Number.isFinite(value); export function feedbackPower(value, kind) { if (!number(value)) return 'Unknown'; @@ -138,7 +174,7 @@ export function feedbackCurve(row, live = true) { } // On-box renderer. The app uses the same presentation functions in Svelte. -export function renderFeedback(root, value, live = true) { +export function renderFeedback(root, value, live = true, {compact = false, expanded = false} = {}) { if (!root) return; const rows = feedbackRows(value); root.hidden = rows.length === 0; @@ -147,13 +183,15 @@ export function renderFeedback(root, value, live = true) { root.replaceChildren(); if (!rows.length) return; const el = (tag, text, parent, cls) => { const node=document.createElement(tag); if (text) node.textContent=text; if(cls) node.className=cls; parent.appendChild(node); return node; }; - el('h2','Are you in control?',root); + if (!compact) el('h2','Are you in control?',root); const statuses=el('ul','',root,'control-status-list'); statuses.setAttribute('aria-label','Control status by device'); for (const row of rows) { const status=feedbackStatus(row,live), item=el('li','',statuses); el('span',`${row.driver} · ${row.kind || 'device'}`,item); - const badge=el('strong',status.label,item,'control-status'); badge.dataset.tone=status.tone; + const badge=el(compact ? 'button' : 'strong',status.label,item,'control-status'); badge.dataset.tone=status.tone; + if (compact) { badge.type='button'; badge.dataset.driver=row.driver; badge.dataset.kind=row.kind; badge.setAttribute('aria-label',`${row.driver}: ${status.label}. View measurements`); } } + if (compact) return; for (const row of rows) { const text=feedbackText(row,live); const card=el('article','',root,'control-result' + (live && row.severity === 'warning' ? ' needs-attention' : '') + (!live ? ' not-current' : '') + (live && row.verification_lost ? ' control-alarm' : '')); @@ -164,7 +202,7 @@ export function renderFeedback(root, value, live = true) { el('p',text.action,card,'control-action'); const proof=el('p',feedbackProof(row,live),card,'control-proof'); proof.dataset.tier=live ? String(row.verification_tier) : ''; el('p',feedbackSite(row,live),card,'control-action'); - const details=el('details','',card); details.dataset.device=`${row.driver}:${row.kind}`; details.open=open.has(details.dataset.device); + const details=el('details','',card); details.dataset.device=`${row.driver}:${row.kind}`; details.open=expanded || open.has(details.dataset.device); el('summary','Request and measurements',details); const dl=el('dl','',details); for (const [label,value] of feedbackValues(row,live)) {el('dt',label,dl);el('dd',value,dl);} @@ -181,4 +219,4 @@ export function renderFeedback(root, value, live = true) { if (number(row.observed_at_ms)) el('p',`Last reading: ${new Date(row.observed_at_ms).toLocaleTimeString()}`,details,'control-time'); } } -if (typeof window !== 'undefined') window.FTWControlFeedback = {render:renderFeedback,text:feedbackText}; +if (typeof window !== 'undefined') window.FTWControlFeedback = {render:renderFeedback,text:feedbackText,forPlanet:feedbackForPlanet}; diff --git a/src/lib/state/flow.ts b/src/lib/state/flow.ts index aa599c2..8a1627b 100644 --- a/src/lib/state/flow.ts +++ b/src/lib/state/flow.ts @@ -9,6 +9,7 @@ * told is decided here, which is what makes this file worth testing. */ +import { withControlProof, type ControlProof } from '$lib/format/control-feedback.mjs' import { FID } from '$lib/format/explanation' import { FLOW_IDLE_W } from '$vendor/ftw/ftw-energy-flow.js' @@ -19,6 +20,8 @@ export interface FlowDailyPart { } export interface FlowPlanet { + controlProof?: ControlProof + placeholder?: boolean id: string corner: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' title: string @@ -323,7 +326,7 @@ export function fmtKwhShort(kwh: number): string { * 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 { +export function flowReadingsFromStatus(status: SiteStatus, live = true): FlowReadings { const planets: FlowPlanet[] = [] const today = status.energy?.today ?? {} const importKwh = (num(today.import_wh) ?? 0) / 1000 @@ -434,7 +437,7 @@ export function flowReadingsFromStatus(status: SiteStatus): FlowReadings { return { load: (num(status.load_w) ?? 0) / 1000, - planets, + planets: withControlProof(planets, status.control_feedback, live), selfPoweredPctToday, } } diff --git a/src/lib/ui/ControlEvidencePanel.svelte b/src/lib/ui/ControlEvidencePanel.svelte new file mode 100644 index 0000000..5ad4eea --- /dev/null +++ b/src/lib/ui/ControlEvidencePanel.svelte @@ -0,0 +1,29 @@ + + + { event.preventDefault(); onclose() }}> +

Control evidence

+ +
+
+ + diff --git a/src/lib/ui/ControlFeedback.svelte b/src/lib/ui/ControlFeedback.svelte index 1fa671c..4fda362 100644 --- a/src/lib/ui/ControlFeedback.svelte +++ b/src/lib/ui/ControlFeedback.svelte @@ -1,6 +1,6 @@ @@ -24,7 +24,7 @@

{text.action}

{feedbackProof(row, live)}

{feedbackSite(row, live)}

-
+
Request and measurements
{#each feedbackValues(row, live) as [label, value]}
{label}
{value}
{/each}
{#if curve} diff --git a/src/vendor/ftw/digests.json b/src/vendor/ftw/digests.json index e244a1d..f2be210 100644 --- a/src/vendor/ftw/digests.json +++ b/src/vendor/ftw/digests.json @@ -2,7 +2,7 @@ "api-fetch.js": "ae8d2c3a8508d0b20c4afb599cf41f1cf5a5bba55deb6f496a530c233ea69934", "ftw-bar-chart.js": "08b61533ed52b0031a39e723278c812b2b27356c1d574b65dfd5603992065d82", "ftw-element.js": "7241b586582187cef2d76d3ee11bbc596c4f428cea3a5c8c84227e113aa3c1a0", - "ftw-energy-flow.js": "b6dec1cddebf19da4beed8a1af1b2addc4ed18243b42ed786756696ea060000c", + "ftw-energy-flow.js": "89d665a47b7e8295d42e3cfd9f0bff610b63c8437e557e79d3fee67ae0b97c6a", "ftw-price-chart.js": "29d32b1f309f58d8431a0a8c2e93bfb67d3b6ea0fc86965a57ddb54ca4e16537", "price-math.js": "bc6b104259f76d799c0a9d7f1d8a780d897cb7dc0d7a3e16a6572fb9c626d508", "price-strip.js": "413b2946c311e910c9a052a8407a3a403ed6caca0e210307c6f3e951896c2b9b", diff --git a/src/vendor/ftw/ftw-energy-flow.d.ts b/src/vendor/ftw/ftw-energy-flow.d.ts index 1da8b07..11d046c 100644 --- a/src/vendor/ftw/ftw-energy-flow.d.ts +++ b/src/vendor/ftw/ftw-energy-flow.d.ts @@ -13,6 +13,7 @@ export interface FtwFlowDailyPart { } export interface FtwFlowPlanet { + controlProof?: {label:string; detail:string; tone:string} id: string corner: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' title: string diff --git a/src/vendor/ftw/ftw-energy-flow.js b/src/vendor/ftw/ftw-energy-flow.js index 13a0b14..8596312 100644 --- a/src/vendor/ftw/ftw-energy-flow.js +++ b/src/vendor/ftw/ftw-energy-flow.js @@ -1,6 +1,6 @@ -// Vendored from srcfl/ftw web/components/ftw-energy-flow.js at 754f0475. -// Do not edit here — change it upstream and re-copy. The app and the -// box's own dashboard render this exact file; that is the point. +// Vendored from srcfl/ftw web/components/ftw-energy-flow.js at a12ef32e6f21575151a440fb6c87d71010b810b5. +// Do not edit here; change the box source, then copy it here. +// // — hero diagram for /next. // // Planet/sun layout. The HOUSE sits at the center (the sun), and every @@ -62,11 +62,10 @@ import { FtwElement, ftwDebugDelay } from "./ftw-element.js"; // idle/balanced" threshold (in watts, magnitude). Used by: // - this component (beam activation, sub-label "idle / charging / // generating", aggregated-bubble greyscale, self-powered %) -// - web/app.js per-planet object construction (mirrors via -// window.FTW_FLOW_IDLE_W set below — non-module script, can't -// import; falls back to the same literal if this module hasn't -// loaded yet) -// +// - energy-flow-readings.js (and the phone app's copy of that mapping) +// via window.FTW_FLOW_IDLE_W. Classic app.js cannot import; it falls +// back to the same literal if this module has not loaded yet. + // Inclusive comparison everywhere: |kW| <= threshold ⇒ idle, strictly // > threshold ⇒ active. So at exactly 42 W the planet is idle AND the // beam is inactive — no mixed state at the boundary. @@ -505,6 +504,13 @@ class FtwEnergyFlow extends FtwElement { this._particles = []; this._bound = []; this._snapshot = null; + this._onVisibility = () => { + if (document.hidden) this._stopParticleLoop(); + else this._startParticleLoop(); + }; + if (typeof document !== "undefined" && document.addEventListener) { + document.addEventListener("visibilitychange", this._onVisibility); + } // Anchored once at construction so `t = now - tickStart` is on the // same timeline for the entire component lifetime. Resetting it // each afterRender would make restored bornAt values (from the @@ -543,8 +549,11 @@ class FtwEnergyFlow extends FtwElement { } disconnectedCallback() { - if (this._rafId) cancelAnimationFrame(this._rafId); - this._rafId = null; + this._stopParticleLoop(); + if (this._onVisibility && typeof document !== "undefined") { + document.removeEventListener("visibilitychange", this._onVisibility); + this._onVisibility = null; + } this._particles = []; if (this._resizeRaf) { cancelAnimationFrame(this._resizeRaf); @@ -564,7 +573,10 @@ class FtwEnergyFlow extends FtwElement { // `planets` leaves the previous cluster intact (useful during // transient /api/status errors so the diagram doesn't blank out). setReadings(r) { - if (r.load != null) this._readings.load = r.load; + // `in` so an explicit null (stale meter, unknown house load) replaces + // a previous number. `!= null` would keep drawing the last 0 W as if + // the house were idle. + if ("load" in r) this._readings.load = r.load; if (Array.isArray(r.planets)) this._readings.planets = r.planets; // Optional today's-totals payload pushed through to the central // hub render. selfPoweredPctToday is the share of consumption @@ -655,7 +667,8 @@ class FtwEnergyFlow extends FtwElement { socStale: !p.placeholder && !!p.socStale, socSource: p.placeholder ? null : p.socSource, radius: p._r, - clickable: p.clickable === false ? false : (!p.placeholder && !!p.role), + clickable: p.clickable === false ? false : (!!p.controlProof || !p.placeholder && !!p.role), + controlProof: p.controlProof, role: p.role || "", name: p.name || "", id: p.id, @@ -697,6 +710,14 @@ class FtwEnergyFlow extends FtwElement { super.update(); } + _syncLayerAccess() { + for (const layer of this.shadowRoot.querySelectorAll('.ef-layer')) { + const visible = layer.classList.contains(this._aggregated ? 'ef-layer-agg' : 'ef-layer-ind'); + layer.setAttribute('aria-hidden', visible ? 'false' : 'true'); + for (const node of layer.querySelectorAll('.ef-clickable')) node.setAttribute('tabindex', visible ? '0' : '-1'); + } + } + // Called by FtwElement after each render() replaces the shadow DOM. // We cancel any in-flight rAF, bind the freshly-rendered // elements to the particle-param list `render()` just built, and @@ -704,10 +725,7 @@ class FtwEnergyFlow extends FtwElement { // every particle — cheaper than SMIL when you have hundreds of them, // and gives us per-frame noise terms SMIL can't express. afterRender() { - if (this._rafId) { - cancelAnimationFrame(this._rafId); - this._rafId = null; - } + this._stopParticleLoop(); // Aggregation toggle — flipping the aria-checked attribute and // the svg's data-agg triggers the CSS opacity transition between // layers. Intentionally NOT calling this.update() here: a full @@ -720,6 +738,7 @@ class FtwEnergyFlow extends FtwElement { this._aggregated = !this._aggregated; const svgEl = this.shadowRoot.querySelector("svg"); if (svgEl) svgEl.dataset.agg = this._aggregated ? "on" : "off"; + this._syncLayerAccess(); toggleBtn.setAttribute("aria-checked", this._aggregated ? "true" : "false"); toggleBtn.setAttribute("title", this._aggregated ? "Split multi-device corners into individual bubbles" @@ -732,6 +751,7 @@ class FtwEnergyFlow extends FtwElement { // `ftw-planet-click` so callers (app.js) can route per-role // (e.g. ev → open EV modal scoped to this driver). const svg = this.shadowRoot.querySelector('svg'); + this._syncLayerAccess(); if (svg) { const fire = (g) => { const role = g.getAttribute('data-role') || ''; @@ -753,9 +773,15 @@ class FtwEnergyFlow extends FtwElement { } // Static means "not now": a cached view must hold still, because a // moving particle is a claim that power is flowing at this moment. - if (this.hasAttribute("static")) return; + if (this.hasAttribute("static")) { + this._bound = []; + return; + } const nodes = this.shadowRoot.querySelectorAll('.ef-p'); - if (!nodes.length || !this._particles.length) return; + if (!nodes.length || !this._particles.length) { + this._bound = []; + return; + } // Wire each DOM node to its param slot. `render()` assigned indices // via `data-i`; we trust those rather than node order in case the // browser reorders subtree attribute-only nodes in the future. @@ -791,67 +817,87 @@ class FtwEnergyFlow extends FtwElement { this._snapshot = null; } this._bound = bound; - const tick = (now) => { - const t = (now - this._tickStart) / 1000; - for (let k = 0; k < bound.length; k++) { - const b = bound[k]; - const p = b.p; - let age = t - p.bornAt; - if (age >= p.life || p.life === 0) { - rollLife(p, t); - // First-ever spawn: backdate bornAt uniformly across the - // pool's lifetime so particles are spread evenly instead of - // bursting together. p._warmUpIdx is in (0, 1), so this - // seeds the fountain with a steady state. - if (p._warmUp) { - p.bornAt = t - p._warmUpIdx * p.life; - p._warmUp = false; - } - age = t - p.bornAt; + this._startParticleLoop(); + } + + _stopParticleLoop() { + if (this._rafId) { + cancelAnimationFrame(this._rafId); + this._rafId = null; + } + } + + _startParticleLoop() { + if (this._rafId || document.hidden || this.hasAttribute("static") || !this._bound || !this._bound.length) return; + this._rafId = requestAnimationFrame((now) => this._pumpParticles(now)); + } + + _pumpParticles(now) { + this._rafId = null; + if (document.hidden) return; + const bound = this._bound; + if (!bound || !bound.length) return; + const t = (now - this._tickStart) / 1000; + for (let k = 0; k < bound.length; k++) { + const b = bound[k]; + const p = b.p; + let age = t - p.bornAt; + if (age >= p.life || p.life === 0) { + rollLife(p, t); + // First-ever spawn: backdate bornAt uniformly across the + // pool's lifetime so particles are spread evenly instead of + // bursting together. p._warmUpIdx is in (0, 1), so this + // seeds the fountain with a steady state. + if (p._warmUp) { + p.bornAt = t - p._warmUpIdx * p.life; + p._warmUp = false; } - // Along-path progress: linear travel from spawn toward target. - // No easing — real electrons don't decelerate. - const along = p.vx * age; // along-vector component - const alongY = p.vy * age; - // Perpendicular offset: damped harmonic oscillator. This is - // the "gravity circling the beam" effect — a spring pulls the - // particle toward the beam centerline with angular frequency - // omega, while γ damps amplitude over time so particles - // spiral IN as they approach the target. - // perp(t) = A * e^(−γt) * cos(ωt + φ) - const envelope = Math.exp(-p.damp * age); - const wave = Math.cos(p.omega * age + p.phase); - const perp = p.amp * envelope * wave; - const x = p.sx + along + p.perpX * perp; - const y = p.sy + alongY + p.perpY * perp; - // Opacity is fixed — set at render time, never touched here. - // Size variance (per-particle `radius`) replaces the old - // opacity pulse as the "texture" cue. - b.el.setAttribute('cx', x.toFixed(1)); - b.el.setAttribute('cy', y.toFixed(1)); + age = t - p.bornAt; } - this._rafId = requestAnimationFrame(tick); - }; - this._rafId = requestAnimationFrame(tick); + // Along-path progress: linear travel from spawn toward target. + // No easing — real electrons don't decelerate. + const along = p.vx * age; // along-vector component + const alongY = p.vy * age; + // Perpendicular offset: damped harmonic oscillator. This is + // the "gravity circling the beam" effect — a spring pulls the + // particle toward the beam centerline with angular frequency + // omega, while γ damps amplitude over time so particles + // spiral IN as they approach the target. + // perp(t) = A * e^(−γt) * cos(ωt + φ) + const envelope = Math.exp(-p.damp * age); + const wave = Math.cos(p.omega * age + p.phase); + const perp = p.amp * envelope * wave; + const x = p.sx + along + p.perpX * perp; + const y = p.sy + alongY + p.perpY * perp; + // Opacity is fixed — set at render time, never touched here. + // Size variance (per-particle `radius`) replaces the old + // opacity pulse as the "texture" cue. + b.el.setAttribute('cx', x.toFixed(1)); + b.el.setAttribute('cy', y.toFixed(1)); + } + this._rafId = requestAnimationFrame((ts) => this._pumpParticles(ts)); } render() { const { load } = this._readings; + const loadKnown = load != null && Number.isFinite(Number(load)); // Self-powered % for the visible site demand — house load plus any // active EV charger. When EV is excluded, a 9 kW car charge can make a // PV+battery-covered house display 0 % simply because grid import exceeds // the house-only load. The energy-flow diagram shows the EV as part of the // live balance, so the denominator should match what is on screen. + // Unknown load (stale meter) is not 0 % — that would claim the house + // is fully self-powered while we cannot see it. let selfPoweredPct = null; - { + if (loadKnown) { let gridImport = 0; for (const p of (this._readings.planets || [])) { - if (p.role === "grid" && p.toHub) gridImport += Math.max(0, p.kw || 0); + if (p.role === "grid" && !p.placeholder && p.toHub) gridImport += Math.max(0, p.kw || 0); } let evDemandKw = 0; for (const p of (this._readings.planets || [])) { - if (p.role === "ev") evDemandKw += Math.max(0, p.kw || 0); + if (p.role === "ev" && !p.placeholder) evDemandKw += Math.max(0, p.kw || 0); } const consumptionKw = (Math.abs(load) || 0) + evDemandKw; if (!isIdleKw(consumptionKw)) { @@ -1120,7 +1166,7 @@ class FtwEnergyFlow extends FtwElement { ` : ""} -

Control evidence

- -
- - - diff --git a/src/lib/ui/ControlFeedback.svelte b/src/lib/ui/ControlFeedback.svelte index 4fda362..683ee33 100644 --- a/src/lib/ui/ControlFeedback.svelte +++ b/src/lib/ui/ControlFeedback.svelte @@ -1,11 +1,16 @@ {#if rows.length}
+ + {#if embedded} + {@const summary = feedbackSummary(value,live)} + Are we in control?{summary.label} + {:else}

Are you in control?

    {#each rows as row (`${row.driver}:${row.kind}`)} @@ -13,6 +18,7 @@
  • {row.driver} · {row.kind ?? 'device'}{status.label}
  • {/each}
+ {/if} {#each rows as row (`${row.driver}:${row.kind}`)} {@const text = feedbackText(row, live)} {@const curve = feedbackCurve(row,live)} @@ -40,10 +46,14 @@
{/each} +
{/if} diff --git a/src/lib/ui/ControlFeedback.svelte.test.ts b/src/lib/ui/ControlFeedback.svelte.test.ts index 879bb54..e7548f5 100644 --- a/src/lib/ui/ControlFeedback.svelte.test.ts +++ b/src/lib/ui/ControlFeedback.svelte.test.ts @@ -7,7 +7,7 @@ it('shows device limits even while charging and keeps proof levels distinct',()= render(ControlFeedback,{value:[{driver:'Garage',kind:'ev',reason:'device_limit',severity:'warning',verification_tier:0,device_limit_a:8,requested_a:16,actual_w:5500,site_confirmation:'device_response_unconfirmed'}]}) expect(screen.getByText('Charger limit',{selector:'h3'})).toBeTruthy() expect(screen.getByText('8.0 A')).toBeTruthy() - expect(screen.getByText('Needs attention · Tier 0')).toBeTruthy() + expect(screen.getByText('Tier 0 · Charger limit')).toBeTruthy() expect(screen.queryByText(/Tier 2/)).toBeNull() }) it('withdraws independent confirmation when status is stale',()=>{ @@ -41,7 +41,7 @@ it('shows independent overview tiers and a loss alarm without lowering another d const list=within(screen.getByRole('list',{name:'Control status by device'})) expect(list.getByText('Tier 2 · Site confirmed')).toBeTruthy() expect(list.getByText('Alarm · Tier 0')).toBeTruthy() - expect(screen.getByRole('heading',{name:'Alarm · Measured control lost'})).toBeTruthy() + expect(screen.getByRole('heading',{name:'Alarm · Measurements lost'})).toBeTruthy() expect(screen.getByText('Background, not required sources')).toBeTruthy() await view.rerender({value:[value[0],{...value[1],reason:'power_observed',severity:'info',verification_tier:1,verification_lost:false}]}) expect(screen.queryByText('Alarm · Tier 0')).toBeNull() @@ -64,3 +64,25 @@ it('keeps embedded evidence folded below controls and preserves an open section expect(disclosure.open).toBe(true); expect(disclosure.querySelector('summary')!.textContent).toContain('Alarm'); }); + +it('shows a site-confirmed shortfall, then recovery, without losing the evidence tier',async()=>{ + const row={driver:'Sungrow',kind:'battery',reason:'power_below_target',severity:'warning',verification_tier:2,requested_w:-5000,sent_w:-5000,actual_w:-4400,site_confirmation:'confirmed'}; + const view=render(ControlFeedback,{value:[row],embedded:true}); + const disclosure=document.querySelector('details')!; + expect(disclosure.open).toBe(false); + expect(disclosure.querySelector('summary')!.textContent).toContain('Tier 2 · Site confirmed · Below target'); + expect(disclosure.querySelector('summary strong')!.getAttribute('data-tone')).toBe('warning'); + disclosure.open=true; + expect(screen.getByText('Power is below the target')).toBeTruthy(); + expect(screen.getByText('600 W')).toBeTruthy(); + expect(screen.getByText(/cause is not confirmed/)).toBeTruthy(); + expect(screen.queryByText(/Measurements lost/)).toBeNull(); + await view.rerender({value:[{...row,actual_w:-5000,reason:'power_observed',severity:'info'}],embedded:true}); + expect(disclosure.open).toBe(true); + expect(disclosure.querySelector('summary')!.textContent).toContain('Tier 2 · Site confirmed'); + expect(screen.queryByText('600 W')).toBeNull(); + expect(screen.getByText('Device follows the command')).toBeTruthy(); + await view.rerender({value:[row],embedded:true,live:false}); + expect(disclosure.querySelector('summary')!.textContent).not.toContain('Tier 2'); + expect(screen.queryByText('600 W')).toBeNull(); +}); diff --git a/src/vendor/ftw/digests.json b/src/vendor/ftw/digests.json index 13e74b8..24defb3 100644 --- a/src/vendor/ftw/digests.json +++ b/src/vendor/ftw/digests.json @@ -2,7 +2,7 @@ "api-fetch.js": "ae8d2c3a8508d0b20c4afb599cf41f1cf5a5bba55deb6f496a530c233ea69934", "ftw-bar-chart.js": "08b61533ed52b0031a39e723278c812b2b27356c1d574b65dfd5603992065d82", "ftw-element.js": "7241b586582187cef2d76d3ee11bbc596c4f428cea3a5c8c84227e113aa3c1a0", - "ftw-energy-flow.js": "c1b2b7ff6e6c83f7e810c9320cd027c2a795dd62f866b2bbc3cab6220b0ea746", + "ftw-energy-flow.js": "16ac207d4be34cd5525454e6c2ac126c2d64501a5c3cef755a723720b0fafdda", "ftw-price-chart.js": "29d32b1f309f58d8431a0a8c2e93bfb67d3b6ea0fc86965a57ddb54ca4e16537", "price-math.js": "bc6b104259f76d799c0a9d7f1d8a780d897cb7dc0d7a3e16a6572fb9c626d508", "price-strip.js": "413b2946c311e910c9a052a8407a3a403ed6caca0e210307c6f3e951896c2b9b", diff --git a/src/vendor/ftw/ftw-energy-flow.js b/src/vendor/ftw/ftw-energy-flow.js index 822bae2..9730320 100644 --- a/src/vendor/ftw/ftw-energy-flow.js +++ b/src/vendor/ftw/ftw-energy-flow.js @@ -1,4 +1,4 @@ -// Vendored from srcfl/ftw web/components/ftw-energy-flow.js at c4dc4b85f5e80ee19bf38c4aa4991e6fb0ee386b. +// Vendored from srcfl/ftw web/components/ftw-energy-flow.js at 79518637c89d5a9fa459001c3c43109f14153175. // Do not edit here; change the box source, then copy it here. // // — hero diagram for /next. @@ -1630,10 +1630,10 @@ function renderCircleNode({ pos, title, nameLabel, value, sub, color, soc, // the whole bubble opens the explanation and remains the accessible target. function renderProofBadge(proof, x, y, r) { if (proof.inactive) return ''; - const colors = {confirmed:'var(--green-e)', measured:'var(--green-e)', waiting:'var(--fg-muted)', alarm:'var(--red-e)', unknown:'var(--fg-muted)'}; + const colors = {confirmed:'var(--green-e)', measured:'var(--green-e)', waiting:'var(--fg-muted)', warning:'var(--amber)', alarm:'var(--red-e)', unknown:'var(--fg-muted)'}; const color = colors[proof.tone] || colors.unknown; const size = Math.max(3.5, Math.min(5, r * .065)); - const mark = proof.tone === 'alarm' + const mark = proof.tone === 'alarm' || proof.tone === 'warning' ? '' : proof.tone === 'measured' || proof.tone === 'confirmed' ? `${proof.tone === 'confirmed' ? '' : ''}` @@ -1649,6 +1649,8 @@ function combinedProof(group) { if (!proofs.length) return null; const alarms = proofs.filter(p => p.tone === 'alarm').length; if (alarms) return {tone:'alarm', label:`⚠ ${alarms} alarm${alarms === 1 ? '' : 's'}`, detail:`${alarms} device${alarms === 1 ? '' : 's'} need attention`}; + const warnings = proofs.filter(p => p.tone === 'warning').length; + if (warnings) return {tone:'warning', label:`⚠ ${warnings} warning${warnings === 1 ? '' : 's'}`, detail:`${warnings} device${warnings === 1 ? '' : 's'} need attention`}; if (proofs.length === group.length && proofs.every(p => p.label === proofs[0].label && p.tone === proofs[0].tone)) return proofs[0]; return {tone:'unknown', label:'Mixed tiers', detail:'Devices have different control evidence'}; } diff --git a/src/views/Now.control-proof.svelte.test.ts b/src/views/Now.control-proof.svelte.test.ts index da0cf07..b70b8a4 100644 --- a/src/views/Now.control-proof.svelte.test.ts +++ b/src/views/Now.control-proof.svelte.test.ts @@ -39,7 +39,7 @@ test('opens the device panel first, with collapsed evidence that stays current', evidence.open=true lost=true for(let i=0;i<150;i++){box.tick(20);await vi.advanceTimersByTimeAsync(20)} - expect(dialog.textContent).toContain('Alarm · Measured control lost') + expect(dialog.textContent).toContain('Alarm · Measurements lost') expect(evidence.open).toBe(true) window.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape'})) await vi.advanceTimersByTimeAsync(10) From 64bdfe72e8e3506f7342a036deaa5f6eac6e6cf5 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 30 Sep 2026 15:42:49 +0200 Subject: [PATCH 10/11] Explain the full-battery charging pause Signed-off-by: Fredrik Ahlgren --- .changeset/control-results.md | 2 ++ src/lib/format/control-feedback.d.mts | 2 +- src/lib/format/control-feedback.mjs | 9 +++++++-- src/lib/format/control-feedback.test.ts | 11 +++++++++++ 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/.changeset/control-results.md b/.changeset/control-results.md index fe7fa03..63e0676 100644 --- a/.changeset/control-results.md +++ b/.changeset/control-results.md @@ -11,3 +11,5 @@ Keep the overview quiet with small evidence marks and no tier labels. Show norma Put a collapsed “Are we in control?” section below the device’s main view and charging controls. Open the chosen charger directly, and keep its evidence and failures in that panel. Keep measurement tiers separate from target fulfilment. Show independently confirmed partial power with its shortfall and unknown cause, and use an amber mark for shortfalls and known limits while retaining alarms for faults and wrong responses. + +Show the box's full-battery charging pause, current charge level and resume threshold without promoting the measurement tier or hiding faults. diff --git a/src/lib/format/control-feedback.d.mts b/src/lib/format/control-feedback.d.mts index 89aa71d..df45388 100644 --- a/src/lib/format/control-feedback.d.mts +++ b/src/lib/format/control-feedback.d.mts @@ -4,7 +4,7 @@ export interface ControlFeedback { site_meter?: string; site_before_w?: number | null; site_after_w?: number | null; site_before_at_ms?: number; site_after_at_ms?: number; verification_lost?: boolean; verification_tier?: number | null; site_confirmation?: string; site_source_issue?: string; site_delta_w?: number | null; device_delta_w?: number | null; tolerance_w?: number | null; response?: string; requested_w?: number | null; sent_w?: number | null; readback_w?: number | null; - actual_w?: number | null; requested_a?: number | null; offered_a?: number | null; device_limit_a?: number | null; + battery_soc?: number | null; charge_resume_soc?: number | null; actual_w?: number | null; requested_a?: number | null; offered_a?: number | null; device_limit_a?: number | null; device_reason?: string; observed_at_ms?: number; verified_at_ms?: number; } export function feedbackRows(value: unknown): ControlFeedback[]; diff --git a/src/lib/format/control-feedback.mjs b/src/lib/format/control-feedback.mjs index e129415..f2ea4cd 100644 --- a/src/lib/format/control-feedback.mjs +++ b/src/lib/format/control-feedback.mjs @@ -1,5 +1,6 @@ // Presentation only. Core owns evidence, freshness, limits and response checks. const messages = { + battery_full: ['Battery is full', 'FTW has paused charging because the battery reported 100%.', 'Charging is allowed again at 99% or lower, when the active mode requests it. Discharging remains available.'], solar_below_ceiling: ['Solar is below the requested ceiling', 'The measured output respects the ceiling, but available sunshine may already limit it.', 'FTW cannot yet confirm that curtailment caused the lower output.'], device_limit: ['Charger limit', 'The charger’s own current limit is below this request.', 'Check the charger’s current limit in its app or settings.'], offered_current_lower: ['Charger offers less current', 'The charger reports a lower current offer than FTW requested.', 'Check its load balancing and current limits. The reason is not confirmed.'], @@ -49,7 +50,8 @@ export function feedbackText(row, live = true) { const detail = row.reason === 'power_below_target' && number(row.sent_w) && number(row.actual_w) ? `Measured ${feedbackPower(row.actual_w,row.kind)}, ${Math.round(Math.max(0,Math.abs(row.sent_w)-Math.abs(row.actual_w)))} W below the sent target of ${feedbackPower(row.sent_w,row.kind)}.` : words[1]; - return {title:row.verification_lost ? 'Alarm · Measurements lost' : words[0], detail, action:words[2]}; + const title = row.reason === 'battery_full' && number(row.battery_soc) && row.battery_soc < 1 ? 'Charging paused after full' : words[0]; + return {title:row.verification_lost ? 'Alarm · Measurements lost' : title, detail, action:words[2]}; } // A per-device status, never a site-wide tier or a verdict inferred by the UI. export function feedbackStatus(row, live = true) { @@ -62,6 +64,7 @@ export function feedbackStatus(row, live = true) { const evidence = row.verification_tier === 2 ? 'Tier 2 · Site confirmed' : row.verification_tier === 1 ? 'Tier 1 · Device measured' : row.verification_tier === 0 ? 'Tier 0' : 'Unconfirmed'; return {label:`${evidence} · ${outcome || 'Needs attention'}`,tone:outcome ? 'warning' : 'alarm'}; } + if (row.reason === 'battery_full') return {label:'Charging paused after full',tone:row.verification_tier === 2 ? 'confirmed' : row.verification_tier === 1 ? 'measured' : 'waiting'}; if (row.verification_tier === 2) return {label:'Tier 2 · Site confirmed',tone:'confirmed'}; if (row.verification_tier === 1) return {label:'Tier 1 · Device measured',tone:'measured'}; if (row.verification_tier === 0) return {label:'Tier 0 · Waiting',tone:'waiting'}; @@ -107,7 +110,7 @@ export function withControlProof(planets, value, live = true) { status.label === 'No active command' ? 'No command' : 'Waiting'; // Tier 0 is acknowledgement, not a new alarm on every setpoint change. // Keep its precise tier in the detail view; the overview shows a quiet wait. - const detail = status.tone === 'waiting' ? 'Verifying the response' : status.label; + const detail = status.tone === 'waiting' && row.reason !== 'battery_full' ? 'Verifying the response' : status.label; const proof = {...status, label, detail, inactive:live && status.label === 'No active command'}; const order = {alarm:0, warning:1, waiting:2, unknown:3, measured:4, confirmed:5}; if (!planet.controlProof || order[status.tone] < order[planet.controlProof.tone]) planet.controlProof = proof; @@ -129,6 +132,8 @@ export function feedbackValues(row, live = true) { ['Device setpoint', live ? feedbackPower(row.readback_w, row.kind) : 'Not current'], ['Measured', live ? feedbackPower(row.actual_w, row.kind) : 'Not current'], ]; + if (number(row.battery_soc)) values.push(['Battery charge', live ? `${(row.battery_soc*100).toFixed(1)}%` : 'Not current']); + if (number(row.charge_resume_soc)) values.push(['Charging allowed again', live ? `${Math.round(row.charge_resume_soc*100)}% or lower` : 'Not current']); if (live && row.reason === 'power_below_target' && number(row.sent_w) && number(row.actual_w)) { values.push(['Shortfall from sent target', `${Math.round(Math.max(0,Math.abs(row.sent_w)-Math.abs(row.actual_w)))} W`]); } diff --git a/src/lib/format/control-feedback.test.ts b/src/lib/format/control-feedback.test.ts index 5a5d732..e8f8dcc 100644 --- a/src/lib/format/control-feedback.test.ts +++ b/src/lib/format/control-feedback.test.ts @@ -47,3 +47,14 @@ test('embedded on-box evidence preserves the open disclosure and focus across up assert.match(summary.textContent!,/No live proof/); } finally {root.remove()} }); + +test('full battery is a quiet pause, with the actual stop and charge level visible',()=>{ + const row={driver:'battery',kind:'battery',reason:'battery_full',severity:'info',verification_tier:1,battery_soc:1,charge_resume_soc:.99,requested_w:5000,sent_w:0,actual_w:0}; + assert.equal(feedbackStatus(row).tone,'measured'); + assert.match(feedbackStatus(row).label,/Charging paused/); + assert.deepEqual(feedbackValues(row).find(v=>v[0]==='Battery charge'),['Battery charge','100.0%']); + assert.deepEqual(feedbackValues(row).find(v=>v[0]==='Sent to driver'),['Sent to driver','0 W']); + assert.equal(feedbackStatus({...row,verification_tier:0}).tone,'waiting'); + assert.equal(feedbackStatus({...row,verification_lost:true}).tone,'alarm'); + assert.equal(feedbackValues(row,false).find(v=>v[0]==='Battery charge')?.[1],'Not current'); +}); From 9729a9fe6ba563df4cbd5d59d657b4652a8b8bec Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 1 Oct 2026 11:26:51 +0200 Subject: [PATCH 11/11] Answer "Are we in control?" first, with the box's words Paired with srcfl/ftw#1474 at 6716098a. The box now states status, severity and evidence; the app renders them instead of deriving its own tone from reasons, so a stale grid meter no longer paints every bubble red and a finished car reads "Car is full" instead of an unknown state. The panels open with the answer, one sentence and the next step, and keep "How FTW knows" folded with an evidence receipt, numbers and curves. Marks appear only for warning (amber) and alarm (red). The charging status line keeps power first; a warning no longer replaces it. The control words are now vendored from the box under src/vendor/ftw with a provenance header and recorded digest, like the flow component, so drift fails the vendored test. The box's DOM renderer no longer ships here: the entry bundle is 82.6 kB gzip, down from 85.1 kB. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- .changeset/control-results.md | 12 +- src/lib/format/control-feedback.d.mts | 27 -- src/lib/format/control-feedback.mjs | 278 ------------------- src/lib/format/control-feedback.test.ts | 60 ---- src/lib/format/ev.ts | 5 +- src/lib/state/flow.ts | 6 +- src/lib/ui/ControlFeedback.svelte | 182 +++++++----- src/lib/ui/ControlFeedback.svelte.test.ts | 135 ++++----- src/vendor/ftw/control-feedback.d.ts | 68 +++++ src/vendor/ftw/control-feedback.js | 307 +++++++++++++++++++++ src/vendor/ftw/digests.json | 3 +- src/vendor/ftw/ftw-energy-flow.d.ts | 2 +- src/vendor/ftw/ftw-energy-flow.js | 57 ++-- src/views/EvPanel.svelte | 2 +- src/views/LivePanel.svelte | 2 +- src/views/Now.control-proof.svelte.test.ts | 19 +- src/views/Now.svelte | 10 +- tests/control-proof-bubbles.test.ts | 46 ++- 18 files changed, 617 insertions(+), 604 deletions(-) delete mode 100644 src/lib/format/control-feedback.d.mts delete mode 100644 src/lib/format/control-feedback.mjs delete mode 100644 src/lib/format/control-feedback.test.ts create mode 100644 src/vendor/ftw/control-feedback.d.ts create mode 100644 src/vendor/ftw/control-feedback.js diff --git a/.changeset/control-results.md b/.changeset/control-results.md index 63e0676..ab675fd 100644 --- a/.changeset/control-results.md +++ b/.changeset/control-results.md @@ -2,14 +2,6 @@ "ftw-webapp": patch --- -Show control limits and measured response in Now and the charging panel. Distinguish command acknowledgement, device power and independent site-meter confirmation. Keep missing, stale and uncertain evidence explicit. +Answer "Are we in control?" first when you open the battery, solar or charging panel: Following FTW, Waiting, Limited, Not following, No contact or Not controlled, with one sentence and, when you can act, the next step. "How FTW knows" shows what FTW sent and whether the device accepted it, measured it and a separate grid meter confirmed it, with numbers and response curves for experts. -Show the measured comparison curves, changes in other flows and any unexplained difference behind Core's verdict. - -Keep the overview quiet with small evidence marks and no tier labels. Show normal response waits without an alarm, hide marks for inactive commands and open the device panel on tap. - -Put a collapsed “Are we in control?” section below the device’s main view and charging controls. Open the chosen charger directly, and keep its evidence and failures in that panel. - -Keep measurement tiers separate from target fulfilment. Show independently confirmed partial power with its shortfall and unknown cause, and use an amber mark for shortfalls and known limits while retaining alarms for faults and wrong responses. - -Show the box's full-battery charging pause, current charge level and resume threshold without promoting the measurement tier or hiding faults. +The overview stays quiet while FTW is in control and marks only devices that need a look (amber) or attention now (red); a combined bubble keeps the worst mark. The charging status line keeps power first. The words come from the box's own file, copied with a recorded digest so the two cannot drift apart unnoticed. diff --git a/src/lib/format/control-feedback.d.mts b/src/lib/format/control-feedback.d.mts deleted file mode 100644 index df45388..0000000 --- a/src/lib/format/control-feedback.d.mts +++ /dev/null @@ -1,27 +0,0 @@ -export interface ControlFeedback { - site_evidence?: { unmeasured_flows?: string[]; other_change_w?: number|null; adjusted_site_change_w?: number|null; unexplained_change_w?: number|null; tolerance_w?: number; samples?: number; window_s?: number; max_skew_ms?: number|null; trace?: {at_ms:number;device_change_w:number;adjusted_site_change_w:number}[] }; - driver: string; kind?: string; mode?: string; state?: string; reason: string; severity?: string; - site_meter?: string; site_before_w?: number | null; site_after_w?: number | null; site_before_at_ms?: number; site_after_at_ms?: number; - verification_lost?: boolean; verification_tier?: number | null; site_confirmation?: string; site_source_issue?: string; site_delta_w?: number | null; device_delta_w?: number | null; - tolerance_w?: number | null; response?: string; requested_w?: number | null; sent_w?: number | null; readback_w?: number | null; - battery_soc?: number | null; charge_resume_soc?: number | null; actual_w?: number | null; requested_a?: number | null; offered_a?: number | null; device_limit_a?: number | null; - device_reason?: string; observed_at_ms?: number; verified_at_ms?: number; -} -export function feedbackRows(value: unknown): ControlFeedback[]; -export function feedbackText(row: ControlFeedback, live?: boolean): {title:string;detail:string;action:string}; -export function feedbackPower(value: unknown, kind?: string): string; -export function feedbackValues(row: ControlFeedback, live?: boolean): [string,string][]; -export function feedbackProof(row: ControlFeedback, live?: boolean): string; -export function renderFeedback(root: HTMLElement | null, value: unknown, live?: boolean, options?: {compact?:boolean;expanded?:boolean;embedded?:boolean}): void; - -export function feedbackSite(row: ControlFeedback, live?: boolean): string; -export function feedbackCurve(row: ControlFeedback, live?: boolean): {device:string;site:string;label:string;scale:string;duration:string}|null; - -export function feedbackStatus(row: ControlFeedback, live?: boolean): {label:string;tone:string}; - -export interface ControlProof { label:string; detail:string; tone:string; inactive?:boolean } -export interface ProofPlanetScope { role?:string; name?:string; id?:string } -export function feedbackForPlanet(value:unknown, planet?:ProofPlanetScope): ControlFeedback[]; -export function withControlProof(planets:T[], value:unknown, live?:boolean): (T & {controlProof?:ControlProof})[]; - -export function feedbackSummary(value:unknown, live?:boolean): {label:string;tone:string}; diff --git a/src/lib/format/control-feedback.mjs b/src/lib/format/control-feedback.mjs deleted file mode 100644 index f2ea4cd..0000000 --- a/src/lib/format/control-feedback.mjs +++ /dev/null @@ -1,278 +0,0 @@ -// Presentation only. Core owns evidence, freshness, limits and response checks. -const messages = { - battery_full: ['Battery is full', 'FTW has paused charging because the battery reported 100%.', 'Charging is allowed again at 99% or lower, when the active mode requests it. Discharging remains available.'], - solar_below_ceiling: ['Solar is below the requested ceiling', 'The measured output respects the ceiling, but available sunshine may already limit it.', 'FTW cannot yet confirm that curtailment caused the lower output.'], - device_limit: ['Charger limit', 'The charger’s own current limit is below this request.', 'Check the charger’s current limit in its app or settings.'], - offered_current_lower: ['Charger offers less current', 'The charger reports a lower current offer than FTW requested.', 'Check its load balancing and current limits. The reason is not confirmed.'], - setpoint_changed: ['Setpoint does not match', 'The device reports a different setpoint from the command FTW sent.', 'Check for another controller or a device mode that changes the setpoint. FTW cannot tell which caused this.'], - power_differs: ['Power does not follow the command', 'Fresh measurements differ from the command after the response wait.', 'The device has not reported a confirmed cause. Check its app or status display.'], - power_below_target: ['Power is below the target', 'Fresh measurements show less power than FTW sent after the response wait.', 'The cause is not confirmed. A device limit may explain it; measured power alone does not establish the limit.'], - power_above_target: ['Power is above the target', 'Fresh measurements show more power than FTW sent after the response wait.', 'The cause is not confirmed. Check the device’s limits and control mode.'], - power_wrong_direction: ['Power flows in the wrong direction', 'Fresh measurements show the opposite direction from the command after the response wait.', 'Check the device’s control mode and status. Measurement confirmation does not mean the command was followed.'], - no_power_response: ['No power response', 'Fresh readings show no material power although FTW requested it.', 'The cause is not confirmed. Check the device’s status and limits.'], - power_while_idle: ['Power continues while idle was requested', 'Fresh readings still show power after the response wait.', 'Check the device’s control mode and status. The cause is not confirmed.'], - command_failed: ['Command failed', 'The driver could not complete this command. The device may still have received part of it.', 'Check the device connection and status. FTW attempts to restore its safe default.'], - command_unconfirmed: ['Command result unknown', 'FTW stopped waiting before it could confirm the call’s result.', 'The command may have reached the device. Check fresh power readings before trying again.'], - default_failed: ['Safe default not confirmed', 'FTW has blocked further control while it retries the device’s safe default.', 'Check the device connection and its own status.'], - telemetry_stale: ['Waiting for fresh readings', 'FTW cannot verify the effect with the readings available.', 'Check the device connection. This view updates when fresh readings arrive.'], - site_meter_stale: ['Waiting for the site meter', 'FTW has stopped dispatch because site power readings are missing or too old.', 'Check the site meter connection. Dispatch resumes when readings recover.'], - site_phase_currents_stale: ['Waiting for phase currents', 'FTW has stopped dispatch because it lacks fresh current readings for the main-fuse check.', 'Check the site meter connection and its phase readings. Dispatch resumes when readings recover.'], - device_fault: ['Device cannot follow commands', 'The device or its driver reports a fault that blocks control.', 'Check the reported reason and the device’s status.'], - fuse_limit: ['Limited by the main fuse', 'FTW reduced the request to keep site current within its limit.', 'The rate can rise when other household demand falls.'], - fuse_cooldown: ['Waiting after a fuse limit', 'FTW is waiting before restoring the charging rate.', 'Charging resumes when the fuse protection allows it.'], - charger_limit: ['Limited by FTW’s charger setting', 'FTW reduced the request to the configured charging limit.', 'Check the charger limit in FTW settings.'], - core_limit: ['Adjusted by FTW', 'FTW changed the target through its safety or ramp limits.', 'The control tick does not report which limit applied.'], - observe_only: ['Monitoring only', 'FTW reads this device but does not send commands to it.', 'Enable control in the device settings if you want FTW to control it.'], - disabled: ['Device disabled', 'FTW does not control this device while it is disabled.', 'Check its device settings.'], - device_control: ['Device controls itself', 'FTW returned the device to its own control.', 'The earlier FTW command no longer describes its target.'], - not_connected: ['No car connected', 'The charger reports that no car is connected.', 'Connect a car to charge.'], - waiting_response: ['Waiting for measured response', 'FTW is checking fresh power readings against the command.', 'Starting and changing phases can take time. A driver reply alone does not confirm the effect.'], - response_unknown: ['Effect not verified', 'FTW does not have enough evidence to compare the command with measured power.', 'A successful driver call alone does not confirm the effect.'], - no_command: ['No command recorded yet', 'FTW has no command result for this device since it started.', 'Fresh measurements will show the effect once control runs.'], - power_observed: ['Device follows the command', 'The device’s own power readings follow the command within the response tolerance.', 'Independent confirmation is a separate step. A charging goal needs its own completion evidence.'], - idle: ['No power requested', 'FTW currently requests no charge or discharge.', 'The active mode decides when the next request is needed.'], - no_plan_budget: ['Waiting for the plan', 'The plan assigns no charging energy to this interval.', 'Check the next charging window, or choose Charge now.'], - pv_surplus_pause: ['Waiting for spare solar', 'There is not enough spare solar to start charging.', 'Charging resumes when the solar rule allows it.'], - pv_surplus: ['Using spare solar', 'FTW is matching charging to the available solar surplus.', 'The rate changes with household demand and solar output.'], - plan: ['Following the plan', 'FTW is using the charging plan for this interval.', 'Power readings show whether the charger follows the request.'], - manual_hold: ['Manual request', 'FTW is using your manual charging request.', 'Power readings show whether the charger follows it.'], - wake_kick: ['Starting the charger', 'FTW is trying to wake the connected charger.', 'Waiting for fresh power readings.'], -}; - -export function feedbackRows(value) { - if (!Array.isArray(value)) return []; - return value.filter(row => row && typeof row === 'object' && typeof row.driver === 'string' && typeof row.reason === 'string'); -} - -export function feedbackText(row, live = true) { - if (!live) return {title:'Last known control status', detail:'The box’s current control result is unavailable.', action:'Waiting for a fresh status report.'}; - const words = messages[row.reason] || ['Effect not verified', 'This box reports a control state this app does not recognise.', 'Check the device status.']; - const detail = row.reason === 'power_below_target' && number(row.sent_w) && number(row.actual_w) - ? `Measured ${feedbackPower(row.actual_w,row.kind)}, ${Math.round(Math.max(0,Math.abs(row.sent_w)-Math.abs(row.actual_w)))} W below the sent target of ${feedbackPower(row.sent_w,row.kind)}.` - : words[1]; - const title = row.reason === 'battery_full' && number(row.battery_soc) && row.battery_soc < 1 ? 'Charging paused after full' : words[0]; - return {title:row.verification_lost ? 'Alarm · Measurements lost' : title, detail, action:words[2]}; -} -// A per-device status, never a site-wide tier or a verdict inferred by the UI. -export function feedbackStatus(row, live = true) { - if (!live) return {label:'No live proof',tone:'unknown'}; - if (row.verification_lost) return {label:row.verification_tier === 0 ? 'Alarm · Tier 0' : 'Alarm · No proof',tone:'alarm'}; - if (['observe_only','disabled','device_control','not_connected','no_command'].includes(row.reason)) return {label:'No active command',tone:'unknown'}; - if (row.severity === 'warning') { - const limits = {power_below_target:'Below target',device_limit:'Charger limit',offered_current_lower:'Lower current offer',fuse_limit:'Fuse limit',fuse_cooldown:'Fuse wait',charger_limit:'Charger setting'}; - const outcome = limits[row.reason]; - const evidence = row.verification_tier === 2 ? 'Tier 2 · Site confirmed' : row.verification_tier === 1 ? 'Tier 1 · Device measured' : row.verification_tier === 0 ? 'Tier 0' : 'Unconfirmed'; - return {label:`${evidence} · ${outcome || 'Needs attention'}`,tone:outcome ? 'warning' : 'alarm'}; - } - if (row.reason === 'battery_full') return {label:'Charging paused after full',tone:row.verification_tier === 2 ? 'confirmed' : row.verification_tier === 1 ? 'measured' : 'waiting'}; - if (row.verification_tier === 2) return {label:'Tier 2 · Site confirmed',tone:'confirmed'}; - if (row.verification_tier === 1) return {label:'Tier 1 · Device measured',tone:'measured'}; - if (row.verification_tier === 0) return {label:'Tier 0 · Waiting',tone:'waiting'}; - return {label:'Waiting for acknowledgement',tone:'waiting'}; -} -export function feedbackSummary(value, live = true) { - const statuses = feedbackRows(value).map(row => feedbackStatus(row,live)); - if (statuses.length === 1) return statuses[0]; - const alarms = statuses.filter(status => status.tone === 'alarm').length; - const warnings = statuses.filter(status => status.tone === 'warning').length; - return alarms ? {label:`${alarms} need attention`,tone:'alarm'} : - warnings ? {label:`${warnings} need attention`,tone:'warning'} : - {label:live ? `${statuses.length} devices` : 'No live proof',tone:'unknown'}; -} -// Both views use the same per-function identity, including combined bubbles. -export function feedbackForPlanet(value, planet = {}) { - const name = planet.id?.startsWith('agg-') ? '' : planet.name; - return feedbackRows(value).filter(row => (!name || row.driver === name) && - (!planet.role || row.kind === planet.role || planet.role === 'ev' && row.kind === 'v2x_charger')); -} - -export function withControlProof(planets, value, live = true) { - const result = planets.map(p => ({...p})); - const corners = {battery:'top-right', ev:'bottom-right', pv:'top-left', v2x_charger:'bottom-right'}; - for (const row of feedbackRows(value)) { - if (!corners[row.kind]) continue; - const role = row.kind === 'v2x_charger' ? 'ev' : row.kind; - let planet = result.find(p => p.name === row.driver && p.role === role); - if (!planet) { - // A failed device must remain visible next to a healthy sibling. - planet = {id:`proof-${row.kind}-${row.driver}`, name:row.driver, role, - corner:corners[row.kind], title:role === 'battery' ? 'BATTERY' : role === 'pv' ? 'SOLAR' : 'EV CHARGER', - kw:0, toHub:false, color:'var(--fg-muted)', sub:'no data', placeholder:true}; - result.push(planet); - } - if (live && row.actual_w == null && ['telemetry_stale','device_fault'].includes(row.reason)) { - planet.placeholder = true; planet.kw = 0; planet.sub = 'no data'; planet.color = 'var(--fg-muted)'; - } - const status = feedbackStatus(row,live); - const label = !live ? 'No live proof' : ['alarm','warning'].includes(status.tone) ? - ([0,1,2].includes(row.verification_tier) ? `⚠ Tier ${row.verification_tier}` : '⚠ No proof') : - [0,1,2].includes(row.verification_tier) ? `Tier ${row.verification_tier}` : - status.label === 'No active command' ? 'No command' : 'Waiting'; - // Tier 0 is acknowledgement, not a new alarm on every setpoint change. - // Keep its precise tier in the detail view; the overview shows a quiet wait. - const detail = status.tone === 'waiting' && row.reason !== 'battery_full' ? 'Verifying the response' : status.label; - const proof = {...status, label, detail, inactive:live && status.label === 'No active command'}; - const order = {alarm:0, warning:1, waiting:2, unknown:3, measured:4, confirmed:5}; - if (!planet.controlProof || order[status.tone] < order[planet.controlProof.tone]) planet.controlProof = proof; - planet.clickable = true; - } - return result; -} -const number = value => typeof value === 'number' && Number.isFinite(value); -export function feedbackPower(value, kind) { - if (!number(value)) return 'Unknown'; - if (Math.abs(value) < 1) return '0 W'; - const size = Math.abs(value) >= 1000 ? `${(Math.abs(value)/1000).toFixed(1)} kW` : `${Math.round(Math.abs(value))} W`; - return `${size} ${kind === 'pv' ? 'generation' : value < 0 ? 'discharge' : 'charge'}`; -} -export function feedbackValues(row, live = true) { - const values = [ - ['Requested', feedbackPower(row.requested_w, row.kind)], - ['Sent to driver', feedbackPower(row.sent_w, row.kind)], - ['Device setpoint', live ? feedbackPower(row.readback_w, row.kind) : 'Not current'], - ['Measured', live ? feedbackPower(row.actual_w, row.kind) : 'Not current'], - ]; - if (number(row.battery_soc)) values.push(['Battery charge', live ? `${(row.battery_soc*100).toFixed(1)}%` : 'Not current']); - if (number(row.charge_resume_soc)) values.push(['Charging allowed again', live ? `${Math.round(row.charge_resume_soc*100)}% or lower` : 'Not current']); - if (live && row.reason === 'power_below_target' && number(row.sent_w) && number(row.actual_w)) { - values.push(['Shortfall from sent target', `${Math.round(Math.max(0,Math.abs(row.sent_w)-Math.abs(row.actual_w)))} W`]); - } - if (number(row.tolerance_w)) values.push(['Response tolerance', `${Math.round(row.tolerance_w)} W`]); - if (number(row.requested_a)) values.push(['Requested current', `${row.requested_a.toFixed(1)} A`]); - if (number(row.offered_a)) values.push(['Charger offer', live ? `${row.offered_a.toFixed(1)} A` : 'Not current']); - if (number(row.device_limit_a)) values.push(['Charger limit', live ? `${row.device_limit_a.toFixed(1)} A` : 'Not current']); - const grid = value => `${(Math.abs(value)/1000).toFixed(2)} kW ${value < 0 ? 'export' : 'import'}`; - if (number(row.site_before_w)) values.push(['Site before command', live ? grid(row.site_before_w) : 'Not current']); - if (number(row.site_after_w)) values.push(['Site after command', live ? grid(row.site_after_w) : 'Not current']); - if (number(row.device_delta_w)) values.push(['Device change', live ? `${(Math.abs(row.device_delta_w)/1000).toFixed(2)} kW ${row.device_delta_w < 0 ? "less" : "more"} site demand` : 'Not current']); - if (number(row.site_delta_w)) values.push(['Site change', live ? `${(Math.abs(row.site_delta_w)/1000).toFixed(2)} kW ${row.site_delta_w < 0 ? 'less' : 'more'} import` : 'Not current']); - const e = row.site_evidence; - if (e && live) { - const change = value => `${(Math.abs(value)/1000).toFixed(2)} kW ${value < 0 ? 'less' : 'more'} site demand`; - for (const [label,key] of [['Other measured flows','other_change_w'],['Site after adjustment','adjusted_site_change_w'],['Unexplained change','unexplained_change_w']]) { - if (number(e[key])) values.push([label, change(e[key])]); - } - if (number(e.samples) && e.samples > 0) values.push(['Comparison',`${e.samples} samples across ${Math.round(e.window_s)} s`]); - if (number(e.tolerance_w) && e.tolerance_w > 0) values.push(['Site tolerance',`${Math.round(e.tolerance_w)} W`]); - if (Array.isArray(e.unmeasured_flows) && e.unmeasured_flows.length) values.push(['Background, not required sources', e.unmeasured_flows.filter(v=>typeof v === 'string').join(', ')]); - if (e.samples > 0 && number(e.max_skew_ms)) values.push(['Largest time gap',`${Math.round(e.max_skew_ms)} ms`]); - } - return values; -} -export function feedbackProof(row, live = true) { - if (!live) return 'Current effect unknown'; - if (['observe_only','disabled','device_control'].includes(row.reason)) return 'No active FTW power command'; - if (row.verification_tier === 2) return 'Tier 2 · Confirmed at site meter'; - if (row.verification_tier === 1) return 'Tier 1 · Device power measured'; - if (row.verification_tier === 0) return 'Tier 0 · Driver accepted the command'; - return 'No command acknowledgement'; -} -export function feedbackSite(row, live = true) { - if (!live) return 'Waiting for fresh measurements.'; - if (['observe_only','disabled','device_control'].includes(row.reason)) return 'Measurements remain context for the site; this device has no current control verdict.'; - const text = { - confirmed: 'A separate site meter follows the device’s change across several samples, after accounting for other measured flows.', - independent_source_unknown: 'Independent confirmation needs a separate, identified meter. These sources do not establish that.', - no_site_meter: 'No site meter is available for independent confirmation.', - no_baseline: 'No steady reading before the command is available for a site comparison.', - waiting_for_meter: 'Waiting for a fresh, stable site-meter window.', - readings_not_aligned: 'There are not enough distinct, time-aligned measurements to compare the curves.', - flows_changing: 'The readings before this command varied too much for a reliable comparison. The device or the unmeasured background changed; the cause is not known.', - no_clear_change: 'The change is too small to distinguish from other site activity.', - other_flows_changed: 'Other equipment changed or its readings are missing. FTW cannot isolate this response.', - other_flows_missing: 'Another measured flow is missing or stale. FTW cannot account for its effect on the site.', - measurement_sources_unclear: 'Some site measurement sources are missing or may overlap. Independent confirmation remains uncertain.', - energy_balance_conflict: 'The readings do not form a plausible site power balance. Check measurement sources, signs and shared flows.', - site_change_differs: 'The site-meter change does not match. Another household load may have changed; the cause is not confirmed.', - device_response_unconfirmed: 'Independent confirmation waits for the device’s measured response.', - }; - if (row.site_confirmation === 'measurement_sources_unclear' && typeof row.site_source_issue === 'string') { - const [reason, driver, kind] = row.site_source_issue.split(':'); - if (driver && kind && reason === 'missing_fresh_power') return `Fresh power readings from ${driver} (${kind}) are missing. Independent confirmation waits for these measurements.`; - if (driver && kind && reason === 'offline') return `${driver} (${kind}) is offline. Independent confirmation waits for fresh measurements.`; - if (driver && kind && reason === 'duplicate') return `${driver} (${kind}) overlaps another measured flow. Check the measurement sources.`; - } - return text[row.site_confirmation] || 'Independent confirmation is not available.'; -} - -export function feedbackCurve(row, live = true) { - const trace = row.site_evidence?.trace; - if (!live || !Array.isArray(trace) || trace.length < 3 || trace.length > 64) return null; - if (!trace.every(p => p && number(p.at_ms) && number(p.device_change_w) && number(p.adjusted_site_change_w))) return null; - const start=trace[0].at_ms, span=trace[trace.length-1].at_ms-start; - if (span <= 0 || trace.some((p,i) => i > 0 && p.at_ms <= trace[i-1].at_ms)) return null; - const scale=Math.max(500,...trace.flatMap(p=>[Math.abs(p.device_change_w),Math.abs(p.adjusted_site_change_w)])); - const line=key=>trace.map(p=>`${(10+(p.at_ms-start)/span*260).toFixed(1)},${(55-p[key]/scale*40).toFixed(1)}`).join(' '); - return {device:line('device_change_w'),site:line('adjusted_site_change_w'),label:`Measured changes over ${(span/1000).toFixed(0)} seconds. Device: solid line. Site after other measured flows: dashed line.`,scale:`${(scale/1000).toFixed(1)} kW`,duration:`${Math.round(span/1000)} s`}; -} - -// On-box renderer. The app uses the same presentation functions in Svelte. -export function renderFeedback(root, value, live = true, {compact = false, expanded = false, embedded = false, nested = false} = {}) { - if (!root) return; - const rows = feedbackRows(value); - root.hidden = rows.length === 0; - if (embedded) { - // Keep the disclosure itself in place: polling must not close it or move - // focus away from the device's controls. - let disclosure = root.querySelector('details[data-control-evidence]'); - if (!disclosure) { - root.replaceChildren(); - disclosure = document.createElement('details'); - disclosure.dataset.controlEvidence = ''; - disclosure.className = 'control-evidence'; - const summary = document.createElement('summary'); - const title = document.createElement('span'); title.textContent = 'Are we in control?'; - summary.append(title, document.createElement('strong')); - disclosure.append(summary, document.createElement('div')); - root.append(disclosure); - } - const status = feedbackSummary(rows,live); - const badge = disclosure.querySelector('summary strong'); - badge.textContent = status.label; badge.dataset.tone = status.tone; - renderFeedback(disclosure.querySelector('div'),rows,live,{nested:true}); - return; - } - // Preserve an open evidence table across the two-second status refresh. - const open = new Set(Array.from(root.querySelectorAll('details[open]')).map(el => el.dataset.device)); - root.replaceChildren(); - if (!rows.length) return; - const el = (tag, text, parent, cls) => { const node=document.createElement(tag); if (text) node.textContent=text; if(cls) node.className=cls; parent.appendChild(node); return node; }; - if (!compact && !nested) el('h2','Are you in control?',root); - const statuses=nested ? null : el('ul','',root,'control-status-list'); - if (statuses) statuses.setAttribute('aria-label','Control status by device'); - for (const row of rows) { - if (!statuses) break; - const status=feedbackStatus(row,live), item=el('li','',statuses); - el('span',`${row.driver} · ${row.kind || 'device'}`,item); - const badge=el(compact ? 'button' : 'strong',status.label,item,'control-status'); badge.dataset.tone=status.tone; - if (compact) { badge.type='button'; badge.dataset.driver=row.driver; badge.dataset.kind=row.kind; badge.setAttribute('aria-label',`${row.driver}: ${status.label}. View measurements`); } - } - if (compact) return; - for (const row of rows) { - const text=feedbackText(row,live); - const card=el('article','',root,'control-result' + (live && row.severity === 'warning' ? ' needs-attention' : '') + (!live ? ' not-current' : '') + (live && row.verification_lost ? ' control-alarm' : '')); - el('div',`${row.driver} · ${row.kind || 'device'}`,card,'control-device'); - el('h3',text.title,card); - el('p',text.detail,card); - if (live && row.device_reason) el('p',`Device reports: ${row.device_reason}`,card,'device-reason'); - el('p',text.action,card,'control-action'); - const proof=el('p',feedbackProof(row,live),card,'control-proof'); proof.dataset.tier=live ? String(row.verification_tier) : ''; - el('p',feedbackSite(row,live),card,'control-action'); - const details=el('details','',card); details.dataset.device=`${row.driver}:${row.kind}`; details.open=expanded || open.has(details.dataset.device); - el('summary','Request and measurements',details); - const dl=el('dl','',details); - for (const [label,value] of feedbackValues(row,live)) {el('dt',label,dl);el('dd',value,dl);} - const curve=feedbackCurve(row,live); - if (curve) { - const svg=document.createElementNS('http://www.w3.org/2000/svg','svg'); - svg.setAttribute('viewBox','0 0 280 110'); svg.setAttribute('role','img'); svg.setAttribute('aria-label',curve.label); svg.classList.add('control-curve'); details.appendChild(svg); - for (const [points,cls] of [['10,55 270,55','zero'],[curve.device,'device'],[curve.site,'site']]) { - const line=document.createElementNS(svg.namespaceURI,'polyline'); line.setAttribute('points',points); line.setAttribute('class',cls); line.setAttribute('fill','none'); svg.appendChild(line); - } - el('p',`Device: solid · Adjusted site: dashed · ±${curve.scale} · ${curve.duration}`,details,'control-time'); - } - if (live && row.site_meter && number(row.site_after_at_ms)) el('p',`Site meter: ${row.site_meter} · ${new Date(row.site_after_at_ms).toLocaleTimeString()}`,details,'control-time'); - if (number(row.observed_at_ms)) el('p',`Last reading: ${new Date(row.observed_at_ms).toLocaleTimeString()}`,details,'control-time'); - } -} -if (typeof window !== 'undefined') window.FTWControlFeedback = {render:renderFeedback,text:feedbackText,forPlanet:feedbackForPlanet}; diff --git a/src/lib/format/control-feedback.test.ts b/src/lib/format/control-feedback.test.ts deleted file mode 100644 index e8f8dcc..0000000 --- a/src/lib/format/control-feedback.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { test } from 'vitest'; -import assert from 'node:assert/strict'; -import { feedbackStatus, feedbackValues, feedbackSite } from './control-feedback.mjs'; - -test('comparison time gaps use milliseconds and require measured evidence',()=>{ - const gap=(e: {samples:number;max_skew_ms:number|null})=>feedbackValues({driver:'hybrid',reason:'power_observed',site_evidence:e}).find(v=>v[0]==='Largest time gap'); - assert.equal(gap({samples:0,max_skew_ms:0}),undefined); - assert.equal(gap({samples:3,max_skew_ms:null}),undefined); - assert.deepEqual(gap({samples:3,max_skew_ms:0}),['Largest time gap','0 ms']); - assert.deepEqual(gap({samples:3,max_skew_ms:37}),['Largest time gap','37 ms']); - assert.deepEqual(gap({samples:3,max_skew_ms:1250}),['Largest time gap','1250 ms']); -}); - -test('a missing measurement names the source holding back site confirmation',()=>{ - const row={driver:'hybrid',reason:'power_observed',site_confirmation:'measurement_sources_unclear',site_source_issue:'missing_fresh_power:easee:ev'}; - assert.match(feedbackSite(row),/Fresh power readings from easee \(ev\) are missing/); - assert.doesNotMatch(feedbackSite(row,false),/Fresh power readings/); -}); - -test('each device has its own status and unknown background is not a veto',()=>{ - const row={driver:'battery',reason:'power_observed',verification_tier:2,site_confirmation:'confirmed',site_evidence:{unmeasured_flows:['offline-ev:ev']}}; - assert.equal(feedbackStatus(row).tone,'confirmed'); - assert.match(feedbackSite(row),/separate site meter/); - assert.deepEqual(feedbackValues(row).find(v=>v[0]==='Background, not required sources'),['Background, not required sources','offline-ev:ev']); - assert.equal(feedbackStatus({driver:'ev',reason:'waiting_response',verification_tier:0}).tone,'waiting'); - assert.equal(feedbackStatus({driver:'ev',reason:'telemetry_stale',verification_tier:0,verification_lost:true}).tone,'alarm'); - assert.equal(feedbackStatus(row,false).tone,'unknown'); -}); - -test('embedded on-box evidence preserves the open disclosure and focus across updates',async()=>{ - const {renderFeedback}=await import('./control-feedback.mjs'); - const root=document.createElement('section');document.body.append(root); - const row={driver:'battery',kind:'battery',reason:'power_observed',verification_tier:2}; - try { - renderFeedback(root,[row],true,{embedded:true}); - const disclosure=root.querySelector('details')!; - const summary=disclosure.querySelector('summary')!; - assert.equal(disclosure.open,false); - assert.match(summary.textContent!,/Are we in control\?/); - disclosure.open=true;summary.tabIndex=0;summary.focus(); - renderFeedback(root,[{...row,reason:'telemetry_stale',verification_lost:true,verification_tier:0}],true,{embedded:true}); - assert.equal(root.querySelector('details'),disclosure); - assert.equal(disclosure.open,true); - assert.equal(document.activeElement,summary); - assert.match(summary.textContent!,/Alarm/); - renderFeedback(root,[row],false,{embedded:true}); - assert.match(summary.textContent!,/No live proof/); - } finally {root.remove()} -}); - -test('full battery is a quiet pause, with the actual stop and charge level visible',()=>{ - const row={driver:'battery',kind:'battery',reason:'battery_full',severity:'info',verification_tier:1,battery_soc:1,charge_resume_soc:.99,requested_w:5000,sent_w:0,actual_w:0}; - assert.equal(feedbackStatus(row).tone,'measured'); - assert.match(feedbackStatus(row).label,/Charging paused/); - assert.deepEqual(feedbackValues(row).find(v=>v[0]==='Battery charge'),['Battery charge','100.0%']); - assert.deepEqual(feedbackValues(row).find(v=>v[0]==='Sent to driver'),['Sent to driver','0 W']); - assert.equal(feedbackStatus({...row,verification_tier:0}).tone,'waiting'); - assert.equal(feedbackStatus({...row,verification_lost:true}).tone,'alarm'); - assert.equal(feedbackValues(row,false).find(v=>v[0]==='Battery charge')?.[1],'Not current'); -}); diff --git a/src/lib/format/ev.ts b/src/lib/format/ev.ts index 0578719..d20e5bb 100644 --- a/src/lib/format/ev.ts +++ b/src/lib/format/ev.ts @@ -11,7 +11,7 @@ */ import { formatPower } from './power' -import { feedbackRows, feedbackText, type ControlFeedback } from './control-feedback.mjs' +import { controlRows, type ControlFeedback } from '$vendor/ftw/control-feedback.js' /** The box's own field names for one charger, as `/api/loadpoints` serves them. */ export interface WireLoadpoint { @@ -190,7 +190,7 @@ export function toLoadpoint(w: WireLoadpoint): Loadpoint { manualSaveError: w.manual_save_error === true, ...(w.manual ? { manual: w.manual } : {}), ...(w.charger ? { charger: w.charger } : {}), - ...(feedbackRows([w.control_feedback])[0] ? {controlFeedback: feedbackRows([w.control_feedback])[0]!} : {}), + ...(controlRows([w.control_feedback])[0] ? { controlFeedback: controlRows([w.control_feedback])[0]! } : {}), commandedW: num(w.commanded_w), commandedReason: typeof w.commanded_reason === 'string' ? w.commanded_reason : '', commandedKnown: w.commanded_known === true, @@ -288,7 +288,6 @@ export function evStatusSentence(lp: Loadpoint, canControl = true): string { if (lp.charger && lp.charger.available !== true) return lp.charger.known ? 'Charger status is out of date. FTW cannot confirm whether the car is charging.' : 'Waiting for the charger’s first status report.' if (!lp.pluggedIn) return 'Not plugged in' - if (lp.controlFeedback?.severity === "warning") { const f = feedbackText(lp.controlFeedback); return `${f.detail} ${f.action}` } if (lp.manualActive) return manualStatusSentence(lp) if (lp.powerW >= 100) { const p = formatPower(lp.powerW) diff --git a/src/lib/state/flow.ts b/src/lib/state/flow.ts index 8a1627b..d51d72e 100644 --- a/src/lib/state/flow.ts +++ b/src/lib/state/flow.ts @@ -9,7 +9,7 @@ * told is decided here, which is what makes this file worth testing. */ -import { withControlProof, type ControlProof } from '$lib/format/control-feedback.mjs' +import { withControlMarks, type ControlMark } from '$vendor/ftw/control-feedback.js' import { FID } from '$lib/format/explanation' import { FLOW_IDLE_W } from '$vendor/ftw/ftw-energy-flow.js' @@ -20,7 +20,7 @@ export interface FlowDailyPart { } export interface FlowPlanet { - controlProof?: ControlProof + controlMark?: ControlMark placeholder?: boolean id: string corner: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' @@ -437,7 +437,7 @@ export function flowReadingsFromStatus(status: SiteStatus, live = true): FlowRea return { load: (num(status.load_w) ?? 0) / 1000, - planets: withControlProof(planets, status.control_feedback, live), + planets: withControlMarks(planets, status.control_feedback, live), selfPoweredPctToday, } } diff --git a/src/lib/ui/ControlFeedback.svelte b/src/lib/ui/ControlFeedback.svelte index 966bcd3..6459c1a 100644 --- a/src/lib/ui/ControlFeedback.svelte +++ b/src/lib/ui/ControlFeedback.svelte @@ -1,88 +1,126 @@ + {#if rows.length} -
- - {#if embedded} - {@const summary = feedbackSummary(value,live)} - Are we in control?{summary.label} - {:else} -

Are you in control?

-
    - {#each rows as row (`${row.driver}:${row.kind}`)} - {@const status = feedbackStatus(row,live)} -
  • {row.driver} · {row.kind ?? 'device'}{status.label}
  • - {/each} -
- {/if} +
+

Are we in control?

{#each rows as row (`${row.driver}:${row.kind}`)} - {@const text = feedbackText(row, live)} - {@const curve = feedbackCurve(row,live)} -
-
{row.driver} · {row.kind ?? 'device'}
-

{text.title}

-

{text.detail}

- {#if live && row.device_reason}

Device reports: {row.device_reason}

{/if} -

{text.action}

-
{feedbackProof(row, live)}
-

{feedbackSite(row, live)}

-
- Request and measurements -
{#each feedbackValues(row, live) as [label, value]}
{label}
{value}
{/each}
- {#if curve} - - - - - -

Device: solid · Adjusted site: dashed · ±{curve.scale} · {curve.duration}

+ {@const status = controlStatus(row, live)} + {@const numbers = controlNumbers(row, live)} + {@const curve = controlCurve(row, live)} +
+

{status.title}

+

{status.text}

+ {#if status.proof}

{status.proof}

{/if} + {#if status.next}

{status.next}

{/if} +
+ How FTW knows +
    + {#each controlReceipt(row, live) as step (step.step)} +
  1. {step.step}{step.value}
  2. + {/each} +
+ {#if numbers.length || curve} +
+ Numbers +
+ {#each numbers as [label, text] (label)}
{label}
{text}
{/each} +
+ {#if curve} + + + + + + {/if} +
{/if} - {#if live && row.site_meter && row.site_after_at_ms}

Site meter: {row.site_meter} · {new Date(row.site_after_at_ms).toLocaleTimeString()}

{/if} - {#if row.observed_at_ms}

Last device reading: {new Date(row.observed_at_ms).toLocaleTimeString()}

{/if}
-
+ {/each} -
{/if} diff --git a/src/lib/ui/ControlFeedback.svelte.test.ts b/src/lib/ui/ControlFeedback.svelte.test.ts index e7548f5..d3a3e6f 100644 --- a/src/lib/ui/ControlFeedback.svelte.test.ts +++ b/src/lib/ui/ControlFeedback.svelte.test.ts @@ -1,88 +1,69 @@ // @vitest-environment jsdom -import {render,screen,cleanup,within} from '@testing-library/svelte' -import {afterEach,expect,it} from 'vitest' +import { render, screen, cleanup } from '@testing-library/svelte' +import { afterEach, expect, it } from 'vitest' import ControlFeedback from './ControlFeedback.svelte' afterEach(cleanup) -it('shows device limits even while charging and keeps proof levels distinct',()=>{ - render(ControlFeedback,{value:[{driver:'Garage',kind:'ev',reason:'device_limit',severity:'warning',verification_tier:0,device_limit_a:8,requested_a:16,actual_w:5500,site_confirmation:'device_response_unconfirmed'}]}) - expect(screen.getByText('Charger limit',{selector:'h3'})).toBeTruthy() - expect(screen.getByText('8.0 A')).toBeTruthy() - expect(screen.getByText('Tier 0 · Charger limit')).toBeTruthy() - expect(screen.queryByText(/Tier 2/)).toBeNull() + +const row = (extra: Record = {}) => ({ + driver: 'Sungrow', kind: 'battery', mode: 'planner_arbitrage', status: 'following', reason: 'power_observed', + severity: 'info', evidence: 'measured', readings_fresh: true, sent_w: -2250, actual_w: -2249, readback_w: -2250, + command_at_ms: 1, observed_at_ms: 2, site_confirmation: 'device_response_unconfirmed', ...extra, }) -it('withdraws independent confirmation when status is stale',()=>{ - render(ControlFeedback,{live:false,value:[{driver:'Battery',kind:'battery',reason:'power_observed',verification_tier:2,actual_w:1000}]}) - expect(screen.queryByText(/Tier 2/)).toBeNull() - expect(screen.getByText('Current effect unknown')).toBeTruthy() - expect(screen.queryByText('1.0 kW charge')).toBeNull() + +it('answers first, in words, without tier numbers', () => { + render(ControlFeedback, { value: [row({ evidence: 'confirmed', confirmed_at_ms: 3 })] }) + expect(screen.getByText('Are we in control?')).toBeTruthy() + expect(screen.getByRole('heading', { name: /Following FTW/ })).toBeTruthy() + expect(screen.getByText('Discharging 2.2 kW as planned.')).toBeTruthy() + expect(screen.getByText(/The grid meter confirmed this at/)).toBeTruthy() + expect(document.body.textContent).not.toMatch(/tier/i) + expect(document.querySelector('details')!.open).toBe(false) }) -it('renders device reasons as text',()=>{ - render(ControlFeedback,{value:[{driver:'',reason:'device_fault',device_reason:''}]}) - expect(document.querySelector('script')).toBeNull() - expect(document.querySelector('img')).toBeNull() - expect(screen.getByText('Device reports: ')).toBeTruthy() + +it('says what is wrong and what the owner can do', () => { + render(ControlFeedback, { value: [row({ status: 'not_following', reason: 'no_power_response', severity: 'warning', sent_w: 3000, actual_w: 0 })] }) + expect(screen.getByRole('heading', { name: /Not following/ })).toBeTruthy() + expect(screen.getByText('Asked for 3.0 kW charge, but it delivers no power.')).toBeTruthy() + expect(screen.getByText('The battery has not said why. Check its app or display.')).toBeTruthy() + expect(document.querySelector('.block')!.getAttribute('data-tone')).toBe('warning') }) -it('shows the measured comparison curve and removes it when status expires',async()=>{ - const value=[{driver:'Battery',reason:'power_observed',verification_tier:2,site_evidence:{samples:3,window_s:10,unexplained_change_w:10,trace:[0,1,2].map(i=>({at_ms:1000+i*5000,device_change_w:i*500,adjusted_site_change_w:i*500+10}))}}] - const view=render(ControlFeedback,{value}) - expect(screen.getByRole('img',{name:/Measured changes over 10 seconds/})).toBeTruthy() - expect(screen.getByText('Unexplained change')).toBeTruthy() - await view.rerender({value,live:false}) - expect(screen.queryByRole('img')).toBeNull() - expect(screen.queryByText(/Tier 2/)).toBeNull() + +it('raises lost control as an alarm and shows nothing old as current', async () => { + const lost = row({ status: 'no_contact', reason: 'readings_lost', severity: 'alarm', evidence: 'accepted', readings_fresh: false, actual_w: null }) + const view = render(ControlFeedback, { value: [lost] }) + expect(screen.getByRole('heading', { name: /Lost control/ })).toBeTruthy() + expect(document.querySelector('.block')!.getAttribute('data-tone')).toBe('alarm') + expect(screen.getByText('No fresh readings')).toBeTruthy() + await view.rerender({ value: [row()], live: false }) + expect(screen.getByRole('heading', { name: /Status not current/ })).toBeTruthy() + expect(screen.queryByText('Discharging 2.2 kW as planned.')).toBeNull() + expect(screen.getAllByText('Not current').length).toBe(2) }) -it('shows independent overview tiers and a loss alarm without lowering another device',async()=>{ - const value=[ - {driver:'Sungrow',kind:'battery',reason:'power_observed',verification_tier:2,site_confirmation:'confirmed',site_evidence:{unmeasured_flows:['Easee:ev']}}, - {driver:'Easee',kind:'ev',reason:'telemetry_stale',severity:'warning',verification_tier:0,verification_lost:true}, - ] - const view=render(ControlFeedback,{value}) - const list=within(screen.getByRole('list',{name:'Control status by device'})) - expect(list.getByText('Tier 2 · Site confirmed')).toBeTruthy() - expect(list.getByText('Alarm · Tier 0')).toBeTruthy() - expect(screen.getByRole('heading',{name:'Alarm · Measurements lost'})).toBeTruthy() - expect(screen.getByText('Background, not required sources')).toBeTruthy() - await view.rerender({value:[value[0],{...value[1],reason:'power_observed',severity:'info',verification_tier:1,verification_lost:false}]}) - expect(screen.queryByText('Alarm · Tier 0')).toBeNull() - expect(list.getByText('Tier 1 · Device measured')).toBeTruthy() - await view.rerender({value,live:false}) - expect(screen.queryByText('Alarm · Tier 0')).toBeNull() - expect(screen.queryByText('Tier 2 · Site confirmed')).toBeNull() +it('renders device reasons as text', () => { + render(ControlFeedback, { value: [row({ driver: '', kind: 'device', status: 'not_following', reason: 'device_fault', severity: 'alarm', device_reason: '' })] }) + expect(document.querySelector('script')).toBeNull() + expect(document.querySelector('img')).toBeNull() + expect(screen.getByText('The device reports a fault: .')).toBeTruthy() }) -it('keeps embedded evidence folded below controls and preserves an open section on updates',async()=>{ - const value=[{driver:'Sungrow',kind:'battery',reason:'power_observed',verification_tier:2}]; - const view=render(ControlFeedback,{value,embedded:true}); - const disclosure=document.querySelector('details')!; - expect(disclosure.open).toBe(false); - expect(disclosure.querySelector('summary')!.textContent).toContain('Are we in control?'); - expect(disclosure.contains(screen.getByText('Device follows the command'))).toBe(true); - disclosure.open=true; - await view.rerender({value:[{...value[0],verification_tier:0,reason:'telemetry_stale',verification_lost:true}],embedded:true}); - expect(document.querySelector('details')).toBe(disclosure); - expect(disclosure.open).toBe(true); - expect(disclosure.querySelector('summary')!.textContent).toContain('Alarm'); -}); +it('keeps an open receipt open while the status changes', async () => { + const view = render(ControlFeedback, { value: [row()] }) + const receipt = document.querySelector('details')! + receipt.open = true + await view.rerender({ value: [row({ status: 'not_following', reason: 'setpoint_changed', severity: 'warning', readback_w: -1500 })] }) + expect(document.querySelector('details')).toBe(receipt) + expect(receipt.open).toBe(true) + expect(screen.getByText('The battery reports a target of 1.5 kW discharge, not the 2.3 kW discharge FTW sent.')).toBeTruthy() +}) -it('shows a site-confirmed shortfall, then recovery, without losing the evidence tier',async()=>{ - const row={driver:'Sungrow',kind:'battery',reason:'power_below_target',severity:'warning',verification_tier:2,requested_w:-5000,sent_w:-5000,actual_w:-4400,site_confirmation:'confirmed'}; - const view=render(ControlFeedback,{value:[row],embedded:true}); - const disclosure=document.querySelector('details')!; - expect(disclosure.open).toBe(false); - expect(disclosure.querySelector('summary')!.textContent).toContain('Tier 2 · Site confirmed · Below target'); - expect(disclosure.querySelector('summary strong')!.getAttribute('data-tone')).toBe('warning'); - disclosure.open=true; - expect(screen.getByText('Power is below the target')).toBeTruthy(); - expect(screen.getByText('600 W')).toBeTruthy(); - expect(screen.getByText(/cause is not confirmed/)).toBeTruthy(); - expect(screen.queryByText(/Measurements lost/)).toBeNull(); - await view.rerender({value:[{...row,actual_w:-5000,reason:'power_observed',severity:'info'}],embedded:true}); - expect(disclosure.open).toBe(true); - expect(disclosure.querySelector('summary')!.textContent).toContain('Tier 2 · Site confirmed'); - expect(screen.queryByText('600 W')).toBeNull(); - expect(screen.getByText('Device follows the command')).toBeTruthy(); - await view.rerender({value:[row],embedded:true,live:false}); - expect(disclosure.querySelector('summary')!.textContent).not.toContain('Tier 2'); - expect(screen.queryByText('600 W')).toBeNull(); -}); +it('shows numbers and the response curve for experts, and drops them when stale', async () => { + const evidence = { samples: 3, window_s: 10, unexplained_change_w: 10, device_change_w: -1000, trace: [0, 1, 2].map(i => ({ at_ms: 1000 + i * 5000, device_change_w: -i * 500, adjusted_site_change_w: -i * 500 - 10 })) } + const view = render(ControlFeedback, { value: [row({ evidence: 'confirmed', site_confirmation: 'confirmed', site_evidence: evidence })] }) + expect(screen.getByRole('img', { name: /Measured changes over 10 seconds/ })).toBeTruthy() + expect(screen.getByText('10 W more drawn')).toBeTruthy() + expect(screen.getByText('1.0 kW toward discharge')).toBeTruthy() + await view.rerender({ value: [row({ evidence: 'confirmed', site_evidence: evidence })], live: false }) + expect(screen.queryByRole('img')).toBeNull() + expect(screen.queryByText('10 W more drawn')).toBeNull() +}) diff --git a/src/vendor/ftw/control-feedback.d.ts b/src/vendor/ftw/control-feedback.d.ts new file mode 100644 index 0000000..0d9180e --- /dev/null +++ b/src/vendor/ftw/control-feedback.d.ts @@ -0,0 +1,68 @@ +/* Types for the vendored control-status words — the surface the app uses. + * The implementation is the box's own file, untouched; see the header there. + */ + +export type ControlStatusName = 'following' | 'waiting' | 'limited' | 'not_following' | 'no_contact' | 'not_controlled' +export type ControlSeverity = 'info' | 'warning' | 'alarm' +export type ControlEvidence = 'none' | 'accepted' | 'measured' | 'confirmed' + +export interface ControlSiteEvidence { + grid_before_w?: number + grid_after_w?: number + device_change_w?: number | null + grid_change_w?: number | null + other_change_w?: number | null + adjusted_site_change_w?: number | null + unexplained_change_w?: number | null + tolerance_w?: number + samples?: number + window_s?: number + max_skew_ms?: number | null + unmeasured_flows?: string[] + trace?: { at_ms: number; device_change_w: number; adjusted_site_change_w: number }[] +} + +/** One device function's answer, as Core sends it. */ +export interface ControlFeedback { + driver: string + kind: string + mode?: string + status: ControlStatusName + reason: string + severity: ControlSeverity + evidence: ControlEvidence + readings_fresh?: boolean + confirmed_at_ms?: number + site_confirmation?: string + site_meter?: string + site_evidence?: ControlSiteEvidence | null + tolerance_w?: number | null + requested_w?: number | null + sent_w?: number | null + readback_w?: number | null + actual_w?: number | null + battery_soc?: number | null + charge_resume_soc?: number | null + requested_a?: number | null + offered_a?: number | null + device_limit_a?: number | null + device_reason?: string + since_ms?: number + command_at_ms?: number + observed_at_ms?: number +} + +export type ControlTone = 'ok' | 'neutral' | 'warning' | 'alarm' | 'stale' +export interface ControlMark { tone: 'warning' | 'alarm'; label: string } +export interface ControlScope { role?: string; name?: string | null; id?: string } + +export function controlRows(value: unknown): ControlFeedback[] +export function controlPower(watts: unknown): string +export function controlFlow(row: ControlFeedback, watts: unknown): string +export function controlStatus(row: ControlFeedback, live?: boolean): { title: string; text: string; proof: string; next: string; tone: ControlTone } +export function controlReceipt(row: ControlFeedback, live?: boolean): { step: string; value: string; state: 'done' | 'wait' | 'fail' | 'none' }[] +export function controlNumbers(row: ControlFeedback, live?: boolean): [string, string][] +export function controlCurve(row: ControlFeedback, live?: boolean): { device: string; site: string; label: string; scale: string; duration: string } | null +export function controlForPlanet(value: unknown, planet?: ControlScope): ControlFeedback[] +export function controlSummary(value: unknown, live?: boolean): { title: string; tone: ControlTone } +export function withControlMarks(planets: T[], value: unknown, live?: boolean): (T & { controlMark?: ControlMark; placeholder?: boolean; clickable?: boolean })[] diff --git a/src/vendor/ftw/control-feedback.js b/src/vendor/ftw/control-feedback.js new file mode 100644 index 0000000..42ccacd --- /dev/null +++ b/src/vendor/ftw/control-feedback.js @@ -0,0 +1,307 @@ +// Vendored from srcfl/ftw web/control-feedback.js at 6716098ab794e5a78fc976a27947b37fa063e635. +// Do not edit here; change the box source, then copy it here. +// +// Words for Core's answer to "Are we in control?". Core owns every verdict: +// status, severity, evidence and reason. This module only picks words, marks +// and receipt rows for them. The app copies this file byte for byte, so it +// stays free of DOM and window access. + +const num = value => typeof value === 'number' && Number.isFinite(value); + +export function controlRows(value) { + if (!Array.isArray(value)) return []; + return value.filter(row => row && typeof row === 'object' && typeof row.driver === 'string' && + typeof row.status === 'string' && typeof row.reason === 'string'); +} + +export function controlPower(watts) { + if (!num(watts)) return 'unknown'; + const size = Math.abs(watts); + if (size < 1) return '0 W'; + return size >= 1000 ? `${(size / 1000).toFixed(1)} kW` : `${Math.round(size)} W`; +} + +const NOUNS = {battery: 'battery', ev: 'charger', v2x_charger: 'charger', pv: 'solar inverter', device: 'device'}; +const noun = row => NOUNS[row.kind] || 'device'; +const percent = soc => num(soc) ? `${Math.round(soc * 100)}%` : 'its current level'; +const amps = value => num(value) ? `${Math.round(value)} A` : 'the current'; +const clock = ms => num(ms) && ms > 0 ? new Date(ms).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'}) : ''; +const capital = text => text.charAt(0).toUpperCase() + text.slice(1); + +// Site convention: positive power is drawn into the site, so a battery or car +// charges and solar is negative. +function doing(row, watts) { + if (row.kind === 'pv') return 'producing'; + if (!num(watts) || Math.abs(watts) < 100) return 'idle'; + return watts > 0 ? 'charging' : 'discharging'; +} + +export function controlFlow(row, watts) { + if (!num(watts)) return 'unknown'; + if (row.kind === 'pv') return `${controlPower(watts)} solar`; + if (Math.abs(watts) < 100) return row.kind === 'ev' ? 'no charging' : 'idle'; + return `${controlPower(watts)} ${watts > 0 ? 'charge' : 'discharge'}`; +} + +function target(row) { + if (!num(row.sent_w)) return 'the new target'; + return row.kind === 'pv' ? `a ${controlPower(row.sent_w)} cap` : controlFlow(row, row.sent_w); +} + +function source(row) { + if (row.mode === 'manual') return 'as you asked'; + if (row.mode === 'solar') return 'from spare solar'; + if (row.mode === 'plan' || String(row.mode || '').startsWith('planner')) return 'as planned'; + return 'as FTW asked'; +} + +const STATUS_TITLES = { + following: 'Following FTW', waiting: 'Waiting', limited: 'Limited', not_following: 'Not following', + no_contact: 'No contact', not_controlled: 'Not controlled by FTW', +}; + +// A reason title replaces the status word when the word alone says too little. +const REASON_TITLES = { + waiting_response: 'Adjusting', no_command: 'No command yet', not_connected: 'No car connected', + no_plan_budget: 'Waiting for the plan', pv_surplus_pause: 'Waiting for spare solar', wake_kick: 'Starting the charger', + vehicle_complete: 'Car is full', vehicle_limit_completion: 'Car reached its limit', vehicle_not_requesting: 'Car not taking power', + battery_full: 'Battery full', battery_nearly_full: 'Battery nearly full', battery_nearly_empty: 'Battery nearly empty', + core_limit: 'Limited by FTW', fuse_limit: 'Limited by the main fuse', fuse_cooldown: 'Waiting after a fuse limit', + charger_limit: 'Limited by FTW’s charger setting', device_limit: 'Charger limit', offered_current_lower: 'Charger offers less current', + device_fault: 'Device fault', telemetry_stale: 'No fresh readings', readings_lost: 'Lost control', + command_failed: 'Commands failing', command_unconfirmed: 'Command not confirmed', default_failed: 'Cannot hand back control', + response_unknown: 'No power reading', observe_only: 'Monitoring only', disabled: 'Disabled', device_control: 'Own control', + site_meter_stale: 'Paused for safety', site_phase_currents_stale: 'Paused for safety', +}; + +function sentence(row) { + const name = noun(row), actual = row.actual_w; + switch (row.reason) { + case 'power_observed': case 'plan': case 'manual_hold': case 'pv_surplus': case 'idle': { + const now = doing(row, actual); + return now === 'idle' ? `Idle ${source(row)}.` : `${capital(now)} ${controlPower(actual)} ${source(row)}.`; + } + case 'solar_below_ceiling': return `Producing ${controlPower(actual)}, below ${target(row)}.`; + case 'vehicle_complete': return 'Not charging: the car reports that it is full.'; + case 'vehicle_limit_completion': return 'Not charging: the car reached its own charge limit.'; + case 'vehicle_not_requesting': return 'The car is not asking for power right now.'; + case 'waiting_response': return `Moving to ${target(row)}. The ${name} usually responds within ${row.kind === 'ev' ? 'two minutes' : '15 seconds'}.`; + case 'no_command': return 'FTW has not sent a command since it started.'; + case 'not_connected': return 'Plug in a car to charge.'; + case 'no_plan_budget': return 'The plan has no charging in this period.'; + case 'pv_surplus_pause': return 'Charging starts when there is enough spare solar.'; + case 'wake_kick': return 'FTW is waking the charger.'; + case 'battery_full': return `FTW paused charging at ${percent(row.battery_soc)}. Charging resumes at ${percent(row.charge_resume_soc)} or lower; discharging works as normal.`; + case 'battery_nearly_full': return `Taking ${controlPower(actual)} of ${controlPower(row.sent_w)} at ${percent(row.battery_soc)}. A battery charges slower when it is nearly full.`; + case 'battery_nearly_empty': return `Giving ${controlPower(actual)} of ${controlPower(row.sent_w)} at ${percent(row.battery_soc)}. A battery limits discharge when it is nearly empty.`; + case 'core_limit': return `FTW’s safety limits set the target to ${target(row)}.`; + case 'fuse_limit': return `FTW lowered the rate to ${target(row)} to protect the main fuse. It rises when the house uses less.`; + case 'fuse_cooldown': return 'FTW waits a moment after a fuse limit before it raises the rate.'; + case 'charger_limit': return 'The charger limit in FTW’s settings caps the rate.'; + case 'device_limit': return `The charger’s own limit is ${amps(row.device_limit_a)}, below the ${amps(row.requested_a)} FTW asked for.`; + case 'offered_current_lower': return `The charger offers ${amps(row.offered_a)}, below the ${amps(row.requested_a)} FTW asked for.`; + case 'power_below_target': case 'power_above_target': return `Asked for ${target(row)}, delivering ${controlPower(actual)}.`; + case 'power_wrong_direction': return `Asked for ${target(row)}, but it is ${doing(row, actual)} ${controlPower(actual)}.`; + case 'no_power_response': return `Asked for ${target(row)}, but it delivers no power.`; + case 'power_while_idle': return `Asked to stay idle, but it is ${doing(row, actual)} ${controlPower(actual)}.`; + case 'setpoint_changed': return `The ${name} reports a target of ${controlFlow(row, row.readback_w)}, not the ${target(row)} FTW sent.`; + case 'device_fault': return row.device_reason ? `The ${name} reports a fault: ${row.device_reason}.` : `The ${name} reports a fault that stops control.`; + case 'telemetry_stale': return `FTW has no fresh readings from the ${name}.`; + case 'readings_lost': return clock(row.observed_at_ms) + ? `No usable readings from the ${name} since ${clock(row.observed_at_ms)}. FTW cannot see what its commands do.` + : `No usable readings from the ${name}. FTW cannot see what its commands do.`; + case 'command_failed': return `The last command to the ${name} failed. FTW keeps trying.`; + case 'command_unconfirmed': return `The last command timed out. It may still have reached the ${name}.`; + case 'default_failed': return `FTW could not return the ${name} to its own mode, so it sends no other commands until it can.`; + case 'response_unknown': return `FTW has no power reading from the ${name} to compare with its command.`; + case 'observe_only': return `FTW reads this ${name} but does not control it.`; + case 'disabled': return 'This device is disabled in FTW’s settings.'; + case 'device_control': return `The ${name} runs its own mode. FTW is not sending commands.`; + case 'site_meter_stale': return 'FTW stopped control because the grid meter readings are out of date.'; + case 'site_phase_currents_stale': return 'FTW stopped control because it lacks fresh phase currents for the main fuse check.'; + default: return `The box reports “${row.reason}”, which this version cannot describe.`; + } +} + +function nextStep(row) { + const name = noun(row); + switch (row.reason) { + case 'power_below_target': case 'power_above_target': case 'power_wrong_direction': + case 'no_power_response': case 'power_while_idle': + return `The ${name} has not said why. Check its app or display.`; + case 'setpoint_changed': return 'Another app or controller may be changing it.'; + case 'device_fault': return `Check the ${name}.`; + case 'telemetry_stale': case 'readings_lost': case 'command_failed': case 'default_failed': + return `Check the ${name}’s connection.`; + case 'site_meter_stale': case 'site_phase_currents_stale': return 'Check the grid meter’s connection.'; + case 'device_limit': return 'Raise the limit in the charger’s app if you want faster charging.'; + case 'offered_current_lower': return 'Check the charger’s load balancing and limits.'; + case 'observe_only': return 'Turn on control in the device settings if you want FTW to steer it.'; + default: return ''; + } +} + +function proofLine(row) { + if (row.evidence === 'confirmed' && clock(row.confirmed_at_ms)) return `The grid meter confirmed this at ${clock(row.confirmed_at_ms)}.`; + if (row.evidence === 'measured' && row.status === 'following') return `Based on the ${noun(row)}’s own readings.`; + return ''; +} + +// One answer per device function: a title, one sentence and, when the owner +// can act, the next step. Tone follows Core's severity and status. +export function controlStatus(row, live = true) { + if (!live) return {title: 'Status not current', text: 'FTW has not reported for a while.', proof: '', next: 'Waiting for a fresh report.', tone: 'stale'}; + const title = REASON_TITLES[row.reason] || STATUS_TITLES[row.status] || 'Checking'; + const tone = row.severity === 'alarm' ? 'alarm' : row.severity === 'warning' ? 'warning' : row.status === 'following' ? 'ok' : 'neutral'; + return {title, text: sentence(row), proof: proofLine(row), next: nextStep(row), tone}; +} + +const CONFIRMATION = { + no_site_meter: ['No grid meter to compare with', 'none'], + independent_source_unknown: ['The grid meter is not a separate sensor', 'none'], + not_controlling: ['Not needed while FTW does not control it', 'none'], + device_response_unconfirmed: ['Waits for the device’s measured response', 'wait'], + waiting_for_meter: ['Waiting for fresh grid meter readings', 'wait'], + no_baseline: ['Waiting for a clear change to compare', 'wait'], + readings_not_aligned: ['Waiting for readings close enough in time', 'wait'], + flows_changing: ['Other loads changed at the same time', 'wait'], + site_change_differs: ['The grid change did not match; another load may have changed', 'wait'], + no_clear_change: ['The change was too small to compare', 'wait'], +}; + +function modeName(mode) { + if (mode === 'manual') return 'manual'; + if (mode === 'solar') return 'solar only'; + if (mode === 'plan' || String(mode || '').startsWith('planner')) return 'plan'; + return String(mode || 'FTW').replace(/_/g, ' '); +} + +// The receipt shows how FTW knows: each step with its value and state +// (done, wait, fail or none). +export function controlReceipt(row, live = true) { + const steps = []; + const at = ms => clock(ms) ? ` · ${clock(ms)}` : ''; + const sent = row.sent_w, asked = row.requested_w; + if (num(asked) && num(sent) && Math.abs(asked - sent) > Math.max(50, Math.abs(sent) * 0.02)) { + steps.push({step: 'Asked', value: `${controlFlow(row, asked)} · ${modeName(row.mode)}`, state: 'done'}); + } + steps.push({step: 'Sent', value: num(sent) ? `${controlFlow(row, sent)}${at(row.command_at_ms)}` : 'No command', state: num(sent) ? 'done' : 'none'}); + const failed = ['command_failed', 'command_unconfirmed', 'default_failed'].includes(row.reason); + const accepted = ['accepted', 'measured', 'confirmed'].includes(row.evidence); + steps.push({step: 'Accepted', + value: failed ? REASON_TITLES[row.reason] : accepted + ? (live && num(row.readback_w) ? `The ${noun(row)} reports target ${controlFlow(row, row.readback_w)}` : 'The driver took the command') + : num(sent) ? 'Waiting for the driver' : '—', + state: failed ? 'fail' : accepted ? 'done' : num(sent) ? 'wait' : 'none'}); + const measured = ['measured', 'confirmed'].includes(row.evidence); + steps.push({step: 'Measured', + value: !live ? 'Not current' : measured ? `${controlFlow(row, row.actual_w)}${at(row.observed_at_ms)}` + : row.readings_fresh === false ? 'No fresh readings' : num(sent) ? 'Waiting for fresh readings' : '—', + state: !live ? 'none' : measured ? 'done' : row.readings_fresh === false && num(sent) ? 'fail' : num(sent) ? 'wait' : 'none'}); + const change = row.site_evidence && row.site_evidence.device_change_w; + const [why, state] = CONFIRMATION[row.site_confirmation] || ['Waiting for a clear change to compare', 'wait']; + steps.push({step: 'Confirmed', + value: !live ? 'Not current' : row.evidence === 'confirmed' + ? `The grid meter matched ${num(change) ? `a ${controlPower(change)} change` : 'the change'}${at(row.confirmed_at_ms)}` + : why, + state: !live ? 'none' : row.evidence === 'confirmed' ? 'done' : state}); + return steps; +} + +// Changes in words, never a bare sign: positive power is drawn into the site. +function deviceChange(row, watts) { + if (row.kind === 'pv') return `${controlPower(watts)} ${watts < 0 ? 'more' : 'less'} solar`; + if (row.kind === 'ev') return `${controlPower(watts)} ${watts < 0 ? 'less' : 'more'} charging`; + return `${controlPower(watts)} toward ${watts < 0 ? 'discharge' : 'charge'}`; +} +const gridChange = watts => `${controlPower(watts)} toward ${watts < 0 ? 'export' : 'import'}`; +const drawnChange = watts => `${controlPower(watts)} ${watts < 0 ? 'less' : 'more'} drawn`; + +// Numbers for experts and support. +export function controlNumbers(row, live = true) { + if (!live) return []; + const values = []; + if (num(row.battery_soc)) values.push(['Battery charge', percent(row.battery_soc)]); + if (num(row.tolerance_w)) values.push(['Response tolerance', `±${controlPower(row.tolerance_w)}`]); + if (num(row.requested_a)) values.push(['Requested current', amps(row.requested_a)]); + if (num(row.offered_a)) values.push(['Charger offer', amps(row.offered_a)]); + if (num(row.device_limit_a)) values.push(['Charger limit', amps(row.device_limit_a)]); + const e = row.site_evidence; + if (e && typeof e === 'object') { + for (const [label, key] of [['Grid before', 'grid_before_w'], ['Grid after', 'grid_after_w']]) { + if (num(e[key])) values.push([label, `${controlPower(e[key])} ${e[key] < 0 ? 'export' : 'import'}`]); + } + for (const [label, key, words] of [['Device change', 'device_change_w', w => deviceChange(row, w)], ['Grid change', 'grid_change_w', gridChange], + ['Other measured change', 'other_change_w', drawnChange], ['Unexplained change', 'unexplained_change_w', drawnChange]]) { + if (num(e[key])) values.push([label, words(e[key])]); + } + if (num(e.samples) && e.samples > 0) values.push(['Compared', `${e.samples} readings over ${Math.round(e.window_s)} s`]); + if (num(e.tolerance_w) && e.tolerance_w > 0) values.push(['Match tolerance', `±${controlPower(e.tolerance_w)}`]); + if (Array.isArray(e.unmeasured_flows) && e.unmeasured_flows.length) { + const kinds = {battery: 'battery', ev: 'car charger', v2x_charger: 'car charger', pv: 'solar', meter: 'meter'}; + values.push(['Left in the background', e.unmeasured_flows.filter(v => typeof v === 'string') + .map(v => { const [driver, kind] = v.split(':'); return kinds[kind] ? `${driver} (${kinds[kind]})` : driver; }).join(', ')]); + } + if (num(e.max_skew_ms) && e.samples > 0) values.push(['Largest time gap', `${Math.round(e.max_skew_ms)} ms`]); + } + return values; +} + +export function controlCurve(row, live = true) { + const trace = row.site_evidence?.trace; + if (!live || !Array.isArray(trace) || trace.length < 3 || trace.length > 64) return null; + if (!trace.every(p => p && num(p.at_ms) && num(p.device_change_w) && num(p.adjusted_site_change_w))) return null; + const start = trace[0].at_ms, span = trace[trace.length - 1].at_ms - start; + if (span <= 0 || trace.some((p, i) => i > 0 && p.at_ms <= trace[i - 1].at_ms)) return null; + const scale = Math.max(500, ...trace.flatMap(p => [Math.abs(p.device_change_w), Math.abs(p.adjusted_site_change_w)])); + const line = key => trace.map(p => `${(10 + (p.at_ms - start) / span * 260).toFixed(1)},${(55 - p[key] / scale * 40).toFixed(1)}`).join(' '); + return {device: line('device_change_w'), site: line('adjusted_site_change_w'), + label: `Measured changes over ${Math.round(span / 1000)} seconds. Device: solid line. Grid after other measured flows: dashed line.`, + scale: controlPower(scale), duration: `${Math.round(span / 1000)} s`}; +} + +// Rows for one bubble or device sheet. A combined bubble includes every device. +export function controlForPlanet(value, planet = {}) { + const name = planet.id?.startsWith('agg-') ? '' : planet.name; + return controlRows(value).filter(row => (!name || row.driver === name) && + (!planet.role || row.kind === planet.role || row.kind === 'device' || planet.role === 'ev' && row.kind === 'v2x_charger')); +} + +export function controlSummary(value, live = true) { + const rows = controlRows(value); + if (!live) return {title: 'Status not current', tone: 'stale'}; + const count = (n, one, many) => `${n} ${n === 1 ? one : many}`; + const alarms = rows.filter(row => row.severity === 'alarm').length; + const warnings = rows.filter(row => row.severity === 'warning').length; + if (alarms) return {title: `${count(alarms, 'device needs', 'devices need')} attention now`, tone: 'alarm'}; + if (warnings) return {title: `${count(warnings, 'device needs', 'devices need')} a look`, tone: 'warning'}; + const controlled = rows.filter(row => row.status !== 'not_controlled'); + if (!controlled.length) return {title: 'FTW controls no device right now', tone: 'neutral'}; + return {title: 'Yes, FTW is in control', tone: 'ok'}; +} + +// Overview marks stay off while all is well. Only Core's warning and alarm +// severities draw one; a device that stopped reporting keeps its bubble. +export function withControlMarks(planets, value, live = true) { + const result = planets.map(p => ({...p})); + const corners = {battery: 'top-right', ev: 'bottom-right', pv: 'top-left', v2x_charger: 'bottom-right'}; + const titles = {battery: 'BATTERY', pv: 'SOLAR', ev: 'EV CHARGER'}; + for (const row of controlRows(value)) { + const role = row.kind === 'v2x_charger' ? 'ev' : row.kind; + let planet = result.find(p => p.name === row.driver && (row.kind === 'device' || p.role === role)); + if (!planet) { + if (!corners[row.kind]) continue; + planet = {id: `control-${row.kind}-${row.driver}`, name: row.driver, role, corner: corners[row.kind], + title: titles[role], kw: 0, toHub: false, color: 'var(--fg-muted)', sub: 'no data', placeholder: true}; + result.push(planet); + } + if (live && row.readings_fresh === false && row.status !== 'not_controlled') { + Object.assign(planet, {placeholder: true, kw: 0, sub: 'no data', color: 'var(--fg-muted)'}); + } + if (row.status !== 'not_controlled') planet.clickable = true; + if (!live || !['warning', 'alarm'].includes(row.severity)) continue; + const mark = {tone: row.severity, label: controlStatus(row).title}; + if (!planet.controlMark || mark.tone === 'alarm' && planet.controlMark.tone !== 'alarm') planet.controlMark = mark; + } + return result; +} diff --git a/src/vendor/ftw/digests.json b/src/vendor/ftw/digests.json index 24defb3..363b691 100644 --- a/src/vendor/ftw/digests.json +++ b/src/vendor/ftw/digests.json @@ -1,8 +1,9 @@ { "api-fetch.js": "ae8d2c3a8508d0b20c4afb599cf41f1cf5a5bba55deb6f496a530c233ea69934", + "control-feedback.js": "389b5f227b7d125fa4138cc9c803660bfcff58e5ba50a18a4384c58704317d4a", "ftw-bar-chart.js": "08b61533ed52b0031a39e723278c812b2b27356c1d574b65dfd5603992065d82", "ftw-element.js": "7241b586582187cef2d76d3ee11bbc596c4f428cea3a5c8c84227e113aa3c1a0", - "ftw-energy-flow.js": "16ac207d4be34cd5525454e6c2ac126c2d64501a5c3cef755a723720b0fafdda", + "ftw-energy-flow.js": "833251935de572cf7b23da8ed92b3e7409ccb8c25d6f2e7d98ac5f443d19b764", "ftw-price-chart.js": "29d32b1f309f58d8431a0a8c2e93bfb67d3b6ea0fc86965a57ddb54ca4e16537", "price-math.js": "bc6b104259f76d799c0a9d7f1d8a780d897cb7dc0d7a3e16a6572fb9c626d508", "price-strip.js": "413b2946c311e910c9a052a8407a3a403ed6caca0e210307c6f3e951896c2b9b", diff --git a/src/vendor/ftw/ftw-energy-flow.d.ts b/src/vendor/ftw/ftw-energy-flow.d.ts index 11d046c..301c47d 100644 --- a/src/vendor/ftw/ftw-energy-flow.d.ts +++ b/src/vendor/ftw/ftw-energy-flow.d.ts @@ -13,7 +13,7 @@ export interface FtwFlowDailyPart { } export interface FtwFlowPlanet { - controlProof?: {label:string; detail:string; tone:string} + controlMark?: { tone: 'warning' | 'alarm'; label: string } id: string corner: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' title: string diff --git a/src/vendor/ftw/ftw-energy-flow.js b/src/vendor/ftw/ftw-energy-flow.js index 9730320..30c1c65 100644 --- a/src/vendor/ftw/ftw-energy-flow.js +++ b/src/vendor/ftw/ftw-energy-flow.js @@ -1,4 +1,4 @@ -// Vendored from srcfl/ftw web/components/ftw-energy-flow.js at 79518637c89d5a9fa459001c3c43109f14153175. +// Vendored from srcfl/ftw web/components/ftw-energy-flow.js at 6716098ab794e5a78fc976a27947b37fa063e635. // Do not edit here; change the box source, then copy it here. // // — hero diagram for /next. @@ -667,8 +667,8 @@ class FtwEnergyFlow extends FtwElement { socStale: !p.placeholder && !!p.socStale, socSource: p.placeholder ? null : p.socSource, radius: p._r, - clickable: p.clickable === false ? false : (!!p.controlProof || !p.placeholder && !!p.role), - controlProof: p.controlProof, + clickable: p.clickable === false ? false : (!!p.controlMark || !p.placeholder && !!p.role), + controlMark: p.controlMark, role: p.role || "", name: p.name || "", id: p.id, @@ -1468,7 +1468,7 @@ function renderCircleNode({ pos, title, nameLabel, value, sub, color, soc, clickable = false, role = "", name = "", id = "", aggregated = false, dailyKwh = null, dailyKwhParts = null, - compact = false, controlProof = null }) { + compact = false, controlMark = null }) { const r = radius; const { x, y } = pos; // Daily totals line — empty string when no payload was passed (back- @@ -1482,7 +1482,7 @@ function renderCircleNode({ pos, title, nameLabel, value, sub, color, soc, // derived from the visible title/name so the announcement names // what activating this node will open. const nodeLabel = [title, nameLabel].filter(Boolean).join(" "); - const ariaLabel = controlProof ? `${nodeLabel}: ${controlProof.detail}. Open device` : nodeLabel ? `Open ${nodeLabel}` : "Open node"; + const ariaLabel = controlMark ? `${nodeLabel}: ${controlMark.label}. Open device` : nodeLabel ? `Open ${nodeLabel}` : "Open node"; const groupAttrs = clickable ? ` class="ef-node ef-clickable" data-role="${escapeXml(role)}" data-name="${escapeXml(name)}" data-id="${escapeXml(id)}" tabindex="0" role="button" aria-label="${escapeXml(ariaLabel)}"` : ` class="ef-node"`; @@ -1609,7 +1609,7 @@ function renderCircleNode({ pos, title, nameLabel, value, sub, color, soc, fill="none" stroke="${color}" stroke-width="1" stroke-dasharray="2 4"/> ${iconSvg} - ${controlProof ? renderProofBadge(controlProof, x, y, r) : ''} + ${controlMark ? renderControlMark(controlMark, x, y, r) : ''} ${titleSvg} ${value} @@ -1626,33 +1626,26 @@ function renderCircleNode({ pos, title, nameLabel, value, sub, color, soc, `; } -// Keep proof quiet in the overview. Shape distinguishes the evidence levels; -// the whole bubble opens the explanation and remains the accessible target. -function renderProofBadge(proof, x, y, r) { - if (proof.inactive) return ''; - const colors = {confirmed:'var(--green-e)', measured:'var(--green-e)', waiting:'var(--fg-muted)', warning:'var(--amber)', alarm:'var(--red-e)', unknown:'var(--fg-muted)'}; - const color = colors[proof.tone] || colors.unknown; - const size = Math.max(3.5, Math.min(5, r * .065)); - const mark = proof.tone === 'alarm' || proof.tone === 'warning' - ? '' - : proof.tone === 'measured' || proof.tone === 'confirmed' - ? `${proof.tone === 'confirmed' ? '' : ''}` - : ``; - return ` - ${escapeXml(proof.detail)} - - ${mark} +// The overview stays quiet while FTW is in control. Core's warning draws an +// amber triangle and its alarm a red disc; the whole bubble stays the target. +function renderControlMark(mark, x, y, r) { + const alarm = mark.tone === 'alarm'; + const size = Math.max(7, Math.min(11, r * 0.15)); + const glyph = alarm + ? '' + : ''; + return ` + ${escapeXml(mark.label)} + + ${glyph} `; } -function combinedProof(group) { - const proofs = group.map(p => p.controlProof).filter(Boolean); - if (!proofs.length) return null; - const alarms = proofs.filter(p => p.tone === 'alarm').length; - if (alarms) return {tone:'alarm', label:`⚠ ${alarms} alarm${alarms === 1 ? '' : 's'}`, detail:`${alarms} device${alarms === 1 ? '' : 's'} need attention`}; - const warnings = proofs.filter(p => p.tone === 'warning').length; - if (warnings) return {tone:'warning', label:`⚠ ${warnings} warning${warnings === 1 ? '' : 's'}`, detail:`${warnings} device${warnings === 1 ? '' : 's'} need attention`}; - if (proofs.length === group.length && proofs.every(p => p.label === proofs[0].label && p.tone === proofs[0].tone)) return proofs[0]; - return {tone:'unknown', label:'Mixed tiers', detail:'Devices have different control evidence'}; +function combinedMark(group) { + const marks = group.map(p => p.controlMark).filter(Boolean); + if (!marks.length) return null; + const tone = marks.some(m => m.tone === 'alarm') ? 'alarm' : 'warning'; + const n = marks.filter(m => m.tone === tone).length; + return {tone, label: n === 1 && marks.length === 1 ? marks[0].label : `${n} ${n === 1 ? 'device needs' : 'devices need'} attention`}; } // ---------- primitives ---------- @@ -1764,7 +1757,7 @@ function aggregateGroups(groups) { socSource, name: `${group.length}×`, aggregated: true, - controlProof: combinedProof(group), + controlMark: combinedMark(group), placeholder: group.some(p => p.placeholder), dailyKwh, dailyKwhParts, diff --git a/src/views/EvPanel.svelte b/src/views/EvPanel.svelte index 58c850e..541ceb2 100644 --- a/src/views/EvPanel.svelte +++ b/src/views/EvPanel.svelte @@ -464,6 +464,7 @@ ? 'Goal saved. Updating the plan…' : evPlanSentence({ ...lp, planPending: lp.planPending || store.planPending, planOutdated: lp.planOutdated || store.planOutdated }, now, site.canConfigure)}
+

{stale ? 'Waiting for current charger status. The last reading is out of date.' : evStatusSentence(lp, site.canConfigure)}

{#if lp.manualSaveError}

{MANUAL_SAVE_ERROR_TEXT}

{/if} {#if !stale && planStatus}

{planStatus}

{/if} @@ -844,7 +845,6 @@ list here would claim an idle week the app has not read. -->

Charging times aren't readable right now.

{/if} -
{:else} {#if store.loaded && !store.error && store.points.length > 0} diff --git a/src/views/LivePanel.svelte b/src/views/LivePanel.svelte index a82f79f..43266d0 100644 --- a/src/views/LivePanel.svelte +++ b/src/views/LivePanel.svelte @@ -138,6 +138,7 @@

{spec.title}

+ {#if parts === null}

No reading from your box yet.

@@ -156,7 +157,6 @@

last two minutes

{/if} - diff --git a/src/views/Now.control-proof.svelte.test.ts b/src/views/Now.control-proof.svelte.test.ts index b70b8a4..d46e914 100644 --- a/src/views/Now.control-proof.svelte.test.ts +++ b/src/views/Now.control-proof.svelte.test.ts @@ -5,7 +5,7 @@ import {SiteStore} from '$lib/state/site.svelte' import {LoopbackCarrier} from '$lib/carrier/loopback' import {SimBox} from '$lib/sim/box' -test('opens the device panel first, with collapsed evidence that stays current',async()=>{ +test('opens the device panel with the answer first and keeps an open receipt through an alarm',async()=>{ vi.useFakeTimers() vi.setSystemTime(new Date(2026,6,15,12)) const box=new SimBox({now:()=>Date.now()}) @@ -15,8 +15,10 @@ test('opens the device panel first, with collapsed evidence that stays current', vi.spyOn(site,'api').mockImplementation(req=>{ if(req.path!=='/api/status') return api(req) const status={grid_w:1000,load_w:500,drivers:{sungrow:{status:'ok',bat_w:1000}},control_feedback:[ - {driver:'sungrow',kind:'battery',reason:lost?'telemetry_stale':'power_observed',verification_tier:lost?0:2,verification_lost:lost,actual_w:lost?null:1000}, - {driver:'easee',kind:'ev',reason:'telemetry_stale',verification_tier:0,verification_lost:true,actual_w:null}, + lost + ? {driver:'sungrow',kind:'battery',mode:'planner_arbitrage',status:'no_contact',reason:'readings_lost',severity:'alarm',evidence:'accepted',readings_fresh:false,actual_w:null} + : {driver:'sungrow',kind:'battery',mode:'planner_arbitrage',status:'following',reason:'power_observed',severity:'info',evidence:'confirmed',readings_fresh:true,actual_w:1000,confirmed_at_ms:Date.now()}, + {driver:'easee',kind:'ev',status:'no_contact',reason:'readings_lost',severity:'alarm',evidence:'accepted',readings_fresh:false,actual_w:null}, ]} return Promise.resolve({status:200,headers:{},body:new TextEncoder().encode(JSON.stringify(status))}) }) @@ -24,14 +26,15 @@ test('opens the device panel first, with collapsed evidence that stays current', site.connect(new LoopbackCarrier(box,{latencyMs:0})) const view=render(Now,{props:{site}}) for(let i=0;i<200;i++){box.tick(20);await vi.advanceTimersByTimeAsync(20)} - expect(view.queryByText('Are you in control?')).toBeNull() + expect(view.queryByText('Are we in control?')).toBeNull() const flow=document.querySelector('ftw-energy-flow')! const button=flow.shadowRoot!.querySelector('.ef-layer[aria-hidden="false"] [data-role="battery"]')! - expect(button.getAttribute('aria-label')).toContain('Tier 2') + expect(button.getAttribute('aria-label')).not.toMatch(/tier/i) button.dispatchEvent(new MouseEvent('click',{bubbles:true})) await vi.advanceTimersByTimeAsync(10) const dialog=document.querySelector('[role="dialog"]')! - expect(dialog.textContent).toContain('sungrow · battery') + expect(dialog.textContent).toContain('Following FTW') + expect(dialog.textContent).toContain('Charging 1.0 kW as planned.') expect(dialog.textContent).not.toContain('easee') expect(dialog.getAttribute('aria-label')).toBe('Battery') const evidence=dialog.querySelector('details')! @@ -39,7 +42,7 @@ test('opens the device panel first, with collapsed evidence that stays current', evidence.open=true lost=true for(let i=0;i<150;i++){box.tick(20);await vi.advanceTimersByTimeAsync(20)} - expect(dialog.textContent).toContain('Alarm · Measurements lost') + expect(dialog.textContent).toContain('Lost control') expect(evidence.open).toBe(true) window.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape'})) await vi.advanceTimersByTimeAsync(10) @@ -53,7 +56,7 @@ test('an EV tap goes straight to charging controls even when status carries evid vi.useFakeTimers();vi.setSystemTime(new Date(Date.UTC(2026,6,15,18,30))); const box=new SimBox({now:()=>Date.now()});const site=new SiteStore('ev-proof-test'); const api=site.api.bind(site); - vi.spyOn(site,'api').mockImplementation(req=>req.path==='/api/status' ? Promise.resolve({status:200,headers:{},body:new TextEncoder().encode(JSON.stringify({drivers:{easee:{status:'ok',ev_w:7200}},control_feedback:[{driver:'easee',kind:'ev',reason:'power_observed',verification_tier:1}]}))}) : api(req)); + vi.spyOn(site,'api').mockImplementation(req=>req.path==='/api/status' ? Promise.resolve({status:200,headers:{},body:new TextEncoder().encode(JSON.stringify({drivers:{easee:{status:'ok',ev_w:7200}},control_feedback:[{driver:'easee',kind:'ev',status:'following',reason:'power_observed',severity:'info',evidence:'measured',readings_fresh:true,actual_w:7200}]}))}) : api(req)); try { site.connect(new LoopbackCarrier(box,{latencyMs:0}));render(Now,{props:{site}}); for(let i=0;i<200;i++){box.tick(20);await vi.advanceTimersByTimeAsync(20)} diff --git a/src/views/Now.svelte b/src/views/Now.svelte index cc4278e..3bdb0bb 100644 --- a/src/views/Now.svelte +++ b/src/views/Now.svelte @@ -7,7 +7,7 @@ The readings sit underneath for anyone who wants them. -->