From 88ee0f38ea0613097ad77b456ad60d78a2857b47 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 29 Sep 2026 19:11:25 +0000 Subject: [PATCH 1/3] feat(plan): follow the box household planner prefs The Plan screen uses the forecast slider and battery-export choice already on the box. Passive and Active are no longer buttons. Use the plan asks for the mode the box maps from those prefs. Signed-off-by: Cursor Agent Co-authored-by: Fredrik Ahlgren --- .changeset/plan-household-prefs.md | 5 + contract/registry.yaml | 5 +- docs/protocol.md | 3 +- src/lib/format/command.ts | 1 + src/lib/format/plan-prefs.test.ts | 63 +++++++ src/lib/format/plan-prefs.ts | 163 ++++++++++++++++ src/lib/protocol/messages.ts | 1 + src/lib/sim/api.ts | 17 +- src/lib/sim/box.ts | 74 ++++++++ src/lib/state/plan.svelte.ts | 137 ++++++++++++-- src/lib/state/plan.test.ts | 36 +++- src/views/Plan.svelte | 286 ++++++++++++++++++++++------- src/views/Plan.svelte.test.ts | 81 +++++--- 13 files changed, 758 insertions(+), 114 deletions(-) create mode 100644 .changeset/plan-household-prefs.md create mode 100644 src/lib/format/plan-prefs.test.ts create mode 100644 src/lib/format/plan-prefs.ts diff --git a/.changeset/plan-household-prefs.md b/.changeset/plan-household-prefs.md new file mode 100644 index 0000000..9e1f256 --- /dev/null +++ b/.changeset/plan-household-prefs.md @@ -0,0 +1,5 @@ +--- +"ftw-webapp": patch +--- + +The Plan screen follows the household forecast slider and battery-export choice already on the box. Passive and Active are no longer buttons. Use the plan hands a manual house back to the mode the box maps from those prefs. 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 fc50fed..731c793 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -298,7 +298,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..f2731b9 --- /dev/null +++ b/src/lib/format/plan-prefs.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import { + PLANNER_FALLBACK_MODE, + exportSentence, + hedgeLine, + 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('says what the forecast margin is, in the box’s words', () => { + expect(hedgeLine(0)).toBe('No forecast margin requested.') + expect(hedgeLine(1)).toMatch(/forecast margin varies by interval/) + }) + + 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.' + ) + }) +}) diff --git a/src/lib/format/plan-prefs.ts b/src/lib/format/plan-prefs.ts new file mode 100644 index 0000000..9d2ddb5 --- /dev/null +++ b/src/lib/format/plan-prefs.ts @@ -0,0 +1,163 @@ +/* Household planner prefs, in the box's own words. + * + * safety_k is the slider. 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 + +/** 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 1 + 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 1 +} + +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 function hedgeLine(k: number): string { + return clampSafetyK(k) === 0 + ? 'No forecast margin requested.' + : 'The forecast margin varies by interval. This box has not supplied separate forecast and planning values.' +} + +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 da7416f..d33399b 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' @@ -95,6 +95,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' }, @@ -256,6 +258,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 @@ -336,9 +340,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 = [ { @@ -502,6 +508,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: 1, + mapped_k: 1, + 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..62e1c3c 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 { 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' @@ -227,6 +229,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 +346,9 @@ export class SimBox { #lastSent = new Map() #lastSourcesJson = '' #mode: SiteMode = DEFAULT_MODE + /** Household planner prefs. k=1 and an unanswered export is what a box that used to sell starts from. */ + #safetyK = 1 + #batteryExport: BatteryExport = 'unknown' #planRev = 1 #role: Role #scopes: string[] | null @@ -389,9 +395,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 +781,48 @@ 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 (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 !== '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 + } + this.#safetyK = clampSafetyK(k) + this.#batteryExport = exp + 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..f3ac30d 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 BatteryExport, + type PlannerPrefs, + type PlannerPrefsWire, +} from '$lib/format/plan-prefs' +import { callBox } from './box-api' import type { SiteStore } from './site.svelte' import { FID } from '$lib/format/explanation' @@ -59,6 +70,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 prefs write is in flight. The slider stays put; the plan is being remade. */ + 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 +92,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 +129,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 +224,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 +236,87 @@ export class PlanStore { } } + /** + * Read household prefs. mapped_mode is whatever the box said. + */ + async #refreshPrefs(): Promise { + const wire = await callBox(this.#site, { + method: 'GET', + path: '/api/planner/prefs', + }) + this.prefs = prefsFromWire(wire) + } + + /** + * Store a safety factor and an export permission. + * + * The box maps the permission onto a planner mode. This method sends the + * two fields and then reads mapped_mode back; it never picks a mode from + * the permission itself. + */ + async setPrefs(safetyK: number, batteryExport: BatteryExport): Promise { + if (this.controlsLocked || !this.canControl) return + const prev = this.prefs + const k = clampSafetyK(safetyK) + this.prefsHelp = null + this.prefsBusy = true + this.prefs = { + forecastTrust: trustFromSafetyK(k), + batteryExport, + safetyK: k, + mappedMode: prev?.mappedMode ?? PLANNER_FALLBACK_MODE, + } + + try { + const result: CmdResult = await this.#site.command(OP_PLANNER_PREFS_SET, { + safety_k: k, + battery_export: batteryExport, + }) + 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 +326,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..1e5d8f1 100644 --- a/src/lib/state/plan.test.ts +++ b/src/lib/state/plan.test.ts @@ -49,13 +49,45 @@ 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.setPrefs(1, '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.setPrefs(1, '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') diff --git a/src/views/Plan.svelte b/src/views/Plan.svelte index 8902cb0..af1ca23 100644 --- a/src/views/Plan.svelte +++ b/src/views/Plan.svelte @@ -15,6 +15,15 @@ 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, + hedgeLine, + strategyHint, + trustFromSafetyK, + type BatteryExport, + } from '$lib/format/plan-prefs' import { formatPower } from '$lib/format/power' interface Props { @@ -89,22 +98,47 @@ 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(1) + 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) + + function commitSlider() { + sliderDirty = true + void plan.setPrefs(shownK, exportPermission).finally(() => { + sliderDirty = false + }) + } + + function setExport(next: BatteryExport) { + void plan.setPrefs(shownK, next) + } // ---- Prices ------------------------------------------------------------ @@ -247,37 +281,90 @@ {/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} - - {/if} + +
+ Follow the forecast + { + sliderDirty = true + sliderK = clampSafetyK(Number(e.currentTarget.value)) + }} + onchange={() => commitSlider()} + /> +
+ Trust forecast + k {formatSafetyK(shownK)} + Hold reserve
+

