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/box-iana-timezone.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 9 additions & 6 deletions go/cmd/ftw/app_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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}
}
Expand Down Expand Up @@ -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}

Expand Down
130 changes: 130 additions & 0 deletions go/cmd/ftw/app_link_zone_test.go
Original file line number Diff line number Diff line change
@@ -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 }
28 changes: 2 additions & 26 deletions go/cmd/ftw/forecast_site.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ import (
"fmt"
"log/slog"
"math"
"os"
"path/filepath"
"sort"
"strings"
"sync"
Expand All @@ -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"
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
3 changes: 2 additions & 1 deletion go/cmd/ftw/forecast_site_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 3 additions & 2 deletions go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 3 additions & 2 deletions go/internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 ----
Expand Down
3 changes: 2 additions & 1 deletion go/internal/api/api_research.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"strings"
"time"

"github.com/srcfl/ftw/go/internal/localzone"
"github.com/srcfl/ftw/go/internal/state"
)

Expand Down Expand Up @@ -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{
Expand Down
9 changes: 5 additions & 4 deletions go/internal/api/api_savings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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"
// }
//
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
})
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading