diff --git a/.changeset/household-time-zone.md b/.changeset/household-time-zone.md new file mode 100644 index 000000000..32bbfe945 --- /dev/null +++ b/.changeset/household-time-zone.md @@ -0,0 +1,5 @@ +--- +"ftw": minor +--- + +Store the home's time zone on the site. Car goals, the import hour, daily history and savings now read the site's `site.time_zone` instead of the host's clock, so a Docker box on UTC meets a 17:00 goal at 17:00 at home. The app, the web client and the on-box setup offer their own zone to a box that keeps none, and the box restarts once to run on it. A box that already keeps a zone, stored or from its host, keeps it; change it under Settings → Site. diff --git a/config.example.yaml b/config.example.yaml index b0945ad87..c9073eea6 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -5,6 +5,7 @@ site: name: "Home" + # time_zone: Europe/Stockholm # the home's clock; empty uses the host's zone (UTC in Docker) control_interval_s: 2 # 2 s matches Ferroamp ehub ~1 Hz; drop to 1 for snappier response grid_target_w: 0 # 0 = self-consumption grid_tolerance_w: 42 # deadband (The Answer) diff --git a/contract/registry.yaml b/contract/registry.yaml index a874b6af9..94d7f805e 100644 --- a/contract/registry.yaml +++ b/contract/registry.yaml @@ -223,6 +223,7 @@ cloud_purposes: [serve, stats] ops: - { name: site.mode.set, scope: ftw.mode.write, agent: ftw.goals.request, desc: Change the site operating mode } - { name: planner.prefs.set, scope: ftw.mode.write, agent: ftw.goals.request, desc: Set household planner preferences } + - { name: site.time_zone.propose, scope: ftw.setup.write, desc: Offer the phone's time zone to a box that keeps none } - { name: battery.hold, scope: ftw.dispatch.write, desc: Hold the battery at a fixed setpoint } - { name: loadpoint.hold, scope: ftw.dispatch.write, agent: ftw.dispatch.hold, desc: Charge the car now at a fixed current } - { name: loadpoint.boost, scope: ftw.dispatch.write, desc: Boost the car from the house battery } diff --git a/docs/cars.md b/docs/cars.md index aaa49c50a..7a087afa8 100644 --- a/docs/cars.md +++ b/docs/cars.md @@ -181,7 +181,12 @@ charger's phase mode. A change of car or goal rolls the charger's deadline before the replan, so that plan has the car's goal in it. - **One goal per car**, used wherever the car is plugged in. `readyByMin` is - in the box's time zone. + in the box's time zone: the site's `site.time_zone`, or the host's when the + site stores none. A Docker host runs on UTC, so the app, the web client + and the on-box setup offer their own zone (`site.time_zone.propose`). The + box takes an offer only while it keeps no zone, and restarts to run on it. + A recurring goal keeps its clock time in the new zone. A one-off goal that + finishes at the car's limit keeps the instant it was saved for. - **Surplus-only is the car's choice.** The site's battery rule (`surplus_unlock_bat_soc`) is the charger's: it rides `PUT /api/loadpoints/{id}/schedule` as before, and no car op carries it. diff --git a/go/cmd/ftw/app_link.go b/go/cmd/ftw/app_link.go index 2b9e58206..61e3cf1d6 100644 --- a/go/cmd/ftw/app_link.go +++ b/go/cmd/ftw/app_link.go @@ -822,6 +822,7 @@ func startAppLink( Modes: modes, Plans: plans, PlannerPrefs: gateway, + TimeZones: gateway, History: history, Prices: priceReader, Loadpoints: loadpoints, @@ -936,6 +937,16 @@ func (l *lateAPI) Apply(safetyK *float64, export *string) (appproto.PlannerPrefs return srv.ApplyPlannerPrefs(safetyK, export) } +// ProposeTimeZone stores a client's time zone through the API server, once it +// exists, for the reason Apply waits. +func (l *lateAPI) ProposeTimeZone(zone string) (appproto.TimeZoneAnswer, error) { + srv := l.srv.Load() + if srv == nil { + return appproto.TimeZoneAnswer{}, errors.New("the box is still starting") + } + return srv.ProposeTimeZone(zone) +} + func (l *lateAPI) Route(r *http.Request) apiauth.RouteFacts { srv := l.srv.Load() if srv == nil { diff --git a/go/cmd/ftw/household_clock_test.go b/go/cmd/ftw/household_clock_test.go new file mode 100644 index 000000000..95d42a872 --- /dev/null +++ b/go/cmd/ftw/household_clock_test.go @@ -0,0 +1,34 @@ +package main + +import ( + "os" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/localzone" +) + +// Erik's car goal (31 October 2026): 100 % by 17:00 on Sunday, on a Docker +// box whose host clock is UTC. With the zone stored on the site, the box +// plans 17:00 in Stockholm, 16:00Z in winter, instead of 17:00Z (18:00 at +// home). +func TestABoxOnUTCPlansACarGoalOnTheStoredHouseholdClock(t *testing.T) { + local := time.Local + t.Setenv("TZ", os.Getenv("TZ")) + t.Cleanup(func() { + _ = localzone.Use("") + time.Local = local + }) + time.Local = time.UTC + + useHouseholdClock("Europe/Stockholm") + + const sunday = 1 << 6 + goal := loadpoint.NewSchedule(1, 17*60, true, sunday, false) + saturday := time.Date(2026, 10, 31, 12, 0, 0, 0, time.UTC) + got := goal.NextDeadlineUTC(saturday, nil) + if want := time.Date(2026, 11, 1, 16, 0, 0, 0, time.UTC); !got.Equal(want) { + t.Fatalf("17:00 goal due %s, want %s (17:00 in Stockholm)", got.UTC().Format(time.RFC3339), want.Format(time.RFC3339)) + } +} diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 88e1d4ab0..f732c4e8b 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -425,6 +425,9 @@ func main() { os.Exit(1) } slog.Info("config loaded", "site", cfg.Site.Name, "drivers", len(cfg.Drivers)) + // The household clock is set before anything else reads it: every + // goroutine from here on reads time.Local. + useHouseholdClock(cfg.Site.TimeZone) // Repaired-but-wrong config: ERROR so it reaches the log ring and the // support report, without stopping a boot the repair made safe. for _, w := range cfg.LoadWarnings { @@ -526,6 +529,11 @@ func main() { slog.Error("absorb cold history", "err", err) os.Exit(1) } + if cleared, err := st.KeepDailyZone(context.Background(), localzone.Name()); err != nil { + slog.Warn("could not check the zone of cached daily energy", "err", err) + } else if cleared { + slog.Info("household zone changed; daily energy is computed again on the new clock", "zone", localzone.Name()) + } defer func() { if err := st.Close(); err != nil { slog.Error("state shutdown failed", "err", err) @@ -3475,6 +3483,15 @@ func rolloffLoop(ctx context.Context, st *state.Store, coldDir string, dataMaint // seedImportHour gives the import ceiling the clock hour's import that // state.db recorded before this start, so a restart keeps it. +// useHouseholdClock runs Core on the site's stored zone, or the host's when +// none is stored. +func useHouseholdClock(stored string) { + if err := localzone.Use(stored); err != nil { + slog.Error("site.time_zone does not load; the box keeps the host's zone", "err", err) + } + slog.Info("household clock", "zone", localzone.Name(), "stored", localzone.Stored() != "") +} + func seedImportHour(st *state.Store, ctrl *control.State, now time.Time) { start := control.ImportHourStart(now) wh, covered, err := st.ImportWhIntervals(context.Background(), [][2]int64{{start.UnixMilli(), now.UnixMilli()}}) diff --git a/go/internal/api/api_time_zone.go b/go/internal/api/api_time_zone.go new file mode 100644 index 000000000..9786d7ab3 --- /dev/null +++ b/go/internal/api/api_time_zone.go @@ -0,0 +1,55 @@ +package api + +import ( + "errors" + "log/slog" + "time" + + "github.com/srcfl/ftw/go/internal/appproto" + "github.com/srcfl/ftw/go/internal/configreload" + "github.com/srcfl/ftw/go/internal/localzone" +) + +// householdZoneKnown is localzone.Known; tests stand in a host on UTC. +var householdZoneKnown = localzone.Known + +// ProposeTimeZone stores a client's time zone as the site's household clock, +// when the box keeps none: no zone stored, and a host on UTC. A box that +// keeps one answers with it, so a phone abroad never moves the house's clock; +// the owner changes a kept zone in setup. +// +// Core sets the clock once at start, so a zone with other rules than the +// running clock restarts Core. That happens once per box, on its first +// offer. +func (s *Server) ProposeTimeZone(zone string) (appproto.TimeZoneAnswer, error) { + loc, err := localzone.Load(zone) + if err != nil { + return appproto.TimeZoneAnswer{}, err + } + if s.deps.Cfg == nil || s.deps.CfgMu == nil || s.deps.SaveConfig == nil { + return appproto.TimeZoneAnswer{}, errors.New("settings are not available yet") + } + s.configWriteMu.Lock() + defer s.configWriteMu.Unlock() + + s.deps.CfgMu.RLock() + next := *s.deps.Cfg + s.deps.CfgMu.RUnlock() + if next.Site.TimeZone != "" { + return appproto.TimeZoneAnswer{Zone: next.Site.TimeZone}, nil + } + if householdZoneKnown() { + return appproto.TimeZoneAnswer{Zone: localzone.Name()}, nil + } + next.Site.TimeZone = zone + if err := s.deps.SaveConfig(s.deps.ConfigPath, &next); err != nil { + return appproto.TimeZoneAnswer{}, err + } + configreload.Apply(s.deps.CfgMu, s.deps.Cfg, s.deps.CtrlMu, s.deps.Ctrl, &next, s.deps.ConfigApplier) + restart := !localzone.SameRules(loc, time.Local) + slog.Info("household time zone stored from a client", "tz", zone, "restart", restart) + if restart && s.deps.Restart != nil { + s.queueRestart(nil, nil) + } + return appproto.TimeZoneAnswer{Zone: zone, Taken: true}, nil +} diff --git a/go/internal/api/api_time_zone_test.go b/go/internal/api/api_time_zone_test.go new file mode 100644 index 000000000..0f1cad2d0 --- /dev/null +++ b/go/internal/api/api_time_zone_test.go @@ -0,0 +1,115 @@ +package api + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/control" +) + +// zoneServer is a box whose host clock is UTC unless known says otherwise. +// It counts saves and restarts. +func zoneServer(t *testing.T, stored string, known bool) (srv *Server, cfg *config.Config, saves *int, restarts chan struct{}) { + t.Helper() + local := time.Local + time.Local = time.UTC + wasKnown := householdZoneKnown + householdZoneKnown = func() bool { return known } + t.Cleanup(func() { time.Local, householdZoneKnown = local, wasKnown }) + + var cfgMu sync.RWMutex + var ctrlMu sync.Mutex + cfg = &config.Config{Site: config.Site{TimeZone: stored}} + saves = new(int) + restarts = make(chan struct{}, 1) + srv = New(&Deps{ + Ctrl: control.NewState(0, 42, ""), CtrlMu: &ctrlMu, + Cfg: cfg, CfgMu: &cfgMu, + ConfigPath: t.TempDir() + "/config.yaml", + SaveConfig: func(_ string, c *config.Config) error { + *saves++ + return nil + }, + Restart: func(context.Context) error { + restarts <- struct{}{} + return nil + }, + }) + return srv, cfg, saves, restarts +} + +// Erik's box: Docker on UTC, nothing stored. The phone's zone becomes the +// household clock, and Core restarts to run on it. +func TestABoxOnUTCTakesThePhonesZoneAndRestarts(t *testing.T) { + srv, cfg, saves, restarts := zoneServer(t, "", false) + answer, err := srv.ProposeTimeZone("Europe/Stockholm") + if err != nil { + t.Fatal(err) + } + if !answer.Taken || answer.Zone != "Europe/Stockholm" { + t.Fatalf("answer = %+v, want Europe/Stockholm taken", answer) + } + if *saves != 1 || cfg.Site.TimeZone != "Europe/Stockholm" { + t.Fatalf("saves=%d live zone %q, want one save of Europe/Stockholm", *saves, cfg.Site.TimeZone) + } + select { + case <-restarts: + case <-time.After(2 * time.Second): + t.Fatal("Core did not restart onto the household clock") + } +} + +// A zone with UTC's rules needs no restart: the clock already reads it. +func TestAZoneWithUTCRulesIsStoredWithoutARestart(t *testing.T) { + srv, _, saves, restarts := zoneServer(t, "", false) + if answer, err := srv.ProposeTimeZone("Atlantic/Reykjavik"); err != nil || !answer.Taken { + t.Fatalf("answer %+v err %v", answer, err) + } + if *saves != 1 { + t.Fatalf("saves = %d, want 1", *saves) + } + select { + case <-restarts: + t.Fatal("restarted for a zone on the clock it already runs") + case <-time.After(400 * time.Millisecond): + } +} + +// A phone abroad never moves a house's clock the site keeps. +func TestAStoredZoneIsKept(t *testing.T) { + srv, cfg, saves, _ := zoneServer(t, "Europe/Stockholm", false) + answer, err := srv.ProposeTimeZone("America/New_York") + if err != nil { + t.Fatal(err) + } + if answer.Taken || answer.Zone != "Europe/Stockholm" || *saves != 0 || cfg.Site.TimeZone != "Europe/Stockholm" { + t.Fatalf("answer %+v saves %d zone %q, want Europe/Stockholm kept", answer, *saves, cfg.Site.TimeZone) + } +} + +// A native install keeps the host zone it plans on today. +func TestAHostZoneIsKept(t *testing.T) { + srv, cfg, saves, _ := zoneServer(t, "", true) + answer, err := srv.ProposeTimeZone("America/New_York") + if err != nil { + t.Fatal(err) + } + if answer.Taken || *saves != 0 || cfg.Site.TimeZone != "" { + t.Fatalf("answer %+v saves %d zone %q, want the host zone kept", answer, *saves, cfg.Site.TimeZone) + } +} + +func TestAZoneNoClientCanLoadIsRefused(t *testing.T) { + srv, _, saves, _ := zoneServer(t, "", false) + for _, zone := range []string{"", "Local", "Mars/Olympus"} { + if _, err := srv.ProposeTimeZone(zone); err == nil { + t.Fatalf("took %q", zone) + } + } + if *saves != 0 { + t.Fatalf("saves = %d, want none", *saves) + } +} diff --git a/go/internal/appproto/command.go b/go/internal/appproto/command.go index 82410204c..8617ea4b9 100644 --- a/go/internal/appproto/command.go +++ b/go/internal/appproto/command.go @@ -42,6 +42,9 @@ func defaultOps() map[string]opSpec { // meets the dispatch gate before anything moves, so a sick meter must // not lock the household out of holding more reserve. OpPlannerPrefsSet: {scope: ScopeModeWrite, dispatchWrite: false}, + // The household clock is setup the box holds, and a goal read on the + // wrong clock is wrong whatever the meter says. + OpSiteTimeZonePropose: {scope: ScopeSetupWrite, dispatchWrite: false}, // The loadpoint pair moves energy — a manual hold commands the // charger and a boost opens the battery-to-EV path — so both sit // behind the dispatch gate. A box with no loadpoint controller diff --git a/go/internal/appproto/handler.go b/go/internal/appproto/handler.go index 0c895bcde..5984f8c37 100644 --- a/go/internal/appproto/handler.go +++ b/go/internal/appproto/handler.go @@ -61,6 +61,10 @@ type Config struct { // window. PlannerPrefs PlannerPrefs + // TimeZones stores a client's time zone on a box that keeps none, or nil + // before the API server is bound. + TimeZones TimeZones + // PlanChargers puts each charger's share on the plan, or nil on a box with // no chargers. PlanFeed pushes the plan when the box replans, or nil for // a box that pushes it only after a command. @@ -1145,6 +1149,8 @@ func (h *Handler) runCmd(ctx context.Context, cmd Cmd, by string) error { return h.setMode(ctx, cmd, uptimeMs) case OpPlannerPrefsSet: return h.setPlannerPrefs(cmd, uptimeMs) + case OpSiteTimeZonePropose: + return h.proposeTimeZone(cmd, uptimeMs) case OpLoadpointHold: return h.loadpointHold(cmd, uptimeMs) case OpLoadpointBoost: diff --git a/go/internal/appproto/ports.go b/go/internal/appproto/ports.go index aac78eb20..2fa92bb9b 100644 --- a/go/internal/appproto/ports.go +++ b/go/internal/appproto/ports.go @@ -257,6 +257,20 @@ type PlannerPrefs interface { Apply(safetyK *float64, export *string) (PlannerPrefsSnapshot, error) } +// TimeZoneAnswer is what the box did with a client's time zone. +type TimeZoneAnswer struct { + // Zone is the household zone the site keeps. + Zone string + // Taken is true when the offer became the site's zone. + Taken bool +} + +// TimeZones takes a client's time zone as the household clock while the box +// keeps none of its own. Nil answers E_UNAVAILABLE. +type TimeZones interface { + ProposeTimeZone(zone string) (TimeZoneAnswer, error) +} + // PlanReader hands over the planner's current output. // // Nil means the planner has produced nothing at all, which the wire reports as diff --git a/go/internal/appproto/protocol.go b/go/internal/appproto/protocol.go index 0a4010bee..728cdd8a2 100644 --- a/go/internal/appproto/protocol.go +++ b/go/internal/appproto/protocol.go @@ -142,6 +142,7 @@ const ( MsgNow = protocol.MsgNow OpSetMode = protocol.OpSetMode OpPlannerPrefsSet = protocol.OpPlannerPrefsSet + OpSiteTimeZonePropose = protocol.OpSiteTimeZonePropose OpLoadpointHold = protocol.OpLoadpointHold OpLoadpointBoost = protocol.OpLoadpointBoost OpLoadpointSoCSet = protocol.OpLoadpointSoCSet @@ -246,6 +247,7 @@ const ( ScopeMembersRead = protocol.ScopeMembersRead ScopeMembersWrite = protocol.ScopeMembersWrite ScopeCarsWrite = protocol.ScopeCarsWrite + ScopeSetupWrite = protocol.ScopeSetupWrite CapDerCar = protocol.CapDerCar ErrBooting = protocol.ErrBooting ErrUnknownOp = protocol.ErrUnknownOp diff --git a/go/internal/appproto/time_zone.go b/go/internal/appproto/time_zone.go new file mode 100644 index 000000000..d0f63c789 --- /dev/null +++ b/go/internal/appproto/time_zone.go @@ -0,0 +1,54 @@ +package appproto + +import "github.com/srcfl/ftw/go/internal/localzone" + +// proposeTimeZone offers the client's time zone as the household clock. The +// box decides: it takes the zone only while it keeps none of its own, and +// otherwise answers E_PRECONDITION with the zone it keeps, so a phone abroad +// never moves the house's clock. +func (h *Handler) proposeTimeZone(cmd Cmd, uptimeMs int64) error { + zones := h.cfg.TimeZones + if zones == nil { + return h.rejectWith(cmd, &ErrorBody{ + Code: ErrUnavailable, + Retryable: ErrorRetryable[ErrUnavailable], + Args: map[string]any{"op": cmd.Op}, + }) + } + zone, _ := cmd.Args["tz"].(string) + if _, err := localzone.Load(zone); err != nil { + return h.rejectWith(cmd, badArg(cmd.Op, "tz", cmd.Args["tz"])) + } + + if took, err := h.acceptCmd(cmd, uptimeMs); !took || err != nil { + return err + } + + answer, err := zones.ProposeTimeZone(zone) + if err != nil { + h.log.Warn("time zone not stored", "tz", zone, "err", err) + return h.settleAndReport(cmd.CmdID, CmdResult{ + CmdID: cmd.CmdID, + State: CmdRejected, + Error: &ErrorBody{ + Code: ErrUnavailable, + Retryable: ErrorRetryable[ErrUnavailable], + Args: map[string]any{"op": cmd.Op}, + }, + }) + } + if !answer.Taken { + return h.settleAndReport(cmd.CmdID, CmdResult{ + CmdID: cmd.CmdID, + State: CmdRejected, + Error: &ErrorBody{ + Code: ErrPrecondition, + Retryable: ErrorRetryable[ErrPrecondition], + Args: map[string]any{"op": cmd.Op, "tz": answer.Zone}, + }, + }) + } + // Applied: the site stores the zone. Core restarts to run on it, and the + // next session's hello names it. + return h.settleAndReport(cmd.CmdID, CmdResult{CmdID: cmd.CmdID, State: CmdApplied}) +} diff --git a/go/internal/appproto/time_zone_test.go b/go/internal/appproto/time_zone_test.go new file mode 100644 index 000000000..83db46ec7 --- /dev/null +++ b/go/internal/appproto/time_zone_test.go @@ -0,0 +1,86 @@ +package appproto + +import "testing" + +// memZones keeps a site zone the way the API server does: an offer is taken +// only while none is kept. +type memZones struct { + kept string + calls int +} + +func (m *memZones) ProposeTimeZone(zone string) (TimeZoneAnswer, error) { + m.calls++ + if m.kept != "" { + return TimeZoneAnswer{Zone: m.kept}, nil + } + m.kept = zone + return TimeZoneAnswer{Zone: zone, Taken: true}, nil +} + +func cmdProposeTimeZone(id string, zone any) Cmd { + return Cmd{ + CmdID: id, + Op: OpSiteTimeZonePropose, + Args: map[string]any{"tz": zone}, + NotValidAfterMs: 200_000, + Expect: Expect{Rev: 7}, + } +} + +func zoneRig(t *testing.T, zones TimeZones) (*Handler, *recorder) { + t.Helper() + h, _, rec, _ := newRig(t) + h.cfg.TimeZones = zones + subscribe(t, h, rec) + rec.reset() + return h, rec +} + +func TestATimeZoneOfferIsTakenByABoxWithoutOne(t *testing.T) { + zones := &memZones{} + h, rec := zoneRig(t, zones) + + deliver(t, h, MsgCmd, nil, cmdProposeTimeZone("0192f2a0-7c1e-7000-8000-0123456789b0", "Europe/Stockholm")) + + res := body[CmdResult](t, rec.only(t, MsgCmdResult)) + if res.State != CmdApplied || zones.kept != "Europe/Stockholm" { + t.Fatalf("result %+v kept %q, want applied Europe/Stockholm", res, zones.kept) + } +} + +// The box decides: a kept zone stays, and the answer names it. +func TestATimeZoneOfferMeetsTheZoneTheBoxKeeps(t *testing.T) { + zones := &memZones{kept: "Europe/Stockholm"} + h, rec := zoneRig(t, zones) + + deliver(t, h, MsgCmd, nil, cmdProposeTimeZone("0192f2a0-7c1e-7000-8000-0123456789b1", "America/New_York")) + + res := body[CmdResult](t, rec.only(t, MsgCmdResult)) + if res.State != CmdRejected || res.Error == nil || res.Error.Code != ErrPrecondition || res.Error.Args["tz"] != "Europe/Stockholm" { + t.Fatalf("result %+v, want E_PRECONDITION naming Europe/Stockholm", res) + } +} + +func TestATimeZoneNoClientCanLoadIsRefusedBeforeTheBoxSeesIt(t *testing.T) { + zones := &memZones{} + h, rec := zoneRig(t, zones) + + deliver(t, h, MsgCmd, nil, cmdProposeTimeZone("0192f2a0-7c1e-7000-8000-0123456789b2", "Local")) + + res := body[CmdResult](t, rec.only(t, MsgCmdResult)) + if res.State != CmdRejected || res.Error == nil || res.Error.Code != ErrUnknownOp || zones.calls != 0 { + t.Fatalf("result %+v calls %d, want a refused arg and no offer", res, zones.calls) + } +} + +func TestATimeZoneOfferWithoutAPortIsUnavailable(t *testing.T) { + h, rec := zoneRig(t, nil) + + deliver(t, h, MsgCmd, nil, cmdProposeTimeZone("0192f2a0-7c1e-7000-8000-0123456789b3", "Europe/Stockholm")) + + res := body[CmdResult](t, rec.only(t, MsgCmdResult)) + if res.State != CmdRejected || res.Error == nil || res.Error.Code != ErrUnavailable { + t.Fatalf("result %+v, want E_UNAVAILABLE", res) + } +} diff --git a/go/internal/config/config.go b/go/internal/config/config.go index b5fb17a08..39b36fea9 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -17,6 +17,7 @@ import ( "sync" "time" + "github.com/srcfl/ftw/go/internal/localzone" "gopkg.in/yaml.v3" ) @@ -801,6 +802,11 @@ type Site struct { SlewRateW float64 `yaml:"slew_rate_w" json:"slew_rate_w"` MinDispatchIntervalS int `yaml:"min_dispatch_interval_s" json:"min_dispatch_interval_s"` + // TimeZone is the household's IANA zone: the clock car goals, the import + // hour, daily history and savings read. Empty keeps the host's zone. + // Core reads it once at start (localzone.Use). + TimeZone string `yaml:"time_zone,omitempty" json:"time_zone,omitempty"` + // SlewEnabled gates the external per-cycle ramp limiter. Both // supported inverter families (Ferroamp, Sungrow) have their own // internal power-ramp control loops; the external slew was @@ -1977,6 +1983,11 @@ func (c *Config) Validate() error { if err := c.Price.Validate(); err != nil { return err } + if c.Site.TimeZone != "" { + if _, err := localzone.Load(c.Site.TimeZone); err != nil { + return fmt.Errorf("site.time_zone: %w", err) + } + } // Empty drivers list is a valid shape — e.g. an EV-only site that // configured a cloud EV charger in the setup wizard and doesn't diff --git a/go/internal/config/restart_required.go b/go/internal/config/restart_required.go index 9757a1389..ecaccb0f2 100644 --- a/go/internal/config/restart_required.go +++ b/go/internal/config/restart_required.go @@ -25,6 +25,9 @@ func RestartRequiredFor(oldCfg, newCfg *Config) []string { reasons = append(reasons, "setup.measurement_only — restart to change equipment control") } + if oldCfg.Site.TimeZone != newCfg.Site.TimeZone { + reasons = append(reasons, "site.time_zone — the household clock is set at startup") + } if oldCfg.API.Port != newCfg.API.Port { reasons = append(reasons, "api.port — HTTP server binds the port at startup") } diff --git a/go/internal/localzone/localzone.go b/go/internal/localzone/localzone.go index 48cf95daf..3c33cc07c 100644 --- a/go/internal/localzone/localzone.go +++ b/go/internal/localzone/localzone.go @@ -1,8 +1,11 @@ -// Package localzone names the zone the box keeps its clock in. +// Package localzone keeps the household's clock: the zone every wall-clock +// rule reads, and its name for clients. package localzone import ( "bytes" + "errors" + "fmt" "io/fs" "os" "path/filepath" @@ -22,7 +25,66 @@ import ( // The name must keep the rules time.Local runs on. Clients read the box's // household clock (a car's ready time, a goal's days) in this zone, so a name // with other rules moves every goal by the difference. -func Name() string { return name() } +// +// A zone the site stores (Use) wins over the host's. +func Name() string { + if household != "" { + return household + } + return name() +} + +// household is the zone the site stores, set once by Use. +var household string + +// Stored returns the zone the site stores, or "" when the box runs on the +// host's zone. +func Stored() string { return household } + +// Known reports whether the box has a household clock: a zone the site +// stores, or a host zone other than UTC. A Docker box has none until a +// client offers one. +func Known() bool { + return household != "" || !utcNames[strings.TrimPrefix(name(), "Etc/")] +} + +// Use makes the site's stored zone the household clock: time.Local, and the +// Name clients read. A Docker box runs on UTC whatever house it is in, so a +// car goal of 17:00 was met at 18:00 in a Stockholm winter until the zone +// became a fact of the site rather than of the host. +// +// Core calls it once at start, before any goroutine reads the clock: +// assigning time.Local later races every time.Now. A change takes effect +// when Core next starts. Empty keeps the host's zone and names it again. +func Use(zone string) error { + if zone == "" { + household = "" + return nil + } + loc, err := Load(zone) + if err != nil { + return err + } + time.Local = loc + household = zone + // A worker Core starts reads the same clock. + return os.Setenv("TZ", zone) +} + +// Load reads an IANA zone a site may store. "Local" and "" name no zone. +func Load(zone string) (*time.Location, error) { + if zone == "" || zone == "Local" { + return nil, errors.New("name a time zone such as Europe/Stockholm") + } + loc, err := time.LoadLocation(zone) + if err != nil { + return nil, fmt.Errorf("%q is not a time zone such as Europe/Stockholm", zone) + } + return loc, nil +} + +// SameRules reports whether a and b keep the same clock from 1970 to 2038. +func SameRules(a, b *time.Location) bool { return sameRules(a, b) } // Read once: time.Local is fixed when the process starts, so a zone changed // on the host afterwards must not rename the one the box still runs on. diff --git a/go/internal/localzone/localzone_test.go b/go/internal/localzone/localzone_test.go index 517b22aa2..9c96dc617 100644 --- a/go/internal/localzone/localzone_test.go +++ b/go/internal/localzone/localzone_test.go @@ -206,3 +206,51 @@ func TestAMountOverTheEtcUTCLinkIsNotUTC(t *testing.T) { t.Fatalf("zone = %q, want Europe/Stockholm", got) } } + +// keepClock restores the process clock a test moves. +func keepClock(t *testing.T) { + t.Helper() + local, stored := time.Local, household + t.Setenv("TZ", os.Getenv("TZ")) + t.Cleanup(func() { time.Local, household = local, stored }) +} + +// A Docker box runs on UTC. Once the site stores its zone, the box's own +// clock reads the household's hours. +func TestAStoredZoneIsTheClockOfABoxOnUTC(t *testing.T) { + keepClock(t) + time.Local = time.UTC + if err := Use("Europe/Stockholm"); err != nil { + t.Fatal(err) + } + winter := time.Date(2026, 11, 1, 16, 0, 0, 0, time.UTC) + if got := time.UnixMilli(winter.UnixMilli()).Hour(); got != 17 { + t.Fatalf("16:00Z reads %d:00 on the box, want 17:00", got) + } + if Name() != "Europe/Stockholm" || Stored() != "Europe/Stockholm" || os.Getenv("TZ") != "Europe/Stockholm" { + t.Fatalf("name %q stored %q TZ %q, want Europe/Stockholm", Name(), Stored(), os.Getenv("TZ")) + } +} + +func TestNoStoredZoneKeepsTheHostClock(t *testing.T) { + keepClock(t) + host := time.Local + if err := Use(""); err != nil { + t.Fatal(err) + } + if time.Local != host || Stored() != "" { + t.Fatalf("Use(\"\") moved the clock to %v, stored %q", time.Local, Stored()) + } +} + +func TestAZoneThatDoesNotLoadIsRefused(t *testing.T) { + keepClock(t) + for _, zone := range []string{"Local", "Mars/Olympus", "../../etc/passwd"} { + if err := Use(zone); err == nil { + t.Fatalf("Use(%q) took a zone no client can load", zone) + } + } + if Stored() != "" { + t.Fatalf("stored %q after refusals", Stored()) + } +} diff --git a/go/internal/onboarding/guide.go b/go/internal/onboarding/guide.go index e4c8b8f31..db1b93672 100644 --- a/go/internal/onboarding/guide.go +++ b/go/internal/onboarding/guide.go @@ -34,7 +34,7 @@ func Guide(c *config.Config) map[string]any { add("solar", "Does this home have solar panels? If so, do you know their total kWp?", "", "kWp (convert to W for pv_rated_w)", "Capacity and roof orientation are optional estimates. Record their source and time in forecast_priors.solar_source and solar_recorded_at. They are not verified inverter control limits. Do not ask for a capacity already known from reliable device metadata.", "/setup/solar_present", "/weather/pv_rated_w", "/weather/pv_arrays") } if c.Setup == nil || !c.Setup.GoalsReviewed { - add("goals", "What should FTW do for this home: use solar, shift energy to cheap hours, keep a battery reserve, or prepare cars for departure?", "", "W, Wh; SoC as 0..1", "Read the mode catalogue in setup_schema and active mode/preferences in commissioning. Confirm runtime_timezone before any local HH:MM departure target. If it is wrong, help the owner set the host time zone and restart FTW; there is no writable configuration time-zone field. Store equipment limits and car/loadpoint configuration in this draft. After configuration is running and measurements are fresh, while measurement mode still blocks equipment control, use car_goal_request for car departure schedules and goal_request for the chosen operating mode and planning style, including explicit battery-export permission. The owner approves each request in Access on the local box or in the FTW app; read requests_list and active status to verify the result. Planner config fields do not replace these active preferences. Use Check requests and access on the local Access page to refresh approvals. Mark goals_reviewed only after applying the requested goals or the owner explicitly leaves them for later; include it in the final control-enabling review.", "/setup/goals_reviewed", "/batteries", "/vehicles", "/loadpoints", "/v2x") + add("goals", "What should FTW do for this home: use solar, shift energy to cheap hours, keep a battery reserve, or prepare cars for departure?", "", "W, Wh; SoC as 0..1", "Read the mode catalogue in setup_schema and active mode/preferences in commissioning. Confirm runtime_timezone before any local HH:MM departure target. If it is wrong, set /site/time_zone to the household's IANA zone; Core applies it when it restarts. Store equipment limits and car/loadpoint configuration in this draft. After configuration is running and measurements are fresh, while measurement mode still blocks equipment control, use car_goal_request for car departure schedules and goal_request for the chosen operating mode and planning style, including explicit battery-export permission. The owner approves each request in Access on the local box or in the FTW app; read requests_list and active status to verify the result. Planner config fields do not replace these active preferences. Use Check requests and access on the local Access page to refresh approvals. Mark goals_reviewed only after applying the requested goals or the owner explicitly leaves them for later; include it in the final control-enabling review.", "/setup/goals_reviewed", "/site/time_zone", "/batteries", "/vehicles", "/loadpoints", "/v2x") } if c.Setup == nil || !c.Setup.PriceReviewed { add("electricity_price", "Which electricity price area and import/export contract apply here?", "", "", "Read supported countries, zones and currencies from setup_schema. Ask only for missing contract terms. If unknown, continue measuring and show that price planning still needs this information.", "/price", "/setup/price_reviewed") diff --git a/go/internal/onboarding/schema.go b/go/internal/onboarding/schema.go index 1d212da6e..21d060bd4 100644 --- a/go/internal/onboarding/schema.go +++ b/go/internal/onboarding/schema.go @@ -67,6 +67,9 @@ func schemaFor(t reflect.Type) map[string]any { } func setupFieldDescription(t reflect.Type, name string) string { + if t == reflect.TypeFor[config.Site]() && name == "time_zone" { + return "The household's IANA time zone, such as Europe/Stockholm. Car goals, the import hour, daily history and savings read this clock. Empty keeps the host's zone, which is UTC in Docker. Core applies a change when it restarts." + } if t == reflect.TypeFor[config.Setup]() { return map[string]string{ "goals_reviewed": "The owner reviewed operating mode, battery reserve/export, planning style and any car/loadpoint goals. Unknown optional goals can wait.", diff --git a/go/internal/state/daily_zone.go b/go/internal/state/daily_zone.go new file mode 100644 index 000000000..a3410cdef --- /dev/null +++ b/go/internal/state/daily_zone.go @@ -0,0 +1,60 @@ +package state + +import "context" + +// dailyZonePrefix marks, in history_migrations, the household zone the cached +// local-day energy was computed on. +const dailyZonePrefix = "daily-zone:" + +// KeepDailyZone ties the cached local-day energy to the household zone. +// +// Day keys are dates on the household clock, so a day cached on another clock +// covers other hours. A new zone clears the cache, and each day is computed +// again from the UTC buckets on its next read; cleared reports that. The +// first call on a box records the zone and keeps the rows, which were cached +// on the clock it already runs on. +func (s *Store) KeepDailyZone(ctx context.Context, zone string) (cleared bool, err error) { + s.historyWriteMu.Lock() + defer s.historyWriteMu.Unlock() + tx, err := s.history.BeginTx(ctx, nil) + if err != nil { + return false, err + } + defer tx.Rollback() + rows, err := tx.QueryContext(ctx, `SELECT name FROM history_migrations WHERE name LIKE ?`, dailyZonePrefix+"%") + if err != nil { + return false, err + } + var marks []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + rows.Close() + return false, err + } + marks = append(marks, name) + } + rows.Close() + if err := rows.Err(); err != nil { + return false, err + } + want := dailyZonePrefix + zone + if len(marks) == 1 && marks[0] == want { + return false, nil + } + cleared = len(marks) > 0 + if cleared { + for _, table := range []string{"energy_daily", "energy_daily_partial", "history_daily_quality"} { + if _, err := tx.ExecContext(ctx, `DELETE FROM `+table); err != nil { + return false, err + } + } + if _, err := tx.ExecContext(ctx, `DELETE FROM history_migrations WHERE name LIKE ?`, dailyZonePrefix+"%"); err != nil { + return false, err + } + } + if _, err := tx.ExecContext(ctx, `INSERT INTO history_migrations(name) VALUES(?)`, want); err != nil { + return false, err + } + return cleared, tx.Commit() +} diff --git a/go/internal/state/daily_zone_test.go b/go/internal/state/daily_zone_test.go new file mode 100644 index 000000000..526ffbffb --- /dev/null +++ b/go/internal/state/daily_zone_test.go @@ -0,0 +1,34 @@ +package state + +import ( + "context" + "testing" +) + +// A Docker box cached its days on UTC. Once the household zone is stored, +// those dates cover other hours, so they go and are computed again. +func TestANewHouseholdZoneClearsTheCachedDays(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + if cleared, err := s.KeepDailyZone(ctx, "UTC"); err != nil || cleared { + t.Fatalf("first zone: cleared=%v err=%v, want the rows kept", cleared, err) + } + if err := s.SaveDailyEnergy("2026-10-30", DayEnergy{LoadWh: 1, Intervals: 1}); err != nil { + t.Fatal(err) + } + if cleared, err := s.KeepDailyZone(ctx, "UTC"); err != nil || cleared { + t.Fatalf("same zone: cleared=%v err=%v", cleared, err) + } + if _, ok, err := s.LoadDailyEnergy("2026-10-30"); err != nil || !ok { + t.Fatalf("same zone dropped the day: ok=%v err=%v", ok, err) + } + if cleared, err := s.KeepDailyZone(ctx, "Europe/Stockholm"); err != nil || !cleared { + t.Fatalf("new zone: cleared=%v err=%v, want the days cleared", cleared, err) + } + if _, ok, err := s.LoadDailyEnergy("2026-10-30"); err != nil || ok { + t.Fatalf("a UTC day survived the move to Stockholm: ok=%v err=%v", ok, err) + } + if cleared, err := s.KeepDailyZone(ctx, "Europe/Stockholm"); err != nil || cleared { + t.Fatalf("after the move: cleared=%v err=%v", cleared, err) + } +} diff --git a/go/protocol/contract_gen.go b/go/protocol/contract_gen.go index 9df0cd81c..d87ca9394 100644 --- a/go/protocol/contract_gen.go +++ b/go/protocol/contract_gen.go @@ -272,6 +272,8 @@ var RegistryOps = map[string]string{ "site.mode.set": "ftw.mode.write", // planner.prefs.set — Set household planner preferences. "planner.prefs.set": "ftw.mode.write", + // site.time_zone.propose — Offer the phone's time zone to a box that keeps none. + "site.time_zone.propose": "ftw.setup.write", // battery.hold — Hold the battery at a fixed setpoint. "battery.hold": "ftw.dispatch.write", // loadpoint.hold — Charge the car now at a fixed current. diff --git a/go/protocol/messages.go b/go/protocol/messages.go index 9dfa42b28..b53dbbe46 100644 --- a/go/protocol/messages.go +++ b/go/protocol/messages.go @@ -42,6 +42,11 @@ const ( // permission. The box maps that permission onto a planner mode; the // caller does not name the mode. OpPlannerPrefsSet = "planner.prefs.set" + // OpSiteTimeZonePropose offers the client's time zone as the household + // clock. The box stores it only while it keeps no zone of its own, and + // restarts to run on it; a box that keeps one answers E_PRECONDITION + // with the zone it keeps in args.tz. + OpSiteTimeZonePropose = "site.time_zone.propose" // OpLoadpointHold pins one EV loadpoint to a fixed charging power, or // releases it with `clear`. The same manual hold the HTTP route // installs, reached through this door's gates instead of a verb. diff --git a/go/test/site/runner/runner.go b/go/test/site/runner/runner.go index 9c005a46d..955fab08b 100644 --- a/go/test/site/runner/runner.go +++ b/go/test/site/runner/runner.go @@ -25,7 +25,8 @@ var watchedPaths = []string{ "go/internal/mpc/", "go/internal/optimizercontract/", // plans "go/internal/gridcost/", "go/internal/forecasting/", "go/internal/sunpos/", // plan inputs "go/internal/config/", "go/internal/configreload/", // limits - "go/internal/units/", // charge steps + "go/internal/units/", // charge steps + "go/internal/localzone/", // household clock: goal deadlines, import hour "go/internal/cars/", "go/internal/sitepower/", "go/internal/events/", "go/internal/state/", "drivers/", "optimizer/native/bundle/", // bundled drivers and solver diff --git a/web/settings/tabs/control.js b/web/settings/tabs/control.js index 40d9a7f4b..cd34889a6 100644 --- a/web/settings/tabs/control.js +++ b/web/settings/tabs/control.js @@ -31,6 +31,8 @@ '' : ''; return setup + '
Site' + field("Name", "site.name", "text", "Home") + + field("Time zone", "site.time_zone", "text", "", + "The home's clock, such as Europe/Stockholm. Car goals, the import hour and daily history read it. Empty uses the host's zone, which is UTC in Docker. FTW restarts to apply a change.") + '
' + field("Grid target (W)", "site.grid_target_w", "number", 0) + '
' + diff --git a/web/setup.js b/web/setup.js index 9e37b503d..11fd65e55 100644 --- a/web/setup.js +++ b/web/setup.js @@ -889,6 +889,8 @@ esc(document.getElementById('fuse-voltage').value) + ' V per phase'; } html += ' · ' + esc(zone); + var clock = browserTimeZone(); + if (clock) html += ' · clock ' + esc(clock); html += '
'; // Devices @@ -1001,12 +1003,25 @@ }); }; + // The household clock car goals and daily history read. A Docker box runs + // on UTC, so the zone comes from the browser that sets the home up. A + // browser on UTC says nothing about the home; the box then keeps its own. + function browserTimeZone() { + try { + var tz = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; + return /^(Etc\/)?(UTC|UCT|GMT|Universal|Zulu)$/.test(tz) ? '' : tz; + } catch (e) { + return ''; + } + } + function buildConfig() { var zone = document.getElementById('price-zone').value; var cfg = { site: { name: document.getElementById('site-name').value.trim() || 'My Home', + time_zone: browserTimeZone() || undefined, control_interval_s: 5, grid_target_w: 0, grid_tolerance_w: 42, diff --git a/web/setup.test.mjs b/web/setup.test.mjs index 3750f0c14..e5d967f15 100644 --- a/web/setup.test.mjs +++ b/web/setup.test.mjs @@ -261,3 +261,15 @@ describe("setup wizard — the house password on a new box", () => { assert.match(JS, /Short passwords are easy to guess/); }); }); + +describe("setup wizard — household clock", () => { + it("stores the browser's zone as site.time_zone", () => { + assert.match(JS, /time_zone:\s*browserTimeZone\(\)/, + "a Docker box runs on UTC; the home's zone comes from the browser that sets it up"); + assert.match(JS, /resolvedOptions\(\)\.timeZone/); + }); + + it("names the clock on the review screen", () => { + assert.match(JS, /' · clock ' \+ esc\(clock\)/); + }); +});