From 207299d02cbf88a623b3e22667b39e483e009d17 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 15:57:41 +0200 Subject: [PATCH] fix(loadpoint): keep the EV ready time on the household clock Core stored the ready time as a UTC minute of day, so 07:00 became 06:00 when summer time ended and 08:00 when it began, until the owner saved again. Sweden leaves summer time on 2026-10-25. Core now keeps the time on the household clock (the box's zone, the same zone the weekday mask uses) and stores it in state.db as time_of_day_min. The API keeps time_of_day_min_utc in both directions: a client's UTC minute is read on today's date, and Core answers with today's UTC minute, so existing clients need no change. A row saved before this change holds only a UTC minute. It is read on the boot date, the time the owner sees then, and saved back once. Stored rows keep time_of_day_min_utc so a rollback still works. A time a DST change skips resolves to the end of the skipped hour; a repeated time resolves to its first occurrence. Co-Authored-By: Claude Opus 5.5 --- .changeset/ev-ready-time-household-clock.md | 5 + go/cmd/ftw/main.go | 12 +- .../api/api_loadpoint_schedule_test.go | 21 +- go/internal/loadpoint/loadpoint.go | 33 ++- go/internal/loadpoint/main_test.go | 16 ++ go/internal/loadpoint/persistence.go | 5 + go/internal/loadpoint/schedule.go | 167 ++++++++++--- go/internal/loadpoint/schedule_days_test.go | 24 +- go/internal/loadpoint/schedule_dst_test.go | 231 ++++++++++++++++++ go/internal/loadpoint/schedule_save_test.go | 2 +- go/internal/loadpoint/schedule_test.go | 16 +- go/internal/loadpoint/vehicle_goal_state.go | 2 +- go/internal/state/control_writes_test.go | 5 +- 13 files changed, 474 insertions(+), 65 deletions(-) create mode 100644 .changeset/ev-ready-time-household-clock.md create mode 100644 go/internal/loadpoint/main_test.go create mode 100644 go/internal/loadpoint/schedule_dst_test.go diff --git a/.changeset/ev-ready-time-household-clock.md b/.changeset/ev-ready-time-household-clock.md new file mode 100644 index 000000000..aaabd14a0 --- /dev/null +++ b/.changeset/ev-ready-time-household-clock.md @@ -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. diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 172dd0679..b78fac4c8 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -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:` stores "true" or // "false". Operators toggle this from the dashboard EV modal, not diff --git a/go/internal/api/api_loadpoint_schedule_test.go b/go/internal/api/api_loadpoint_schedule_test.go index b5f21f16d..e445aa5ee 100644 --- a/go/internal/api/api_loadpoint_schedule_test.go +++ b/go/internal/api/api_loadpoint_schedule_test.go @@ -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 != "" { @@ -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) } }) @@ -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) } @@ -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) +} diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index 9cc903971..66ac9e07b 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -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 } @@ -1483,11 +1490,20 @@ 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) @@ -1495,10 +1511,19 @@ func (m *Manager) HydrateSchedules(loader func(id string) (Schedule, bool)) { 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 diff --git a/go/internal/loadpoint/main_test.go b/go/internal/loadpoint/main_test.go new file mode 100644 index 000000000..40a5c3d07 --- /dev/null +++ b/go/internal/loadpoint/main_test.go @@ -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()) +} diff --git a/go/internal/loadpoint/persistence.go b/go/internal/loadpoint/persistence.go index b9c61b002..cbdcef158 100644 --- a/go/internal/loadpoint/persistence.go +++ b/go/internal/loadpoint/persistence.go @@ -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) diff --git a/go/internal/loadpoint/schedule.go b/go/internal/loadpoint/schedule.go index 100b103d9..90ca9d600 100644 --- a/go/internal/loadpoint/schedule.go +++ b/go/internal/loadpoint/schedule.go @@ -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 @@ -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 } @@ -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< 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() +} + +// 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 + } + return loc } diff --git a/go/internal/loadpoint/schedule_days_test.go b/go/internal/loadpoint/schedule_days_test.go index c75c257dc..ae609768f 100644 --- a/go/internal/loadpoint/schedule_days_test.go +++ b/go/internal/loadpoint/schedule_days_test.go @@ -23,16 +23,20 @@ func stockholm(t *testing.T) *time.Location { return loc } -// A zero mask is exactly the pre-mask behaviour: NextDeadlineUTC and -// NextDailyUTC agree on every case the old function was tested on, -// and a stored schedule without the field decodes to that zero. +// A zero mask is every day: within one offset the deadline is the +// stored UTC minute's next occurrence, and a stored schedule without +// the field decodes to that zero. func TestNextDeadlineUTC_ZeroMaskIsEveryDay(t *testing.T) { loc := stockholm(t) base := time.Date(2026, 5, 10, 8, 0, 0, 0, time.UTC) for _, min := range []int{0, 360, 480, 600, 1439} { s := Schedule{SoC: 0.8, TimeOfDayMinUTC: min} - if got, want := s.NextDeadlineUTC(base, loc), NextDailyUTC(base, min); !got.Equal(want) { - t.Errorf("zero mask, min=%d: got %v, want NextDailyUTC's %v", min, got, want) + want := time.Date(2026, 5, 10, min/60, min%60, 0, 0, time.UTC) + if !want.After(base) { + want = want.Add(24 * time.Hour) + } + if got := s.NextDeadlineUTC(base, loc); !got.Equal(want) { + t.Errorf("zero mask, min=%d: got %v, want %v", min, got, want) } } @@ -101,12 +105,12 @@ func TestManager_RollSchedules_WeekdayMaskSkipsWeekend(t *testing.T) { m := NewManager() m.Load([]Config{{ID: "garage", DriverName: "easee"}}) m.SetLocation(stockholm(t)) + // Friday 2026-05-15 19:00 UTC, past today's 06:00 slot. + now := time.Date(2026, 5, 15, 19, 0, 0, 0, time.UTC) + m.SetNowFn(func() time.Time { return now }) m.SetSchedule("garage", Schedule{ SoC: 0.8, TimeOfDayMinUTC: 360, Recurring: true, Days: maskMonToFri, }) - - // Friday 2026-05-15 19:00 UTC, past today's 06:00 slot. - now := time.Date(2026, 5, 15, 19, 0, 0, 0, time.UTC) m.RollSchedules(now) st, _ := m.State("garage") @@ -125,9 +129,9 @@ func TestManager_RollSchedules_ZeroMaskStillRollsDaily(t *testing.T) { m := NewManager() m.Load([]Config{{ID: "garage", DriverName: "easee"}}) m.SetLocation(stockholm(t)) - m.SetSchedule("garage", Schedule{SoC: 0.8, TimeOfDayMinUTC: 360, Recurring: true}) - now := time.Date(2026, 5, 15, 19, 0, 0, 0, time.UTC) // Friday evening + m.SetNowFn(func() time.Time { return now }) + m.SetSchedule("garage", Schedule{SoC: 0.8, TimeOfDayMinUTC: 360, Recurring: true}) m.RollSchedules(now) st, _ := m.State("garage") diff --git a/go/internal/loadpoint/schedule_dst_test.go b/go/internal/loadpoint/schedule_dst_test.go new file mode 100644 index 000000000..3b447c7e8 --- /dev/null +++ b/go/internal/loadpoint/schedule_dst_test.go @@ -0,0 +1,231 @@ +package loadpoint + +import ( + "encoding/json" + "testing" + "time" +) + +// The ready time is a time on the household's clock. Sweden leaves +// summer time on 2026-10-25 and returns on 2027-03-28; 07:00 must stay +// 07:00 across both, which moves it an hour in UTC. + +func dstManager(t *testing.T, now *time.Time) *Manager { + t.Helper() + m := NewManager() + m.Load([]Config{{ID: "garage", DriverName: "easee"}}) + m.SetLocation(stockholm(t)) + m.SetNowFn(func() time.Time { return *now }) + return m +} + +func utc(y int, mo time.Month, d, h, min int) time.Time { + return time.Date(y, mo, d, h, min, 0, 0, time.UTC) +} + +func TestReadyTimeKeepsWallClockOverDSTChanges(t *testing.T) { + loc := stockholm(t) + for _, c := range []struct { + name string + saved time.Time + minUTC int // what a client sends for 07:00 on the save date + want []time.Time // the next three deadlines around the change + }{ + {"autumn 2026-10-25", utc(2026, 10, 12, 10, 0), 5 * 60, + []time.Time{utc(2026, 10, 24, 5, 0), utc(2026, 10, 25, 6, 0), utc(2026, 10, 26, 6, 0)}}, + {"spring 2027-03-28", utc(2027, 2, 1, 10, 0), 6 * 60, + []time.Time{utc(2027, 3, 27, 6, 0), utc(2027, 3, 28, 5, 0), utc(2027, 3, 29, 5, 0)}}, + } { + t.Run(c.name, func(t *testing.T) { + now := c.saved + m := dstManager(t, &now) + m.SetSchedule("garage", Schedule{SoC: 0.8, TimeOfDayMinUTC: c.minUTC, Recurring: true}) + for _, want := range c.want { + now = want.Add(-10 * time.Hour) // the evening before + m.RollSchedules(now) + st, _ := m.State("garage") + if !st.TargetTime.Equal(want) { + t.Fatalf("deadline = %v, want %v", st.TargetTime, want) + } + if got := st.TargetTime.In(loc).Format("15:04"); got != "07:00" { + t.Fatalf("deadline on %s is %s on the household clock, want 07:00", want.Format("2006-01-02"), got) + } + } + }) + } +} + +// Clients turn the UTC minute into a local time with today's offset, so +// Core answers with today's UTC minute: 07:00 reads as 05:00 UTC in +// summer and 06:00 UTC in winter. +func TestClientsReadTodaysUTCMinute(t *testing.T) { + now := utc(2026, 11, 1, 10, 0) // CET + m := dstManager(t, &now) + var s Schedule + if err := json.Unmarshal([]byte(`{"soc":0.8,"time_of_day_min_utc":360,"recurring":true}`), &s); err != nil { + t.Fatal(err) + } + m.SetSchedule("garage", s) + for _, c := range []struct { + now time.Time + want int + }{ + {utc(2026, 11, 1, 10, 0), 6 * 60}, + {utc(2027, 3, 27, 10, 0), 6 * 60}, + {utc(2027, 3, 29, 10, 0), 5 * 60}, + {utc(2027, 10, 30, 10, 0), 5 * 60}, + {utc(2027, 11, 1, 10, 0), 6 * 60}, + } { + now = c.now + st, _ := m.State("garage") + if st.Schedule.TimeOfDayMinUTC != c.want { + t.Errorf("%s: time_of_day_min_utc = %d, want %d", c.now.Format("2006-01-02"), st.Schedule.TimeOfDayMinUTC, c.want) + } + } +} + +// The weekday is the household's. A Monday-only 00:30 goal falls on +// Sunday in UTC. Kept as a UTC minute it became Sunday 23:30 after the +// autumn change, so the mask skipped it and the car was due on Monday +// at 23:30, almost a day late. +func TestWeekdayMaskAtHalfPastMidnightOverDSTChanges(t *testing.T) { + loc := stockholm(t) + const monday = 1 << 0 + for _, c := range []struct { + name string + saved time.Time + minUTC int // 00:30 on the save date + now time.Time + want time.Time + }{ + {"autumn", utc(2026, 10, 12, 10, 0), 22*60 + 30, utc(2026, 10, 24, 12, 0), utc(2026, 10, 25, 23, 30)}, + {"spring", utc(2027, 2, 1, 10, 0), 23*60 + 30, utc(2027, 3, 27, 12, 0), utc(2027, 3, 28, 22, 30)}, + } { + t.Run(c.name, func(t *testing.T) { + now := c.saved + m := dstManager(t, &now) + m.SetSchedule("garage", Schedule{SoC: 0.8, TimeOfDayMinUTC: c.minUTC, Recurring: true, Days: monday}) + now = c.now + m.RollSchedules(now) + st, _ := m.State("garage") + if !st.TargetTime.Equal(c.want) { + t.Fatalf("deadline = %v, want %v", st.TargetTime, c.want) + } + local := st.TargetTime.In(loc) + if local.Weekday() != time.Monday || local.Format("15:04") != "00:30" { + t.Fatalf("deadline is %s %s on the household clock, want Monday 00:30", local.Weekday(), local.Format("15:04")) + } + }) + } +} + +// 02:30 never shows on 2027-03-28 and shows twice on 2026-10-25. The car +// is due when the clock first reaches it, and only once that day. +func TestReadyTimeInSkippedOrRepeatedHour(t *testing.T) { + loc := stockholm(t) + s := Schedule{SoC: 0.8, Recurring: true, clock: 2*60 + 30, clockSet: true} + for _, c := range []struct { + name string + now time.Time + want time.Time + wantWall string + }{ + {"skipped: the clock jumps from 02:00 to 03:00", utc(2027, 3, 27, 20, 0), utc(2027, 3, 28, 1, 0), "03:00"}, + {"repeated: the first 02:30", utc(2026, 10, 24, 20, 0), utc(2026, 10, 25, 0, 30), "02:30"}, + {"repeated: not again an hour later", utc(2026, 10, 25, 0, 45), utc(2026, 10, 26, 1, 30), "02:30"}, + } { + got := s.NextDeadlineUTC(c.now, loc) + if !got.Equal(c.want) || got.In(loc).Format("15:04") != c.wantWall { + t.Errorf("%s: got %v (%s), want %v (%s)", c.name, got, got.In(loc).Format("15:04"), c.want, c.wantWall) + } + } +} + +// A row saved before Core kept the household clock holds only a UTC +// minute. It is read on the boot date and saved back: before +// 2026-10-25 the summer time the owner set survives, after it the time +// the owner has seen since the change does. +func TestHydrateReadsOldRowOnBootDateAndSavesIt(t *testing.T) { + loc := stockholm(t) + const row = `{"soc":0.8,"time_of_day_min_utc":300,"recurring":true}` // 07:00 CEST + for _, c := range []struct { + name string + boot time.Time + want string + }{ + {"booted before the change", utc(2026, 10, 12, 10, 0), "07:00"}, + {"booted after the change", utc(2026, 11, 2, 10, 0), "06:00"}, + } { + t.Run(c.name, func(t *testing.T) { + now := c.boot + m := dstManager(t, &now) + var stored []byte + m.SetScheduleSaver(func(_ string, s Schedule) (err error) { + stored, err = s.MarshalStored() + return err + }) + err := m.HydrateSchedules(func(string) (Schedule, bool) { + s, err := UnmarshalStored([]byte(row)) + return s, err == nil + }) + if err != nil || stored == nil { + t.Fatalf("old row not saved back: err=%v", err) + } + + // A restart in the next season reads the saved clock, not UTC. + now = utc(2027, 4, 1, 10, 0) + again := dstManager(t, &now) + again.SetScheduleSaver(func(string, Schedule) error { + t.Fatal("a row with the household clock was saved again") + return nil + }) + if err := again.HydrateSchedules(func(string) (Schedule, bool) { + s, err := UnmarshalStored(stored) + return s, err == nil + }); err != nil { + t.Fatal(err) + } + again.RollSchedules(now) + st, _ := again.State("garage") + if got := st.TargetTime.In(loc).Format("15:04"); got != c.want { + t.Fatalf("deadline %v is %s on the household clock, want %s", st.TargetTime, got, c.want) + } + }) + } +} + +// state.db keeps the household clock. The API answer has the same fields +// as before, and a rollback to a Core without the clock still finds the +// UTC minute. +func TestStoredFormKeepsClockAndAPIFormDoesNot(t *testing.T) { + s := Schedule{SoC: 0.8, TimeOfDayMinUTC: 300, Recurring: true, Days: 31, clock: 420, clockSet: true} + raw, err := s.MarshalStored() + if err != nil { + t.Fatal(err) + } + back, err := UnmarshalStored(raw) + if err != nil || back != s { + t.Fatalf("stored round trip: got %+v, %v; want %+v from %s", back, err, s, raw) + } + var old struct { + TimeOfDayMinUTC int `json:"time_of_day_min_utc"` + } + if err := json.Unmarshal(raw, &old); err != nil || old.TimeOfDayMinUTC != 300 { + t.Fatalf("a rollback reads time_of_day_min_utc = %d from %s", old.TimeOfDayMinUTC, raw) + } + + wire, err := json.Marshal(s) + if err != nil { + t.Fatal(err) + } + var fields map[string]any + if err := json.Unmarshal(wire, &fields); err != nil { + t.Fatal(err) + } + if _, ok := fields["time_of_day_min"]; ok { + t.Fatalf("API form carries the stored clock: %s", wire) + } + if fields["time_of_day_min_utc"] != 300.0 { + t.Fatalf("API form lost time_of_day_min_utc: %s", wire) + } +} diff --git a/go/internal/loadpoint/schedule_save_test.go b/go/internal/loadpoint/schedule_save_test.go index b3dac1802..591adc59a 100644 --- a/go/internal/loadpoint/schedule_save_test.go +++ b/go/internal/loadpoint/schedule_save_test.go @@ -29,7 +29,7 @@ func TestScheduleCheckedReportsStorageFailureWithoutChangingGoal(t *testing.T) { } m.RollSchedules(time.Now()) after, _ := m.State("garage") - if after.Schedule != old || after.TargetSoC != before.TargetSoC || after.TargetTime != before.TargetTime { + if after.Schedule.withoutClock() != old || after.TargetSoC != before.TargetSoC || after.TargetTime != before.TargetTime { t.Fatalf("failed save changed the running goal: %+v", after) } } diff --git a/go/internal/loadpoint/schedule_test.go b/go/internal/loadpoint/schedule_test.go index 169d84fa6..981c40a8f 100644 --- a/go/internal/loadpoint/schedule_test.go +++ b/go/internal/loadpoint/schedule_test.go @@ -5,7 +5,9 @@ import ( "time" ) -func TestNextDailyUTC(t *testing.T) { +// In UTC the household clock is the UTC minute, so a zero mask rolls +// the stored minute to its next occurrence. +func TestNextDeadlineUTC_InUTC(t *testing.T) { base := time.Date(2026, 5, 10, 8, 0, 0, 0, time.UTC) cases := []struct { name string @@ -22,13 +24,13 @@ func TestNextDailyUTC(t *testing.T) { } for _, c := range cases { t.Run(c.name, func(t *testing.T) { - got := NextDailyUTC(c.now, c.min) + got := Schedule{SoC: 0.5, TimeOfDayMinUTC: c.min}.NextDeadlineUTC(c.now, time.UTC) if got.Hour() != c.wantH || got.Day() != c.wantD { - t.Errorf("NextDailyUTC(%v,%d) = %v, want H=%d D=%d", + t.Errorf("NextDeadlineUTC(%v,%d) = %v, want H=%d D=%d", c.now, c.min, got, c.wantH, c.wantD) } if !got.After(c.now) { - t.Errorf("NextDailyUTC must return time strictly after now; got %v <= %v", + t.Errorf("NextDeadlineUTC must return time strictly after now; got %v <= %v", got, c.now) } }) @@ -63,7 +65,7 @@ func TestManager_SetGetClearSchedule(t *testing.T) { t.Fatal("SetSchedule should succeed for known ID") } got, ok := m.GetSchedule("garage") - if !ok || got != s { + if !ok || got.withoutClock() != s { t.Errorf("GetSchedule roundtrip mismatch: got %+v, want %+v", got, s) } if m.SetSchedule("nope", s) { @@ -166,7 +168,7 @@ func TestManager_HydrateSchedules(t *testing.T) { return Schedule{}, false }) got, ok := m.GetSchedule("garage") - if !ok || got != want { + if !ok || got.withoutClock() != want { t.Errorf("HydrateSchedules: got=%+v ok=%v want=%+v", got, ok, want) } if _, ok := m.GetSchedule("street"); ok { @@ -225,7 +227,7 @@ func TestManager_LoadPreservesSchedule(t *testing.T) { // Re-load (config hot reload): same id should keep its schedule. m.Load([]Config{{ID: "garage", DriverName: "easee", MaxChargeW: 11000}}) got, ok := m.GetSchedule("garage") - if !ok || got != s { + if !ok || got.withoutClock() != s { t.Errorf("Load should preserve schedule across reload; got=%+v ok=%v", got, ok) } } diff --git a/go/internal/loadpoint/vehicle_goal_state.go b/go/internal/loadpoint/vehicle_goal_state.go index 08fc06e8f..717fd2feb 100644 --- a/go/internal/loadpoint/vehicle_goal_state.go +++ b/go/internal/loadpoint/vehicle_goal_state.go @@ -36,7 +36,7 @@ func (m *Manager) retainFinishGoal(id string) { if check { if raw, ok := m.sessionStore.LoadConfig(finishGoalKey(record.DeviceID)); ok { var saved savedFinishGoal - if json.Unmarshal([]byte(raw), &saved) == nil && saved.DeviceID == record.DeviceID && (saved.SessionID == record.SessionID || (saved.Completed && !record.Schedule.Recurring)) && saved.Schedule == record.Schedule && !saved.Deadline.IsZero() { + if json.Unmarshal([]byte(raw), &saved) == nil && saved.DeviceID == record.DeviceID && (saved.SessionID == record.SessionID || (saved.Completed && !record.Schedule.Recurring)) && saved.Schedule == record.Schedule.withoutClock() && !saved.Deadline.IsZero() { m.mu.Lock() if lp.finishGoalExplicit || !lp.finishAtVehicleLimit || lp.schedule != record.Schedule || lp.targetTime != record.Deadline { m.mu.Unlock() diff --git a/go/internal/state/control_writes_test.go b/go/internal/state/control_writes_test.go index 0907265e4..eb3d29002 100644 --- a/go/internal/state/control_writes_test.go +++ b/go/internal/state/control_writes_test.go @@ -307,7 +307,7 @@ func TestControlWritesVehicleGoalRestoresPastDeadline(t *testing.T) { } m := newManager() m.SetScheduleSaver(func(id string, goal loadpoint.Schedule) error { - data, err := json.Marshal(goal) + data, err := goal.MarshalStored() if err != nil { return err } @@ -336,8 +336,7 @@ func TestControlWritesVehicleGoalRestoresPastDeadline(t *testing.T) { m = newManager() m.HydrateSchedules(func(id string) (loadpoint.Schedule, bool) { raw, found := s.LoadConfig("loadpoint_schedule:" + id) - var goal loadpoint.Schedule - err := json.Unmarshal([]byte(raw), &goal) + goal, err := loadpoint.UnmarshalStored([]byte(raw)) return goal, found && err == nil }) m.RollSchedules(before.Add(2 * time.Hour))