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/ev-live-split-from-faulted-charger.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 14 additions & 8 deletions go/cmd/ftw/app_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
74 changes: 74 additions & 0 deletions go/cmd/ftw/app_link_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
30 changes: 30 additions & 0 deletions go/internal/control/control_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go/internal/control/dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions go/internal/telemetry/device_fault_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
40 changes: 27 additions & 13 deletions go/internal/telemetry/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -578,7 +589,7 @@ func (s *Store) SumOnlineEVW() float64 {
continue
}
h, ok := s.health[r.Driver]
if !ok || !h.IsOnline() {
if !ok || !h.TelemetryLive() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Exclude uncommandable EV draw from the surplus budget

When a DeviceFault charger is still drawing, this now includes its power in every SumOnlineEVW caller, including main.go's SetSiteSurplusForEV calculation -gridW + batW + evW. That formula adds EV draw back because it assumes the controller can reduce or redistribute that draw, but SetDriverOnline separately prevents dispatch to the faulted charger. With a faulted/unmanaged charger drawing alongside a healthy surplus-only loadpoint, the faulted draw is therefore reported as reclaimable PV surplus and the healthy charger can be commanded to import that amount from the grid, breaking the surplus-only guarantee. Keep the telemetry-inclusive sum for display and battery protection, but use a control-online EV sum for this reclaimable-surplus calculation.

Useful? React with 👍 / 👎.

continue
}
sum += r.SmoothedW
Expand All @@ -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()
Expand All @@ -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
Expand Down
16 changes: 16 additions & 0 deletions go/internal/telemetry/telemetry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions web/app-link-tab.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 5 additions & 4 deletions web/settings/tabs/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -419,10 +419,11 @@

return (
"<fieldset><legend>The FTW app</legend>" +
'<p class="hint">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.</p>" +
'<p class="hint">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.</p>" +
'<label><input type="checkbox" id="app-link-enabled" ' +
'data-checkbox-path="app_link.enabled"' + (enabled ? " checked" : "") +
"> Let the FTW app connect to this box</label>" +
Expand Down