Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/control-results.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions src/lib/format/ev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
8 changes: 6 additions & 2 deletions src/lib/state/flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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
Expand Down Expand Up @@ -66,6 +69,7 @@ export interface StatusEnergyToday {
}

export interface SiteStatus {
control_feedback?: unknown
grid_w?: unknown
pv_w?: unknown
bat_w?: unknown
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
}
}
126 changes: 126 additions & 0 deletions src/lib/ui/ControlFeedback.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<!--
"Are we in control?" for one device sheet. The box decides the status, its
severity and the evidence; this component only renders the words the box's
own dashboard uses. Keyed blocks keep an open "How FTW knows" open while
the status refreshes.
-->
<script lang="ts">
import {
controlRows,
controlStatus,
controlReceipt,
controlNumbers,
controlCurve,
} from '$vendor/ftw/control-feedback.js'

let { value, live = true }: { value: unknown; live?: boolean } = $props()
const rows = $derived(controlRows(value))
const ICONS: Record<string, string> = { ok: '✓', neutral: '•', warning: '▲', alarm: '!', stale: '…' }
</script>

{#if rows.length}
<section class="control" aria-label="Are we in control?">
<p class="question">Are we in control?</p>
{#each rows as row (`${row.driver}:${row.kind}`)}
{@const status = controlStatus(row, live)}
{@const numbers = controlNumbers(row, live)}
{@const curve = controlCurve(row, live)}
<div class="block" data-tone={status.tone}>
<h3><span class="icon" aria-hidden="true">{ICONS[status.tone] ?? ICONS.neutral}</span>{status.title}</h3>
<p class="text">{status.text}</p>
{#if status.proof}<p class="proof">{status.proof}</p>{/if}
{#if status.next}<p class="next">{status.next}</p>{/if}
<details>
<summary>How FTW knows</summary>
<ol>
{#each controlReceipt(row, live) as step (step.step)}
<li data-state={step.state}><span class="step">{step.step}</span><span>{step.value}</span></li>
{/each}
</ol>
{#if numbers.length || curve}
<details>
<summary>Numbers</summary>
<dl>
{#each numbers as [label, text] (label)}<dt>{label}</dt><dd>{text}</dd>{/each}
</dl>
{#if curve}
<svg viewBox="0 0 280 110" role="img" aria-label={curve.label}>
<polyline class="zero" points="10,55 270,55" />
<polyline class="device-line" points={curve.device} />
<polyline class="site-line" points={curve.site} />
</svg>
{/if}
</details>
{/if}
</details>
</div>
{/each}
</section>
{/if}

<style>
.control { margin: 0 0 var(--space-3, 12px); }
.question {
font-family: var(--mono);
font-size: 0.7rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--fg-muted);
margin: 0 0 8px;
}
.block {
border: 1px solid var(--line);
border-radius: 10px;
padding: 12px 14px;
margin: 0 0 10px;
background: var(--surface-raised);
}
.block[data-tone='warning'] { border-color: var(--amber); }
.block[data-tone='alarm'] { border-color: var(--red-e); }
.block[data-tone='stale'] { opacity: 0.75; }
h3 {
display: flex;
align-items: center;
gap: 8px;
font-size: 1rem;
font-weight: 600;
margin: 0 0 4px;
}
.icon {
display: inline-grid;
place-items: center;
flex: none;
width: 20px;
height: 20px;
border-radius: 50%;
font-size: 12px;
font-weight: 700;
line-height: 1;
background: var(--line);
color: var(--fg-dim);
}
[data-tone='ok'] .icon { background: color-mix(in srgb, var(--green-e) 22%, transparent); color: var(--green-e); }
[data-tone='warning'] .icon { background: color-mix(in srgb, var(--amber) 22%, transparent); color: var(--amber); }
[data-tone='alarm'] .icon { background: var(--red-e); color: var(--on-accent); }
p { margin: 0; font-size: 0.92rem; line-height: 1.45; }
.proof, .next { margin-top: 4px; font-size: 0.85rem; color: var(--fg-dim); }
.next { color: var(--fg); }
details { margin-top: 8px; }
summary { cursor: pointer; font-size: 0.82rem; color: var(--fg-dim); padding: 4px 0; }
ol { list-style: none; margin: 6px 0 0; padding: 0; display: grid; gap: 6px; }
li { display: grid; grid-template-columns: 6.5rem 1fr; gap: 8px; align-items: baseline; font-size: 0.85rem; overflow-wrap: anywhere; }
.step { display: flex; align-items: center; gap: 6px; color: var(--fg-dim); }
.step::before { content: ''; flex: none; width: 8px; height: 8px; border-radius: 50%; border: 1.5px solid var(--fg-muted); }
li[data-state='done'] .step::before { background: var(--green-e); border-color: var(--green-e); }
li[data-state='wait'] .step::before { border-style: dashed; }
li[data-state='fail'] .step::before { background: var(--red-e); border-color: var(--red-e); }
li[data-state='none'] { opacity: 0.65; }
dl { display: grid; grid-template-columns: 1fr auto; gap: 4px 12px; margin: 6px 0; font-size: 0.82rem; }
dt { color: var(--fg-dim); }
dd { margin: 0; text-align: right; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
svg { width: 100%; max-height: 140px; }
polyline { fill: none; }
.zero { stroke: var(--line); stroke-width: 1; }
.device-line { stroke: var(--cyan); stroke-width: 3; }
.site-line { stroke: var(--green-e); stroke-width: 2; stroke-dasharray: 5 4; }
</style>
69 changes: 69 additions & 0 deletions src/lib/ui/ControlFeedback.svelte.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) => ({
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: '<script>bad</script>', kind: 'device', status: 'not_following', reason: 'device_fault', severity: 'alarm', device_reason: '<img onerror=bad>' })] })
expect(document.querySelector('script')).toBeNull()
expect(document.querySelector('img')).toBeNull()
expect(screen.getByText('The device reports a fault: <img onerror=bad>.')).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()
})
68 changes: 68 additions & 0 deletions src/vendor/ftw/control-feedback.d.ts
Original file line number Diff line number Diff line change
@@ -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<T>(planets: T[], value: unknown, live?: boolean): (T & { controlMark?: ControlMark; placeholder?: boolean; clickable?: boolean })[]
Loading
Loading