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-on-the-wire.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 3 additions & 3 deletions go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
Expand Down
9 changes: 8 additions & 1 deletion go/internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
}
Expand Down Expand Up @@ -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,
Expand Down
131 changes: 131 additions & 0 deletions go/internal/api/api_loadpoint_household_clock_test.go
Original file line number Diff line number Diff line change
@@ -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])
})
}
}
27 changes: 17 additions & 10 deletions go/internal/api/api_loadpoint_schedule_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"net/http"
"net/http/httptest"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -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},
Expand Down Expand Up @@ -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))
}
25 changes: 13 additions & 12 deletions go/internal/loadpoint/loadpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand Down
5 changes: 3 additions & 2 deletions go/internal/loadpoint/persistence.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading