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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ev-ready-time-household-clock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw-webapp": patch
---

The car's ready time shows and saves on the household clock when the box sends it, so a phone in another time zone shows the time at home. The app still sends the UTC minute, so an older box keeps working. Pair with the Core change that adds `time_of_day_min`.
36 changes: 36 additions & 0 deletions src/lib/format/ev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import {
daysWord,
localInputToUtcMinutes,
utcMinutesToLocalInput,
inputMinutes,
readyClock,
readyInput,
chargeCurrent,
ampsToWatts,
wattsToAmps,
Expand Down Expand Up @@ -127,6 +130,39 @@ describe('a charger described in words', () => {
expect(localInputToUtcMinutes('25:99')).toBeNull()
})

it('shows the household clock when the box sends it, in any zone', () => {
// 07:00 at home is 05:00 UTC in Swedish summer. A phone abroad reads
// that UTC minute as another time; the household minute it keeps.
const lp = toLoadpoint({
...WIRE,
schedule: { soc: 0.8, time_of_day_min: 420, time_of_day_min_utc: 300, recurring: true },
})
expect(lp.schedule?.timeOfDayMin).toBe(420)
expect(readyInput(lp.schedule!)).toBe('07:00')
expect(readyClock(lp.schedule!)).toMatch(/^07:00/)
expect(evScheduleSentence(lp)).toContain(`Ready by ${readyClock(lp.schedule!)}`)
})

it('reads the UTC minute in this zone from a box without the household clock', () => {
const lp = toLoadpoint(WIRE)
expect(lp.schedule?.timeOfDayMin).toBeNull()
expect(readyInput(lp.schedule!)).toBe(utcMinutesToLocalInput(360))
expect(readyClock(lp.schedule!)).toBe(localClock(360))
})

it('ignores a household minute out of range', () => {
const lp = toLoadpoint({ ...WIRE, schedule: { soc: 0.8, time_of_day_min: 1440, time_of_day_min_utc: 300 } })
expect(lp.schedule?.timeOfDayMin).toBeNull()
})

it('reads a time input as minutes after midnight', () => {
expect(inputMinutes('00:00')).toBe(0)
expect(inputMinutes('06:30')).toBe(390)
expect(inputMinutes('23:59')).toBe(1439)
expect(inputMinutes('24:00')).toBeNull()
expect(inputMinutes('7')).toBeNull()
})

it('holds every sentence to the no-minus rule', () => {
// A driver mid-fault can report a small negative power; the wire sign
// convention must not reach the panel.
Expand Down
58 changes: 45 additions & 13 deletions src/lib/format/ev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export interface WireLoadpoint {
schedule?: {
soc?: unknown
soc_pct?: unknown
time_of_day_min?: unknown
time_of_day_min_utc?: unknown
surplus_unlock_bat_soc?: unknown
recurring?: unknown
Expand Down Expand Up @@ -137,6 +138,11 @@ export interface Loadpoint {
boostStopReason: string | null
schedule: {
socPct: number | null
/**
* The ready time on the household clock: minutes after midnight in
* the box's zone. Null from a box that sends only the UTC minute.
*/
timeOfDayMin: number | null
timeOfDayMinUtc: number
surplusUnlockPct?: number
recurring: boolean
Expand Down Expand Up @@ -164,6 +170,7 @@ const pct = (v: unknown): number | null => {
export function toLoadpoint(w: WireLoadpoint): Loadpoint {
const sched = w.schedule
const schedMin = sched ? num(sched.time_of_day_min_utc) : null
const schedClock = sched ? num(sched.time_of_day_min) : null
const schedPct = pct(sched?.soc) ?? num(sched?.soc_pct)
const socFraction = num(w.current_soc)
const boost = w.battery_boost
Expand Down Expand Up @@ -214,6 +221,7 @@ export function toLoadpoint(w: WireLoadpoint): Loadpoint {
? null
: {
socPct: schedPct,
timeOfDayMin: schedClock !== null && schedClock >= 0 && schedClock < 1440 ? schedClock : null,
timeOfDayMinUtc: schedMin,
recurring: sched!.recurring === true,
surplusUnlockPct: typeof sched!.surplus_unlock_bat_soc === 'number' ? sched!.surplus_unlock_bat_soc * 100 : 0,
Expand All @@ -222,6 +230,26 @@ export function toLoadpoint(w: WireLoadpoint): Loadpoint {
}
}

type Schedule = NonNullable<Loadpoint['schedule']>

/**
* The ready time on the kitchen clock. The household clock when the box
* sends it, so a phone abroad shows the time at home; an older box sends
* only the UTC minute, read in this phone's zone.
*/
export function readyClock(s: Schedule, at: Date = new Date()): string {
if (s.timeOfDayMin == null) return localClock(s.timeOfDayMinUtc, at)
// Printed in UTC, so no zone of the phone's can move it.
const d = new Date(Date.UTC(2000, 0, 1, Math.floor(s.timeOfDayMin / 60), s.timeOfDayMin % 60))
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', timeZone: 'UTC' })
}

/** The same time for an <input type="time">: "HH:MM". */
export function readyInput(s: Schedule, at: Date = new Date()): string {
if (s.timeOfDayMin == null) return utcMinutesToLocalInput(s.timeOfDayMinUtc, at)
return `${String(Math.floor(s.timeOfDayMin / 60)).padStart(2, '0')}:${String(s.timeOfDayMin % 60).padStart(2, '0')}`
}

/** Minutes-of-day in UTC, printed on the kitchen clock. */
export function localClock(minUtc: number, at: Date = new Date()): string {
const d = new Date(at)
Expand All @@ -236,23 +264,27 @@ export function utcMinutesToLocalInput(minUtc: number, at: Date = new Date()): s
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}

/**
* A local "HH:MM" back to the wire's UTC minutes-of-day.
*
* Anchored on `at`, so it is exact for today's offset — the same rule the
* box's own page uses when it saves. The stored minute is fixed; a DST
* change shifts the local deadline an hour until re-saved, a known drift
* the box documents where it computes the deadline.
*/
export function localInputToUtcMinutes(hhmm: string, at: Date = new Date()): number | null {
/** "HH:MM" as minutes after midnight, the wire's time_of_day_min. */
export function inputMinutes(hhmm: string): number | null {
const m = hhmm.match(/^(\d{1,2}):(\d{2})$/)
if (!m) return null
const h = Number(m[1])
const min = Number(m[2])
// setHours would roll "25:99" into tomorrow rather than refuse it.
if (h > 23 || min > 59) return null
return h > 23 || min > 59 ? null : h * 60 + min
}

/**
* A local "HH:MM" as the UTC minute an older box reads.
*
* Anchored on `at`, so it is exact for today's offset in this phone's
* zone. A box that knows time_of_day_min ignores it and keeps the
* household clock, so a DST change does not move the ready time.
*/
export function localInputToUtcMinutes(hhmm: string, at: Date = new Date()): number | null {
const min = inputMinutes(hhmm)
if (min === null) return null
const d = new Date(at)
d.setHours(h, min, 0, 0)
d.setHours(Math.floor(min / 60), min % 60, 0, 0)
return d.getUTCHours() * 60 + d.getUTCMinutes()
}

Expand Down Expand Up @@ -366,7 +398,7 @@ export function evPlanSentence(lp: Loadpoint, now = Date.now(), canControl = tru
export function evScheduleSentence(lp: Loadpoint, at: Date = new Date()): string | null {
const s = lp.schedule
if (!s) return null
const when = localClock(s.timeOfDayMinUtc, at)
const when = readyClock(s, at)
const cadence = s.recurring ? daysWord(s.days) : 'once'
const target = s.socPct !== null ? `${Math.round(s.socPct)} % ` : ''
return `${target}Ready by ${when} · ${cadence}`
Expand Down
33 changes: 28 additions & 5 deletions src/lib/sim/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,7 @@ export class SimApi {
*/
#schedule: Record<string, unknown> | null = {
soc: 0.84,
time_of_day_min_utc: 360,
time_of_day_min: 420,
recurring: true,
}

Expand Down Expand Up @@ -756,7 +756,9 @@ export class SimApi {
}
: { state: 'inactive', active: false },
surplus_only: door.surplusOnly,
schedule: this.#schedule ?? { soc: 0, time_of_day_min_utc: 0, recurring: false },
schedule: this.#schedule
? { ...this.#schedule, time_of_day_min_utc: this.#utcMinute(this.#schedule['time_of_day_min'] as number) }
: { soc: 0, time_of_day_min_utc: 0, recurring: false },
},
],
})
Expand Down Expand Up @@ -784,21 +786,42 @@ export class SimApi {
if (typeof parsed !== 'object') return json(400, { error: 'invalid schedule' })

const s = parsed as Record<string, unknown>
const min = s['time_of_day_min_utc']
const min = s['time_of_day_min_utc'] ?? 0
if (typeof min !== 'number' || min < 0 || min >= 1440) {
return json(400, { error: 'time_of_day_min_utc must be 0..1439' })
}
// The household clock wins; an older client sends only the UTC minute.
const clock = s['time_of_day_min'] ?? this.#clock(min)
if (typeof clock !== 'number' || clock < 0 || clock >= 1440) {
return json(400, { error: 'time_of_day_min must be 0..1439' })
}
const days = s['days']
if (days !== undefined && (typeof days !== 'number' || days < 0 || days > 127)) {
return json(400, { error: 'days must be a 7-bit weekday mask (0..127, bit 0 = Monday)' })
}
const soc = typeof s['soc'] === 'number' ? s['soc'] : typeof s['soc_pct'] === 'number' ? s['soc_pct'] / 100 : 0
if (soc < 0 || soc > 1) return json(400, { error: 'soc must be between 0 and 1' })
const { soc_pct: _legacy, ...fields } = s
this.#schedule = { ...fields, soc }
const { soc_pct: _legacy, time_of_day_min_utc: _utc, ...fields } = s
this.#schedule = { ...fields, soc, time_of_day_min: clock }
return json(200, { ok: true })
}

/**
* The household clock and its UTC minute today, as the box converts
* them. The sim's house keeps this browser's zone.
*/
#clock(minUtc: number): number {
const d = new Date(this.#opts.now())
d.setUTCHours(Math.floor(minUtc / 60), minUtc % 60, 0, 0)
return d.getHours() * 60 + d.getMinutes()
}

#utcMinute(clock: number): number {
const d = new Date(this.#opts.now())
d.setHours(Math.floor(clock / 60), clock % 60, 0, 0)
return d.getUTCHours() * 60 + d.getUTCMinutes()
}

#clearSchedule(id: string): ApiAnswer {
if (id !== 'carport') return json(404, { error: 'loadpoint not found' })
this.#schedule = null
Expand Down
16 changes: 10 additions & 6 deletions src/views/EvPanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
evPlanSentence,
evScheduleSentence,
evSessionSentence,
utcMinutesToLocalInput,
readyInput,
inputMinutes,
localInputToUtcMinutes,
chargeCurrent,
wattsToAmps,
Expand Down Expand Up @@ -297,7 +298,7 @@
const wireDays = lp.schedule?.days ?? 0
draft = {
lpId: lp.id,
time: lp.schedule ? utcMinutesToLocalInput(lp.schedule.timeOfDayMinUtc) : '07:00',
time: lp.schedule ? readyInput(lp.schedule) : '07:00',
days: wireDays === 0 ? 0x7f : wireDays & 0x7f,
socPct: Math.round(lp.schedule?.socPct ?? (lp.targetSocPct != null && lp.targetSocPct >= 10 ? lp.targetSocPct : 80)),
recurring: lp.schedule?.recurring ?? false,
Expand Down Expand Up @@ -341,11 +342,11 @@
if (!draft || saving) return
const next = { ...draft }
const revision = scheduleRevision
const minUtc = localInputToUtcMinutes(next.time)
const min = inputMinutes(next.time)
pendingSchedule = false
if (!Number.isFinite(next.surplusUnlockPct) || next.surplusUnlockPct < 0 || next.surplusUnlockPct > 100) { saveError = 'Choose a home battery level from 1 to 100 %.'; return }
if (minUtc === null || (next.recurring && next.days === 0) || !Number.isFinite(next.socPct) || next.socPct < 10 || next.socPct > 100) {
saveError = minUtc === null ? 'Choose a valid ready time.' : next.recurring && next.days === 0 ? 'Choose at least one day.' : 'Choose a charge target from 10 to 100 %.'
if (min === null || (next.recurring && next.days === 0) || !Number.isFinite(next.socPct) || next.socPct < 10 || next.socPct > 100) {
saveError = min === null ? 'Choose a valid ready time.' : next.recurring && next.days === 0 ? 'Choose at least one day.' : 'Choose a charge target from 10 to 100 %.'
return
}
saving = true
Expand All @@ -356,7 +357,10 @@
path: `/api/loadpoints/${encodeURIComponent(next.lpId)}/schedule`,
body: {
soc: next.socPct / 100,
time_of_day_min_utc: minUtc,
// The household clock. An older box reads only the UTC minute,
// worked out in this phone's zone.
time_of_day_min: min,
time_of_day_min_utc: localInputToUtcMinutes(next.time),
recurring: next.recurring,
days: !next.recurring || next.days === 0x7f ? 0 : next.days & 0x7f,
surplus_unlock_bat_soc: next.surplusUnlockPct / 100,
Expand Down
3 changes: 3 additions & 0 deletions src/views/EvPanel.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,8 @@ describe('the charger behind its bubble', () => {
)
expect(saved, 'no schedule PUT reached the box').toBeDefined()
const bodyOnWire = JSON.parse(new TextDecoder().decode(saved![0].body!))
// The household clock, and the UTC minute an older box reads.
expect(bodyOnWire.time_of_day_min).toBe(8 * 60)
expect(bodyOnWire.time_of_day_min_utc).toBe(localInputToUtcMinutes('08:00'))
expect(bodyOnWire.days).toBe(0b0011111)
expect(bodyOnWire.recurring).toBe(true)
Expand Down Expand Up @@ -520,6 +522,7 @@ describe('the charger behind its bubble', () => {
expect(writes).toHaveLength(1)
const selected = JSON.parse(new TextDecoder().decode(writes[0]![0].body!))
expect(selected.soc).toBe(expectedSoc)
expect(selected.time_of_day_min).toBe(7 * 60)
expect(selected.time_of_day_min_utc).toBe(localInputToUtcMinutes('07:00'))
expect(document.body.textContent).toContain('Schedule saved')
})
Expand Down
Loading