diff --git a/.changeset/ev-ready-time-on-the-wire.md b/.changeset/ev-ready-time-on-the-wire.md new file mode 100644 index 00000000..7cacc92f --- /dev/null +++ b/.changeset/ev-ready-time-on-the-wire.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Show and save the EV ready time on the household clock, even from a browser in another time zone. `GET /api/loadpoints` now sends `time_of_day_min`, minutes after midnight in the box's zone, and the schedule routes accept it. It wins over `time_of_day_min_utc`, which Core still sends and accepts for older clients. diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index c6a40516..cffc1766 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -871,7 +871,7 @@ func main() { if s.Empty() { return st.SaveConfig(key, "{}") } - b, err := s.MarshalStored() + b, err := json.Marshal(s) if err != nil { return err } @@ -882,8 +882,8 @@ func main() { if !ok || v == "" || v == "{}" { return loadpoint.Schedule{}, false } - s, err := loadpoint.UnmarshalStored([]byte(v)) - if err != nil { + var s loadpoint.Schedule + if err := json.Unmarshal([]byte(v), &s); err != nil { slog.Warn("failed to parse persisted loadpoint schedule", "lp", id, "err", err) return loadpoint.Schedule{}, false diff --git a/go/internal/api/api.go b/go/internal/api/api.go index 5935a437..9466dbb7 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -3938,6 +3938,9 @@ func (s *Server) applyLoadpointSchedule(id string, raw json.RawMessage) (int, st if sched.TimeOfDayMinUTC < 0 || sched.TimeOfDayMinUTC >= 1440 { return 400, "time_of_day_min_utc must be 0..1439" } + if min, ok := sched.TimeOfDayMin(); ok && (min < 0 || min >= 1440) { + return 400, "time_of_day_min must be 0..1439" + } if sched.Days > 0x7F { return 400, "days must be a 7-bit weekday mask (0..127, bit 0 = Monday)" } @@ -3990,7 +3993,11 @@ func (s *Server) replanForScheduleChange(id string) { // PUT /api/loadpoints/{id}/schedule replaces the loadpoint's schedule. // The body is the schedule object itself: // -// {"soc_pct": 80, "time_of_day_min_utc": 360, "recurring": true, "days": 31} +// {"soc": 0.8, "time_of_day_min": 420, "time_of_day_min_utc": 300, "recurring": true, "days": 31} +// +// time_of_day_min is the ready time on the household clock and wins +// when present. Older clients send only time_of_day_min_utc, the UTC +// minute of that time on today's date. // // Priced Configure where its sibling POST …/target is Actuate, because // a schedule is a standing instruction about future days: saved late, diff --git a/go/internal/api/api_loadpoint_household_clock_test.go b/go/internal/api/api_loadpoint_household_clock_test.go new file mode 100644 index 00000000..ebe80eab --- /dev/null +++ b/go/internal/api/api_loadpoint_household_clock_test.go @@ -0,0 +1,131 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// time_of_day_min is the ready time on the household clock, in the box's +// zone. Europe/Stockholm leaves summer time on 2026-10-25 and returns on +// 2027-03-28; 07:00 is 05:00 UTC in summer and 06:00 UTC in winter. + +type wireSchedule struct { + TimeOfDayMin *int `json:"time_of_day_min"` + TimeOfDayMinUTC int `json:"time_of_day_min_utc"` +} + +// householdServer is a schedule server in Stockholm at *now. +func householdServer(t *testing.T, now *time.Time) *Server { + t.Helper() + srv, mgr, _ := newScheduleServer(t) + loc, err := time.LoadLocation("Europe/Stockholm") + if err != nil { + t.Fatal(err) + } + mgr.SetLocation(loc) + mgr.SetNowFn(func() time.Time { return *now }) + return srv +} + +func getSchedule(t *testing.T, srv *Server) wireSchedule { + t.Helper() + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/loadpoints", nil)) + var body struct { + Loadpoints []struct { + Schedule wireSchedule `json:"schedule"` + } `json:"loadpoints"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil || len(body.Loadpoints) != 1 { + t.Fatalf("GET /api/loadpoints: %v: %s", err, rr.Body.String()) + } + return body.Loadpoints[0].Schedule +} + +func checkSchedule(t *testing.T, got wireSchedule, clock, minUTC int) { + t.Helper() + if got.TimeOfDayMin == nil || *got.TimeOfDayMin != clock || got.TimeOfDayMinUTC != minUTC { + t.Fatalf("schedule = %+v (time_of_day_min %v), want time_of_day_min %d and time_of_day_min_utc %d", + got, got.TimeOfDayMin, clock, minUTC) + } +} + +func postTarget(t *testing.T, srv *Server, body string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/api/loadpoints/garage/target", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, req) + return rr +} + +func TestHouseholdMinuteRoundTrip(t *testing.T) { + now := time.Date(2026, 10, 12, 10, 0, 0, 0, time.UTC) + srv := householdServer(t, &now) + if rr := putSchedule(t, srv, "garage", `{"soc":0.8,"time_of_day_min":420,"recurring":true}`); rr.Code != http.StatusOK { + t.Fatalf("PUT: %d %s", rr.Code, rr.Body.String()) + } + checkSchedule(t, getSchedule(t, srv), 420, 300) +} + +func TestHouseholdMinuteWinsOverUTCMinute(t *testing.T) { + now := time.Date(2026, 10, 12, 10, 0, 0, 0, time.UTC) + srv := householdServer(t, &now) + // The UTC minute says 13:00 in Stockholm. The household minute wins. + if rr := putSchedule(t, srv, "garage", `{"soc":0.8,"time_of_day_min":420,"time_of_day_min_utc":660,"recurring":true}`); rr.Code != http.StatusOK { + t.Fatalf("PUT: %d %s", rr.Code, rr.Body.String()) + } + checkSchedule(t, getSchedule(t, srv), 420, 300) + if rr := postTarget(t, srv, `{"schedule":{"soc":0.8,"time_of_day_min":0,"time_of_day_min_utc":300}}`); rr.Code != http.StatusOK { + t.Fatalf("POST target: %d %s", rr.Code, rr.Body.String()) + } + checkSchedule(t, getSchedule(t, srv), 0, 22*60) +} + +// A client that sends only time_of_day_min_utc is read as before: that +// UTC minute on today's date, answered with the same UTC minute. +func TestOldClientsSendAndReadTheUTCMinute(t *testing.T) { + now := time.Date(2026, 10, 12, 10, 0, 0, 0, time.UTC) + srv := householdServer(t, &now) + if rr := putSchedule(t, srv, "garage", `{"soc":0.8,"time_of_day_min_utc":300,"recurring":true}`); rr.Code != http.StatusOK { + t.Fatalf("PUT: %d %s", rr.Code, rr.Body.String()) + } + checkSchedule(t, getSchedule(t, srv), 420, 300) + if rr := postTarget(t, srv, `{"schedule":{"soc":0.8,"time_of_day_min_utc":360}}`); rr.Code != http.StatusOK { + t.Fatalf("POST target: %d %s", rr.Code, rr.Body.String()) + } + checkSchedule(t, getSchedule(t, srv), 480, 360) +} + +// The household minute stays put across both changes; only the UTC +// minute that older clients read moves. +func TestHouseholdMinuteOverStockholmDSTChanges(t *testing.T) { + for _, c := range []struct { + name string + saved time.Time + before time.Time + after time.Time + utc [2]int + }{ + {"autumn 2026-10-25", time.Date(2026, 10, 12, 10, 0, 0, 0, time.UTC), + time.Date(2026, 10, 24, 10, 0, 0, 0, time.UTC), time.Date(2026, 10, 26, 10, 0, 0, 0, time.UTC), [2]int{300, 360}}, + {"spring 2027-03-28", time.Date(2027, 2, 1, 10, 0, 0, 0, time.UTC), + time.Date(2027, 3, 27, 10, 0, 0, 0, time.UTC), time.Date(2027, 3, 29, 10, 0, 0, 0, time.UTC), [2]int{360, 300}}, + } { + t.Run(c.name, func(t *testing.T) { + now := c.saved + srv := householdServer(t, &now) + if rr := putSchedule(t, srv, "garage", `{"soc":0.8,"time_of_day_min":420,"recurring":true}`); rr.Code != http.StatusOK { + t.Fatalf("PUT: %d %s", rr.Code, rr.Body.String()) + } + now = c.before + checkSchedule(t, getSchedule(t, srv), 420, c.utc[0]) + now = c.after + checkSchedule(t, getSchedule(t, srv), 420, c.utc[1]) + }) + } +} diff --git a/go/internal/api/api_loadpoint_schedule_test.go b/go/internal/api/api_loadpoint_schedule_test.go index e445aa5e..830c3376 100644 --- a/go/internal/api/api_loadpoint_schedule_test.go +++ b/go/internal/api/api_loadpoint_schedule_test.go @@ -5,6 +5,7 @@ import ( "net/http" "net/http/httptest" "path/filepath" + "reflect" "strings" "testing" "time" @@ -226,6 +227,8 @@ func TestSchedulePutValidates(t *testing.T) { }{ {"time of day too large", "garage", `{"soc_pct":80,"time_of_day_min_utc":1440}`, http.StatusBadRequest}, {"negative time of day", "garage", `{"soc_pct":80,"time_of_day_min_utc":-1}`, http.StatusBadRequest}, + {"household minute too large", "garage", `{"soc_pct":80,"time_of_day_min":1440}`, http.StatusBadRequest}, + {"negative household minute", "garage", `{"soc_pct":80,"time_of_day_min":-1}`, http.StatusBadRequest}, {"days beyond seven bits", "garage", `{"soc_pct":80,"time_of_day_min_utc":360,"days":128}`, http.StatusBadRequest}, {"days not a number", "garage", `{"soc_pct":80,"days":"weekdays"}`, http.StatusBadRequest}, {"malformed json", "garage", `{`, http.StatusBadRequest}, @@ -324,17 +327,21 @@ func TestVehicleLimitGoalCapabilityAndSchedule(t *testing.T) { } } -// sameGoal compares schedules as the API carries them. Core also keeps -// the household clock, which the API does not send. +// sameGoal compares schedules as older clients read them. The household +// clock Core adds as time_of_day_min has its own tests. 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) + fields := func(s loadpoint.Schedule) map[string]any { + raw, err := json.Marshal(s) + var m map[string]any + if err == nil { + err = json.Unmarshal(raw, &m) + } + if err != nil { + t.Fatal(err) + } + delete(m, "time_of_day_min") + return m } - return string(ja) == string(jb) + return reflect.DeepEqual(fields(a), fields(b)) } diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index 66ac9e07..ce4dcfa4 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -1396,9 +1396,12 @@ func (m *Manager) SetScheduleChecked(id string, s Schedule) (bool, error) { 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() { + // Keep the household clock the owner picked: time_of_day_min when the + // request has it, else the UTC minute read on today's date. Clients + // still read its UTC minute today. A cleared goal has no ready time. + if s.Empty() { + s = s.withoutClock() + } else { s.readClock(now, loc) s.TimeOfDayMinUTC = utcMinuteToday(now, s.clock, loc) } @@ -1493,9 +1496,10 @@ func (m *Manager) ClearScheduleChecked(id string) (bool, error) { // 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. +// the next DST change cannot move it. A clock out of range is read the +// same way. 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() @@ -1511,12 +1515,9 @@ func (m *Manager) HydrateSchedules(loader func(id string) (Schedule, bool)) erro continue } s.Normalize() - if !s.clockSet { - s.readClock(now, loc) - if saver != nil { - if err := saver(id, s); err != nil && failed == nil { - failed = err - } + if s.readClock(now, loc) && saver != nil { + if err := saver(id, s); err != nil && failed == nil { + failed = err } } m.mu.Lock() diff --git a/go/internal/loadpoint/persistence.go b/go/internal/loadpoint/persistence.go index cbdcef15..88f3398d 100644 --- a/go/internal/loadpoint/persistence.go +++ b/go/internal/loadpoint/persistence.go @@ -26,9 +26,10 @@ func (m *Manager) WaitForPersistence(ctx context.Context) error { 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. + // Older 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) + st.Schedule.readClock(now, loc) + st.Schedule.TimeOfDayMinUTC = utcMinuteToday(now, st.Schedule.clock, loc) } if writer, ok := m.sessionStore.(interface{ GroupStatus(string) error }); ok { retention := func(group string) string { diff --git a/go/internal/loadpoint/schedule.go b/go/internal/loadpoint/schedule.go index 90ca9d60..c9c083fe 100644 --- a/go/internal/loadpoint/schedule.go +++ b/go/internal/loadpoint/schedule.go @@ -20,10 +20,10 @@ type Schedule struct { IntentID string `json:"intent_id,omitempty"` FirstDeadlineMS int64 `json:"first_deadline_ms,omitempty"` SoC float64 `json:"soc"` - // 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 is the ready time as older clients send and read + // it: the UTC minute of day (0..1439) of that time on today's date. + // Core answers with today's UTC minute. A request that carries + // time_of_day_min ignores it. 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, @@ -33,9 +33,10 @@ type Schedule struct { 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, the box's zone that Days is read in, so a DST change does not + // move it. JSON carries it as time_of_day_min, in state.db and on the + // API. clockSet is false for a request or row without it, until Core + // reads TimeOfDayMinUTC into it. clock int clockSet bool } @@ -60,6 +61,7 @@ func (s *Schedule) UnmarshalJSON(b []byte) error { type alias Schedule aux := struct { alias + Clock *int `json:"time_of_day_min"` SoCPct *float64 `json:"soc_pct"` SurplusUnlockBatSoCPct *float64 `json:"surplus_unlock_bat_soc_pct"` }{} @@ -67,6 +69,9 @@ func (s *Schedule) UnmarshalJSON(b []byte) error { return err } *s = Schedule(aux.alias) + if aux.Clock != nil { + s.clock, s.clockSet = *aux.Clock, true + } if s.SoC == 0 && aux.SoCPct != nil { s.SoC = *aux.SoCPct } @@ -77,10 +82,10 @@ 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) { +// MarshalJSON adds the household clock as time_of_day_min once Core has +// one. time_of_day_min_utc stays for older clients, and so that a +// rollback to a Core without the clock still finds a ready time. +func (s Schedule) MarshalJSON() ([]byte, error) { type fields Schedule row := struct { fields @@ -92,24 +97,10 @@ func (s Schedule) MarshalStored() ([]byte, error) { 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 -} +// TimeOfDayMin is the ready time on the household clock, in minutes +// after midnight, and whether the schedule carries one. A decoded +// request may carry a value out of range; the API rejects it. +func (s Schedule) TimeOfDayMin() (int, bool) { return s.clock, s.clockSet } // HasTarget is true when automatic dispatch must treat the plan as a // floor. Surplus may add to it and must not throttle it. @@ -124,24 +115,25 @@ func (s Schedule) Empty() bool { } // 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 - } +// date, once, so that later DST changes leave the ready time alone. A +// clock out of range is read again. It reports whether it read one. +func (s *Schedule) readClock(now time.Time, loc *time.Location) bool { + if s.clockSet && s.clock >= 0 && s.clock < 1440 { + return false + } + s.clock, s.clockSet = clockFromUTC(now, s.TimeOfDayMinUTC, zone(loc)), true + return 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 clockFromUTC(now, s.TimeOfDayMinUTC, loc) + s.readClock(now, loc) + return s.clock } -// withoutClock is s as plain JSON carries it, for comparing a copy read -// back from state.db with the goal in memory. +// withoutClock is s without the household clock, for comparing a goal +// with a copy saved before Core kept the clock, and for a cleared goal. func (s Schedule) withoutClock() Schedule { s.clock, s.clockSet = 0, false return s diff --git a/go/internal/loadpoint/schedule_dst_test.go b/go/internal/loadpoint/schedule_dst_test.go index 3b447c7e..c8210966 100644 --- a/go/internal/loadpoint/schedule_dst_test.go +++ b/go/internal/loadpoint/schedule_dst_test.go @@ -2,6 +2,7 @@ package loadpoint import ( "encoding/json" + "strings" "testing" "time" ) @@ -161,11 +162,12 @@ func TestHydrateReadsOldRowOnBootDateAndSavesIt(t *testing.T) { m := dstManager(t, &now) var stored []byte m.SetScheduleSaver(func(_ string, s Schedule) (err error) { - stored, err = s.MarshalStored() + stored, err = json.Marshal(s) return err }) err := m.HydrateSchedules(func(string) (Schedule, bool) { - s, err := UnmarshalStored([]byte(row)) + var s Schedule + err := json.Unmarshal([]byte(row), &s) return s, err == nil }) if err != nil || stored == nil { @@ -180,7 +182,8 @@ func TestHydrateReadsOldRowOnBootDateAndSavesIt(t *testing.T) { return nil }) if err := again.HydrateSchedules(func(string) (Schedule, bool) { - s, err := UnmarshalStored(stored) + var s Schedule + err := json.Unmarshal(stored, &s) return s, err == nil }); err != nil { t.Fatal(err) @@ -194,38 +197,74 @@ func TestHydrateReadsOldRowOnBootDateAndSavesIt(t *testing.T) { } } -// 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) { +// state.db and the API carry the same form: the household clock, and +// the UTC minute for older clients and for a rollback to a Core +// without the clock. +func TestJSONKeepsClockAndUTCMinute(t *testing.T) { s := Schedule{SoC: 0.8, TimeOfDayMinUTC: 300, Recurring: true, Days: 31, clock: 420, clockSet: true} - raw, err := s.MarshalStored() + raw, err := json.Marshal(s) 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 back Schedule + if err := json.Unmarshal(raw, &back); err != nil || back != s { + t.Fatalf("round trip: got %+v, %v; want %+v from %s", back, err, s, raw) } var old struct { - TimeOfDayMinUTC int `json:"time_of_day_min_utc"` + TimeOfDayMin *int `json:"time_of_day_min"` + 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) + if err := json.Unmarshal(raw, &old); err != nil || old.TimeOfDayMin == nil || *old.TimeOfDayMin != 420 || old.TimeOfDayMinUTC != 300 { + t.Fatalf("want time_of_day_min 420 and time_of_day_min_utc 300 in %s", 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) + // A schedule without a clock, such as a cleared goal, sends none. + raw, err = json.Marshal(Schedule{}) + if err != nil || strings.Contains(string(raw), `"time_of_day_min"`) { + t.Fatalf("empty schedule: %s, %v", raw, err) } - if fields["time_of_day_min_utc"] != 300.0 { - t.Fatalf("API form lost time_of_day_min_utc: %s", wire) +} + +// A goal saved with time_of_day_min stays on the household clock across +// both changes. GET answers the same household minute all year, and the +// UTC minute older clients read moves with the offset. +func TestHouseholdMinuteOverDSTChanges(t *testing.T) { + loc := stockholm(t) + for _, c := range []struct { + name string + saved time.Time + days []time.Time // ready times, read the evening before + utc []int // time_of_day_min_utc on each of those evenings + }{ + {"autumn 2026-10-25", utc(2026, 10, 12, 10, 0), + []time.Time{utc(2026, 10, 24, 5, 0), utc(2026, 10, 25, 6, 0), utc(2026, 10, 26, 6, 0)}, + []int{5 * 60, 5 * 60, 6 * 60}}, + {"spring 2027-03-28", utc(2027, 2, 1, 10, 0), + []time.Time{utc(2027, 3, 27, 6, 0), utc(2027, 3, 28, 5, 0), utc(2027, 3, 29, 5, 0)}, + []int{6 * 60, 6 * 60, 5 * 60}}, + } { + t.Run(c.name, func(t *testing.T) { + now := c.saved + m := dstManager(t, &now) + var s Schedule + if err := json.Unmarshal([]byte(`{"soc":0.8,"time_of_day_min":420,"recurring":true}`), &s); err != nil { + t.Fatal(err) + } + m.SetSchedule("garage", s) + for i, want := range c.days { + now = want.Add(-10 * time.Hour) + m.RollSchedules(now) + st, _ := m.State("garage") + if !st.TargetTime.Equal(want) || st.TargetTime.In(loc).Format("15:04") != "07:00" { + t.Fatalf("deadline = %v, want %v at 07:00", st.TargetTime, want) + } + if min, ok := st.Schedule.TimeOfDayMin(); !ok || min != 420 { + t.Fatalf("%s: time_of_day_min = %d (%v), want 420", now.Format("2006-01-02"), min, ok) + } + if st.Schedule.TimeOfDayMinUTC != c.utc[i] { + t.Fatalf("%s: time_of_day_min_utc = %d, want %d", now.Format("2006-01-02"), st.Schedule.TimeOfDayMinUTC, c.utc[i]) + } + } + }) } } diff --git a/go/internal/loadpoint/vehicle_goal_state.go b/go/internal/loadpoint/vehicle_goal_state.go index 717fd2fe..4a98d54f 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.withoutClock() && !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.withoutClock() == 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 eb3d2900..0907265e 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 := goal.MarshalStored() + data, err := json.Marshal(goal) if err != nil { return err } @@ -336,7 +336,8 @@ func TestControlWritesVehicleGoalRestoresPastDeadline(t *testing.T) { m = newManager() m.HydrateSchedules(func(id string) (loadpoint.Schedule, bool) { raw, found := s.LoadConfig("loadpoint_schedule:" + id) - goal, err := loadpoint.UnmarshalStored([]byte(raw)) + var goal loadpoint.Schedule + err := json.Unmarshal([]byte(raw), &goal) return goal, found && err == nil }) m.RollSchedules(before.Add(2 * time.Hour)) diff --git a/web/app.js b/web/app.js index ae7d4402..ffb2e8e1 100644 --- a/web/app.js +++ b/web/app.js @@ -4104,13 +4104,12 @@ // threshold (5 pp release hysteresis). function buildScheduleSection(lp, hasPV, supportsVehicleLimit) { var sched = (lp && lp.schedule) || {}; - // Convert "minutes-of-day-UTC" to a "HH:MM" string in the browser's - // local zone. The UI shows local time everywhere; we marshal back to - // UTC minutes on save. + // The ready time is on the household clock, the box's zone, wherever + // the browser is. supportsVehicleLimit = supportsVehicleLimit === true; var useVehicleLimit = sched.finish_at_vehicle_limit === true; var hasSched = !!(useVehicleLimit || sched.soc || sched.recurring || sched.surplus_unlock_bat_soc); - var initLocalHHMM = hasSched ? utcMinsToLocalHHMM(typeof sched.time_of_day_min_utc === "number" ? sched.time_of_day_min_utc : 360) : "07:00"; + var initHHMM = hasSched ? readyHHMM(sched) : "07:00"; var initSoC = typeof sched.soc === "number" && sched.soc > 0 ? sched.soc * 100 : 80; var percentSoC = useVehicleLimit && typeof sched.soc === "number" ? sched.soc : initSoC / 100; var initRec = !!sched.recurring; @@ -4219,7 +4218,7 @@ var timeInp = document.createElement("input"); timeInp.type = "time"; - timeInp.value = initLocalHHMM; + timeInp.value = initHHMM; timeInp.style.padding = "0.25rem 0.4rem"; timeInp.style.background = "var(--ink-raised)"; timeInp.style.color = "var(--fg)"; @@ -4476,8 +4475,7 @@ } function doSave() { var seq = saveSeq; - var localHHMM = timeInp.value || initLocalHHMM; - var minUTC = localHHMMToUtcMins(localHHMM); + var hhmm = timeInp.value || initHHMM; // Surplus checkbox gates the threshold: when off (or hidden on // PV-less sites), the threshold is sent as 0 — the backend // interprets 0 as "feature disabled". @@ -4490,7 +4488,10 @@ var body = { schedule: { soc: percentSoC, - time_of_day_min_utc: minUTC, + // The household clock. A Core without time_of_day_min reads + // the UTC minute instead, worked out in this browser's zone. + time_of_day_min: hhmmToMins(hhmm), + time_of_day_min_utc: localHHMMToUtcMins(hhmm), recurring: !!recCb.checked, // All seven days is the wire's zero. days: days === 0x7f ? 0 : days, @@ -4626,7 +4627,7 @@ var hasGoal = !!(s && (s.finish_at_vehicle_limit === true || s.soc > 0)); summary.textContent = hasGoal ? (s.finish_at_vehicle_limit === true ? "Car's charge limit" : Math.round(s.soc * 100) + " %") + - " by " + utcMinsToLocalHHMM(s.time_of_day_min_utc) + + " by " + readyHHMM(s) + (nextLp.goal_complete === true ? " · completed" : s.recurring ? " · repeats" : " · once") : "No ready time set."; editLabel.textContent = hasGoal ? "Change goal" : "Set a ready time"; @@ -4642,18 +4643,29 @@ return container; } + // The ready time as "HH:MM" on the household clock. A Core without + // time_of_day_min sends only the UTC minute, read in this browser's zone. + function readyHHMM(s) { + if (typeof s.time_of_day_min === "number") { + return String(Math.floor(s.time_of_day_min / 60)).padStart(2, "0") + ":" + String(s.time_of_day_min % 60).padStart(2, "0"); + } + return utcMinsToLocalHHMM(typeof s.time_of_day_min_utc === "number" ? s.time_of_day_min_utc : 360); + } function utcMinsToLocalHHMM(min) { var d = new Date(); d.setUTCHours(Math.floor(min / 60), min % 60, 0, 0); return String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0"); } - function localHHMMToUtcMins(hhmm) { + // "HH:MM" as minutes after midnight; 06:00 when it does not parse. + function hhmmToMins(hhmm) { var parts = String(hhmm).split(":"); - if (parts.length !== 2) return 360; var h = parseInt(parts[0], 10), m = parseInt(parts[1], 10); - if (isNaN(h) || isNaN(m)) return 360; + return parts.length === 2 && !isNaN(h) && !isNaN(m) ? h * 60 + m : 360; + } + function localHHMMToUtcMins(hhmm) { + var min = hhmmToMins(hhmm); var d = new Date(); - d.setHours(h, m, 0, 0); + d.setHours(Math.floor(min / 60), min % 60, 0, 0); return d.getUTCHours() * 60 + d.getUTCMinutes(); } diff --git a/web/ev-vehicle-limit-goal.test.mjs b/web/ev-vehicle-limit-goal.test.mjs index d91aa176..ed514fa3 100644 --- a/web/ev-vehicle-limit-goal.test.mjs +++ b/web/ev-vehicle-limit-goal.test.mjs @@ -176,3 +176,23 @@ test('pending goal write is saving, not failed or confirmed saved', () => { ui.root.update({ ...ui.lp, goal_retention: 'session' }, null); assert.match(note.textContent, /goal is saved and can be restored/); }); + +const clock = d => String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0'); + +test('the ready time is on the household clock in any browser zone', async () => { + const ui = fixture({ schedule: { soc: .8, time_of_day_min: 420, time_of_day_min_utc: 300, recurring: true } }); + assert.equal(ui.time.value, '07:00'); + assert.match(ui.text(), /80 % by 07:00 · repeats/); + ui.time.value = '08:15'; ui.time.emit('change'); await ui.flush(); + assert.equal(ui.requests[0].schedule.time_of_day_min, 8 * 60 + 15); + // An older Core reads the UTC minute, worked out in this browser's zone. + const d = new Date(); d.setHours(8, 15, 0, 0); + assert.equal(ui.requests[0].schedule.time_of_day_min_utc, d.getUTCHours() * 60 + d.getUTCMinutes()); +}); + +test('a Core without time_of_day_min is read in the browser zone', () => { + const ui = fixture({ schedule: { soc: .8, time_of_day_min_utc: 300, recurring: true } }); + const d = new Date(); d.setUTCHours(5, 0, 0, 0); + assert.equal(ui.time.value, clock(d)); + assert.match(ui.text(), new RegExp('80 % by ' + clock(d))); +});