diff --git a/.changeset/plan-household-prefs.md b/.changeset/plan-household-prefs.md new file mode 100644 index 0000000..51292fc --- /dev/null +++ b/.changeset/plan-household-prefs.md @@ -0,0 +1,5 @@ +--- +"ftw-webapp": patch +--- + +The Plan screen uses the box's five planning styles and a separate battery-export permission. Manual controls stay behind Manual, with Use the plan to return to automatic planning. Changing a style keeps the export permission stored by the box. diff --git a/.changeset/stop-batteries-label.md b/.changeset/stop-batteries-label.md new file mode 100644 index 0000000..750db6a --- /dev/null +++ b/.changeset/stop-batteries-label.md @@ -0,0 +1,5 @@ +--- +"ftw-webapp": patch +--- + +The manual mode formerly labelled Idle is Stop batteries, matching the box. The mode key is still idle. diff --git a/contract/registry.yaml b/contract/registry.yaml index c7fe978..d407bcd 100644 --- a/contract/registry.yaml +++ b/contract/registry.yaml @@ -114,6 +114,7 @@ roles: # --------------------------------------------------------------------------- ops: - { name: site.mode.set, scope: ftw.mode.write, desc: Change the site operating mode } + - { name: planner.prefs.set, scope: ftw.mode.write, desc: Set household planner preferences } - { name: battery.hold, scope: ftw.dispatch.write, desc: Hold the battery at a fixed setpoint } - { name: loadpoint.hold, scope: ftw.dispatch.write, desc: Charge the car now at a fixed current } - { name: loadpoint.boost, scope: ftw.dispatch.write, desc: Boost the car from the house battery } @@ -134,8 +135,8 @@ ops: # release, and it is the same degrade-don't-die rule as capabilities. # # tier decides placement, not permission: -# primary — the forecast-driven strategies, shown as the main choices -# advanced — manual fallbacks, behind a "More ways to run it" disclosure +# primary — forecast-driven strategies; the Plan card sets them through household prefs, not buttons +# advanced — manual fallbacks, behind "Manual…" # hidden — valid over the API but never rendered as a button # --------------------------------------------------------------------------- modes: diff --git a/docs/protocol.md b/docs/protocol.md index 2930da9..48e09ce 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -299,7 +299,8 @@ is a different instruction than the one given, it is a `cmd`; if it is merely a late setting, it is a passthrough.* The refusal carries an `op` argument **only when a command for that route -exists**. Today three do: `POST /api/mode` names `site.mode.set`, +exists**. Today four do: `POST /api/mode` names `site.mode.set`, +`POST /api/planner/prefs` names `planner.prefs.set`, `POST /api/loadpoints/{id}/soc` names `loadpoint.soc.set`, and `POST /api/loadpoints/{id}/target` names `loadpoint.surplus_only.set` — the one field of that route's body the session can set; the target and its diff --git a/src/lib/format/command.ts b/src/lib/format/command.ts index 3a28bde..a0bab6d 100644 --- a/src/lib/format/command.ts +++ b/src/lib/format/command.ts @@ -23,6 +23,7 @@ export function commandHelp(result: CmdResult): string { return 'Your box is still starting. Give it a minute.' case 'E_UNAVAILABLE': if (result.error.args?.['op'] === 'loadpoint.surplus_only.set') return 'Solar rule not saved. Your previous choice is unchanged. Try again.' + if (result.error.args?.['op'] === 'planner.prefs.set') return "Your box couldn't save how the plan runs. Try again." return "Your box can't reach the charger right now. Try again shortly." default: return "That didn't go through. Try again." diff --git a/src/lib/format/plan-prefs.test.ts b/src/lib/format/plan-prefs.test.ts new file mode 100644 index 0000000..6780a85 --- /dev/null +++ b/src/lib/format/plan-prefs.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { + PLANNER_FALLBACK_MODE, + PLAN_STYLES, + SAFETY_K_DEFAULT, + styleForK, + exportSentence, + mappedPlannerMode, + prefsFromWire, + type SaleSlot, +} from './plan-prefs' + +const slot = (startMs: number, batteryW: number, gridW: number): SaleSlot => ({ + startMs, + durationMs: 15 * 60_000, + batteryW, + gridW, +}) + +describe('planner prefs from the box', () => { + it('keeps the slider on safety_k and does not invent a mode from export', () => { + const prefs = prefsFromWire({ + safety_k: 0.4, + forecast_trust: 'cautious', + battery_export: 'allowed', + mapped_mode: 'planner_arbitrage', + }) + expect(prefs.safetyK).toBe(0.4) + expect(prefs.forecastTrust).toBe('balanced') + expect(prefs.batteryExport).toBe('allowed') + expect(prefs.mappedMode).toBe('planner_arbitrage') + }) + + it('uses the passive mode when mapped_mode is missing or not a planner key', () => { + expect(mappedPlannerMode({})).toBe(PLANNER_FALLBACK_MODE) + expect(mappedPlannerMode({ mapped_mode: 'self_consumption' })).toBe(PLANNER_FALLBACK_MODE) + expect(mappedPlannerMode({ mapped_mode: 'planner_arbitrage' })).toBe('planner_arbitrage') + expect(prefsFromWire({ battery_export: 'allowed' }).mappedMode).toBe(PLANNER_FALLBACK_MODE) + }) + + it('names a battery sale, a solar export, an allowed idle, and a block', () => { + const start = Date.parse('2026-07-15T18:00:00') + const hh = (ms: number) => { + const d = new Date(ms) + return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0') + } + expect(exportSentence([slot(start, -400, -400)], 'allowed', start)).toBe( + `Battery sale planned ${hh(start)}–${hh(start + 15 * 60_000)}.` + ) + expect(exportSentence([slot(start, 0, -400)], 'not_allowed', start)).toBe( + 'Solar export only; the battery is not selling.' + ) + expect(exportSentence([slot(start, 200, 200)], 'allowed', start)).toBe( + 'Battery export is allowed, but FTW found no worthwhile sale.' + ) + expect(exportSentence([slot(start, 200, 200)], 'unknown', start)).toBe( + 'Battery sale blocked: permission is off or not checked.' + ) + }) +}) + + +describe('planning styles', () => { + it('matches the box default and preserves a fine-tuned margin', () => { + expect(SAFETY_K_DEFAULT).toBe(0.3) + expect(prefsFromWire({ forecast_trust: 'balanced' }).safetyK).toBe(0.3) + expect(prefsFromWire({ safety_k: 0.85 }).safetyK).toBe(0.85) + expect(styleForK(0.85)).toEqual({ style: PLAN_STYLES[0], exact: false }) + expect(styleForK(0.3)).toEqual({ style: PLAN_STYLES[2], exact: true }) + }) +}) diff --git a/src/lib/format/plan-prefs.ts b/src/lib/format/plan-prefs.ts new file mode 100644 index 0000000..1b3397b --- /dev/null +++ b/src/lib/format/plan-prefs.ts @@ -0,0 +1,187 @@ +/* Household planner prefs, in the box's own words. + * + * Planning styles and fine-tuning edit safety_k. forecast_trust is the enum + * an older box still answers with. mapped_mode is the box's mapping of battery_export onto a + * planner mode — this file never derives one from the other. + */ + +export const SAFETY_K_MIN = 0 +export const SAFETY_K_MAX = 2 +export const SAFETY_K_STEP = 0.05 +export const SAFETY_K_DEFAULT = 0.3 + +/** Same names, values and help as Core's web/plan-prefs.js. */ +export const PLAN_STYLES = [ + { key: 'very_careful', name: 'Very careful', k: 1, + text: 'Plans for a poor day: much less sun and more use than forecast. Keeps the most in the battery.' }, + { key: 'careful', name: 'Careful', k: 0.6, + text: 'Plans for less sun and more use than forecast.' }, + { key: 'balanced', name: 'Balanced', k: 0.3, + text: 'Plans for a little less sun and a little more use than forecast. A good start for most homes.' }, + { key: 'bold', name: 'Bold', k: 0.15, + text: 'Plans close to the forecast.' }, + { key: 'very_bold', name: 'Very bold', k: 0, + text: 'Plans on the forecast as it is. Earns the most when it is right and costs more when it is wrong.' }, +] as const + +export function styleForK(k: number) { + const n = clampSafetyK(k) + let style: (typeof PLAN_STYLES)[number] = PLAN_STYLES[0] + for (const candidate of PLAN_STYLES) { + if (Math.abs(candidate.k - n) < Math.abs(style.k - n)) style = candidate + } + return { style, exact: Math.abs(style.k - n) < 0.001 } +} + +export interface PlannerPrefsChange { + safety_k?: number + battery_export?: BatteryExport +} + +/** What "Use the plan" sends when the prefs read fails or names nothing usable. */ +export const PLANNER_FALLBACK_MODE = 'planner_passive_arbitrage' + +export type BatteryExport = 'unknown' | 'not_allowed' | 'allowed' +export type ForecastTrust = 'cautious' | 'balanced' | 'bold' + +export interface PlannerPrefsWire { + forecast_trust?: unknown + battery_export?: unknown + safety_k?: unknown + mapped_k?: unknown + mapped_mode?: unknown +} + +export interface PlannerPrefs { + forecastTrust: ForecastTrust + batteryExport: BatteryExport + safetyK: number + /** The box's mapped_mode, or the passive fallback when the field is unusable. */ + mappedMode: string +} + +const SALE_W = 100 + +export function clampSafetyK(v: number): number { + if (!Number.isFinite(v)) return SAFETY_K_DEFAULT + if (v < SAFETY_K_MIN) return SAFETY_K_MIN + if (v > SAFETY_K_MAX) return SAFETY_K_MAX + return v +} + +/** The slider's own resolution, without trailing zeros. */ +export function formatSafetyK(k: number): string { + return String(Math.round(clampSafetyK(k) * 100) / 100) +} + +export function trustFromSafetyK(k: number): ForecastTrust { + const n = clampSafetyK(k) + if (n <= 0.25) return 'bold' + if (n < 1.5) return 'balanced' + return 'cautious' +} + +function safetyKFromTrust(trust: ForecastTrust): number { + if (trust === 'cautious') return 2 + if (trust === 'bold') return 0 + return SAFETY_K_DEFAULT +} + +function asTrust(v: unknown): ForecastTrust { + return v === 'cautious' || v === 'balanced' || v === 'bold' ? v : 'balanced' +} + +function asExport(v: unknown): BatteryExport { + return v === 'allowed' || v === 'not_allowed' || v === 'unknown' ? v : 'unknown' +} + +/** + * The planner mode "Use the plan" will ask for. + * + * A string that starts with `planner_` is the box's answer. Anything else — + * a failed read, a missing field — is the mode that never sells from the battery. + */ +export function mappedPlannerMode(wire: { mapped_mode?: unknown } | null | undefined): string { + const mapped = wire?.mapped_mode + return typeof mapped === 'string' && mapped.startsWith('planner_') ? mapped : PLANNER_FALLBACK_MODE +} + +function finiteK(v: unknown): number | null { + return typeof v === 'number' && Number.isFinite(v) ? v : null +} + +export function prefsFromWire(wire: PlannerPrefsWire | null | undefined): PlannerPrefs { + const trust = asTrust(wire?.forecast_trust) + const safetyK = clampSafetyK( + finiteK(wire?.safety_k) ?? finiteK(wire?.mapped_k) ?? safetyKFromTrust(trust) + ) + return { + forecastTrust: trustFromSafetyK(safetyK), + batteryExport: asExport(wire?.battery_export), + safetyK, + mappedMode: mappedPlannerMode(wire), + } +} + +/** What the dashboard shows under the manual drawer for a mode the plan is not running. */ +const MANUAL_HINT: Record = { + self_consumption: + 'Self (manual). Simple grid-zero controller with no planner; charges surplus and discharges to cover local import.', + peak_shaving: 'Manual peak shaving. Limits grid import to the peak-limit setting.', + charge: 'Manual full charge — forces the battery to charge regardless of price.', + idle: + "Stop batteries. Every battery is held at 0 W while this mode is on, so none drifts back to the inverter's own behaviour. Fuse protection still applies. EV charging and PV curtailment carry on.", +} + +/** Empty while a planner mode is driving. The slider is the explanation then. */ +export function strategyHint(mode: string | null | undefined): string { + if (!mode || mode.startsWith('planner_')) return '' + return MANUAL_HINT[mode] ?? '' +} + + +export interface SaleSlot { + startMs: number + durationMs: number + batteryW: number + gridW: number +} + +function isBatterySale(slot: SaleSlot): boolean { + return slot.batteryW < -SALE_W && slot.gridW < -SALE_W +} + +function isGridExport(slot: SaleSlot): boolean { + return slot.gridW < -SALE_W +} + +function clock(ms: number): string { + const d = new Date(ms) + return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0') +} + +export function exportSentence( + slots: readonly SaleSlot[], + exportPermission: BatteryExport, + nowMs: number +): string { + const sale = slots.filter(isBatterySale) + if (sale.length > 0) { + const upcoming = sale.filter((s) => s.startMs + s.durationMs > nowMs) + const block = upcoming.length > 0 ? upcoming : sale + let last = block[0]! + for (let i = 1; i < block.length; i++) { + const expected = last.startMs + last.durationMs + const next = block[i]! + if (Math.abs(next.startMs - expected) > 1000) break + last = next + } + const end = last.startMs + last.durationMs + return 'Battery sale planned ' + clock(block[0]!.startMs) + '–' + clock(end) + '.' + } + if (slots.some(isGridExport)) return 'Solar export only; the battery is not selling.' + if (exportPermission === 'allowed') { + return 'Battery export is allowed, but FTW found no worthwhile sale.' + } + return 'Battery sale blocked: permission is off or not checked.' +} diff --git a/src/lib/protocol/messages.ts b/src/lib/protocol/messages.ts index e490515..f9a66b5 100644 --- a/src/lib/protocol/messages.ts +++ b/src/lib/protocol/messages.ts @@ -483,6 +483,7 @@ export function carriesOverSession(contentType: string | undefined): boolean { * reads both back. */ export const OP_SET_MODE = 'site.mode.set' +export const OP_PLANNER_PREFS_SET = 'planner.prefs.set' export const OP_BATTERY_HOLD = 'battery.hold' export const OP_LOADPOINT_HOLD = 'loadpoint.hold' export const OP_LOADPOINT_BOOST = 'loadpoint.boost' diff --git a/src/lib/sim/api.ts b/src/lib/sim/api.ts index f4fb8e1..8579197 100644 --- a/src/lib/sim/api.ts +++ b/src/lib/sim/api.ts @@ -28,7 +28,7 @@ */ import { DAY_ANCHOR_PERMILLE, sample, stepSoc, type HouseConfig, type Reading } from './energy' -import { OP_SET_MODE, ROLE_OWNER, ROLE_VIEWER, type Role } from '$lib/protocol/messages' +import { OP_PLANNER_PREFS_SET, OP_SET_MODE, ROLE_OWNER, ROLE_VIEWER, type Role } from '$lib/protocol/messages' import { roleHasScope } from '$lib/protocol/contract' import { wireBytes } from '$lib/protocol/frame' import { buildEnrollmentUrl } from '$lib/identity/enrollment' @@ -101,6 +101,8 @@ export interface RouteFacts { const ROUTES: Record = { // Reads the app makes, and one it never will. 'GET /api/status': { tier: 'read' }, + 'GET /api/planner/prefs': { tier: 'read' }, + 'POST /api/planner/prefs': { tier: 'actuate', cmdOp: OP_PLANNER_PREFS_SET }, 'GET /api/energy/daily': { tier: 'read' }, 'GET /api/savings/daily': { tier: 'read' }, 'GET /api/app-link/devices': { tier: 'read' }, @@ -262,6 +264,8 @@ export interface SimApiOptions { * the same moment or the hero and the charger sheet disagree. */ liveReading?: () => Reading | null + /** GET /api/planner/prefs, as the box currently holds it. */ + plannerPrefs?: () => Record } const DAY_MS = 86_400_000 @@ -342,9 +346,11 @@ export class SimApi { #testPushes = 0 /** How many times this box was asked to restart, for a test to look at. */ #restarts = 0 + #plannerPrefs?: () => Record constructor(opts: SimApiOptions) { this.#opts = opts + if (opts.plannerPrefs) this.#plannerPrefs = opts.plannerPrefs const now = opts.now() this.#devices = [ { @@ -508,6 +514,15 @@ export class SimApi { const route = matched.pattern if (route === 'GET /api/status') return this.#status() + if (route === 'GET /api/planner/prefs') { + return json(200, this.#plannerPrefs?.() ?? { + forecast_trust: 'balanced', + battery_export: 'unknown', + safety_k: 0.3, + mapped_k: 0.3, + mapped_mode: 'planner_passive_arbitrage', + }) + } if (route === 'GET /api/energy/daily') return this.#energyDaily(req.query) if (route === 'GET /api/savings/daily') return this.#savingsDaily(req.query) if (route === 'GET /api/loadpoints') return this.#loadpoints() diff --git a/src/lib/sim/box.ts b/src/lib/sim/box.ts index 9cc0fe7..8d331de 100644 --- a/src/lib/sim/box.ts +++ b/src/lib/sim/box.ts @@ -40,6 +40,7 @@ import { ROLE_OWNER, API_CHUNK_BYTES, API_MAX_BYTES, + OP_PLANNER_PREFS_SET, OP_SET_MODE, OP_BATTERY_HOLD, OP_LOADPOINT_HOLD, @@ -49,6 +50,7 @@ import { carriesOverSession, isRetryable, } from '$lib/protocol/messages' +import { SAFETY_K_DEFAULT, clampSafetyK, trustFromSafetyK, type BatteryExport } from '$lib/format/plan-prefs' import { SimApi, evPluggedIn } from './api' import { roleHasScope, ROLE_SCOPES } from '$lib/protocol/contract' import { buildPlan, priceAt, importTotalMinor } from './planner' @@ -178,7 +180,13 @@ const MODE_CATALOG: ModeInfo[] = [ 'Full price arbitrage \u2014 charge cheap, discharge into expensive hours (battery may export to grid).', tier: 'primary', }, - { key: 'idle', label: 'Idle', tooltip: 'Do nothing \u2014 no dispatch.', tier: 'advanced' }, + { + key: 'idle', + label: 'Stop batteries', + tooltip: + "Hold every battery at 0 W for as long as this mode is on, so none of them drifts back to the inverter's own behaviour. Fuse protection still applies: a battery discharges if the site is about to trip its main fuse. EV charging and PV curtailment carry on \u2014 stop those on their own controls.", + tier: 'advanced', + }, { key: 'self_consumption', label: 'Self (manual)', @@ -227,6 +235,7 @@ const DEFAULT_MODE: SiteMode = 'planner_passive_arbitrage' */ const OP_SCOPES: Record = { [OP_SET_MODE]: 'ftw.mode.write', + [OP_PLANNER_PREFS_SET]: 'ftw.mode.write', [OP_BATTERY_HOLD]: 'ftw.dispatch.write', [OP_LOADPOINT_HOLD]: 'ftw.dispatch.write', [OP_LOADPOINT_BOOST]: 'ftw.dispatch.write', @@ -343,6 +352,9 @@ export class SimBox { #lastSent = new Map() #lastSourcesJson = '' #mode: SiteMode = DEFAULT_MODE + /** Household planner prefs. The default matches Core; export starts unchecked. */ + #safetyK = SAFETY_K_DEFAULT + #batteryExport: BatteryExport = 'unknown' #planRev = 1 #role: Role #scopes: string[] | null @@ -389,9 +401,35 @@ export class SimBox { surplusOnly: this.#evSurplusOnly, }), liveReading: () => this.#lastReading, + plannerPrefs: () => this.plannerPrefsBody(), }) } + /** + * GET /api/planner/prefs, in the box's shape. + * + * mapped_mode is this simulator's answer. Callers read it; they do not + * derive a mode from battery_export themselves. + */ + plannerPrefsBody(): Record { + const safetyK = clampSafetyK(this.#safetyK) + return { + forecast_trust: trustFromSafetyK(safetyK), + battery_export: this.#batteryExport, + safety_k: safetyK, + mapped_k: safetyK, + mapped_mode: this.#batteryExport === 'allowed' ? 'planner_arbitrage' : 'planner_passive_arbitrage', + } + } + + get safetyK(): number { + return clampSafetyK(this.#safetyK) + } + + get batteryExport(): BatteryExport { + return this.#batteryExport + } + /** What this session's enrolment is allowed to do. */ get role(): Role { return this.#role @@ -749,6 +787,52 @@ export class SimBox { return } + // Household prefs. The mapped planner mode is decided here, the way + // ApplyPlannerPrefs does, and only applied when a planner mode is + // already driving. A manual house stays manual until "Use the plan". + if (cmd.op === OP_PLANNER_PREFS_SET) { + const k = cmd.args['safety_k'] + const exp = cmd.args['battery_export'] + if (k !== undefined && (typeof k !== 'number' || !Number.isFinite(k))) { + this.#cmdResult(cmd.cmdId, 'rejected', { + code: 'E_UNKNOWN_OP', + args: { op: cmd.op, arg: 'safety_k', value: k ?? null }, + }) + return + } + if (exp !== undefined && exp !== 'unknown' && exp !== 'not_allowed' && exp !== 'allowed') { + this.#cmdResult(cmd.cmdId, 'rejected', { + code: 'E_UNKNOWN_OP', + args: { op: cmd.op, arg: 'battery_export', value: exp ?? null }, + }) + return + } + if (k === undefined && exp === undefined) { + this.#cmdResult(cmd.cmdId, 'rejected', { code: 'E_UNKNOWN_OP', args: { op: cmd.op } }) + return + } + if (typeof k === 'number') this.#safetyK = clampSafetyK(k) + if (exp !== undefined) this.#batteryExport = exp as BatteryExport + const mapped = this.plannerPrefsBody()['mapped_mode'] + if ( + this.#mode.startsWith('planner_') && + typeof mapped === 'string' && + MODE_KEYS.includes(mapped) && + mapped !== this.#mode + ) { + this.#mode = mapped + } + this.#planRev += 1 + if (this.#subscribed) this.#sendSnapshot() + this.#cmdResult(cmd.cmdId, 'applied', undefined, { + value: this.#safetyK, + src: 'core', + uptimeMs: this.uptimeMs, + }) + this.#sendPlan() + return + } + // The charger's two ops, box conventions throughout: clearing is its // own signal because 0 W is a valid pause hold, an unknown loadpoint is // a bad argument, and the readback reports what the charger holds now. diff --git a/src/lib/state/plan.svelte.ts b/src/lib/state/plan.svelte.ts index 5a2d8f0..5372e67 100644 --- a/src/lib/state/plan.svelte.ts +++ b/src/lib/state/plan.svelte.ts @@ -7,10 +7,21 @@ */ import type { Plan, SiteMode, CmdResult, ModeInfo } from '$lib/protocol/messages' -import { OP_SET_MODE } from '$lib/protocol/messages' +import { OP_PLANNER_PREFS_SET, OP_SET_MODE } from '$lib/protocol/messages' import { CommandError } from '$lib/protocol/session' import { commandHelp } from '$lib/format/command' import { CAP_PLAN_DISPATCH, SCOPE_MODE_WRITE } from '$lib/protocol/contract' +import { + PLANNER_FALLBACK_MODE, + clampSafetyK, + mappedPlannerMode, + prefsFromWire, + trustFromSafetyK, + type PlannerPrefs, + type PlannerPrefsChange, + type PlannerPrefsWire, +} from '$lib/format/plan-prefs' +import { callBox } from './box-api' import type { SiteStore } from './site.svelte' import { FID } from '$lib/format/explanation' @@ -28,6 +39,7 @@ const SETTLE_MS = 4_000 export class PlanStore { #site: SiteStore #timer: ReturnType | null = null + #prefsVersion = 0 /** * Which `setMode` call is current. A tap while another is in flight must * not let the earlier result paint over the later one. @@ -59,6 +71,12 @@ export class PlanStore { /** Set when the box could not answer. A sentence, never a code. */ problem = $state(null) command = $state({ kind: 'idle' }) + /** Household planner prefs, as the last GET /api/planner/prefs answered. */ + prefs = $state(null) + /** A preference write is in flight. */ + prefsBusy = $state(false) + /** Why the last prefs write did not land. Null when there is nothing to say. */ + prefsHelp = $state(null) constructor(site: SiteStore) { this.#site = site @@ -75,12 +93,15 @@ export class PlanStore { return this.#site.session.modes.filter((m) => m.tier !== 'hidden') } - get primaryModes(): ModeInfo[] { - return this.modes.filter((m) => m.tier === 'primary') - } - - get advancedModes(): ModeInfo[] { - return this.modes.filter((m) => m.tier === 'advanced') + /** + * Manual fallbacks, and only those. + * + * Planner keys stay in the catalogue — Home Assistant still sets them — + * and they are not buttons. The Plan card's knobs are the household prefs. + * Hidden-tier modes stay hidden. + */ + get manualModes(): ModeInfo[] { + return this.modes.filter((m) => !m.key.startsWith('planner_')) } /** @@ -109,23 +130,18 @@ export class PlanStore { /** * True when the shown mode is a manual fallback, not a forecast plan. * - * Uses `shownMode` so a tap on "Use the plan" hides the manual banner at - * once, rather than waiting for the box to confirm. + * A planner key — including one this build has never rendered a button + * for — is the plan. Uses `shownMode` so a tap on "Use the plan" hides + * that button at once, rather than waiting for the box to confirm. */ get inManual(): boolean { const mode = this.shownMode - return mode !== null && this.advancedModes.some((m) => m.key === mode) + return mode !== null && !mode.startsWith('planner_') } - /** - * The recommended plan to return to: the first primary mode, which is - * FTW's default (`planner_passive_arbitrage` today). - * - * The app does not invent a third strategy named "optimal". It offers the - * same first primary the box already put at the front of the catalogue. - */ - get planHome(): ModeInfo | null { - return this.primaryModes[0] ?? null + /** A mode change or a prefs write is on the wire. One at a time. */ + get controlsLocked(): boolean { + return this.command.kind === 'sending' || this.prefsBusy } /** @@ -209,6 +225,7 @@ export class PlanStore { this.problem = null try { await this.#site.plan() + await this.#refreshPrefs() } catch (err) { // A plan the box could not send is not a broken app. What happens now // is that the app asks again on its own, so that is what it says — the @@ -220,6 +237,85 @@ export class PlanStore { } } + /** + * Read household prefs. mapped_mode is whatever the box said. + */ + async #refreshPrefs(): Promise { + const version = ++this.#prefsVersion + const wire = await callBox(this.#site, { + method: 'GET', + path: '/api/planner/prefs', + }) + if (version === this.#prefsVersion) this.prefs = prefsFromWire(wire) + } + + /** + * Send only the changed preference. Core keeps the other value, including + * changes another client made after this phone's last read. + */ + async setPrefs(change: PlannerPrefsChange): Promise { + if (this.controlsLocked || !this.canControl || !this.prefs) return + const prev = this.prefs + const k = clampSafetyK(change.safety_k ?? prev.safetyK) + const batteryExport = change.battery_export ?? prev.batteryExport + this.#prefsVersion += 1 + this.prefsHelp = null + this.prefsBusy = true + this.prefs = { + forecastTrust: trustFromSafetyK(k), + batteryExport, + safetyK: k, + mappedMode: prev?.mappedMode ?? PLANNER_FALLBACK_MODE, + } + + try { + const args = change.safety_k === undefined ? { ...change } : { ...change, safety_k: k } + const result: CmdResult = await this.#site.command(OP_PLANNER_PREFS_SET, args) + if (result.state !== 'applied') { + this.prefs = prev + this.prefsHelp = commandHelp(result) + return + } + // The write landed. Read mapped_mode back. A failed read leaves the + // last mapped mode in place — it does not invent one from the + // permission that was just sent. + try { + await this.#refreshPrefs() + } catch { + /* the next load() asks again */ + } + void this.#followPlan() + } catch (err) { + this.prefs = prev + this.prefsHelp = err instanceof CommandError ? err.help : "That didn't go through. Try again." + } finally { + this.prefsBusy = false + } + } + + /** + * Hand a manually driven house back to the plan. + * + * Which planner mode that is comes from a fresh prefs read. A failed read, + * or a mapped_mode that is not a planner key, is the mode that never sells + * from the battery — never a hard-coded active arbitrage, and never + * whichever primary mode the catalogue listed first. + */ + async usePlan(): Promise { + let mode: SiteMode = PLANNER_FALLBACK_MODE + try { + const wire = await callBox(this.#site, { + method: 'GET', + path: '/api/planner/prefs', + }) + this.prefs = prefsFromWire(wire) + mode = mappedPlannerMode(wire) + } catch { + mode = PLANNER_FALLBACK_MODE + } + await this.setMode(mode) + } + /** * Ask the box to run the site differently. * @@ -229,7 +325,7 @@ export class PlanStore { */ async setMode(mode: SiteMode): Promise { // A second request would carry the in-flight request's control revision. - if (this.command.kind === 'sending' || mode === this.shownMode) return + if (this.controlsLocked || mode === this.shownMode) return this.#clearTimer() this.command = { kind: 'sending', mode } diff --git a/src/lib/state/plan.test.ts b/src/lib/state/plan.test.ts index 2689a10..a2a4491 100644 --- a/src/lib/state/plan.test.ts +++ b/src/lib/state/plan.test.ts @@ -49,13 +49,47 @@ describe('the mode the Plan store offers a way back from', () => { return { box, site, store } } - it('names the first primary mode as the plan to return to', async () => { + it('treats a planner mode as the plan, and hides planner keys from the manual list', async () => { const { store } = await connected() - expect(store.planHome?.key).toBe('planner_passive_arbitrage') expect(store.inManual).toBe(false) + expect(store.manualModes.every((m) => !m.key.startsWith('planner_'))).toBe(true) + expect(store.manualModes.some((m) => m.key === 'self_consumption')).toBe(true) + expect(store.manualModes.some((m) => m.key === 'planner_self')).toBe(false) store.destroy() }) + it('follows mapped_mode when handing the house back to the plan', async () => { + const { store, box, site } = await connected('self_consumption') + await store.load() + await store.setPrefs({ battery_export: 'allowed' }) + expect(box.mode, 'a prefs write must not leave a manual mode on its own').toBe('self_consumption') + expect(box.batteryExport).toBe('allowed') + const sent = vi.spyOn(site, 'command') + await store.usePlan() + expect(box.mode).toBe('planner_arbitrage') + expect(sent.mock.calls.some((c) => c[0] === 'site.mode.set' && c[1]?.mode === 'planner_arbitrage')).toBe( + true + ) + expect(sent.mock.calls.some((c) => c[1]?.mode === 'planner_passive_arbitrage')).toBe(false) + store.destroy() + site.destroy() + }) + + it('uses the passive mode when the prefs read fails', async () => { + const { store, box, site } = await connected('self_consumption') + await store.load() + await store.setPrefs({ battery_export: 'allowed' }) + const api = site.api.bind(site) + vi.spyOn(site, 'api').mockImplementation(async (req) => { + if (req.path === '/api/planner/prefs') throw new Error('down') + return api(req) + }) + await store.usePlan() + expect(box.mode).toBe('planner_passive_arbitrage') + store.destroy() + site.destroy() + }) + it('treats Self (manual) as a manual fallback, not a plan', async () => { const { store, site } = await connected() await store.setMode('self_consumption') @@ -90,3 +124,51 @@ describe('the mode the Plan store offers a way back from', () => { site.destroy() }) }) + + +describe('planner preference changes from another client', () => { + it('ignores a preference read sent before this phone changed the margin', async () => { + const box = new SimBox({}) + const site = new SiteStore('test') + const store = new PlanStore(site) + site.connect(new LoopbackCarrier(box, { latencyMs: 0 })) + await vi.waitFor(() => expect(site.session.phase).toBe('streaming'), { timeout: 2_000 }) + await store.load() + const old = box.plannerPrefsBody() + let finish!: (value: Awaited>) => void + const pending = new Promise>>((resolve) => { finish = resolve }) + const api = site.api.bind(site) + let reading = false + vi.spyOn(site, 'api').mockImplementationOnce(async (request) => { + expect(request.path).toBe('/api/planner/prefs') + reading = true + return pending + }).mockImplementation(api) + const load = store.load() + await vi.waitFor(() => expect(reading).toBe(true)) + await store.setPrefs({ safety_k: 0.15 }) + finish({ status: 200, headers: { 'content-type': 'application/json' }, body: new TextEncoder().encode(JSON.stringify(old)) }) + await load + expect(store.prefs!.safetyK).toBe(0.15) + store.destroy() + site.destroy() + }) + + it('keeps the box export permission when this phone changes style from an old read', async () => { + const box = new SimBox({}) + const site = new SiteStore('test') + const store = new PlanStore(site) + site.connect(new LoopbackCarrier(box, { latencyMs: 0 })) + await vi.waitFor(() => expect(site.session.phase).toBe('streaming'), { timeout: 2_000 }) + await store.load() + expect(store.prefs!.batteryExport).toBe('unknown') + await site.command('planner.prefs.set', { battery_export: 'allowed' }) + const sent = vi.spyOn(site, 'command') + await store.setPrefs({ safety_k: 0.15 }) + expect(sent.mock.calls[0]).toEqual(['planner.prefs.set', { safety_k: 0.15 }]) + expect(box.batteryExport).toBe('allowed') + expect(box.safetyK).toBe(0.15) + store.destroy() + site.destroy() + }) +}) diff --git a/src/views/Plan.svelte b/src/views/Plan.svelte index 8902cb0..af4cb78 100644 --- a/src/views/Plan.svelte +++ b/src/views/Plan.svelte @@ -15,6 +15,16 @@ import type { FtwPriceChartElement, FtwPriceChartWindow } from '$vendor/ftw/ftw-price-chart.js' import { activeCurrency, unitPerKwh } from '$vendor/ftw/price-units.js' import { modeLabel, modeHelp, planHeadline, slotAction, reasonText, formatPrice } from '$lib/format/plan' + import { + clampSafetyK, + exportSentence, + formatSafetyK, + PLAN_STYLES, + SAFETY_K_DEFAULT, + styleForK, + strategyHint, + type BatteryExport, + } from '$lib/format/plan-prefs' import { formatPower } from '$lib/format/power' interface Props { @@ -89,22 +99,54 @@ function choose(mode: SiteMode) { void plan.setMode(mode) - // The selected fallback already renders when the drawer is closed. - // Folding the extras keeps "Use the plan" on screen instead of - // scrolling it off under Idle / Peak / Charge. - if (plan.advancedModes.some((m) => m.key === mode)) showAdvanced = false } - // FTW's own split: forecast-driven strategies are the choice most people - // want, the manual fallbacks are a drawer. The current fallback stays on - // the page even when the drawer is closed — see selectedAdvanced — so a - // house already on Self (manual) never needs the extras opened to see - // what is running, or to get back to the plan. - let showAdvanced = $state(false) + // Manual fallbacks stay behind "Manual…". Open the drawer when the live + // mode lives there, so a house already on Self (manual) has that button + // on screen. Closing it sticks until the mode changes — a status repeat + // must not undo "Hide manual". + let showManual = $state(false) + let revealedMode: string | null = null - const selectedAdvanced = $derived( - plan.advancedModes.find((m) => m.key === plan.shownMode) ?? null - ) + $effect(() => { + const mode = plan.shownMode + if (!mode || mode === revealedMode) return + revealedMode = mode + if (!mode.startsWith('planner_')) showManual = true + }) + + // The slider's position while a finger is on it. Released, it follows the + // box again. Moving it never changes battery export. + let sliderK = $state(SAFETY_K_DEFAULT) + let sliderDirty = $state(false) + + $effect(() => { + const k = plan.prefs?.safetyK + if (sliderDirty || k === undefined) return + sliderK = k + }) + + const shownK = $derived(sliderDirty ? sliderK : (plan.prefs?.safetyK ?? sliderK)) + const exportPermission = $derived(plan.prefs?.batteryExport ?? 'unknown') + const locked = $derived(!plan.canControl || plan.controlsLocked) + const prefsLocked = $derived(locked || plan.prefs === null) + const selectedStyle = $derived(styleForK(shownK)) + + function commitSlider() { + sliderDirty = true + void plan.setPrefs({ safety_k: shownK }).finally(() => { + sliderDirty = false + }) + } + + function setExport(next: BatteryExport) { + void plan.setPrefs({ battery_export: next }) + } + + function pickStyle(k: number) { + sliderK = k + commitSlider() + } // ---- Prices ------------------------------------------------------------ @@ -247,37 +289,104 @@ {/if} -
+