{hedgeLine(shownK)}

+

+ Left follows the forecast fully — if it is right, that earns more. Right keeps more in the + battery in case the sun misses, closer to using the battery only for the house. Every notch + changes how much of each slot's own forecast error the plan holds back. +

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

Not checked — battery export stays off.

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

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

+
+ +

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

+ + + {#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 +372,7 @@ type="button" class="choice" aria-pressed={pressed} - disabled={!plan.canControl || plan.command.kind === 'sending'} + disabled={locked} onclick={() => choose(info.key)} > @@ -300,33 +387,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 +561,100 @@ 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); + } + + .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); + } + + .k { + font-family: var(--mono); + color: var(--fg); + } + + .hedge, + .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..3d79b1a 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,65 @@ 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 forecast slider instead of Passive and Active buttons', async () => { + await mount() + + expect(document.body.textContent).toMatch(/Follow the forecast/) + expect(document.body.textContent).toMatch(/Trust forecast/) + expect(document.body.textContent).toMatch(/Hold reserve/) + 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('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')).toBeUndefined() + expect(choice('Active arbitrage')).toBeUndefined() - 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 + 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 +995,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 +1030,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) }) }) From 0ad01738d7b4750bf191c543e2728eda2562cbb0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 29 Sep 2026 19:19:59 +0000 Subject: [PATCH 2/3] fix(plan): label the idle mode Stop batteries The simulator catalogue still called idle "Idle". The box renamed the label and tooltip; the key is still idle. Signed-off-by: Cursor Agent Co-authored-by: Fredrik Ahlgren --- .changeset/stop-batteries-label.md | 5 +++++ src/lib/sim/box.ts | 8 +++++++- src/views/Plan.svelte.test.ts | 5 +++++ 3 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 .changeset/stop-batteries-label.md 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/src/lib/sim/box.ts b/src/lib/sim/box.ts index 62e1c3c..a54dcd9 100644 --- a/src/lib/sim/box.ts +++ b/src/lib/sim/box.ts @@ -180,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)', diff --git a/src/views/Plan.svelte.test.ts b/src/views/Plan.svelte.test.ts index 3d79b1a..fe6caf5 100644 --- a/src/views/Plan.svelte.test.ts +++ b/src/views/Plan.svelte.test.ts @@ -957,6 +957,11 @@ describe('switching how the home is run', () => { expect(self.textContent).toMatch(/in use/i) 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() From b335369f7f0975fdd21e654ca8c1bbb18ef18cbf Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 4 Oct 2026 17:48:33 +0200 Subject: [PATCH 3/3] fix(plan): match Core household planning styles Signed-off-by: Fredrik Ahlgren --- .changeset/plan-household-prefs.md | 2 +- src/lib/format/plan-prefs.test.ts | 20 ++-- src/lib/format/plan-prefs.ts | 42 ++++++-- src/lib/sim/api.ts | 4 +- src/lib/sim/box.ts | 18 ++-- src/lib/state/plan.svelte.ts | 29 +++--- src/lib/state/plan.test.ts | 54 ++++++++++- src/views/Plan.svelte | 150 ++++++++++++++++++++--------- src/views/Plan.svelte.test.ts | 22 ++++- 9 files changed, 248 insertions(+), 93 deletions(-) diff --git a/.changeset/plan-household-prefs.md b/.changeset/plan-household-prefs.md index 9e1f256..51292fc 100644 --- a/.changeset/plan-household-prefs.md +++ b/.changeset/plan-household-prefs.md @@ -2,4 +2,4 @@ "ftw-webapp": patch --- -The Plan screen follows the household forecast slider and battery-export choice already on the box. Passive and Active are no longer buttons. Use the plan hands a manual house back to the mode the box maps from those prefs. +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/src/lib/format/plan-prefs.test.ts b/src/lib/format/plan-prefs.test.ts index f2731b9..6780a85 100644 --- a/src/lib/format/plan-prefs.test.ts +++ b/src/lib/format/plan-prefs.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from 'vitest' import { PLANNER_FALLBACK_MODE, + PLAN_STYLES, + SAFETY_K_DEFAULT, + styleForK, exportSentence, - hedgeLine, mappedPlannerMode, prefsFromWire, type SaleSlot, @@ -36,11 +38,6 @@ describe('planner prefs from the box', () => { expect(prefsFromWire({ battery_export: 'allowed' }).mappedMode).toBe(PLANNER_FALLBACK_MODE) }) - it('says what the forecast margin is, in the box’s words', () => { - expect(hedgeLine(0)).toBe('No forecast margin requested.') - expect(hedgeLine(1)).toMatch(/forecast margin varies by interval/) - }) - 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) => { @@ -61,3 +58,14 @@ describe('planner prefs from the box', () => { ) }) }) + + +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 index 9d2ddb5..1b3397b 100644 --- a/src/lib/format/plan-prefs.ts +++ b/src/lib/format/plan-prefs.ts @@ -1,13 +1,42 @@ /* Household planner prefs, in the box's own words. * - * safety_k is the slider. forecast_trust is the enum an older box still - * answers with. mapped_mode is the box's mapping of battery_export onto a + * 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' @@ -34,7 +63,7 @@ export interface PlannerPrefs { const SALE_W = 100 export function clampSafetyK(v: number): number { - if (!Number.isFinite(v)) return 1 + 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 @@ -55,7 +84,7 @@ export function trustFromSafetyK(k: number): ForecastTrust { function safetyKFromTrust(trust: ForecastTrust): number { if (trust === 'cautious') return 2 if (trust === 'bold') return 0 - return 1 + return SAFETY_K_DEFAULT } function asTrust(v: unknown): ForecastTrust { @@ -110,11 +139,6 @@ export function strategyHint(mode: string | null | undefined): string { return MANUAL_HINT[mode] ?? '' } -export function hedgeLine(k: number): string { - return clampSafetyK(k) === 0 - ? 'No forecast margin requested.' - : 'The forecast margin varies by interval. This box has not supplied separate forecast and planning values.' -} export interface SaleSlot { startMs: number diff --git a/src/lib/sim/api.ts b/src/lib/sim/api.ts index 658ca75..8579197 100644 --- a/src/lib/sim/api.ts +++ b/src/lib/sim/api.ts @@ -518,8 +518,8 @@ export class SimApi { return json(200, this.#plannerPrefs?.() ?? { forecast_trust: 'balanced', battery_export: 'unknown', - safety_k: 1, - mapped_k: 1, + safety_k: 0.3, + mapped_k: 0.3, mapped_mode: 'planner_passive_arbitrage', }) } diff --git a/src/lib/sim/box.ts b/src/lib/sim/box.ts index a54dcd9..8d331de 100644 --- a/src/lib/sim/box.ts +++ b/src/lib/sim/box.ts @@ -50,7 +50,7 @@ import { carriesOverSession, isRetryable, } from '$lib/protocol/messages' -import { clampSafetyK, trustFromSafetyK, type BatteryExport } from '$lib/format/plan-prefs' +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' @@ -352,8 +352,8 @@ export class SimBox { #lastSent = new Map() #lastSourcesJson = '' #mode: SiteMode = DEFAULT_MODE - /** Household planner prefs. k=1 and an unanswered export is what a box that used to sell starts from. */ - #safetyK = 1 + /** Household planner prefs. The default matches Core; export starts unchecked. */ + #safetyK = SAFETY_K_DEFAULT #batteryExport: BatteryExport = 'unknown' #planRev = 1 #role: Role @@ -793,22 +793,26 @@ export class SimBox { if (cmd.op === OP_PLANNER_PREFS_SET) { const k = cmd.args['safety_k'] const exp = cmd.args['battery_export'] - if (typeof k !== 'number' || !Number.isFinite(k)) { + 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 !== 'unknown' && exp !== 'not_allowed' && exp !== 'allowed') { + 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 } - this.#safetyK = clampSafetyK(k) - this.#batteryExport = exp + 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_') && diff --git a/src/lib/state/plan.svelte.ts b/src/lib/state/plan.svelte.ts index f3ac30d..5372e67 100644 --- a/src/lib/state/plan.svelte.ts +++ b/src/lib/state/plan.svelte.ts @@ -17,8 +17,8 @@ import { mappedPlannerMode, prefsFromWire, trustFromSafetyK, - type BatteryExport, type PlannerPrefs, + type PlannerPrefsChange, type PlannerPrefsWire, } from '$lib/format/plan-prefs' import { callBox } from './box-api' @@ -39,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. @@ -72,7 +73,7 @@ export class PlanStore { command = $state({ kind: 'idle' }) /** Household planner prefs, as the last GET /api/planner/prefs answered. */ prefs = $state(null) - /** A prefs write is in flight. The slider stays put; the plan is being remade. */ + /** 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) @@ -240,24 +241,24 @@ 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', }) - this.prefs = prefsFromWire(wire) + if (version === this.#prefsVersion) this.prefs = prefsFromWire(wire) } /** - * Store a safety factor and an export permission. - * - * The box maps the permission onto a planner mode. This method sends the - * two fields and then reads mapped_mode back; it never picks a mode from - * the permission itself. + * Send only the changed preference. Core keeps the other value, including + * changes another client made after this phone's last read. */ - async setPrefs(safetyK: number, batteryExport: BatteryExport): Promise { - if (this.controlsLocked || !this.canControl) return + async setPrefs(change: PlannerPrefsChange): Promise { + if (this.controlsLocked || !this.canControl || !this.prefs) return const prev = this.prefs - const k = clampSafetyK(safetyK) + 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 = { @@ -268,10 +269,8 @@ export class PlanStore { } try { - const result: CmdResult = await this.#site.command(OP_PLANNER_PREFS_SET, { - safety_k: k, - battery_export: batteryExport, - }) + 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) diff --git a/src/lib/state/plan.test.ts b/src/lib/state/plan.test.ts index 1e5d8f1..a2a4491 100644 --- a/src/lib/state/plan.test.ts +++ b/src/lib/state/plan.test.ts @@ -60,7 +60,8 @@ describe('the mode the Plan store offers a way back from', () => { it('follows mapped_mode when handing the house back to the plan', async () => { const { store, box, site } = await connected('self_consumption') - await store.setPrefs(1, 'allowed') + 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') @@ -76,7 +77,8 @@ describe('the mode the Plan store offers a way back from', () => { it('uses the passive mode when the prefs read fails', async () => { const { store, box, site } = await connected('self_consumption') - await store.setPrefs(1, 'allowed') + 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') @@ -122,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 af1ca23..af4cb78 100644 --- a/src/views/Plan.svelte +++ b/src/views/Plan.svelte @@ -19,9 +19,10 @@ clampSafetyK, exportSentence, formatSafetyK, - hedgeLine, + PLAN_STYLES, + SAFETY_K_DEFAULT, + styleForK, strategyHint, - trustFromSafetyK, type BatteryExport, } from '$lib/format/plan-prefs' import { formatPower } from '$lib/format/power' @@ -116,7 +117,7 @@ // 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(1) + let sliderK = $state(SAFETY_K_DEFAULT) let sliderDirty = $state(false) $effect(() => { @@ -128,16 +129,23 @@ 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(shownK, exportPermission).finally(() => { + void plan.setPrefs({ safety_k: shownK }).finally(() => { sliderDirty = false }) } function setExport(next: BatteryExport) { - void plan.setPrefs(shownK, next) + void plan.setPrefs({ battery_export: next }) + } + + function pickStyle(k: number) { + sliderK = k + commitSlider() } // ---- Prices ------------------------------------------------------------ @@ -285,38 +293,52 @@

How your home is run

+ catalogue for Home Assistant; the styles edit safety_k. -->
- Follow the forecast - { - sliderDirty = true - sliderK = clampSafetyK(Number(e.currentTarget.value)) - }} - onchange={() => commitSlider()} - /> -
- Trust forecast - k {formatSafetyK(shownK)} - Hold reserve +

