diff --git a/.changeset/control-results.md b/.changeset/control-results.md new file mode 100644 index 0000000..ab675fd --- /dev/null +++ b/.changeset/control-results.md @@ -0,0 +1,7 @@ +--- +"ftw-webapp": patch +--- + +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. + +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/ev.ts b/src/lib/format/ev.ts index 55ae0b9..d20e5bb 100644 --- a/src/lib/format/ev.ts +++ b/src/lib/format/ev.ts @@ -11,9 +11,11 @@ */ import { formatPower } from './power' +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 { + control_feedback?: unknown id?: unknown driver_name?: unknown plugged_in?: unknown @@ -81,6 +83,8 @@ export interface WireManualStatus { } export interface Loadpoint { + controlFeedback?: ControlFeedback + driverName?: string id: string /** Whether a cable is in. False means the rest is about an empty bay. */ pluggedIn: boolean @@ -165,6 +169,7 @@ export function toLoadpoint(w: WireLoadpoint): Loadpoint { const boost = w.battery_boost return { id: typeof w.id === 'string' ? w.id : '', + ...(typeof w.driver_name === 'string' ? {driverName:w.driver_name} : {}), pluggedIn: w.plugged_in === true, powerW: num(w.current_power_w) ?? 0, socPct: w.plugged_in === false ? null : socFraction !== null && socFraction >= 0 && socFraction <= 1 @@ -185,6 +190,7 @@ export function toLoadpoint(w: WireLoadpoint): Loadpoint { manualSaveError: w.manual_save_error === true, ...(w.manual ? { manual: w.manual } : {}), ...(w.charger ? { charger: w.charger } : {}), + ...(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, diff --git a/src/lib/state/flow.ts b/src/lib/state/flow.ts index eed7772..d51d72e 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 { 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' @@ -19,6 +20,8 @@ export interface FlowDailyPart { } export interface FlowPlanet { + controlMark?: ControlMark + placeholder?: boolean id: string corner: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' title: string @@ -66,6 +69,7 @@ export interface StatusEnergyToday { } export interface SiteStatus { + control_feedback?: unknown grid_w?: unknown pv_w?: unknown bat_w?: unknown @@ -322,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 @@ -433,7 +437,7 @@ export function flowReadingsFromStatus(status: SiteStatus): FlowReadings { return { load: (num(status.load_w) ?? 0) / 1000, - planets, + planets: withControlMarks(planets, status.control_feedback, live), selfPoweredPctToday, } } diff --git a/src/lib/ui/ControlFeedback.svelte b/src/lib/ui/ControlFeedback.svelte new file mode 100644 index 0000000..6459c1a --- /dev/null +++ b/src/lib/ui/ControlFeedback.svelte @@ -0,0 +1,126 @@ + + + +{#if rows.length} +
+

Are we in control?

+ {#each rows as row (`${row.driver}:${row.kind}`)} + {@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} +
+
+ {/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..d3a3e6f --- /dev/null +++ b/src/lib/ui/ControlFeedback.svelte.test.ts @@ -0,0 +1,69 @@ +// @vitest-environment jsdom +import { render, screen, cleanup } from '@testing-library/svelte' +import { afterEach, expect, it } from 'vitest' +import ControlFeedback from './ControlFeedback.svelte' +afterEach(cleanup) + +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('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('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('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('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 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 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 e244a1d..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": "b6dec1cddebf19da4beed8a1af1b2addc4ed18243b42ed786756696ea060000c", + "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 1da8b07..301c47d 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 { + 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 13a0b14..30c1c65 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 6716098ab794e5a78fc976a27947b37fa063e635. +// 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.controlMark || !p.placeholder && !!p.role), + controlMark: p.controlMark, 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 { ` : ""} -

{site.canConfigure ? 'Connect your first charger on your box: open Settings → Chargers, then choose Connect a charger.' : 'Ask an owner to connect the first charger on the box, under Settings → Chargers.'} Once connected and added there, it appears here too.

{/if} - {#each store.points.filter(lp => !loadpointId || lp.id === loadpointId) as lp (lp.id)} + {#each store.points.filter(lp => (!loadpointId || lp.id === loadpointId) && (!driverName || lp.driverName === driverName)) as lp (lp.id)} {@const planStatus = (lp.planPending || store.planPending) && justSavedGoal?.id === lp.id ? '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} diff --git a/src/views/LivePanel.svelte b/src/views/LivePanel.svelte index 34d1674..43266d0 100644 --- a/src/views/LivePanel.svelte +++ b/src/views/LivePanel.svelte @@ -10,6 +10,7 @@ -->