diff --git a/.changeset/charge-now-amps-and-boost.md b/.changeset/charge-now-amps-and-boost.md new file mode 100644 index 0000000..fe2b690 --- /dev/null +++ b/.changeset/charge-now-amps-and-boost.md @@ -0,0 +1,5 @@ +--- +"ftw-webapp": patch +--- + +Charge now at a chosen current, update or stop a running hold, and boost the car from the house battery with a reserve and a time limit. diff --git a/src/lib/format/command.ts b/src/lib/format/command.ts index c7cd0c5..f298b2c 100644 --- a/src/lib/format/command.ts +++ b/src/lib/format/command.ts @@ -27,3 +27,24 @@ export function commandHelp(result: CmdResult): string { return "That didn't go through. Try again." } } + +/** + * The boost's own refusals, before the door's. + * + * The box answers a boost the live site cannot carry with E_UNAVAILABLE + * naming the op — the session's spelling of the HTTP 409 — and a lease + * outside its bounds with E_UNKNOWN_OP naming `lease`. Neither is the + * charger being out of reach, which is what the shared table says for + * E_UNAVAILABLE, so they get their own sentences and everything else falls + * through to it. + */ +export function boostHelp(result: CmdResult): string { + const e = result.error + if (e?.code === 'E_UNAVAILABLE' && typeof e.args?.['op'] === 'string') { + return "Your box won't boost right now — the house battery or the site isn't ready for it." + } + if (e?.code === 'E_UNKNOWN_OP' && e.args?.['arg'] === 'lease') { + return 'Your box refused that reserve and time. Try other values.' + } + return commandHelp(result) +} diff --git a/src/lib/format/ev.test.ts b/src/lib/format/ev.test.ts index 6d12e32..3f71fd0 100644 --- a/src/lib/format/ev.test.ts +++ b/src/lib/format/ev.test.ts @@ -11,6 +11,12 @@ import { daysWord, localInputToUtcMinutes, utcMinutesToLocalInput, + chargeCurrent, + ampsToWatts, + wattsToAmps, + currentReadout, + boostActiveSentence, + boostStoppedSentence, type WireLoadpoint, } from './ev' @@ -112,3 +118,131 @@ describe('a charger described in words', () => { expect(evStatusSentence(lp)).not.toContain('-') }) }) + +describe('the current a hold may ask for', () => { + /** What a real box serves beside the fixture: a three-phase 16 A wallbox. */ + const RANGE: WireLoadpoint = { + ...WIRE, + min_charge_w: 4140, + max_charge_w: 11000, + phases: 3, + voltage_v: 230, + } + + it('reads the range off the box in whole amps', () => { + const lp = toLoadpoint(RANGE) + expect(chargeCurrent(lp)).toEqual({ minA: 6, maxA: 16, wattsPerAmp: 690, phases: 3 }) + expect(ampsToWatts(lp, 10)).toBe(6900) + expect(currentReadout(lp, 10)).toBe('10 A · 6.9 kW') + }) + + it('never asks above the ceiling the box declared', () => { + // 11 000 W rounds to 16 A, and 16 A back is 11 040 W. + const lp = toLoadpoint(RANGE) + expect(ampsToWatts(lp, 16)).toBe(11000) + expect(currentReadout(lp, 16)).toBe('16 A · 11.0 kW') + }) + + it('falls back the way the box page does when the box says nothing', () => { + // No phases, no voltage, a zero floor and ceiling: three phases at + // 230 V, 6–16 A, and nothing to cap the top against. + const lp = toLoadpoint({ ...WIRE, min_charge_w: 0, max_charge_w: 0 }) + expect(chargeCurrent(lp)).toEqual({ minA: 6, maxA: 16, wattsPerAmp: 690, phases: 3 }) + expect(ampsToWatts(lp, 16)).toBe(11040) + }) + + it('follows a single-phase charger', () => { + const lp = toLoadpoint({ + ...WIRE, + min_charge_w: 1380, + max_charge_w: 3680, + phases: 1, + voltage_v: 230, + }) + expect(chargeCurrent(lp)).toEqual({ minA: 6, maxA: 16, wattsPerAmp: 230, phases: 1 }) + expect(ampsToWatts(lp, 16)).toBe(3680) + }) + + it('keeps the floor under the ceiling when the box reports them together', () => { + const lp = toLoadpoint({ ...RANGE, min_charge_w: 11000 }) + expect(chargeCurrent(lp)).toMatchObject({ minA: 16, maxA: 17 }) + }) + + it('reads the running hold back in amps', () => { + const lp = toLoadpoint({ ...RANGE, manual_active: true, manual_charge_w: 6900 }) + expect(lp.manualChargeW).toBe(6900) + expect(wattsToAmps(lp, lp.manualChargeW!)).toBe(10) + // A setpoint beside an inactive hold is not a running hold. + expect( + toLoadpoint({ ...RANGE, manual_active: false, manual_charge_w: 6900 }).manualChargeW + ).toBeNull() + }) +}) + +describe('the boost, in words', () => { + const EXPIRES = Date.UTC(2026, 6, 15, 20, 30) + + it('reads the lease off the wire and says it', () => { + const lp = toLoadpoint({ + ...WIRE, + battery_boost: { + state: 'active', + active: true, + expires_at_ms: EXPIRES, + min_battery_soc: 0.3, + }, + }) + expect(lp.boostActive).toBe(true) + expect(lp.boostReservePct).toBe(30) + expect(lp.boostExpiresAtMs).toBe(EXPIRES) + const s = boostActiveSentence(lp) + expect(s).toContain('down to 30 %') + expect(s).toContain( + `until ${new Date(EXPIRES).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}` + ) + expect(boostStoppedSentence(lp)).toBeNull() + }) + + it('claims no figure the box did not send', () => { + const lp = toLoadpoint({ ...WIRE, battery_boost: { state: 'active', active: true } }) + expect(boostActiveSentence(lp)).toBe( + 'Battery boost is on — the house battery is helping the car.' + ) + }) + + it('reads a reserve the box wrote as a legacy percent', () => { + const lp = toLoadpoint({ + ...WIRE, + battery_boost: { state: 'active', active: true, min_battery_soc: 30 }, + }) + expect(lp.boostReservePct).toBe(30) + }) + + it("says why the box stopped the last one, in the app's words", () => { + const lp = toLoadpoint({ + ...WIRE, + battery_boost: { + state: 'stopped', + active: false, + stop_reason: 'battery_reserve_reached', + stopped_at_ms: EXPIRES, + }, + }) + expect(lp.boostActive).toBe(false) + expect(boostStoppedSentence(lp)).toBe( + 'The last boost ended because the house battery reached its reserve.' + ) + }) + + it('still reports a stop it has no words for', () => { + const lp = toLoadpoint({ + ...WIRE, + battery_boost: { state: 'stopped', active: false, stop_reason: 'new_reason' }, + }) + expect(boostStoppedSentence(lp)).toBe('The last boost ended because your box stopped it.') + }) + + it('says nothing about a boost that never ran', () => { + expect(boostStoppedSentence(toLoadpoint(WIRE))).toBeNull() + }) +}) diff --git a/src/lib/format/ev.ts b/src/lib/format/ev.ts index 5448dda..0262f96 100644 --- a/src/lib/format/ev.ts +++ b/src/lib/format/ev.ts @@ -23,10 +23,21 @@ export interface WireLoadpoint { target_soc_pct?: unknown updated_at_ms?: unknown soc_source?: unknown + min_charge_w?: unknown max_charge_w?: unknown + phases?: unknown + voltage_v?: unknown manual_active?: unknown + manual_charge_w?: unknown surplus_only?: unknown - battery_boost?: { state?: unknown; active?: unknown } + battery_boost?: { + state?: unknown + active?: unknown + expires_at_ms?: unknown + min_battery_soc?: unknown + stop_reason?: unknown + stopped_at_ms?: unknown + } schedule?: { soc_pct?: unknown time_of_day_min_utc?: unknown @@ -46,11 +57,23 @@ export interface Loadpoint { targetSocPct: number | null /** What this session has delivered, in watt-hours. */ sessionWh: number - /** The charger's ceiling, for a charge-now hold. Null when unreported. */ + /** The charger's floor and ceiling for a hold, in watts. Null when unreported. */ + minChargeW: number | null maxChargeW: number | null + /** Phase count and phase voltage, for the amp slider. Null when the box did not say. */ + phases: number | null + voltageV: number | null manualActive: boolean + /** What the running hold asks for, in watts. Null when there is none, or the box did not say. */ + manualChargeW: number | null surplusOnly: boolean boostActive: boolean + /** When the running boost ends, wall clock. Null when none, or unreported. */ + boostExpiresAtMs: number | null + /** The floor the running boost keeps in the house battery, whole percent. */ + boostReservePct: number | null + /** The box's own token for why the last boost stopped. Null until it has. */ + boostStopReason: string | null schedule: { socPct: number | null timeOfDayMinUtc: number @@ -62,10 +85,24 @@ export interface Loadpoint { const num = (v: unknown): number | null => (typeof v === 'number' && Number.isFinite(v) ? v : null) +/** + * A state of charge off the wire, in whole percent. + * + * The box stores fractions and its status reports carry them, but its + * decoders also take a legacy percent, so a value above one is read the + * same way the box would read it. Zero is "unset" on the wire and null here. + */ +const pct = (v: unknown): number | null => { + const f = num(v) + if (f === null || f <= 0) return null + return Math.round(f > 1 ? f : f * 100) +} + /** One charger off the wire, unknown-tolerant the way every decoder here is. */ export function toLoadpoint(w: WireLoadpoint): Loadpoint { const sched = w.schedule const schedMin = sched ? num(sched.time_of_day_min_utc) : null + const boost = w.battery_boost return { id: typeof w.id === 'string' ? w.id : '', pluggedIn: w.plugged_in === true, @@ -73,10 +110,20 @@ export function toLoadpoint(w: WireLoadpoint): Loadpoint { socPct: num(w.current_soc_pct), targetSocPct: num(w.target_soc_pct), sessionWh: Math.max(0, Math.round(num(w.delivered_wh_session) ?? 0)), + minChargeW: num(w.min_charge_w), maxChargeW: num(w.max_charge_w), + phases: num(w.phases), + voltageV: num(w.voltage_v), manualActive: w.manual_active === true, + manualChargeW: w.manual_active === true ? num(w.manual_charge_w) : null, surplusOnly: w.surplus_only === true, - boostActive: w.battery_boost?.active === true, + boostActive: boost?.active === true, + boostExpiresAtMs: boost?.active === true ? num(boost.expires_at_ms) : null, + boostReservePct: boost?.active === true ? pct(boost.min_battery_soc) : null, + boostStopReason: + boost?.active !== true && typeof boost?.stop_reason === 'string' && boost.stop_reason !== '' + ? boost.stop_reason + : null, schedule: schedMin === null ? null @@ -181,3 +228,144 @@ export function evSessionSentence(lp: Loadpoint): string | null { const text = kwh >= 10 ? String(Math.round(kwh)) : kwh.toFixed(1) return `${text} kWh this session` } + +// -------------------------------------------------------------------------- +// Charge now, in amps +// -------------------------------------------------------------------------- + +/** The current a hold may ask for, and what one amp costs across the phases. */ +export interface ChargeCurrent { + minA: number + maxA: number + /** Watts per amp: phases × volts. */ + wattsPerAmp: number + phases: number +} + +/** + * The slider's range, from what the box served. + * + * The box's own page does this sum — amps = W / (phases × volts) — and its + * fallbacks are copied here so both surfaces offer the same range for the + * same charger: three phases at 230 V when the box did not say, 6–16 A when + * it reported no floor or ceiling. The box omits `phases` and `voltage_v` + * when they are unset; `min_charge_w` and `max_charge_w` always arrive but + * may be zero. + */ +export function chargeCurrent(lp: Loadpoint): ChargeCurrent { + const phases = lp.phases !== null && lp.phases > 0 ? lp.phases : 3 + const volts = lp.voltageV !== null && lp.voltageV > 0 ? lp.voltageV : 230 + const wattsPerAmp = phases * volts + const toA = (w: number | null) => (w !== null && w > 0 ? Math.round(w / wattsPerAmp) : 0) + const minA = Math.max(1, toA(lp.minChargeW) || 6) + let maxA = toA(lp.maxChargeW) || 16 + if (maxA <= minA) maxA = minA + 1 + return { minA, maxA, wattsPerAmp, phases } +} + +/** + * Watts for a chosen current, never above the ceiling the box declared. + * + * 11 000 W rounds to 16 A, and 16 A back is 11 040 W — more than the charger + * said it can do. The ceiling wins, so the top of the slider asks for exactly + * what the box reported, the same figure the plan is allowed to ask for. + */ +export function ampsToWatts(lp: Loadpoint, amps: number): number { + const w = Math.round(amps * chargeCurrent(lp).wattsPerAmp) + return lp.maxChargeW !== null && lp.maxChargeW > 0 ? Math.min(w, lp.maxChargeW) : w +} + +/** A hold's watts as whole amps, for saying what runs now. */ +export function wattsToAmps(lp: Loadpoint, watts: number): number { + return Math.round(watts / chargeCurrent(lp).wattsPerAmp) +} + +/** "16 A · 11.0 kW" — the slider's readout, one decimal like the box's page. */ +export function currentReadout(lp: Loadpoint, amps: number): string { + return `${amps} A · ${(ampsToWatts(lp, amps) / 1000).toFixed(1)} kW` +} + +// -------------------------------------------------------------------------- +// Battery boost +// -------------------------------------------------------------------------- + +/** + * The house-battery floor offered before a person changes it. + * + * The protocol carries no default; this is the one the box's own page seeds + * its field with, so a household sees the same number on both surfaces. + */ +export const BOOST_RESERVE_DEFAULT_PCT = 30 + +/** The box refuses a lease under this. From its own validator. */ +export const BOOST_RESERVE_MIN_PCT = 5 + +/** + * How long a boost may run, as the box's page offers it. + * + * The box caps a lease at four hours — `MaxBatteryBoostDuration` — and + * refuses longer, so the list ends there rather than offering a choice the + * box would turn down. + */ +export const BOOST_DURATIONS = [ + { s: 1800, label: '30 min' }, + { s: 3600, label: '1 h' }, + { s: 7200, label: '2 h' }, + { s: 14400, label: '4 h' }, +] as const + +export const BOOST_DURATION_DEFAULT_S = 3600 + +/** + * The box's stop reasons, in this app's words. + * + * Tokens from the box's `BatteryBoostStopReason`. The box sends the token; + * every word here is the app's. + */ +const BOOST_STOP: Record = { + cancelled: 'it was stopped by hand', + expired: 'its time ran out', + vehicle_unplugged: 'the car was unplugged', + ev_target_reached: 'the car reached its target', + departure_reached: 'the departure time came', + operator_hold: 'a manual charge took over', + surplus_only: 'the charger went back to spare solar only', + site_safety_block: 'the site meter went quiet', + loadpoint_driver_unavailable: 'your box lost touch with the charger', + battery_unavailable: 'your box lost touch with the house battery', + battery_reserve_reached: 'the house battery reached its reserve', + battery_hold: 'the house battery was held for something else', + core_mode: 'the site mode does not allow it', + fuse_safety_block: 'the fuse limit stepped in', + restart_lease_invalid: 'your box restarted and would not resume it', +} + +/** + * The running boost, as one sentence. + * + * Reserve and end time ride along when the box reported them. A box that + * says only "active" gets the bare sentence, never an invented figure. + */ +export function boostActiveSentence(lp: Loadpoint): string { + const reserve = lp.boostReservePct !== null ? ` down to ${lp.boostReservePct} %` : '' + const until = + lp.boostExpiresAtMs !== null + ? ` until ${new Date(lp.boostExpiresAtMs).toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + })}` + : '' + return `Battery boost is on — the house battery is helping the car${reserve}${until}.` +} + +/** + * Why the last boost ended, while none runs. Null when the box has not said. + * + * A token this app has not heard of is still a stop the box reported, so + * the sentence says the box stopped it rather than hiding the fact. + */ +export function boostStoppedSentence(lp: Loadpoint): string | null { + if (lp.boostActive || lp.boostStopReason === null) return null + const why = BOOST_STOP[lp.boostStopReason] ?? 'your box stopped it' + return `The last boost ended because ${why}.` +} diff --git a/src/lib/sim/api.ts b/src/lib/sim/api.ts index 8554e14..7dd2903 100644 --- a/src/lib/sim/api.ts +++ b/src/lib/sim/api.ts @@ -240,7 +240,12 @@ export interface SimApiOptions { * What the door has done to the charger, read live from the box. The * loadpoints answer must describe the same household the stream does. */ - loadpointState?: () => { holdW: number | null; boostActive: boolean } + loadpointState?: () => { + holdW: number | null + boost: { expiresAtMs: number; minBatterySoc: number } | null + /** The last stop's reason, kept until the next boost, as the box keeps it. */ + boostStop: { reason: string; atMs: number } | null + } /** * The live sample the 1 Hz stream is already sending. Status must describe * the same moment or the hero and the charger sheet disagree. @@ -635,7 +640,7 @@ export class SimApi { const r = sample(this.#opts.house, now, 500, this.#opts.ceilingW) // What the door has done overrides what the generator would do — the // stream applies the same override, so both surfaces tell one story. - const door = this.#opts.loadpointState?.() ?? { holdW: null, boostActive: false } + const door = this.#opts.loadpointState?.() ?? { holdW: null, boost: null, boostStop: null } const powerW = door.holdW ?? r.evW const d = new Date(now) const hourOfDay = d.getUTCHours() + d.getUTCMinutes() / 60 @@ -671,9 +676,25 @@ export class SimApi { phases: 3, voltage_v: 230, manual_active: door.holdW !== null, - battery_boost: door.boostActive - ? { state: 'active', active: true } - : { state: 'inactive', active: false }, + // `manual_charge_w` is omitempty on the box: absent for a 0 W + // pause hold, present with the setpoint for any other. + ...(door.holdW ? { manual_charge_w: door.holdW } : {}), + // The box's BatteryBoostStatus, in its three states. + battery_boost: door.boost + ? { + state: 'active', + active: true, + expires_at_ms: door.boost.expiresAtMs, + min_battery_soc: door.boost.minBatterySoc, + } + : door.boostStop + ? { + state: 'stopped', + active: false, + stop_reason: door.boostStop.reason, + stopped_at_ms: door.boostStop.atMs, + } + : { state: 'inactive', active: false }, surplus_only: false, ...(this.#schedule ? { schedule: this.#schedule } : {}), }, diff --git a/src/lib/sim/box.ts b/src/lib/sim/box.ts index ba4a003..8ab21dd 100644 --- a/src/lib/sim/box.ts +++ b/src/lib/sim/box.ts @@ -319,7 +319,12 @@ export class SimBox { /** The operator's manual charge hold, set through the door. Null when none. */ #evHold: { powerW: number } | null = null /** The battery-boost lease, set through the door. Null when none. */ - #evBoost: { expiresAtMs: number } | null = null + #evBoost: { expiresAtMs: number; minBatterySoc: number } | null = null + /** + * Why the last boost stopped, kept until the next one starts — the box + * keeps its terminal status the same way, and its page reads it back. + */ + #evBoostStop: { reason: string; atMs: number } | null = null #subscribed = false #negotiatedProto = PROTO_MAX #bucket: 256 | 512 = 512 @@ -368,7 +373,8 @@ export class SimBox { // household, whichever surface asks. loadpointState: () => ({ holdW: this.#evHold?.powerW ?? null, - boostActive: this.#evBoost !== null, + boost: this.#liveBoost(), + boostStop: this.#evBoostStop, }), liveReading: () => this.#lastReading, }) @@ -751,6 +757,9 @@ export class SimBox { return } this.#evHold = { powerW: Math.round(w) } + // The box's own tick withdraws a boost the moment an operator hold + // appears, and remembers why. + this.#stopBoost('operator_hold') } this.#cmdResult(cmd.cmdId, 'applied', undefined, { value: this.#evHold?.powerW ?? 0, @@ -767,17 +776,44 @@ export class SimBox { return } if (cmd.args['cancel'] === true) { - this.#evBoost = null + this.#stopBoost('cancelled') } else { - const durS = cmd.args['duration_s'] - if (typeof durS !== 'number' || durS <= 0) { + // The box's rules, value for value: exactly one of duration_s and + // expires_at_ms; a reserve of 5–100 % after its legacy-percent + // reading; one minute to four hours; and a refusal, not a lease, + // while an operator hold is on the charger. + const durS = typeof cmd.args['duration_s'] === 'number' ? cmd.args['duration_s'] : 0 + const expiresAt = + typeof cmd.args['expires_at_ms'] === 'number' ? cmd.args['expires_at_ms'] : 0 + if (durS > 0 === expiresAt > 0) { + this.#cmdResult(cmd.cmdId, 'rejected', { + code: 'E_UNKNOWN_OP', + args: { op: cmd.op, arg: 'duration_s', value: durS }, + }) + return + } + const now = this.#now() + const expiresAtMs = durS > 0 ? now + durS * 1000 : expiresAt + const raw = + typeof cmd.args['min_battery_soc_pct'] === 'number' ? cmd.args['min_battery_soc_pct'] : 0 + const minBatterySoc = raw > 1 ? raw / 100 : raw + const lengthS = (expiresAtMs - now) / 1000 + if (minBatterySoc < 0.05 || minBatterySoc > 1 || lengthS < 60 || lengthS > 4 * 3600) { this.#cmdResult(cmd.cmdId, 'rejected', { code: 'E_UNKNOWN_OP', - args: { field: 'duration_s' }, + args: { op: cmd.op, arg: 'lease', value: null }, }) return } - this.#evBoost = { expiresAtMs: this.#now() + durS * 1000 } + if (this.#evHold) { + this.#cmdResult(cmd.cmdId, 'rejected', { + code: 'E_UNAVAILABLE', + args: { op: cmd.op }, + }) + return + } + this.#evBoost = { expiresAtMs, minBatterySoc } + this.#evBoostStop = null } this.#cmdResult(cmd.cmdId, 'applied', undefined, { value: this.#evBoost ? 1 : 0, @@ -796,6 +832,19 @@ export class SimBox { }) } + /** The lease as the box would report it now: expiry is enforced lazily. */ + #liveBoost(): { expiresAtMs: number; minBatterySoc: number } | null { + if (this.#evBoost && this.#evBoost.expiresAtMs <= this.#now()) this.#stopBoost('expired') + return this.#evBoost + } + + /** Withdraw the boost and keep the reason, as the box's controller does. Idempotent. */ + #stopBoost(reason: string): void { + if (!this.#evBoost) return + this.#evBoost = null + this.#evBoostStop = { reason, atMs: this.#now() } + } + #onPlanGet(id: number): void { if (this.faults.booting) { this.#error('E_BOOTING', isRetryable('E_BOOTING'), {}, id) diff --git a/src/lib/state/loadpoints.svelte.ts b/src/lib/state/loadpoints.svelte.ts index 739c883..91de784 100644 --- a/src/lib/state/loadpoints.svelte.ts +++ b/src/lib/state/loadpoints.svelte.ts @@ -12,9 +12,15 @@ */ import { callBox, BoxApiError } from './box-api' -import { commandHelp } from '$lib/format/command' -import { toLoadpoint, type Loadpoint, type WireLoadpoint } from '$lib/format/ev' -import { OP_LOADPOINT_HOLD, type CmdResult } from '$lib/protocol/messages' +import { commandHelp, boostHelp } from '$lib/format/command' +import { + toLoadpoint, + chargeCurrent, + ampsToWatts, + type Loadpoint, + type WireLoadpoint, +} from '$lib/format/ev' +import { OP_LOADPOINT_HOLD, OP_LOADPOINT_BOOST, type CmdResult } from '$lib/protocol/messages' import { CommandError } from '$lib/protocol/session' import type { SiteStore } from './site.svelte' @@ -69,6 +75,12 @@ export function chargeWindows(actions: WireAction[], loadpointId: string): Charg return out } +/** Which control on the panel a command belongs to, so its outcome lands under it. */ +export type Control = 'hold' | 'boost' + +/** What an applied command did, for the one sentence that says so. */ +export type Outcome = 'hold' | 'release' | 'boost' | 'unboost' + export class LoadpointsStore { /** Every charger the box reported. Empty until an answer lands. */ points = $state.raw([]) @@ -103,10 +115,10 @@ export class LoadpointsStore { */ command = $state< | { kind: 'idle' } - | { kind: 'sending' } - | { kind: 'applied'; holding: boolean } - | { kind: 'unconfirmed' } - | { kind: 'failed'; help: string } + | { kind: 'sending'; of: Control } + | { kind: 'applied'; of: Control; did: Outcome } + | { kind: 'unconfirmed'; of: Control } + | { kind: 'failed'; of: Control; help: string } >({ kind: 'idle' }) #site: SiteStore @@ -124,41 +136,85 @@ export class LoadpointsStore { } /** - * Charge now: a manual hold at the charger's own ceiling. + * Charge now: a persistent manual hold at a chosen current. * - * The box revalidates against fresh state and answers; nothing here - * pretends. On any settled outcome the charger is reread, because a hold - * changes what `/api/loadpoints` says and the panel must say the same. + * The same body the box's own page posts to `manual_hold`: the watts for + * the amps, `hold_s: 0` for a hold that only Stop or an unplug releases, + * and the phase mode the charger is wired for. The box revalidates against + * fresh state and answers; nothing here pretends. On any settled outcome + * the charger is reread, because a hold changes what `/api/loadpoints` + * says and the panel must say the same. */ - async chargeNow(lp: Loadpoint): Promise { - await this.#send(lp, { id: lp.id, power_w: lp.maxChargeW ?? 0 }, true) + async chargeNow(lp: Loadpoint, amps: number): Promise { + await this.#send( + OP_LOADPOINT_HOLD, + { + id: lp.id, + power_w: ampsToWatts(lp, amps), + hold_s: 0, + phase_mode: chargeCurrent(lp).phases === 1 ? '1p' : '3p', + }, + 'hold', + 'hold', + commandHelp + ) } /** Release the hold. The plan takes back over. */ async stopCharging(lp: Loadpoint): Promise { - await this.#send(lp, { id: lp.id, clear: true }, false) + await this.#send(OP_LOADPOINT_HOLD, { id: lp.id, clear: true }, 'hold', 'release', commandHelp) + } + + /** + * Boost: let the house battery push the car for a bounded while. + * + * The lease by the box's own names — a floor for the house battery in + * whole percent and a duration in seconds. The box caps a lease at four + * hours, and refuses one the live site cannot carry: a manual hold + * running, the charger on spare solar only, the battery out of reach. + */ + async boost(lp: Loadpoint, reservePct: number, durationS: number): Promise { + await this.#send( + OP_LOADPOINT_BOOST, + { id: lp.id, min_battery_soc_pct: reservePct, duration_s: durationS }, + 'boost', + 'boost', + boostHelp + ) + } + + /** Withdraw the boost. The house battery is the plan's again. */ + async stopBoost(lp: Loadpoint): Promise { + await this.#send(OP_LOADPOINT_BOOST, { id: lp.id, cancel: true }, 'boost', 'unboost', boostHelp) } - async #send(lp: Loadpoint, args: Record, holding: boolean): Promise { + async #send( + op: string, + args: Record, + of: Control, + did: Outcome, + help: (result: CmdResult) => string + ): Promise { if (this.command.kind === 'sending') return if (this.#settle) clearTimeout(this.#settle) - this.command = { kind: 'sending' } + this.command = { kind: 'sending', of } try { - const result: CmdResult = await this.#site.command(OP_LOADPOINT_HOLD, args) + const result: CmdResult = await this.#site.command(op, args) switch (result.state) { case 'applied': - this.command = { kind: 'applied', holding } + this.command = { kind: 'applied', of, did } break case 'unconfirmed': - this.command = { kind: 'unconfirmed' } + this.command = { kind: 'unconfirmed', of } break default: - this.command = { kind: 'failed', help: commandHelp(result) } + this.command = { kind: 'failed', of, help: help(result) } } } catch (err) { this.command = { kind: 'failed', + of, help: err instanceof CommandError ? err.help : "That didn't go through. Try again.", } } diff --git a/src/lib/state/loadpoints.test.ts b/src/lib/state/loadpoints.test.ts index ca5ff6b..9b2ce15 100644 --- a/src/lib/state/loadpoints.test.ts +++ b/src/lib/state/loadpoints.test.ts @@ -10,6 +10,7 @@ import { LoadpointsStore, chargeWindows } from './loadpoints.svelte' import { SiteStore } from './site.svelte' import { LoopbackCarrier } from '$lib/carrier/loopback' import { SimBox } from '$lib/sim/box' +import { OP_LOADPOINT_HOLD, OP_LOADPOINT_BOOST } from '$lib/protocol/messages' /** Half past six in the evening UTC: the sim car is plugged in and drawing. */ const CHARGING_EVENING = Date.UTC(2026, 6, 15, 18, 30, 0) @@ -115,6 +116,116 @@ describe('the charger over the wire', () => { expect(store.points).toHaveLength(1) expect(store.error).toMatch(/out of reach/i) }) + + /** A store that has read the charger once, with the clock in the evening. */ + async function loadedStore(): Promise<{ site: SiteStore; store: LoadpointsStore }> { + const site = await streamingSite(new SimBox({ now: () => Date.now() })) + const store = new LoadpointsStore(site) + const first = store.load() + await vi.advanceTimersByTimeAsync(500) + await first + return { site, store } + } + + /** Run one command and let the reread it triggers land. */ + async function settled(run: Promise): Promise { + await vi.advanceTimersByTimeAsync(500) + await run + await vi.advanceTimersByTimeAsync(500) + } + + it('charges now at the chosen current, as the box page would send it', async () => { + vi.useFakeTimers() + vi.setSystemTime(CHARGING_EVENING) + const { site, store } = await loadedStore() + + const sent = vi.spyOn(site, 'command') + await settled(store.chargeNow(store.points[0]!, 10)) + + // 10 A × 3 × 230 V, a hold only Stop or an unplug releases, on three + // phases: the body the box's own page posts to manual_hold. + expect(sent).toHaveBeenCalledWith(OP_LOADPOINT_HOLD, { + id: 'carport', + power_w: 6900, + hold_s: 0, + phase_mode: '3p', + }) + expect(store.command).toEqual({ kind: 'applied', of: 'hold', did: 'hold' }) + + // The reread says the same: a hold at that setpoint, and the car drawing it. + const lp = store.points[0]! + expect(lp.manualActive).toBe(true) + expect(lp.manualChargeW).toBe(6900) + expect(lp.powerW).toBe(6900) + }) + + it('boosts from the house battery with a reserve and a bound, then stops it', async () => { + vi.useFakeTimers() + vi.setSystemTime(CHARGING_EVENING) + const { site, store } = await loadedStore() + + const sent = vi.spyOn(site, 'command') + await settled(store.boost(store.points[0]!, 30, 3600)) + + expect(sent).toHaveBeenCalledWith(OP_LOADPOINT_BOOST, { + id: 'carport', + min_battery_soc_pct: 30, + duration_s: 3600, + }) + expect(store.command).toEqual({ kind: 'applied', of: 'boost', did: 'boost' }) + + let lp = store.points[0]! + expect(lp.boostActive).toBe(true) + expect(lp.boostReservePct).toBe(30) + expect(lp.boostExpiresAtMs).toBeGreaterThan(Date.now()) + expect(lp.boostExpiresAtMs).toBeLessThanOrEqual(Date.now() + 3600_000) + expect(lp.boostStopReason).toBeNull() + + await settled(store.stopBoost(lp)) + expect(sent).toHaveBeenCalledWith(OP_LOADPOINT_BOOST, { id: 'carport', cancel: true }) + expect(store.command).toEqual({ kind: 'applied', of: 'boost', did: 'unboost' }) + + lp = store.points[0]! + expect(lp.boostActive).toBe(false) + expect(lp.boostStopReason).toBe('cancelled') + }) + + it('reports a hold ending a boost, and the box refusing a boost under a hold', async () => { + vi.useFakeTimers() + vi.setSystemTime(CHARGING_EVENING) + const { store } = await loadedStore() + + await settled(store.boost(store.points[0]!, 30, 3600)) + expect(store.points[0]!.boostActive).toBe(true) + + // A hold takes priority: the box withdraws the boost and keeps the why. + await settled(store.chargeNow(store.points[0]!, 16)) + let lp = store.points[0]! + expect(lp.manualActive).toBe(true) + expect(lp.boostActive).toBe(false) + expect(lp.boostStopReason).toBe('operator_hold') + + // And a boost asked for under that hold is refused, in a sentence about + // the boost rather than about the charger being out of reach. + await settled(store.boost(lp, 30, 3600)) + expect(store.command.kind).toBe('failed') + expect(store.command.kind === 'failed' && store.command.help).toMatch(/won't boost/) + lp = store.points[0]! + expect(lp.boostActive).toBe(false) + expect(lp.manualActive).toBe(true) + }) + + it('carries the sentence for a lease the box would not take', async () => { + vi.useFakeTimers() + vi.setSystemTime(CHARGING_EVENING) + const { store } = await loadedStore() + + // Five hours: over the box's four-hour cap, so the lease is refused. + await settled(store.boost(store.points[0]!, 30, 5 * 3600)) + expect(store.command.kind).toBe('failed') + expect(store.command.kind === 'failed' && store.command.help).toMatch(/reserve and time/) + expect(store.points[0]!.boostActive).toBe(false) + }) }) describe('charge windows folded from plan slots', () => { diff --git a/src/views/EvPanel.svelte b/src/views/EvPanel.svelte index a2cb74e..487f78a 100644 --- a/src/views/EvPanel.svelte +++ b/src/views/EvPanel.svelte @@ -5,11 +5,13 @@ of the house, and the way in is the house diagram. Everything on it is a fact the box served — what flows now, what this session has delivered, what the schedule says, and when the optimiser intends to charge next. - Round two puts an editor under the schedule line; nothing here commands. + The controls express intent — a hold at a chosen current, a bounded boost + from the house battery — and the box decides; the panel repaints from + what the box then reports. -->