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/.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 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..a54dcd9 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' @@ -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. 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 +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,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..fe6caf5 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,70 @@ 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'), '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 +1000,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 +1035,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) }) })