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/household-time-zone.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions contract/registry.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
7 changes: 6 additions & 1 deletion docs/cars.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 11 additions & 0 deletions go/cmd/ftw/app_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -822,6 +822,7 @@ func startAppLink(
Modes: modes,
Plans: plans,
PlannerPrefs: gateway,
TimeZones: gateway,
History: history,
Prices: priceReader,
Loadpoints: loadpoints,
Expand Down Expand Up @@ -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 {
Expand Down
34 changes: 34 additions & 0 deletions go/cmd/ftw/household_clock_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
17 changes: 17 additions & 0 deletions go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()}})
Expand Down
55 changes: 55 additions & 0 deletions go/internal/api/api_time_zone.go
Original file line number Diff line number Diff line change
@@ -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
}
115 changes: 115 additions & 0 deletions go/internal/api/api_time_zone_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
3 changes: 3 additions & 0 deletions go/internal/appproto/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions go/internal/appproto/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
14 changes: 14 additions & 0 deletions go/internal/appproto/ports.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go/internal/appproto/protocol.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading