diff --git a/.changeset/box-iana-timezone.md b/.changeset/box-iana-timezone.md new file mode 100644 index 00000000..dd6776c8 --- /dev/null +++ b/.changeset/box-iana-timezone.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Name the box's time zone, for example Europe/Stockholm, wherever Core reports it: the app hello, daily energy, savings and the load research export. A box without TZ set said "Local", which the app and web client cannot use to show plan times in the household's zone. diff --git a/go/cmd/ftw/app_link.go b/go/cmd/ftw/app_link.go index 45901530..747354aa 100644 --- a/go/cmd/ftw/app_link.go +++ b/go/cmd/ftw/app_link.go @@ -17,6 +17,7 @@ import ( "github.com/srcfl/ftw/go/internal/config" "github.com/srcfl/ftw/go/internal/control" "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/localzone" "github.com/srcfl/ftw/go/internal/mpc" "github.com/srcfl/ftw/go/internal/notifications" "github.com/srcfl/ftw/go/internal/prices" @@ -172,6 +173,13 @@ type appBoxInfo struct { tz string } +// newAppBoxInfo names the box's own zone as the host sets it. The app renders +// plan slot times in it, so a box in Stockholm and a phone abroad still agree +// on when tonight's cheap hours are. +func newAppBoxInfo(id, build string) appBoxInfo { + return appBoxInfo{id: id, build: build, tz: localzone.Name()} +} + func (a appBoxInfo) Identity() appproto.Identity { return appproto.Identity{ID: a.id, Build: a.build, TZ: a.tz} } @@ -522,16 +530,11 @@ func startAppLink( return enroll, nil, false, nil } - // The box's own zone, read from the host. The app renders plan slot times - // in it, so a box in Stockholm and a phone abroad still agree on when - // tonight's cheap hours are. - tz := time.Now().Location().String() - site := &appSite{ tel: tel, ctrl: ctrl, ctrlMu: ctrlMu, revision: revision, started: processStarted, siteMeterStale: siteMeterStale, } - info := appBoxInfo{id: boxID, build: build, tz: tz} + info := newAppBoxInfo(boxID, build) modes := &appModes{ctrl: ctrl, ctrlMu: ctrlMu, state: st, mpc: planner, prefs: prefs} plans := &appPlans{planner: planner, ctrl: ctrl, ctrlMu: ctrlMu} diff --git a/go/cmd/ftw/app_link_zone_test.go b/go/cmd/ftw/app_link_zone_test.go new file mode 100644 index 00000000..8141b431 --- /dev/null +++ b/go/cmd/ftw/app_link_zone_test.go @@ -0,0 +1,130 @@ +package main + +import ( + "context" + "os" + "os/exec" + "strings" + "sync" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/apiauth" + "github.com/srcfl/ftw/go/internal/appproto" + "github.com/srcfl/ftw/go/internal/appuplink" +) + +const childWithoutTZ = "FTW_TEST_CHILD_WITHOUT_TZ" + +// The app draws plan times in the zone the hello names. Go calls the host zone +// "Local" when TZ is unset, which no client can load, so this test runs the +// hello again in a child process whose environment has no TZ at all. +func TestTheHelloNamesTheBoxZoneWhenTZIsUnset(t *testing.T) { + if os.Getenv(childWithoutTZ) != "1" { + cmd := exec.CommandContext(t.Context(), os.Args[0], "-test.run=^TestTheHelloNamesTheBoxZoneWhenTZIsUnset$", "-test.count=1") + cmd.Env = []string{childWithoutTZ + "=1"} + for _, kv := range os.Environ() { + if !strings.HasPrefix(kv, "TZ=") { + cmd.Env = append(cmd.Env, kv) + } + } + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("hello without TZ: %v\n%s", err, out) + } + return + } + + if time.Local.String() != "Local" { + t.Fatalf("time.Local = %q without TZ, want Go's unnamed Local", time.Local) + } + + tz := helloBoxZone(t, newAppBoxInfo("ftw-0001", "test")) + if tz == "" || tz == "Local" { + t.Fatalf("hello tz = %q, want an IANA name or UTC", tz) + } + if _, err := time.LoadLocation(tz); err != nil { + t.Fatalf("hello tz = %q does not load: %v", tz, err) + } +} + +// helloBoxZone sends one hello through a real handler and returns the zone +// the box answered with. The hello reads only the box info, so the other +// ports stay empty. +func helloBoxZone(t *testing.T, info appBoxInfo) string { + t.Helper() + sent := &sentFrames{} + h, err := appproto.New(appproto.Config{ + Clock: appproto.SystemClock{StartedAt: time.Now(), Source: "ntp"}, + Site: &appSite{}, + Info: info, + Modes: &appModes{}, + Plans: &appPlans{}, + Codec: appuplink.Codec(), + Sender: sent, + Caller: apiauth.Caller{ + Kind: apiauth.KindApp, + Role: apiauth.RoleOwner, + Scopes: appproto.ScopesForRole(apiauth.RoleOwner), + }, + Grants: ownerGrant{}, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(h.Close) + + hello, err := appproto.Marshal(appproto.Hello{Proto: appproto.ProtoRange{Min: 0, Max: appproto.ProtoMax}}) + if err != nil { + t.Fatal(err) + } + frame, err := appuplink.Codec().EncodeFrame(appproto.Frame{ + Lane: appproto.LaneControl, + Bucket: 512, + Envelope: appproto.Envelope{T: appproto.MsgHello, B: hello}, + }) + if err != nil { + t.Fatal(err) + } + if err := h.Handle(context.Background(), frame); err != nil { + t.Fatal(err) + } + + for _, raw := range sent.all() { + f, err := appuplink.Codec().DecodeFrame(raw) + if err != nil { + t.Fatal(err) + } + if f.Envelope.T != appproto.MsgHelloOK { + continue + } + var ok appproto.HelloOK + if err := appproto.Unmarshal(f.Envelope.B, &ok); err != nil { + t.Fatal(err) + } + return ok.Box.TZ + } + t.Fatal("the box did not answer the hello") + return "" +} + +type sentFrames struct { + mu sync.Mutex + frames [][]byte +} + +func (s *sentFrames) Send(raw []byte) error { + s.mu.Lock() + defer s.mu.Unlock() + s.frames = append(s.frames, append([]byte(nil), raw...)) + return nil +} + +func (s *sentFrames) all() [][]byte { + s.mu.Lock() + defer s.mu.Unlock() + return append([][]byte(nil), s.frames...) +} + +type ownerGrant struct{} + +func (ownerGrant) Grant() (string, uint64, bool) { return apiauth.RoleOwner, 1, true } diff --git a/go/cmd/ftw/forecast_site.go b/go/cmd/ftw/forecast_site.go index f6a89b77..ff02fa53 100644 --- a/go/cmd/ftw/forecast_site.go +++ b/go/cmd/ftw/forecast_site.go @@ -6,8 +6,6 @@ import ( "fmt" "log/slog" "math" - "os" - "path/filepath" "sort" "strings" "sync" @@ -16,6 +14,7 @@ import ( "github.com/google/uuid" "github.com/srcfl/ftw/go/internal/config" "github.com/srcfl/ftw/go/internal/drivers" + "github.com/srcfl/ftw/go/internal/localzone" "github.com/srcfl/ftw/go/internal/state" "github.com/srcfl/ftw/go/internal/telemetry" ) @@ -86,7 +85,7 @@ func (s *forecastSiteConfig) Snapshot() forecastSite { } func (s *forecastSiteConfig) Configure(cfg *config.Config, catalog []drivers.CatalogEntry) bool { - v := forecastSite{Meter: cfg.SiteMeterDriver(), Timezone: forecastTimezone(), Options: forecastMeasurementOptions(cfg, catalog), HistoryOptions: historyMeasurementOptions(cfg, catalog)} + v := forecastSite{Meter: cfg.SiteMeterDriver(), Timezone: localzone.Name(), Options: forecastMeasurementOptions(cfg, catalog), HistoryOptions: historyMeasurementOptions(cfg, catalog)} if cfg.Weather != nil && cfg.Weather.Provider != "" && cfg.Weather.Provider != "none" { v.Latitude, v.Longitude = cfg.Weather.Latitude, cfg.Weather.Longitude v.HasLocation = !math.IsNaN(v.Latitude) && !math.IsNaN(v.Longitude) && math.Abs(v.Latitude) <= 90 && math.Abs(v.Longitude) <= 180 @@ -387,29 +386,6 @@ func forecastRatedPVW(w *config.Weather) float64 { return 0 } -// Prefer the host's IANA name so persisted clock buckets retain DST rules. -// UTC is valid when that is the box's configured timezone; never guess a country. -func forecastTimezone() string { - candidates := []string{strings.TrimPrefix(os.Getenv("TZ"), ":"), time.Local.String()} - if target, err := filepath.EvalSymlinks("/etc/localtime"); err == nil { - if _, zone, ok := strings.Cut(target, "/zoneinfo/"); ok { - candidates = append(candidates, zone) - } - } - if data, err := os.ReadFile("/etc/timezone"); err == nil { - candidates = append(candidates, strings.TrimSpace(string(data))) - } - for _, zone := range candidates { - if zone == "" || zone == "Local" { - continue - } - if _, err := time.LoadLocation(zone); err == nil { - return zone - } - } - return "UTC" -} - func usableTrainingWeather(row state.ForecastPoint, now time.Time) bool { return row.FetchedAtMs > 0 && row.FetchedAtMs <= now.UnixMilli() && now.UnixMilli()-row.FetchedAtMs <= (12*time.Hour).Milliseconds() } diff --git a/go/cmd/ftw/forecast_site_test.go b/go/cmd/ftw/forecast_site_test.go index de8c4e31..6f13cb9f 100644 --- a/go/cmd/ftw/forecast_site_test.go +++ b/go/cmd/ftw/forecast_site_test.go @@ -7,6 +7,7 @@ import ( "github.com/srcfl/ftw/go/internal/drivers" "github.com/srcfl/ftw/go/internal/loadmodel" + "github.com/srcfl/ftw/go/internal/localzone" "github.com/srcfl/ftw/go/internal/modelstate" "github.com/srcfl/ftw/go/internal/telemetry" "math" @@ -405,7 +406,7 @@ func TestForecastLearningIDCarriesAcrossPortablePathPolicy(t *testing.T) { // The receipt an earlier build wrote for this site. options := forecastMeasurementOptions(cfg, nil) - v := forecastSite{Meter: cfg.SiteMeterDriver(), Timezone: forecastTimezone(), Options: options} + v := forecastSite{Meter: cfg.SiteMeterDriver(), Timezone: localzone.Name(), Options: options} weather := *cfg.Weather learning := forecastLearningDrivers(v.Meter, v.Options.ExpectedFlows, cfg.Drivers) old, err := forecastResolvedPathRevision(v, &weather, learning) diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index b78fac4c..c6a40516 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -54,6 +54,7 @@ import ( "github.com/srcfl/ftw/go/internal/ha" "github.com/srcfl/ftw/go/internal/loadmodel" "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/localzone" modbuscli "github.com/srcfl/ftw/go/internal/modbus" "github.com/srcfl/ftw/go/internal/mpc" mqttcli "github.com/srcfl/ftw/go/internal/mqtt" @@ -1128,7 +1129,7 @@ func main() { economics := mpc.SiteEconomics{ FuseMaxW: newCfg.Fuse.MaxPowerW(), MaxExportW: newCfg.Site.MaxExportW, - Timezone: forecastTimezone(), + Timezone: localzone.Name(), } if newCfg.Price != nil { economics.ExportBonusOreKwh = newCfg.Price.ExportBonusOreKwh @@ -1792,7 +1793,7 @@ func main() { mpcSvc.DemandTopN = cfg.Price.DemandTopN mpcSvc.DemandNightWeight = cfg.Price.DemandNightWeight } - mpcSvc.Timezone = forecastTimezone() + mpcSvc.Timezone = localzone.Name() mpcSvc.HouseholdMeasurement = func() telemetry.ForecastReading { site := forecastSettings.Snapshot() return tel.ForecastMeasurementNow(site.Meter, site.Options) diff --git a/go/internal/api/api.go b/go/internal/api/api.go index dea761cf..5935a437 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -46,6 +46,7 @@ import ( "github.com/srcfl/ftw/go/internal/ha" "github.com/srcfl/ftw/go/internal/loadmodel" "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/localzone" "github.com/srcfl/ftw/go/internal/mpc" "github.com/srcfl/ftw/go/internal/notifications" "github.com/srcfl/ftw/go/internal/ocpp" @@ -2342,7 +2343,7 @@ func parseRange(s string) int64 { // Response: {"days": [{"day":"YYYY-MM-DD","import_wh":..., "export_wh":..., // // "pv_wh":..., "bat_charged_wh":..., "bat_discharged_wh":..., -// "load_wh":...}], "tz": "Local"} +// "load_wh":...}], "tz": "Europe/Stockholm"} // // Buckets are local-day. Today is the last entry. Mirrors the integration // loop in handleStatus's energy-today block — same site convention, same @@ -2435,7 +2436,7 @@ func (s *Server) handleEnergyDaily(w http.ResponseWriter, r *http.Request) { "load_wh": de.LoadWh, }) } - writeJSON(w, 200, map[string]any{"days": out, "tz": loc.String()}) + writeJSON(w, 200, map[string]any{"days": out, "tz": localzone.Name()}) } // ---- /api/prices ---- diff --git a/go/internal/api/api_research.go b/go/internal/api/api_research.go index d98790a4..2e4461fc 100644 --- a/go/internal/api/api_research.go +++ b/go/internal/api/api_research.go @@ -14,6 +14,7 @@ import ( "strings" "time" + "github.com/srcfl/ftw/go/internal/localzone" "github.com/srcfl/ftw/go/internal/state" ) @@ -121,7 +122,7 @@ func (s *Server) handleLoadResearchDump(w http.ResponseWriter, r *http.Request) "bucket_min": loadResearchBucketMin, "excluded_dashboard_summary_rows": excluded, "observation_policy": "original site observations only; dashboard summaries are excluded", - "timezone": time.Local.String(), + "timezone": localzone.Name(), "privacy": "no logs, no raw config, no hostnames, no driver names, no device identifiers", "load_semantics": "house_load_w = max(grid_w - pv_w - bat_w - ev_w - v2x_w, 0); site convention: import/charge positive, PV/battery discharge/V2X discharge negative", "contents": []string{ diff --git a/go/internal/api/api_savings.go b/go/internal/api/api_savings.go index 12a91b19..b0da6011 100644 --- a/go/internal/api/api_savings.go +++ b/go/internal/api/api_savings.go @@ -9,6 +9,7 @@ import ( "time" "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/localzone" "github.com/srcfl/ftw/go/internal/savings" "github.com/srcfl/ftw/go/internal/state" ) @@ -115,7 +116,7 @@ type savingsCacheT struct { // "actual_cost_ore": ..., "baseline_cost_ore": ..., "saved_ore": ..., // "expected_ms": ..., "history_covered_ms": ..., "priced_covered_ms": ..., // "history_coverage_pct": ..., "priced_coverage_pct": ... }, -// "tz": "Local", "value_scope": "site_total", +// "tz": "Europe/Stockholm", "value_scope": "site_total", // "baseline": "no_pv_no_battery_vehicle_energy_at_daily_average" // } // @@ -162,7 +163,7 @@ func (s *Server) handleSavingsDaily(w http.ResponseWriter, r *http.Request) { if zone == "" { // No price provider configured → nothing to compare against. writeJSON(w, 200, map[string]any{ - "days": []any{}, "tz": time.Now().Location().String(), + "days": []any{}, "tz": localzone.Name(), "value_scope": savingsValueScope, "baseline": savingsBaseline, }) return @@ -275,7 +276,7 @@ func (s *Server) handleSavingsDaily(w http.ResponseWriter, r *http.Request) { "history_coverage_pct": boundedCoveragePct(tHistoryCoveredMs, tExpectedMs), "priced_coverage_pct": boundedCoveragePct(tPricedCoveredMs, tExpectedMs), }, - "tz": loc.String(), + "tz": localzone.Name(), "value_scope": savingsValueScope, "baseline": savingsBaseline, }) @@ -412,7 +413,7 @@ func (s *Server) writeLedgerSavings(w http.ResponseWriter, r *http.Request, days "history_coverage_pct": boundedCoveragePct(tHist, tExpected), "priced_coverage_pct": boundedCoveragePct(tPrice, tExpected), }, - "tz": loc.String(), "value_scope": savingsValueScope, "baseline": savingsBaseline, + "tz": localzone.Name(), "value_scope": savingsValueScope, "baseline": savingsBaseline, "baselines": []string{"no_pv_no_battery", "self_consumption"}, }) return true, nil diff --git a/go/internal/localzone/localzone.go b/go/internal/localzone/localzone.go new file mode 100644 index 00000000..c6555245 --- /dev/null +++ b/go/internal/localzone/localzone.go @@ -0,0 +1,46 @@ +// Package localzone names the zone the box keeps its clock in. +package localzone + +import ( + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +// Name returns the IANA name of the box's zone, or "UTC". +// +// Go names the host zone "Local" when TZ is unset, and no client can load a +// zone by that name. Clients render plan times and days in this zone, and +// persisted clock buckets keep its DST rules, so it must be a name that +// time.LoadLocation and Intl accept. UTC is valid when that is the box's +// configured zone; never guess a country. +func Name() string { return name() } + +// 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. +var name = sync.OnceValue(func() string { + return resolve(os.Getenv("TZ"), time.Local.String(), "/etc/localtime", "/etc/timezone") +}) + +func resolve(tz, local, localtime, timezone string) string { + candidates := []string{strings.TrimPrefix(tz, ":"), local} + if target, err := filepath.EvalSymlinks(localtime); err == nil { + if _, zone, ok := strings.Cut(target, "/zoneinfo/"); ok { + candidates = append(candidates, zone) + } + } + if data, err := os.ReadFile(timezone); err == nil { + candidates = append(candidates, strings.TrimSpace(string(data))) + } + for _, zone := range candidates { + if zone == "" || zone == "Local" { + continue + } + if _, err := time.LoadLocation(zone); err == nil { + return zone + } + } + return "UTC" +} diff --git a/go/internal/localzone/localzone_test.go b/go/internal/localzone/localzone_test.go new file mode 100644 index 00000000..ed4e35f3 --- /dev/null +++ b/go/internal/localzone/localzone_test.go @@ -0,0 +1,80 @@ +package localzone + +import ( + "os" + "path/filepath" + "testing" + "time" + _ "time/tzdata" // the binary embeds it too, so the zone names resolve as they do on a box +) + +// host fakes the two files the resolver reads. Either may be left out. +func host(t *testing.T, linkedZone, timezoneFile string) (localtime, timezone string) { + t.Helper() + dir := t.TempDir() + localtime = filepath.Join(dir, "localtime") + timezone = filepath.Join(dir, "timezone") + if linkedZone != "" { + target := filepath.Join(dir, "usr", "share", "zoneinfo", filepath.FromSlash(linkedZone)) + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(target, nil, 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, localtime); err != nil { + t.Fatal(err) + } + } + if timezoneFile != "" { + if err := os.WriteFile(timezone, []byte(timezoneFile), 0o644); err != nil { + t.Fatal(err) + } + } + return localtime, timezone +} + +func TestAHostWithoutTZIsNamedByItsLocaltimeLink(t *testing.T) { + localtime, timezone := host(t, "Europe/Stockholm", "") + if got := resolve("", "Local", localtime, timezone); got != "Europe/Stockholm" { + t.Fatalf("zone = %q, want Europe/Stockholm", got) + } +} + +func TestTZNamesTheZoneBeforeTheHostFiles(t *testing.T) { + localtime, timezone := host(t, "Europe/Stockholm", "Europe/Oslo\n") + if got := resolve(":Europe/Berlin", "Europe/Berlin", localtime, timezone); got != "Europe/Berlin" { + t.Fatalf("zone = %q, want Europe/Berlin", got) + } +} + +func TestAnUnknownTZFallsThroughToTheHost(t *testing.T) { + localtime, timezone := host(t, "Europe/Stockholm", "") + if got := resolve("Mars/Olympus", "Mars/Olympus", localtime, timezone); got != "Europe/Stockholm" { + t.Fatalf("zone = %q, want Europe/Stockholm", got) + } +} + +func TestEtcTimezoneNamesTheZoneWhenLocaltimeIsNotALink(t *testing.T) { + localtime, timezone := host(t, "", "Europe/Oslo\n") + if got := resolve("", "Local", localtime, timezone); got != "Europe/Oslo" { + t.Fatalf("zone = %q, want Europe/Oslo", got) + } +} + +func TestAHostThatNamesNoZoneIsUTC(t *testing.T) { + localtime, timezone := host(t, "", "") + if got := resolve("", "Local", localtime, timezone); got != "UTC" { + t.Fatalf("zone = %q, want UTC", got) + } +} + +func TestNameIsAZoneClientsCanLoad(t *testing.T) { + got := Name() + if got == "Local" || got == "" { + t.Fatalf("Name() = %q, want an IANA name or UTC", got) + } + if _, err := time.LoadLocation(got); err != nil { + t.Fatalf("Name() = %q does not load: %v", got, err) + } +}