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/stale-meter-no-fake-zero.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

A meter that has stopped reporting no longer shows as 0 W balanced. Grid and house load go blank, and solar or battery that went quiet with it stay on the diagram as no data instead of vanishing.
14 changes: 12 additions & 2 deletions go/internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -1141,13 +1141,11 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
"mode": ctrl.Mode,
"troubleshooting_mode": troubleshootingMode,
"plan_stale": ctrl.PlanStale,
"grid_w": gridW,
"pv_w": pvW,
"pv_w_predicted": pvPredictW,
"bat_w": batW,
"ev_w": evW,
"v2x_w": v2xW,
"load_w": loadW,
"load_w_predicted": loadPredictW,
"bat_soc": avgSoC,
"grid_target_w": ctrl.GridTargetW,
Expand Down Expand Up @@ -1177,6 +1175,18 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
// (under). Idle slots (|planned| ≤ 50 Wh) are ignored.
"slot_delivery_stats": ctrl.SlotDeliveryStats,
}
// A stale or missing site meter is not 0 W. Publishing zero made the
// dashboard and the FTW app draw "balanced" / "0 W" as if the house
// were idle. JSON null is what the flow mapping already treats as
// "no data". Development setups with no configured meter keep the
// historical zero (haveGrid is forced true above).
if haveGrid {
resp["grid_w"] = gridW
resp["load_w"] = loadW
} else {
resp["grid_w"] = nil
resp["load_w"] = nil
}
if energyToday != nil || energyCurrentSlot != nil {
energy := map[string]any{}
if energyToday != nil {
Expand Down
53 changes: 53 additions & 0 deletions go/internal/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,59 @@ func TestHandleStatusKeepsFaultedSiteMeterReading(t *testing.T) {
}
}

func TestHandleStatusOmitsWattsWhenSiteMeterIsOffline(t *testing.T) {
tel := telemetry.NewStore()
ctrl := &control.State{SiteMeterDriver: "ferroamp"}

tel.Update("ferroamp", telemetry.DerMeter, 2400, nil, nil)
soc := 0.55
tel.Update("ferroamp", telemetry.DerBattery, -500, &soc, nil)
tel.Update("ferroamp", telemetry.DerPV, -1800, nil, nil)
tel.RecordDriverSuccess("ferroamp")
tel.DriverHealthMut("ferroamp").SetOffline()

srv := New(&Deps{
Tel: tel,
Ctrl: ctrl,
CtrlMu: &sync.Mutex{},
CapMu: &sync.RWMutex{},
Capacities: map[string]float64{"ferroamp": 10_000},
CfgMu: &sync.RWMutex{},
Cfg: &config.Config{},
})
req := httptest.NewRequest(http.MethodGet, "/api/status", nil)
rr := httptest.NewRecorder()
srv.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", rr.Code, rr.Body.String())
}

var raw map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &raw); err != nil {
t.Fatal(err)
}
if _, ok := raw["grid_w"]; !ok {
t.Fatal("grid_w key missing; want explicit JSON null, not an omitted field")
}
if raw["grid_w"] != nil {
t.Fatalf("grid_w = %v, want null so the UI cannot draw 0 W balanced", raw["grid_w"])
}
if raw["load_w"] != nil {
t.Fatalf("load_w = %v, want null", raw["load_w"])
}
drivers, _ := raw["drivers"].(map[string]any)
ferroamp, _ := drivers["ferroamp"].(map[string]any)
if ferroamp["status"] != "offline" {
t.Fatalf("driver status = %v, want offline", ferroamp["status"])
}
if ferroamp["pv_w"] != -1800.0 {
t.Fatalf("pv_w = %v, want last-known -1800 so the UI can keep a solar node", ferroamp["pv_w"])
}
if ferroamp["bat_w"] != -500.0 {
t.Fatalf("bat_w = %v, want last-known -500 so the UI can keep a battery node", ferroamp["bat_w"])
}
}

func TestHandleV2XPolicyReturnsLiveEnvelope(t *testing.T) {
tel := telemetry.NewStore()
tel.Update("meter", telemetry.DerMeter, 1500, nil, nil)
Expand Down
Loading