diff --git a/.changeset/ev-live-split-from-faulted-charger.md b/.changeset/ev-live-split-from-faulted-charger.md new file mode 100644 index 000000000..2ce10564e --- /dev/null +++ b/.changeset/ev-live-split-from-faulted-charger.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +The phone app now shows EV charging as its own number, the same way the local page does. A charger that is drawing but cannot take a command no longer hides that draw inside "house", and the battery no longer discharges into the car when cover-EV is off. diff --git a/go/cmd/ftw/app_link.go b/go/cmd/ftw/app_link.go index 112859592..1a573442a 100644 --- a/go/cmd/ftw/app_link.go +++ b/go/cmd/ftw/app_link.go @@ -141,20 +141,26 @@ func (a *appSite) Snapshot() appproto.Snapshot { // EV chargers. Known means the site has one at all — an idle charger // is a real 0 W reading, a site without one sends no field 10 and the // app draws no EV node. Positive while charging: a charger consumes - // like any other load. + // like any other load. The watts come from SumOnlineEVW so a charger + // that cannot take a command but is still drawing is counted here the + // same way the LAN dashboard counts it — IsOnline() would hide that + // draw in the house node. for _, reading := range a.tel.ReadingsByType(telemetry.DerEV) { addSource(reading.Driver) snap.EVWKnown = true - if health := a.tel.DriverHealth(reading.Driver); health != nil && health.IsOnline() { - snap.EVW += reading.SmoothedW - } } + snap.EVW = a.tel.SumOnlineEVW() - // grid = load + battery + pv, all site-signed. Rearranged, not - // re-derived: a second formula here would be a second thing to keep in - // step with docs/site-convention.md. + // grid = load + battery + pv + ev + v2x, all site-signed. Rearranged, + // not re-derived: a second formula here would be a second thing to + // keep in step with docs/site-convention.md. House load is never + // negative; metering noise that would push it below zero is the car + // or the battery, not the house consuming in reverse. snap.LoadW = snap.GridW - snap.BatteryW - snap.PVW - - a.tel.SumOnlineEVW() - a.tel.SumOnlineV2XW() + snap.EVW - a.tel.SumOnlineV2XW() + if snap.LoadW < 0 { + snap.LoadW = 0 + } return snap } diff --git a/go/cmd/ftw/app_link_test.go b/go/cmd/ftw/app_link_test.go index d4f7dafb3..d718c89e9 100644 --- a/go/cmd/ftw/app_link_test.go +++ b/go/cmd/ftw/app_link_test.go @@ -62,6 +62,80 @@ func TestAppSnapshotKeepsTheSiteSignConvention(t *testing.T) { } } +// The LAN dashboard splits house and car from every charger that is still +// reporting, including one that cannot take a command. The app snapshot has +// to do the same: hiding that draw in load_w is how the phone showed EV at +// 0 W and house at house+car. +func TestAppSnapshotSplitsEVFromHouseLoad(t *testing.T) { + tel, ctrl := seedSite(t) + tel.Update("easee", telemetry.DerEV, 2000, nil, nil) + tel.RecordDriverSuccess("easee") + + site := &appSite{ + tel: tel, ctrl: ctrl, ctrlMu: &sync.Mutex{}, + revision: &control.Revision{}, started: time.Now(), + siteMeterStale: time.Minute, + } + snap := site.Snapshot() + + if !snap.EVWKnown { + t.Fatal("a site with a charger did not send field 10") + } + if snap.EVW != 2000 { + t.Fatalf("ev = %v, want 2000", snap.EVW) + } + // grid 1200, battery +900, PV -3400, EV 2000 → house 1700. + if snap.LoadW != 1700 { + t.Fatalf("load = %v, want 1700 (house, not house+car)", snap.LoadW) + } +} + +func TestAppSnapshotCountsAFaultedChargersDraw(t *testing.T) { + tel, ctrl := seedSite(t) + tel.Update("easee", telemetry.DerEV, 2000, nil, nil) + tel.RecordDriverSuccess("easee") + tel.SetDriverDeviceFault("easee", true, "setpoint refused") + + site := &appSite{ + tel: tel, ctrl: ctrl, ctrlMu: &sync.Mutex{}, + revision: &control.Revision{}, started: time.Now(), + siteMeterStale: time.Minute, + } + snap := site.Snapshot() + + if !snap.EVWKnown || snap.EVW != 2000 { + t.Fatalf("ev = %v known=%v; a faulted charger is still drawing", snap.EVW, snap.EVWKnown) + } + if snap.LoadW != 1700 { + t.Fatalf("load = %v; the car's draw landed in the house", snap.LoadW) + } +} + +func TestAppSnapshotIgnoresAnOfflineChargersDraw(t *testing.T) { + tel, ctrl := seedSite(t) + tel.Update("easee", telemetry.DerEV, 11400, nil, nil) + tel.RecordDriverSuccess("easee") + tel.DriverHealthMut("easee").SetOffline() + + site := &appSite{ + tel: tel, ctrl: ctrl, ctrlMu: &sync.Mutex{}, + revision: &control.Revision{}, started: time.Now(), + siteMeterStale: time.Minute, + } + snap := site.Snapshot() + + if !snap.EVWKnown { + t.Fatal("an idle-looking charger that exists must still be named") + } + if snap.EVW != 0 { + t.Fatalf("ev = %v; an offline charger's last-known draw leaked", snap.EVW) + } + // grid 1200 - bat 900 - pv -3400 = 3700, no EV subtracted. + if snap.LoadW != 1200-900+3400 { + t.Fatalf("load = %v, want house without a live car", snap.LoadW) + } +} + // An offline driver contributes nothing. Adding its last reading would show a // number that is not happening as though it were. func TestAppSnapshotIgnoresAnOfflineDriversReading(t *testing.T) { diff --git a/go/internal/control/control_test.go b/go/internal/control/control_test.go index f10c3c15a..a9a0b562e 100644 --- a/go/internal/control/control_test.go +++ b/go/internal/control/control_test.go @@ -1082,6 +1082,36 @@ func TestPlannerSelfBatteryCoversEVOffExcludesEVFromGrid(t *testing.T) { } } +func TestPlannerDoesNotDischargeIntoAFaultedChargersDraw(t *testing.T) { + // Site importing 9.9 kW because the car is drawing 11.4 kW against + // 1.5 kW of solar-minus-house. The charger is emitting that 11.4 kW + // but DeviceFault so it cannot take a command. BatteryCoversEV is + // off. Before the TelemetryLive split, IsOnline() hid the car and + // the planner discharged the battery into it as if it were house + // load. + store := seedStore(9900, []struct { + name string + currentW, soc float64 + }{ + {"ferroamp", 0, 0.5}, + }) + store.Update("easee", telemetry.DerEV, 11400, nil, nil) + store.DriverHealthMut("easee").RecordSuccess() + store.SetDriverDeviceFault("easee", true, "setpoint refused") + + st := NewState(0, 50, "ferroamp") + st.Mode = ModePlannerSelf + st.BatteryCoversEV = false + st.SlewRateW = 100000 + targets := ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200}), 11040) + if st.EVChargingW < 11000 { + t.Fatalf("EVChargingW = %f; a faulted charger still drawing was ignored", st.EVChargingW) + } + if len(targets) > 0 && targets[0].TargetW < -2000 { + t.Errorf("battery discharged %.0f W into a car that cover-EV is off for", targets[0].TargetW) + } +} + func TestEVChargingSignalOverriddenByDerEVReading(t *testing.T) { // A DerEV driver reports 4000W. EVChargingW was 0 (no manual slider). // After ComputeDispatch, EVChargingW must reflect the live reading diff --git a/go/internal/control/dispatch.go b/go/internal/control/dispatch.go index 3d0bf5d87..7ce189e33 100644 --- a/go/internal/control/dispatch.go +++ b/go/internal/control/dispatch.go @@ -208,7 +208,7 @@ func onlineVehiclePowerFlow(store *telemetry.Store) vehiclePowerFlow { var out vehiclePowerFlow for _, r := range store.ReadingsByType(telemetry.DerEV) { h := store.DriverHealth(r.Driver) - if h == nil || !h.IsOnline() { + if h == nil || !h.TelemetryLive() { continue } out.Live = true @@ -218,7 +218,7 @@ func onlineVehiclePowerFlow(store *telemetry.Store) vehiclePowerFlow { } for _, r := range store.ReadingsByType(telemetry.DerV2X) { h := store.DriverHealth(r.Driver) - if h == nil || !h.IsOnline() { + if h == nil || !h.TelemetryLive() { continue } out.Live = true diff --git a/go/internal/telemetry/device_fault_test.go b/go/internal/telemetry/device_fault_test.go index aec9e7c3c..6b1b1b4d3 100644 --- a/go/internal/telemetry/device_fault_test.go +++ b/go/internal/telemetry/device_fault_test.go @@ -19,6 +19,9 @@ func TestDeviceFaultMakesDriverOfflineForControl(t *testing.T) { if h.IsOnline() { t.Error("a device fault must make IsOnline() false (excluded from control)") } + if !h.TelemetryLive() { + t.Error("a device fault is not a stale meter; telemetry is still live") + } if h.DeviceFaultReason != "ehub fault 0x8030" { t.Errorf("DeviceFaultReason = %q, want the reason", h.DeviceFaultReason) } diff --git a/go/internal/telemetry/store.go b/go/internal/telemetry/store.go index 340ac0a26..5eb606e33 100644 --- a/go/internal/telemetry/store.go +++ b/go/internal/telemetry/store.go @@ -258,6 +258,16 @@ func (h *DriverHealth) IsOnline() bool { return h.Status != StatusOffline && !h.DeviceFault } +// TelemetryLive reports whether this driver's last reading is still a +// measurement of now. DeviceFault does not fail it: that flag means the +// device cannot take a command, not that its meter went quiet. A charger +// that refuses setpoints while still drawing 11 kW is live load, and the +// house-vs-car split, the app's EV node, and BatteryCoversEV all have to +// see it. StatusOffline is the watchdog's word that the reading is stale. +func (h *DriverHealth) TelemetryLive() bool { + return h != nil && h.Status != StatusOffline +} + // MetricSample is one (driver, metric, ts, value) tuple buffered for the // long-format TS database. State.Store consumes these via FlushSamples. type MetricSample struct { @@ -550,15 +560,16 @@ func (s *Store) ReadingsByType(t DerType) []*DerReading { return out } -// SumOnlineEVW returns the summed SmoothedW across every online EV -// driver. Used by the status endpoint, the loadmodel sampler, the MPC -// divergence check, and the control loop's grid bias — all four need -// the same "what is the EV charger drawing right now (and it's -// trustworthy)" signal, derived the same way. +// SumOnlineEVW returns the summed SmoothedW across every EV driver that +// is still reporting. Used by the status endpoint, the loadmodel sampler, +// the MPC divergence check, the app snapshot, and the control loop's +// grid bias — all of them need the same "what is the EV charger drawing +// right now (and it's trustworthy)" signal, derived the same way. // -// Offline drivers (stale telemetry, watchdog tripped) are skipped so a -// dangling 3.6 kW last-known reading can't sneak into load or grid -// accounting after the driver has actually stopped reporting. +// Watchdog-offline drivers are skipped so a dangling 3.6 kW last-known +// reading can't sneak into load or grid accounting after the driver has +// actually stopped reporting. A DeviceFault does not skip: that flag +// means the charger cannot take a command, not that it stopped drawing. // // Sub-watt floor: when the Kalman residual decays toward zero (driver // reports a real 0 W), the smoothed value asymptotes to denormals like @@ -578,7 +589,7 @@ func (s *Store) SumOnlineEVW() float64 { continue } h, ok := s.health[r.Driver] - if !ok || !h.IsOnline() { + if !ok || !h.TelemetryLive() { continue } sum += r.SmoothedW @@ -589,9 +600,12 @@ func (s *Store) SumOnlineEVW() float64 { return sum } -// SumOnlineV2XW returns the summed SmoothedW across online bidirectional -// V2X chargers. Positive values mean vehicle charging; negative values -// mean the vehicle is discharging into the site/grid. +// SumOnlineV2XW returns the summed SmoothedW across bidirectional V2X +// chargers that are still reporting. Positive values mean vehicle +// charging; negative values mean the vehicle is discharging into the +// site/grid. DeviceFault is not a skip, for the same reason as +// SumOnlineEVW: a charger that cannot take a command can still move +// power. func (s *Store) SumOnlineV2XW() float64 { s.mu.RLock() defer s.mu.RUnlock() @@ -601,7 +615,7 @@ func (s *Store) SumOnlineV2XW() float64 { continue } h, ok := s.health[r.Driver] - if !ok || !h.IsOnline() { + if !ok || !h.TelemetryLive() { continue } sum += r.SmoothedW diff --git a/go/internal/telemetry/telemetry_test.go b/go/internal/telemetry/telemetry_test.go index 48c592638..01a8bdb7c 100644 --- a/go/internal/telemetry/telemetry_test.go +++ b/go/internal/telemetry/telemetry_test.go @@ -438,6 +438,22 @@ func TestSumOnlineEVWSumsAllOnline(t *testing.T) { } } +// A charger that cannot take a command is still drawing. DeviceFault must +// not drop that watts from the house-vs-car split — that is how the phone +// app showed EV at 0 W while the LAN dashboard showed 11 kW. +func TestSumOnlineEVWCountsAFaultedDriver(t *testing.T) { + s := NewStore() + s.Update("easee", DerEV, 11400, nil, nil) + s.DriverHealthMut("easee").RecordSuccess() + s.SetDriverDeviceFault("easee", true, "setpoint refused") + if s.DriverHealth("easee").IsOnline() { + t.Fatal("precondition: a faulted charger is not online for control") + } + if got := s.SumOnlineEVW(); got != 11400 { + t.Errorf("faulted charger draw = %f, want 11400", got) + } +} + // Offline drivers are excluded. Without this, a driver whose watchdog // tripped would leak a stale last-known reading into load / grid math // indefinitely after it stopped actually reporting. diff --git a/web/app-link-tab.test.mjs b/web/app-link-tab.test.mjs index 54316a61e..e78f11dfd 100644 --- a/web/app-link-tab.test.mjs +++ b/web/app-link-tab.test.mjs @@ -78,6 +78,13 @@ describe("the app tab", () => { assert.match(html, /cannot read/); }); + it("says this is not the Sourceful app, and LAN use needs no pairing", () => { + const html = render({}); + assert.match(html, /not the Sourceful/); + assert.match(html, /app\.ftw\.energy/); + assert.match(html, /no pairing needed/); + }); + it("starts with the pairing button disabled", () => { // It is enabled once /api/app-link/status reports the uplink running. // Starting enabled means the first press of a fresh page fails. diff --git a/web/settings/tabs/app.js b/web/settings/tabs/app.js index d63cc4852..8647cf913 100644 --- a/web/settings/tabs/app.js +++ b/web/settings/tabs/app.js @@ -419,10 +419,11 @@ return ( "
The FTW app" + - '

The FTW app talks to this box directly. Keeping this on ' + - "lets it reach you when you are away from home. Readings and commands are " + - "end-to-end encrypted. Sourceful can see your IP and when the box is " + - "connected, but cannot read them.

" + + '

This is not the Sourceful (Zap) app. The FTW app at ' + + "app.ftw.energy talks to this box when you are away. On the same Wi-Fi you " + + "can just open this page in a browser — no pairing needed. Readings and " + + "commands are end-to-end encrypted. Sourceful can see your IP and when the " + + "box is connected, but cannot read them.

" + '" +