Planning style

+
+ {#each PLAN_STYLES as style (style.key)} + + {/each}
-

{hedgeLine(shownK)}

-

- Left follows the forecast fully — if it is right, that earns more. Right keeps more in the - battery in case the sun misses, closer to using the battery only for the house. Every notch - changes how much of each slot's own forecast error the plan holds back. -

+ + {#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.

+
@@ -324,8 +346,8 @@ -

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

+ {#if plan.prefs} +

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

+ {/if} @@ -578,6 +600,46 @@ 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); @@ -595,12 +657,6 @@ color: var(--fg-dim); } - .k { - font-family: var(--mono); - color: var(--fg); - } - - .hedge, .help, .sentence, .hint { diff --git a/src/views/Plan.svelte.test.ts b/src/views/Plan.svelte.test.ts index fe6caf5..f4d6d57 100644 --- a/src/views/Plan.svelte.test.ts +++ b/src/views/Plan.svelte.test.ts @@ -935,12 +935,17 @@ describe('switching how the home is run', () => { return { box, site } } - it('offers the forecast slider instead of Passive and Active buttons', async () => { + it('offers the box planning styles instead of Passive and Active buttons', async () => { await mount() - expect(document.body.textContent).toMatch(/Follow the forecast/) - expect(document.body.textContent).toMatch(/Trust forecast/) - expect(document.body.textContent).toMatch(/Hold reserve/) + 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() @@ -948,6 +953,15 @@ describe('switching how the home is run', () => { 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' })