How your home is run

- - {#if plan.inManual && plan.planHome} - {@const home = plan.planHome} -
-

The plan is not running the battery.

- {#if plan.canControl} + +
+

Planning style

+
+ {#each PLAN_STYLES as style (style.key)} - {/if} + aria-pressed={plan.prefs !== null && selectedStyle.style.key === style.key} + disabled={prefsLocked} + onclick={() => pickStyle(style.k)} + >{style.name} + {/each}
+ + {#if plan.prefs} +

{selectedStyle.style.text}

+ {#if !selectedStyle.exact} +

Fine-tuned forecast margin: {formatSafetyK(shownK)}.

+ {/if} + {:else} +

Reading your box's planning preferences…

+ {/if} +

Safety limits are the same in every style.

+
+ Fine-tune forecast margin + + { + sliderDirty = true + sliderK = clampSafetyK(Number(e.currentTarget.value)) + }} + onchange={() => commitSlider()} + /> +

A higher margin keeps more in the battery when the forecast is uncertain.

+
+
+ +
+ {#if exportPermission === 'unknown'} + +

Not checked — battery export stays off.

+ {:else} + + {/if} +

Solar can still export when this is off. Check your electricity contract.

+
+ + {#if plan.prefs} +

{exportSentence(plan.plan?.slots ?? [], exportPermission, nowMs)}

+ {/if} + + + {#if plan.inManual && plan.canControl} + {/if} - {#snippet choice(info: ModeInfo)} {@const pressed = plan.shownMode === info.key} {@const sending = plan.command.kind === 'sending' && plan.command.mode === info.key} @@ -285,7 +394,7 @@ type="button" class="choice" aria-pressed={pressed} - disabled={!plan.canControl || plan.command.kind === 'sending'} + disabled={locked} onclick={() => choose(info.key)} > @@ -300,33 +409,30 @@ {/snippet} -
- {#each plan.primaryModes as info (info.key)} - {@render choice(info)} - {/each} - - {#if plan.advancedModes.length > 0} - {#if showAdvanced} - {#each plan.advancedModes as info (info.key)} + {#if plan.manualModes.length > 0} + + {#if showManual} +
+ {#each plan.manualModes as info (info.key)} {@render choice(info)} {/each} - - {:else} - {#if selectedAdvanced} - {@render choice(selectedAdvanced)} - {/if} - - {/if} +
{/if} -
+ {/if} + + {#if strategyHint(plan.shownMode)} +

{strategyHint(plan.shownMode)}

+ {/if} - {#if plan.command.kind === 'sending'} + {#if plan.prefsBusy} +

Replanning…

+ {:else if plan.prefsHelp} +

{plan.prefsHelp}

+ {:else if plan.command.kind === 'sending'}

Sending…

{:else if plan.command.kind === 'applied'}

Done.

@@ -477,28 +583,134 @@ display: flex; flex-direction: column; gap: var(--space-2); + margin-top: var(--space-3); } - .use-plan { + .forecast, + .export, + .banner { display: flex; flex-direction: column; - align-items: flex-start; + align-items: stretch; gap: var(--space-2); + } + + .forecast, + .export { margin-bottom: var(--space-3); + } + + .plan-styles { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: var(--space-1); + } + + .plan-styles button { + min-height: 44px; + padding: var(--space-2) var(--space-1); + background: var(--surface-raised); + border: 1px solid var(--line); + border-radius: var(--radius-sm); + color: var(--fg-dim); + font-size: 12px; + line-height: 1.3; + } + + .plan-styles button[aria-pressed='true'] { + border-color: var(--accent); + background: var(--surface-elevated); + color: var(--fg); + } + + .plan-styles button:disabled { + opacity: 0.5; + } + + .fine-tune summary { + padding: var(--space-2) 0; + min-height: 44px; + color: var(--fg-dim); + font-size: 13px; + cursor: pointer; + } + + .fine-tune label { + display: block; + margin-bottom: var(--space-2); + } + + .forecast input[type='range'] { + width: 100%; + accent-color: var(--accent); + } + + .forecast input[type='range']:disabled { + opacity: 0.5; + } + + .forecast-labels { + display: flex; + justify-content: space-between; + gap: var(--space-2); + font-size: 12px; + color: var(--fg-dim); + } + + .help, + .sentence, + .hint { + font-size: 13px; + color: var(--fg-dim); + line-height: 1.4; + } + + .banner { padding: var(--pad-card); background: var(--surface-raised); border: 1px solid var(--line); border-radius: var(--radius-md); } - .use-plan-copy { - font-size: 13px; + .banner-actions { + display: flex; + gap: var(--space-3); + align-items: center; + } + + .banner-actions button { + min-height: 44px; + padding: 0 var(--space-4); + background: var(--accent); + color: var(--on-accent); + border-radius: var(--radius-sm); + font-weight: 500; + } + + .banner-actions .link, + .link { + background: transparent; color: var(--fg-dim); + text-decoration: underline; + text-underline-offset: 3px; + } + + .check { + display: flex; + gap: var(--space-2); + align-items: flex-start; + font-size: 13px; line-height: 1.4; } + .check input { + margin-top: 0.2em; + accent-color: var(--accent); + } + .use-plan-btn { min-height: 44px; + margin-bottom: var(--space-3); padding: 0 var(--space-4); background: var(--accent); color: var(--on-accent); diff --git a/src/views/Plan.svelte.test.ts b/src/views/Plan.svelte.test.ts index 8f6b1ab..f4d6d57 100644 --- a/src/views/Plan.svelte.test.ts +++ b/src/views/Plan.svelte.test.ts @@ -172,9 +172,11 @@ describe('a viewer on the Plan screen', () => { new LoopbackCarrier(new SimBox({ now: () => MORNING, role: ROLE_VIEWER }), { latencyMs: 0 }) ) render(Plan, { props: { site } }) - await vi.waitFor(() => expect(document.querySelector('button.choice')).not.toBeNull(), { + await vi.waitFor(() => expect(document.querySelector('button.more')).not.toBeNull(), { timeout: 2_000, }) + ;(document.querySelector('button.more') as HTMLButtonElement).click() + await vi.waitFor(() => expect(document.querySelector('button.choice')).not.toBeNull()) const modes = [...document.querySelectorAll('button.choice')] as HTMLButtonElement[] expect(modes.length).toBeGreaterThan(0) @@ -182,6 +184,7 @@ describe('a viewer on the Plan screen', () => { modes.every((b) => b.disabled), 'a viewer was offered a mode the box would refuse' ).toBe(true) + expect((document.querySelector('input[type="range"]') as HTMLInputElement).disabled).toBe(true) expect(document.body.textContent).toMatch(/view-only access/i) expect(document.body.textContent).not.toMatch(/box doesn't support/i) @@ -205,9 +208,11 @@ describe('a viewer on the Plan screen', () => { ) ) render(Plan, { props: { site } }) - await vi.waitFor(() => expect(document.querySelector('button.choice')).not.toBeNull(), { + await vi.waitFor(() => expect(document.querySelector('button.more')).not.toBeNull(), { timeout: 2_000, }) + ;(document.querySelector('button.more') as HTMLButtonElement).click() + await vi.waitFor(() => expect(document.querySelector('button.choice')).not.toBeNull()) const modes = [...document.querySelectorAll('button.choice')] as HTMLButtonElement[] expect(modes.length).toBeGreaterThan(0) @@ -402,6 +407,10 @@ describe('a plan the box could not answer', () => { // already travelling, so what goes missing is the answer to it and every // answer after — the mode really did change, and the plan for it never // arrives. + const manual = document.querySelector('button.more') as HTMLButtonElement + expect(manual, 'manual modes were not offered').toBeTruthy() + manual.click() + await vi.waitFor(() => expect(document.querySelector('button.choice')).not.toBeNull()) const mode = [...document.querySelectorAll('button.choice')].find( (b) => b.getAttribute('aria-pressed') === 'false' && !(b as HTMLButtonElement).disabled ) as HTMLButtonElement @@ -920,36 +929,84 @@ describe('switching how the home is run', () => { const site = new SiteStore('test') site.connect(new LoopbackCarrier(box, { latencyMs: opts.latencyMs ?? 0 })) render(Plan, { props: { site } }) - await vi.waitFor(() => expect(document.querySelector('button.choice')).not.toBeNull(), { + await vi.waitFor(() => expect(document.querySelector('[data-prefs]')).not.toBeNull(), { timeout: 2_000, }) return { box, site } } - it('keeps the plan choices when Self (manual) is on, and offers a way back', async () => { + it('offers the box planning styles instead of Passive and Active buttons', async () => { + await mount() + + expect(document.body.textContent).toMatch(/Planning style/) + expect(document.body.textContent).toMatch(/Keeps more in the battery/) + expect(document.body.textContent).toMatch(/Counts more on the forecast/) + const styles = [...document.querySelectorAll('.plan-styles button')] + expect(styles.map((button) => button.textContent)).toEqual([ + 'Very careful', 'Careful', 'Balanced', 'Bold', 'Very bold', + ]) + expect(styles[2]!.getAttribute('aria-pressed')).toBe('true') + expect(document.body.textContent).toMatch(/FTW used to sell from the battery/) + expect(choice('Passive arbitrage')).toBeUndefined() + expect(choice('Active arbitrage')).toBeUndefined() + expect(choice('Planner (self)'), 'a hidden planner mode was rendered').toBeUndefined() + expect(document.body.textContent).not.toMatch(/use the plan/i) + }) + + it('saves a style without changing battery export', async () => { + const { box } = await mount() + const bold = [...document.querySelectorAll('.plan-styles button')].find((b) => b.textContent === 'Bold') as HTMLButtonElement + bold.click() + await vi.waitFor(() => expect(box.safetyK).toBe(0.15)) + expect(box.batteryExport).toBe('unknown') + await vi.waitFor(() => expect(bold.getAttribute('aria-pressed')).toBe('true')) + }) + + it('keeps manual modes behind Manual…, and offers a way back', async () => { const { box } = await mount({ mode: 'self_consumption' }) await vi.waitFor(() => expect(choice('Self (manual)')).toBeTruthy()) const self = choice('Self (manual)')! expect(self.getAttribute('aria-pressed')).toBe('true') expect(self.textContent).toMatch(/in use/i) - - expect(choice('Passive arbitrage'), 'the way back to the plan was missing').toBeTruthy() - expect(document.body.textContent).toMatch(/the plan is not running the battery/i) - expect(choice('Peak'), 'the extras were open, hiding the way back').toBeUndefined() - - const back = [...document.querySelectorAll('button')].find((b) => - /use the plan/i.test(b.textContent ?? '') - ) as HTMLButtonElement | undefined + expect(choice('Passive arbitrage')).toBeUndefined() + expect(choice('Active arbitrage')).toBeUndefined() + const stop = choice('Stop batteries') + expect(stop, 'idle is labelled Stop batteries').toBeTruthy() + expect(stop!.textContent).toMatch(/Hold every battery at 0 W/) + expect(choice('Idle')).toBeUndefined() + expect(document.body.textContent).not.toMatch(/Do nothing/) + + const back = document.querySelector('button.use-plan-btn') as HTMLButtonElement | null expect(back, 'Use the plan was not offered').toBeTruthy() back!.click() await vi.waitFor(() => expect(box.mode).toBe('planner_passive_arbitrage')) - await vi.waitFor(() => - expect(choice('Passive arbitrage')!.getAttribute('aria-pressed')).toBe('true') + await vi.waitFor(() => expect(document.querySelector('button.use-plan-btn')).toBeNull()) + expect(choice('Self (manual)')!.getAttribute('aria-pressed')).toBe('false') + }) + + it('follows mapped_mode, including when the battery may sell', async () => { + const { box } = await mount({ mode: 'self_consumption' }) + await vi.waitFor(() => expect(choice('Self (manual)')).toBeTruthy()) + + const allow = [...document.querySelectorAll('button')].find((b) => b.textContent === 'Allow') as + | HTMLButtonElement + | undefined + expect(allow, 'the export banner was missing').toBeTruthy() + allow!.click() + await vi.waitFor(() => { + expect(box.batteryExport).toBe('allowed') + expect((document.querySelector('button.use-plan-btn') as HTMLButtonElement).disabled).toBe( + false + ) + }) + expect(box.mode, 'allowing export must not leave the manual mode by itself').toBe( + 'self_consumption' ) - expect(choice('Passive arbitrage')!.textContent).toMatch(/in use/i) - expect(document.body.textContent).not.toMatch(/the plan is not running the battery/i) + + ;(document.querySelector('button.use-plan-btn') as HTMLButtonElement).click() + await vi.waitFor(() => expect(box.mode).toBe('planner_arbitrage')) }) it('marks a tap at once, before the box has confirmed', async () => { @@ -957,30 +1014,25 @@ describe('switching how the home is run', () => { const more = document.querySelector('button.more') as HTMLButtonElement | null expect(more, 'the manual drawer was not offered').toBeTruthy() + expect(more!.textContent).toMatch(/Manual…/) more!.click() await vi.waitFor(() => expect(choice('Self (manual)')).toBeTruthy()) choice('Self (manual)')!.click() await Promise.resolve() - // Choosing a fallback folds the extras, so the button is the one the - // closed drawer keeps on the page — not the node that was just clicked. const self = choice('Self (manual)')! expect(self.getAttribute('aria-pressed')).toBe('true') expect(self.textContent).toMatch(/sending/i) - expect(choice('Passive arbitrage')!.disabled).toBe(true) const back = document.querySelector('button.use-plan-btn') as HTMLButtonElement expect(back.disabled).toBe(true) back.click() - choice('Passive arbitrage')!.click() - expect(choice('Peak'), 'the extras stayed open after the tap').toBeUndefined() expect(box.mode, 'the box confirmed before the UI had anything to show').not.toBe( 'self_consumption' ) await vi.waitFor(() => expect(box.mode).toBe('self_consumption')) await vi.waitFor(() => expect(choice('Self (manual)')!.textContent).toMatch(/in use/i)) - expect(choice('Passive arbitrage')!.disabled).toBe(false) expect(back.disabled).toBe(false) }) @@ -997,9 +1049,11 @@ describe('switching how the home is run', () => { render(Plan, { props: { site } }) await vi.waitFor(() => expect(choice('Self (manual)')).toBeTruthy(), { timeout: 2_000 }) - expect(document.body.textContent).toMatch(/the plan is not running the battery/i) expect(document.body.textContent).not.toMatch(/use the plan/i) expect(choice('Self (manual)')!.disabled).toBe(true) - expect(choice('Passive arbitrage')!.disabled).toBe(true) + const allow = [...document.querySelectorAll('button')].find((b) => b.textContent === 'Allow') as + | HTMLButtonElement + | undefined + expect(allow?.disabled).toBe(true) }) })