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": patch
---

Keep the EV ready time on the household clock. A goal set to 07:00 stays 07:00 when summer time starts or ends; before, it moved an hour until saved again. A goal saved by an older version keeps the time it shows on the day of the update.
12 changes: 7 additions & 5 deletions go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -870,25 +870,27 @@ func main() {
if s.Empty() {
return st.SaveConfig(key, "{}")
}
b, err := json.Marshal(s)
b, err := s.MarshalStored()
if err != nil {
return err
}
return st.SaveConfig(key, string(b))
})
lpMgr.HydrateSchedules(func(id string) (loadpoint.Schedule, bool) {
if err := lpMgr.HydrateSchedules(func(id string) (loadpoint.Schedule, bool) {
v, ok := st.LoadConfig(lpSchedKeyPrefix + id)
if !ok || v == "" || v == "{}" {
return loadpoint.Schedule{}, false
}
var s loadpoint.Schedule
if err := json.Unmarshal([]byte(v), &s); err != nil {
s, err := loadpoint.UnmarshalStored([]byte(v))
if err != nil {
slog.Warn("failed to parse persisted loadpoint schedule",
"lp", id, "err", err)
return loadpoint.Schedule{}, false
}
return s, !s.Empty()
})
}); err != nil {
slog.Warn("could not save ready time on the household clock; it is read again at next start", "err", err)
}
// Persist surplus_only toggles the same way schedules persist —
// state.config key `loadpoint_surplus_only:<id>` stores "true" or
// "false". Operators toggle this from the dashboard EV modal, not
Expand Down
21 changes: 18 additions & 3 deletions go/internal/api/api_loadpoint_schedule_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ func TestScheduleStorageFailureKeepsGoalAndRetrySaves(t *testing.T) {
t.Fatalf("failed storage returned %d: %s", rr.Code, rr.Body.String())
}
after, _ := mgr.State("garage")
if after.Schedule != old || after.TargetSoC != before.TargetSoC || after.TargetTime != before.TargetTime {
if !sameGoal(t, after.Schedule, old) || after.TargetSoC != before.TargetSoC || after.TargetTime != before.TargetTime {
t.Fatalf("failed request changed the running goal: %+v", after)
}
if _, reason := svc.LastReplanInfo(); reason != "" {
Expand All @@ -84,7 +84,7 @@ func TestScheduleStorageFailureKeepsGoalAndRetrySaves(t *testing.T) {
if !saved.Empty() || !after.Schedule.Empty() || after.TargetSoC != 0 || !after.TargetTime.IsZero() {
t.Fatalf("retry failed to remove goal: saved=%+v state=%+v", saved, after)
}
} else if saved.SoC != .9 || after.Schedule != saved || after.TargetSoC != .9 || after.TargetTime.IsZero() {
} else if saved.SoC != .9 || !sameGoal(t, after.Schedule, saved) || after.TargetSoC != .9 || after.TargetTime.IsZero() {
t.Fatalf("retry did not apply the saved goal: saved=%+v state=%+v", saved, after)
}
})
Expand Down Expand Up @@ -154,7 +154,7 @@ func TestSchedulePutStoresRollsAndReplans(t *testing.T) {
t.Fatal("PUT did not store the schedule")
}
want := loadpoint.Schedule{SoC: 0.8, TimeOfDayMinUTC: 360, Recurring: true, Days: 31}
if got != want {
if !sameGoal(t, got, want) {
t.Fatalf("stored schedule = %+v, want %+v", got, want)
}

Expand Down Expand Up @@ -323,3 +323,18 @@ func TestVehicleLimitGoalCapabilityAndSchedule(t *testing.T) {
t.Fatal(rr.Code, st)
}
}

// sameGoal compares schedules as the API carries them. Core also keeps
// the household clock, which the API does not send.
func sameGoal(t *testing.T, a, b loadpoint.Schedule) bool {
t.Helper()
ja, err := json.Marshal(a)
if err != nil {
t.Fatal(err)
}
jb, err := json.Marshal(b)
if err != nil {
t.Fatal(err)
}
return string(ja) == string(jb)
}
33 changes: 29 additions & 4 deletions go/internal/loadpoint/loadpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -1390,15 +1390,22 @@ func (m *Manager) SetScheduleChecked(id string, s Schedule) (bool, error) {
m.mu.RLock()
lp, ok := m.byID[id]
saver := m.scheduleSaver
now, loc := m.now(), m.loc
m.mu.RUnlock()
if !ok {
return false, nil
}
s.Normalize()
// The owner picked a time on today's clock. Keep that clock time, and
// answer clients with its UTC minute today. A cleared goal has none.
if !s.Empty() {
s.readClock(now, loc)
s.TimeOfDayMinUTC = utcMinuteToday(now, s.clock, loc)
}
if s.FinishAtVehicleLimit {
s.IntentID = rand.Text()
if !s.Recurring {
s.FirstDeadlineMS = s.NextDeadlineUTC(m.now(), m.loc).UnixMilli()
s.FirstDeadlineMS = s.NextDeadlineUTC(now, loc).UnixMilli()
} else {
s.FirstDeadlineMS = 0
}
Expand Down Expand Up @@ -1483,22 +1490,40 @@ func (m *Manager) ClearScheduleChecked(id string) (bool, error) {
// silently ignored — the operator may have renamed a loadpoint, and
// resurrecting a schedule under the new ID would be surprising.
//
// Does NOT invoke the saver — this is a load path. Does NOT call
// RollSchedules either; the controller's first tick will handle that.
func (m *Manager) HydrateSchedules(loader func(id string) (Schedule, bool)) {
// A row saved before Core kept the household clock holds only a UTC
// minute, which does not say what offset it was saved with. It is read
// on today's date — the time the owner sees now — and saved back once so
// the next DST change cannot move it. The first failed save is returned;
// the goal is used either way and read again at the next boot. Does NOT
// call RollSchedules; the controller's first tick will handle that.
func (m *Manager) HydrateSchedules(loader func(id string) (Schedule, bool)) error {
m.intentMu.Lock()
defer m.intentMu.Unlock()
var failed error
m.mu.RLock()
saver := m.scheduleSaver
now, loc := m.now(), m.loc
m.mu.RUnlock()
for _, state := range m.States() {
id := state.ID
s, found := loader(id)
if !found || s.Empty() {
continue
}
s.Normalize()
if !s.clockSet {
s.readClock(now, loc)
if saver != nil {
if err := saver(id, s); err != nil && failed == nil {
failed = err
}
}
}
m.mu.Lock()
m.byID[id].schedule = s
m.mu.Unlock()
}
return failed
}

// RollSchedules brings each loadpoint's one-shot target_soc / target_time
Expand Down
16 changes: 16 additions & 0 deletions go/internal/loadpoint/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package loadpoint

import (
"os"
"testing"
"time"
)

// Tests read the household clock in UTC unless they pin a zone with
// SetLocation. A client's UTC minute is read on the date it is saved,
// so the machine's zone and season would otherwise move deadlines that
// tests compare with fixed dates.
func TestMain(m *testing.M) {
time.Local = time.UTC
os.Exit(m.Run())
}
5 changes: 5 additions & 0 deletions go/internal/loadpoint/persistence.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ func (m *Manager) WaitForPersistence(ctx context.Context) error {
// Caller holds Manager.mu. The optional status reader is memory-only.
func (m *Manager) snapshot(lp *loadpointRuntime) State {
st := lp.snapshot()
if !st.Schedule.Empty() {
// Clients read the ready time as a UTC minute on today's date.
now, loc := m.now(), zone(m.loc)
st.Schedule.TimeOfDayMinUTC = utcMinuteToday(now, st.Schedule.clockAt(now, loc), loc)
}
if writer, ok := m.sessionStore.(interface{ GroupStatus(string) error }); ok {
retention := func(group string) string {
err := writer.GroupStatus(group)
Expand Down
167 changes: 136 additions & 31 deletions go/internal/loadpoint/schedule.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,24 @@ type Schedule struct {
IntentID string `json:"intent_id,omitempty"`
FirstDeadlineMS int64 `json:"first_deadline_ms,omitempty"`
SoC float64 `json:"soc"`
TimeOfDayMinUTC int `json:"time_of_day_min_utc"` // 0..1439
Recurring bool `json:"recurring"`
// TimeOfDayMinUTC is the ready time as clients send and read it: the
// UTC minute of day (0..1439) of that time on today's date. Core keeps
// the household clock it stands for, so a DST change does not move
// the ready time, and answers with today's UTC minute.
TimeOfDayMinUTC int `json:"time_of_day_min_utc"`
Recurring bool `json:"recurring"`
// Days is an ISO weekday mask, Monday in bit 0. Zero is every day,
// which is what rows written before the field decode as. The bit
// is read in the box's zone, not UTC.
Days uint8 `json:"days,omitempty"`
SurplusUnlockBatSoC float64 `json:"surplus_unlock_bat_soc,omitempty"`

// clock is the ready time in minutes after midnight on the household
// clock, the box's zone that Days is read in. clockSet is false until
// Core has read TimeOfDayMinUTC into it. state.db keeps it as
// time_of_day_min; the API does not send it.
clock int
clockSet bool
}

// BatSoCUnlockHyst is the fraction gap between arm and release for the
Expand Down Expand Up @@ -66,6 +77,40 @@ func (s *Schedule) UnmarshalJSON(b []byte) error {
return nil
}

// MarshalStored is the state.db form: the API fields plus the household
// clock as time_of_day_min. time_of_day_min_utc stays so that a rollback
// to a Core without the clock still finds a ready time.
func (s Schedule) MarshalStored() ([]byte, error) {
type fields Schedule
row := struct {
fields
Clock *int `json:"time_of_day_min,omitempty"`
}{fields: fields(s)}
if s.clockSet {
row.Clock = &s.clock
}
return json.Marshal(row)
}

// UnmarshalStored reads a state.db row. A row without time_of_day_min
// predates the household clock; HydrateSchedules reads it on load.
func UnmarshalStored(b []byte) (Schedule, error) {
var s Schedule
if err := json.Unmarshal(b, &s); err != nil {
return Schedule{}, err
}
var row struct {
Clock *int `json:"time_of_day_min"`
}
if err := json.Unmarshal(b, &row); err != nil {
return Schedule{}, err
}
if row.Clock != nil && *row.Clock >= 0 && *row.Clock < 1440 {
s.clock, s.clockSet = *row.Clock, true
}
return s, nil
}

// HasTarget is true when automatic dispatch must treat the plan as a
// floor. Surplus may add to it and must not throttle it.
func (s Schedule) HasTarget() bool { return s.SoC > 0 || s.FinishAtVehicleLimit }
Expand All @@ -75,43 +120,103 @@ func (s Schedule) HasTarget() bool { return s.SoC > 0 || s.FinishAtVehicleLimit
// schedule on disk is naturally GC'd when the operator clears it via
// the API.
func (s Schedule) Empty() bool {
return !s.FinishAtVehicleLimit && s.SoC == 0 && s.TimeOfDayMinUTC == 0 && !s.Recurring && s.SurplusUnlockBatSoC == 0
return !s.FinishAtVehicleLimit && s.SoC == 0 && s.TimeOfDayMinUTC == 0 && s.clock == 0 && !s.Recurring && s.SurplusUnlockBatSoC == 0
}

// NextDailyUTC is the next UTC time-of-day strictly after now.
// minUTC is taken mod 1440.
func NextDailyUTC(now time.Time, minUTC int) time.Time {
minUTC = ((minUTC % 1440) + 1440) % 1440
now = now.UTC()
today := time.Date(now.Year(), now.Month(), now.Day(),
minUTC/60, minUTC%60, 0, 0, time.UTC)
if !today.After(now) {
today = today.Add(24 * time.Hour)
// readClock fixes the household clock from TimeOfDayMinUTC on now's
// date, once, so that later DST changes leave the ready time alone.
func (s *Schedule) readClock(now time.Time, loc *time.Location) {
if !s.clockSet {
s.clock, s.clockSet = clockFromUTC(now, s.TimeOfDayMinUTC, zone(loc)), true
}
}

// clockAt is the household clock of the ready time. A schedule Core has
// not read yet takes TimeOfDayMinUTC on now's date.
func (s Schedule) clockAt(now time.Time, loc *time.Location) int {
if s.clockSet {
return s.clock
}
return today
return clockFromUTC(now, s.TimeOfDayMinUTC, loc)
}

// NextDeadlineUTC is the next stored time-of-day on a day Days allows.
// The weekday is the household's: a 00:30 Saturday in Stockholm is still
// Friday in UTC. A zero mask is every day. nil loc is time.Local.
// The clock stays in UTC minutes, so a DST change moves the wall time
// by an hour until the operator saves again.
// withoutClock is s as plain JSON carries it, for comparing a copy read
// back from state.db with the goal in memory.
func (s Schedule) withoutClock() Schedule {
s.clock, s.clockSet = 0, false
return s
}

// NextDeadlineUTC is the next time the household clock shows the ready
// time, on a day Days allows, strictly after now. The clock and the
// weekday are read in loc; nil is time.Local. A zero mask is every day.
func (s Schedule) NextDeadlineUTC(now time.Time, loc *time.Location) time.Time {
next := NextDailyUTC(now, s.TimeOfDayMinUTC)
loc = zone(loc)
clock := s.clockAt(now, loc)
days := s.Days & 0x7F
if days == 0 {
return next
}
if loc == nil {
loc = time.Local
}
for range 7 {
y, mo, d := now.In(loc).Date()
// A non-zero mask allows one of any seven dates, so this ends.
for i := 0; ; i++ {
// time.Weekday counts Sunday=0; the mask counts Monday=0 (ISO).
iso := (int(next.In(loc).Weekday()) + 6) % 7
if days&(1<<iso) != 0 {
break
iso := (int(time.Date(y, mo, d+i, 12, 0, 0, 0, time.UTC).Weekday()) + 6) % 7
if days != 0 && days&(1<<iso) == 0 {
continue
}
if t := wallClock(y, mo, d+i, clock, loc); t.After(now) {
return t.UTC()
}
next = next.Add(24 * time.Hour)
}
return next
}

// wallClock is the first instant on the date y-mo-d at which the clock
// in loc shows clock minutes or later. A time a DST change skips gives
// the end of the skipped hour; a time it repeats gives the first one.
// Either way the car is ready no later than the owner asked.
func wallClock(y int, mo time.Month, d, clock int, loc *time.Location) time.Time {
t := time.Date(y, mo, d, clock/60, clock%60, 0, 0, loc)
start, end := t.ZoneBounds()
if got := t.Hour()*60 + t.Minute(); got != clock {
if got > clock {
return start
}
return end
}
_, offset := t.Zone()
_, before := start.Add(-time.Second).Zone()
if earlier := t.Add(-time.Duration(before-offset) * time.Second); before > offset && earlier.Before(start) {
return earlier
}
return t
}

// utcMinuteToday is the household clock as clients read it: the UTC
// minute of day of that time on now's date in loc.
func utcMinuteToday(now time.Time, clock int, loc *time.Location) int {
loc = zone(loc)
y, mo, d := now.In(loc).Date()
u := wallClock(y, mo, d, clock, loc).UTC()
return u.Hour()*60 + u.Minute()
Comment on lines +194 to +198

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve skipped-hour values across API round trips

On the spring-forward date, a persisted recurring 02:30 goal reaches this conversion through Manager.snapshot; wallClock normalizes it to 03:00, so utcMinuteToday returns the UTC minute that web/app.js:4645-4648 renders as 03:00. If the owner opens and saves the goal that day—even to change only its SoC—the client sends 03:00 back and clockFromUTC permanently replaces the durable 02:30 schedule with 03:00 for subsequent days. The wire representation needs to preserve the requested household-clock minute rather than the transition day's effective execution instant.

AGENTS.md reference: AGENTS.md:L28-L30

Useful? React with 👍 / 👎.

}

// clockFromUTC reads a UTC minute of day the way clients make it: as the
// time on now's date in loc that has that UTC minute. On the day a
// repeated hour gives two such times, the first wins.
func clockFromUTC(now time.Time, minUTC int, loc *time.Location) int {
minUTC = ((minUTC % 1440) + 1440) % 1440
y, mo, d := now.In(loc).Date()
start := wallClock(y, mo, d, 0, loc)
su := start.UTC()
t := time.Date(su.Year(), su.Month(), su.Day(), minUTC/60, minUTC%60, 0, 0, time.UTC)
if t.Before(start) {
t = t.Add(24 * time.Hour)
}
l := t.In(loc)
return l.Hour()*60 + l.Minute()
}

func zone(loc *time.Location) *time.Location {
if loc == nil {
return time.Local
}
Comment on lines +217 to +220

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Pass the household zone to Docker installations

In the supported 0.x Docker path, deploy/docker/Dockerfile installs tzdata but deploy/docker/compose.yaml supplies neither TZ nor /etc/localtime, so Debian leaves time.Local as UTC; main also never calls SetLocation. Consequently, a Swedish browser sending 05:00 UTC for a 07:00 goal is stored as 05:00 on the supposed household clock, and after the DST change the browser shows 06:00—the original regression remains for every non-UTC Docker household. Resolve the household location in Core or configure it in the supported Compose deployment instead of relying on this fallback.

AGENTS.md reference: AGENTS.md:L24-L25

Useful? React with 👍 / 👎.

return loc
}
Loading
Loading