From 18c8022d33ec82499fa4cf624aace7286f934e52 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 13 Sep 2026 16:12:05 +0200 Subject: [PATCH 1/7] fix(ev): retain measured progress across delayed counters and restarts --- .changeset/ev-counter-time-and-session.md | 5 + drivers/BUNDLED_SOURCE.json | 2 +- go/cmd/ftw/ev_observation.go | 37 +++- go/cmd/ftw/ev_observation_test.go | 21 ++ go/internal/api/api_loadpoint_manual_test.go | 3 +- go/internal/loadpoint/controller.go | 6 +- go/internal/loadpoint/controller_energy.go | 50 ++--- go/internal/loadpoint/loadpoint.go | 22 +++ go/internal/loadpoint/manual_status.go | 6 +- go/internal/loadpoint/manual_status_test.go | 15 ++ go/internal/loadpoint/session_energy.go | 108 ++++++++++ go/internal/loadpoint/session_energy_test.go | 198 +++++++++++++++++++ go/internal/loadpoint/session_sqlite_test.go | 42 ++++ go/internal/loadpoint/session_state.go | 92 +++++++-- 14 files changed, 555 insertions(+), 52 deletions(-) create mode 100644 .changeset/ev-counter-time-and-session.md create mode 100644 go/internal/loadpoint/session_energy.go create mode 100644 go/internal/loadpoint/session_energy_test.go diff --git a/.changeset/ev-counter-time-and-session.md b/.changeset/ev-counter-time-and-session.md new file mode 100644 index 00000000..18c36f80 --- /dev/null +++ b/.changeset/ev-counter-time-and-session.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Keep charger measurement times when estimating EV energy. Use fresh power between delayed session-counter updates, reconcile overlapping energy once, and retain the estimate through a verified session restart. Missing or older counters no longer reset a confirmed battery level. Expose the estimate source and measurement ages. diff --git a/drivers/BUNDLED_SOURCE.json b/drivers/BUNDLED_SOURCE.json index 86d385d9..bf0c5b07 100644 --- a/drivers/BUNDLED_SOURCE.json +++ b/drivers/BUNDLED_SOURCE.json @@ -17,7 +17,7 @@ "for coverage. Run scripts/sync-bundled-drivers.sh to update." ], "repository": "srcfl/device-drivers", - "commit": "d560ca6d7df57a374c9998e1e90329857e3d15c3", + "commit": "f016c67c4c04cd350a342e55a7683ec5790482ea", "source_dir": "drivers/lua", "drivers": [ "ambibox_v2x", "ctek", "ctek_hybrid", "ctek_v2", "deye", "easee_cloud", diff --git a/go/cmd/ftw/ev_observation.go b/go/cmd/ftw/ev_observation.go index 7199f9cd..1d653a08 100644 --- a/go/cmd/ftw/ev_observation.go +++ b/go/cmd/ftw/ev_observation.go @@ -20,14 +20,16 @@ func currentEVSample(r *telemetry.DerReading, health *telemetry.DriverHealth, wa return loadpoint.EVSample{}, false } var d struct { - ConnectionGeneration uint64 `json:"connection_generation"` - ConnectionUnknown bool `json:"connection_unknown"` - Connected *bool `json:"connected"` - SessionWh float64 `json:"session_wh"` - RequestActive *bool `json:"request_active"` - SessionID string `json:"session_id"` - } - if json.Unmarshal(r.Data, &d) != nil || d.SessionWh < 0 { + ConnectionGeneration uint64 `json:"connection_generation"` + ConnectionUnknown bool `json:"connection_unknown"` + Connected *bool `json:"connected"` + SessionWh *float64 `json:"session_wh"` + RequestActive *bool `json:"request_active"` + SessionID string `json:"session_id"` + PowerAt string `json:"power_observed_at"` + EnergyAt string `json:"energy_observed_at"` + } + if json.Unmarshal(r.Data, &d) != nil || (d.SessionWh != nil && *d.SessionWh < 0) { return loadpoint.EVSample{}, false } if d.ConnectionUnknown { @@ -40,8 +42,23 @@ func currentEVSample(r *telemetry.DerReading, health *telemetry.DriverHealth, wa if d.RequestActive != nil { active = *d.RequestActive } - return loadpoint.EVSample{ConnectionGeneration: d.ConnectionGeneration, PowerW: r.SmoothedW, SessionWh: d.SessionWh, - Connected: *d.Connected, RequestActive: active, DeviceID: deviceID, SessionID: d.SessionID}, true + sample := loadpoint.EVSample{ConnectionGeneration: d.ConnectionGeneration, PowerW: r.RawW, + PowerAt: r.UpdatedAt, Connected: *d.Connected, RequestActive: active, DeviceID: deviceID, SessionID: d.SessionID, + SessionWhUnavailable: d.SessionWh == nil} + if d.SessionWh != nil { + sample.SessionWh = *d.SessionWh + } + if d.PowerAt != "" { + at, err := time.Parse(time.RFC3339Nano, d.PowerAt) + sample.PowerAt = at + sample.PowerUnavailable = err != nil || at.After(now.Add(time.Second)) || (now.Sub(at) > 30*time.Second && r.RawW > 0) + } + if d.EnergyAt != "" { + at, err := time.Parse(time.RFC3339Nano, d.EnergyAt) + sample.EnergyAt = at + sample.SessionWhUnavailable = sample.SessionWhUnavailable || err != nil || at.After(now.Add(time.Second)) + } + return sample, true } // OCPP has no driver registry entry. Only the current adopted charger's diff --git a/go/cmd/ftw/ev_observation_test.go b/go/cmd/ftw/ev_observation_test.go index 9f630d1d..1d95e742 100644 --- a/go/cmd/ftw/ev_observation_test.go +++ b/go/cmd/ftw/ev_observation_test.go @@ -32,3 +32,24 @@ func TestEVObservationPreservesSessionWhenCloudIsStale(t *testing.T) { t.Fatalf("fresh OCPP unplug lost: %+v %v", s, ok) } } + +func TestEVObservationKeepsSourceTimesAndMissingCounter(t *testing.T) { + now := time.Now().Truncate(time.Second) + health := &telemetry.DriverHealth{Status: telemetry.StatusOk} + data, _ := json.Marshal(map[string]any{"connected": true, "session_wh": 1000, "session_id": "same", "power_observed_at": now.Format(time.RFC3339Nano), "energy_observed_at": now.Add(-13 * time.Minute).Format(time.RFC3339Nano)}) + r := &telemetry.DerReading{UpdatedAt: now, RawW: 6900, SmoothedW: 4200, Data: data} + s, ok := currentEVSample(r, health, time.Minute, now, false, "charger") + if !ok || s.PowerW != 6900 || !s.PowerAt.Equal(now) || !s.EnergyAt.Equal(now.Add(-13*time.Minute)) || s.PowerUnavailable || s.SessionWhUnavailable { + t.Fatalf("times or raw power lost: %+v", s) + } + r.Data = json.RawMessage(`{"connected":true,"session_id":"same"}`) + s, ok = currentEVSample(r, health, time.Minute, now, false, "charger") + if !ok || !s.Connected || !s.SessionWhUnavailable { + t.Fatalf("missing counter became zero or unplug: %+v", s) + } + r.Data = data + s, ok = currentEVSample(r, health, time.Minute, now.Add(40*time.Second), false, "charger") + if !ok || !s.PowerUnavailable { + t.Fatalf("old vendor power became fresh on receipt: %+v", s) + } +} diff --git a/go/internal/api/api_loadpoint_manual_test.go b/go/internal/api/api_loadpoint_manual_test.go index 10f18259..c04a60c2 100644 --- a/go/internal/api/api_loadpoint_manual_test.go +++ b/go/internal/api/api_loadpoint_manual_test.go @@ -322,7 +322,8 @@ func TestLoadpointsCarryManualStatus(t *testing.T) { t.Fatalf("after a stall: %+v", m) } - // Power flows. + // Power flows and reaches the loadpoint on the next controller observation. + mgr.Observe("garage", true, 10800, 0, true) tel.Update("easee", telemetry.DerEV, 10800, nil, json.RawMessage(`{"max_a":16,"charging":true}`)) if m = manual(); m.State != loadpoint.ManualCharging { t.Fatalf("while charging: %+v", m) diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index c323b990..4f6e821a 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -447,6 +447,10 @@ type EVSample struct { ConnectionGeneration uint64 // process-local transport epoch, not durable session proof PowerW float64 SessionWh float64 + SessionWhUnavailable bool + PowerUnavailable bool + PowerAt time.Time + EnergyAt time.Time Connected bool RequestActive bool DeviceID string @@ -1560,7 +1564,7 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, d enteringSurplusPaused, _ := c.getSurplusPause(lpCfg.ID) selfWithheld := surplusOn && enteringSurplusPaused c.manager.SetSurplusWithheld(lpCfg.ID, selfWithheld) - c.manager.ObserveSession(lpCfg.ID, sample.Connected, sample.PowerW, sample.SessionWh, sample.RequestActive, sample.DeviceID, sample.SessionID) + c.manager.ObserveSample(lpCfg.ID, sample) c.restoreManualHoldForSession(lpCfg.ID) c.evaluateBatteryBoost(lpCfg.ID, now, sample.Connected, dispatchAllowed) if !sample.Connected { diff --git a/go/internal/loadpoint/controller_energy.go b/go/internal/loadpoint/controller_energy.go index 3d6adbbf..32b3a043 100644 --- a/go/internal/loadpoint/controller_energy.go +++ b/go/internal/loadpoint/controller_energy.go @@ -14,7 +14,7 @@ type meteredEnergy struct { driver, device, session string generation uint64 last EVSample - powerWh, counterWh float64 + meter sessionEnergy points []energyPoint } @@ -25,40 +25,46 @@ func (c *Controller) observeEnergy(cfg Config, sample EVSample, now time.Time) { if c.energySamples == nil { c.energySamples = make(map[string]*meteredEnergy) } - if !sample.Connected || sample.ConnectionUnknown || math.IsNaN(sample.PowerW) || math.IsInf(sample.PowerW, 0) { + if !sample.Connected || sample.ConnectionUnknown || sample.PowerUnavailable || math.IsNaN(sample.PowerW) || math.IsInf(sample.PowerW, 0) { delete(c.energySamples, cfg.ID) return } e := c.energySamples[cfg.ID] - if e == nil || e.driver != cfg.DriverName || e.device != sample.DeviceID || e.session != sample.SessionID || e.generation != sample.ConnectionGeneration { + if e == nil || e.driver != cfg.DriverName || e.device != sample.DeviceID || e.session != sample.SessionID || e.generation != sample.ConnectionGeneration || e.meter.counterRegressed(sample) { e = &meteredEnergy{driver: cfg.DriverName, device: sample.DeviceID, session: sample.SessionID, generation: sample.ConnectionGeneration} c.energySamples[cfg.ID] = e } - if len(e.points) == 0 { - e.points = []energyPoint{{at: now}} - e.last = sample - return + measuredAt := now + if !sample.PowerAt.IsZero() { + measuredAt = sample.PowerAt + if !sample.SessionWhUnavailable && sample.EnergyAt.After(measuredAt) { + measuredAt = sample.EnergyAt + } } - previous := e.points[len(e.points)-1] - if !now.After(previous.at) { + if measuredAt.After(now) { return } - elapsed := now.Sub(previous.at) - counterKnown := finite(sample.SessionWh) && finite(e.last.SessionWh) && sample.SessionWh >= e.last.SessionWh && (sample.SessionWh > 0 || e.last.SessionWh > 0) - if elapsed > 30*time.Second && !counterKnown { - e.points = nil - e.powerWh, e.counterWh = 0, 0 - } else { - if elapsed <= 30*time.Second { - e.powerWh += max(0, e.last.PowerW) * elapsed.Hours() + if len(e.points) > 0 { + previous := e.points[len(e.points)-1] + if !measuredAt.After(previous.at) { + return } - if counterKnown { - e.counterWh += sample.SessionWh - e.last.SessionWh + counterAdvanced := !sample.SessionWhUnavailable && sample.SessionWh > e.last.SessionWh && (sample.EnergyAt.IsZero() || sample.EnergyAt.After(previous.at)) + if measuredAt.Sub(previous.at) > 30*time.Second && !counterAdvanced { + e.points = nil + } + } + counterWasKnown := e.meter.counterKnown + wh := e.meter.observe(sample, now) + if !counterWasKnown && e.meter.counterKnown { + // A first counter includes energy from before this slot. Align prior + // power points to its baseline before calculating slot delivery. + baseline := e.meter.counterWh - e.meter.integralAt(e.meter.counterAt) + for i := range e.points { + e.points[i].wh += baseline } } - // Counters can update less often than power. Stop conservatively on - // either measured signal; adding their deltas would count energy twice. - e.points = append(e.points, energyPoint{at: now, wh: max(e.powerWh, e.counterWh)}) + e.points = append(e.points, energyPoint{at: measuredAt, wh: wh}) e.last = sample // Keep one boundary reading for a two-hour slot, with a hard cap for // callers ticking faster than production's five-second loop. diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index 5e360e69..f7efaf63 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -128,6 +128,10 @@ type State struct { // ChargingDeclined is a sustained vehicle-side refusal, not a battery level. ChargingDeclined bool `json:"charging_declined"` // SoCRetention reports whether the confirmed estimate can survive restart. + EnergySource string `json:"energy_source,omitempty"` + EnergyUpdatedAtMs int64 `json:"energy_updated_at_ms,omitempty"` + PowerUpdatedAtMs int64 `json:"power_updated_at_ms,omitempty"` + PowerUnavailable bool `json:"power_unavailable,omitempty"` SoCRetention string `json:"soc_retention,omitempty"` ID string `json:"id"` DriverName string `json:"driver_name"` @@ -335,6 +339,10 @@ type loadpointRuntime struct { connectionGeneration uint64 manualRestoreUnconfirmed bool manualSaveError bool + energy *sessionEnergy + powerAt time.Time + powerUnavailable bool + lastSavedEnergyWh float64 sessionDeviceID string sessionID string socRetention string @@ -541,6 +549,10 @@ func (m *Manager) Load(cfgs []Config) { lp.currentSoC = existing.currentSoC lp.currentPowerW = existing.currentPowerW lp.deliveredWhSession = existing.deliveredWhSession + lp.energy = existing.energy + lp.powerAt = existing.powerAt + lp.powerUnavailable = existing.powerUnavailable + lp.lastSavedEnergyWh = existing.lastSavedEnergyWh lp.targetSoC = existing.targetSoC lp.targetTime = existing.targetTime lp.updatedAtMs = existing.updatedAtMs @@ -1138,6 +1150,16 @@ func (lp *loadpointRuntime) snapshot() State { st.VehicleCapacityWh = 60000 st.CapacitySource = "default" } + if lp.energy != nil { + st.EnergySource = lp.energy.source + if !lp.energy.counterAt.IsZero() { + st.EnergyUpdatedAtMs = lp.energy.counterAt.UnixMilli() + } + } + st.PowerUnavailable = lp.powerUnavailable + if !lp.powerAt.IsZero() { + st.PowerUpdatedAtMs = lp.powerAt.UnixMilli() + } if st.PluggedIn && st.SoCSource == "" && !lp.socConfirmed { st.SoCSource = "assumed" } diff --git a/go/internal/loadpoint/manual_status.go b/go/internal/loadpoint/manual_status.go index 19fe5553..6b47121a 100644 --- a/go/internal/loadpoint/manual_status.go +++ b/go/internal/loadpoint/manual_status.go @@ -173,7 +173,7 @@ func ManualStatusFrom(h ManualHold, held bool, st State, ch ChargerReading, now if h.PowerW == 0 { m.State = ManualPausing switch { - case ch.Unavailable: + case ch.Unavailable || st.PowerUnavailable: m.State = ManualUnavailable case commandMatches && st.CommandedKnown && st.CommandedReason == "manual_hold" && st.CommandedW == 0 && !ch.UpdatedAt.IsZero() && !ch.UpdatedAt.Before(since) && @@ -188,7 +188,7 @@ func ManualStatusFrom(h ManualHold, held bool, st State, ch ChargerReading, now limitMatches := m.ChargerLimitKnown && m.CommandedA >= 0 && math.Abs(ch.LimitA-m.CommandedA) < 1 switch { - case ch.Unavailable: + case ch.Unavailable || st.PowerUnavailable: m.State = ManualUnavailable case !commandMatches && elapsed >= manualConfirmTimeout: m.State = ManualStalled @@ -200,7 +200,7 @@ func ManualStatusFrom(h ManualHold, held bool, st State, ch ChargerReading, now m.State = ManualStalled case (m.ChargerLimitKnown && !limitMatches) || (!ch.UpdatedAt.IsZero() && ch.UpdatedAt.Before(since)): m.State = ManualSent - case st.CurrentPowerW >= manualChargingFloorW || (ch.Known && ch.Charging): + case st.CurrentPowerW >= manualChargingFloorW: m.State = ManualCharging if clamp { m.LimitReason = st.CommandedReason diff --git a/go/internal/loadpoint/manual_status_test.go b/go/internal/loadpoint/manual_status_test.go index af33f6fa..ee3681a8 100644 --- a/go/internal/loadpoint/manual_status_test.go +++ b/go/internal/loadpoint/manual_status_test.go @@ -158,3 +158,18 @@ func TestManualStatusUnavailableDoesNotReuseChargingPower(t *testing.T) { t.Fatalf("did not recover: %+v", got) } } + +func TestManualStatusDoesNotCallOldChargingFlagMeasuredPower(t *testing.T) { + now := time.Now() + hold := ManualHold{PowerW: 6900, StartedAt: now.Add(-3 * time.Minute)} + st := State{Phases: 3, VoltageV: 230, CurrentPowerW: 0, CommandedKnown: true, CommandedW: 6900, CommandedReason: "manual_hold"} + ch := ChargerReading{Known: true, LimitKnown: true, LimitA: 10, Charging: true} + if got := ManualStatusFrom(hold, true, st, ch, now); got.State != ManualNotDrawing { + t.Fatalf("old charging flag reported power: %+v", got) + } + st.CurrentPowerW = 6900 + st.PowerUnavailable = true + if got := ManualStatusFrom(hold, true, st, ch, now); got.State != ManualUnavailable { + t.Fatalf("stale power reported charging: %+v", got) + } +} diff --git a/go/internal/loadpoint/session_energy.go b/go/internal/loadpoint/session_energy.go new file mode 100644 index 00000000..ebcbcba1 --- /dev/null +++ b/go/internal/loadpoint/session_energy.go @@ -0,0 +1,108 @@ +package loadpoint + +import "time" + +// A counter and power can describe different moments. Keep the counter's +// timestamp so its eventual catch-up replaces, rather than adds to, the +// power measured over the same interval. +type sessionPowerPoint struct { + at time.Time + w, wh float64 +} + +type sessionEnergy struct { + counterWh float64 + counterAt time.Time + counterKnown bool + points []sessionPowerPoint + floorWh float64 + floorAt time.Time + estimatedWh float64 + source string +} + +func (e *sessionEnergy) counterRegressed(s EVSample) bool { + return e.counterKnown && !s.SessionWhUnavailable && finite(s.SessionWh) && s.SessionWh >= 0 && + (s.EnergyAt.IsZero() || s.EnergyAt.After(e.counterAt)) && s.SessionWh < e.counterWh +} + +func (e *sessionEnergy) integralAt(at time.Time) float64 { + for i := len(e.points) - 1; i >= 0; i-- { + p := e.points[i] + if at.Before(p.at) { + continue + } + if i+1 < len(e.points) { + next := e.points[i+1] + return p.wh + (next.wh-p.wh)*at.Sub(p.at).Seconds()/next.at.Sub(p.at).Seconds() + } + return p.wh + } + if len(e.points) > 0 { + return e.points[0].wh + } + return 0 +} + +func (e *sessionEnergy) observe(s EVSample, now time.Time) float64 { + powerAt := s.PowerAt + if powerAt.IsZero() { + powerAt = now + } + if !s.PowerUnavailable && finite(s.PowerW) && !powerAt.After(now.Add(time.Second)) && now.Sub(powerAt) <= 30*time.Second { + n := len(e.points) + if n == 0 { + e.points = append(e.points, sessionPowerPoint{at: powerAt, w: max(0, s.PowerW)}) + } else if powerAt.After(e.points[n-1].at) { + prev := e.points[n-1] + wh := prev.wh + if dt := powerAt.Sub(prev.at); dt <= 30*time.Second { + wh += prev.w * dt.Hours() + } + e.points = append(e.points, sessionPowerPoint{at: powerAt, w: max(0, s.PowerW), wh: wh}) + } + } + counterAt := s.EnergyAt + if counterAt.IsZero() { + counterAt = now + } + if !s.SessionWhUnavailable && finite(s.SessionWh) && s.SessionWh >= 0 && !counterAt.After(now.Add(time.Second)) && + (!e.counterKnown || (counterAt.After(e.counterAt) && (!s.EnergyAt.IsZero() || s.SessionWh != e.counterWh))) { + e.counterWh, e.counterAt, e.counterKnown = s.SessionWh, counterAt, true + } + total := 0.0 + if len(e.points) > 0 { + total = e.points[len(e.points)-1].wh + } + e.source = "unavailable" + estimate := total + if total > 0 { + e.source = "power" + } + if e.counterKnown { + extra := max(0, total-e.integralAt(e.counterAt)) + estimate = e.counterWh + extra + e.source = "meter" + if extra > 0 { + e.source = "power" + } + } + if !e.floorAt.IsZero() && (!e.counterKnown || e.counterAt.Before(e.floorAt)) { + estimate = max(estimate, e.floorWh+max(0, total-e.integralAt(e.floorAt))) + e.source = "power" + } + e.estimatedWh = estimate + // Two hours covers delayed cloud counters, with bounded storage. A gap is + // left unmeasured; it never receives the next reading's power retroactively. + if len(e.points) > 2048 || (len(e.points) > 2 && e.points[1].at.Before(now.Add(-2*time.Hour))) { + // Retain accumulated work before trimming the time line. An overdue + // counter must not make the oldest measured energy disappear. + if last := e.points[len(e.points)-1]; !e.counterKnown || e.counterAt.Before(e.points[1].at) { + e.floorWh, e.floorAt = estimate, last.at + } + } + for len(e.points) > 2048 || (len(e.points) > 2 && e.points[1].at.Before(now.Add(-2*time.Hour))) { + e.points = e.points[1:] + } + return estimate +} diff --git a/go/internal/loadpoint/session_energy_test.go b/go/internal/loadpoint/session_energy_test.go new file mode 100644 index 00000000..9b406434 --- /dev/null +++ b/go/internal/loadpoint/session_energy_test.go @@ -0,0 +1,198 @@ +package loadpoint + +import ( + "math" + "testing" + "time" +) + +func TestSessionEnergyDelayedCounterDoesNotDoubleCount(t *testing.T) { + start := time.Now().Add(-time.Hour).Truncate(time.Second) + e := &sessionEnergy{} + for sec := 0; sec <= 120; sec += 5 { + at := start.Add(time.Duration(sec) * time.Second) + sample := EVSample{PowerW: 6900, PowerAt: at, SessionWh: 1000, EnergyAt: start} + if sec >= 110 { + sample.SessionWh = 1000 + 6900*100.0/3600 + sample.EnergyAt = start.Add(100 * time.Second) + } + got := e.observe(sample, at) + want := 1000 + 6900*float64(sec)/3600 + if math.Abs(got-want) > 1e-8 { + t.Fatalf("t=%ds got %.6f Wh want %.6f", sec, got, want) + } + } +} + +func TestSessionEnergyCachedPowerAndGapsDoNotInventCharge(t *testing.T) { + at := time.Now() + e := &sessionEnergy{} + s := EVSample{PowerW: 6900, PowerAt: at, SessionWh: 1000, EnergyAt: at} + e.observe(s, at) + for sec := 5; sec <= 120; sec += 5 { + if got := e.observe(s, at.Add(time.Duration(sec)*time.Second)); got != 1000 { + t.Fatalf("cached power added energy: %v", got) + } + } + s.PowerAt = at.Add(125 * time.Second) + if got := e.observe(s, s.PowerAt); got != 1000 { + t.Fatalf("gap added energy: %v", got) + } + s.PowerAt = s.PowerAt.Add(5 * time.Second) + if got := e.observe(s, s.PowerAt); math.Abs(got-(1000+6900*5.0/3600)) > 1e-8 { + t.Fatal(got) + } +} + +func TestSessionEnergySurvivesRestartBeforeCounterCatchup(t *testing.T) { + store := &sessionMemory{data: map[string]string{}} + m := sessionManager(store, "garage", "charger") + at := time.Now().Add(-time.Hour).Truncate(time.Second) + m.SetNowFn(func() time.Time { return at }) + s := EVSample{Connected: true, RequestActive: true, DeviceID: "easee:A", SessionID: "session-1", SessionWh: 1000, EnergyAt: at, PowerW: 6900, PowerAt: at} + m.ObserveSample("garage", s) + m.SetCurrentSoC("garage", .76) + for i := 0; i < 24; i++ { + at = at.Add(5 * time.Second) + s.PowerAt = at + m.ObserveSample("garage", s) + } + before, _ := m.State("garage") + if math.Abs(before.CurrentSoC-(.76+230*.9/60000)) > 1e-8 { + t.Fatal(before) + } + m = sessionManager(store, "garage", "charger") + m.SetNowFn(func() time.Time { return at }) + m.ObserveSample("garage", s) + after, _ := m.State("garage") + if math.Abs(before.CurrentSoC-after.CurrentSoC) > 1e-8 || after.SoCRetention != "session" { + t.Fatalf("before=%+v after=%+v", before, after) + } + s.SessionWh = 1230 + s.EnergyAt = at + at = at.Add(5 * time.Second) + s.PowerAt = at + m.ObserveSample("garage", s) + after, _ = m.State("garage") + if math.Abs(after.CurrentSoC-(.76+6900*125.0/3600*.9/60000)) > 1e-8 { + t.Fatalf("catch-up changed anchor: %+v", after) + } +} + +func TestMissingAndOutOfOrderCounterKeepConfirmedSession(t *testing.T) { + store := &sessionMemory{data: map[string]string{}} + m := sessionManager(store, "garage", "charger") + at := time.Now().Add(-time.Hour) + m.SetNowFn(func() time.Time { return at }) + s := EVSample{Connected: true, RequestActive: true, DeviceID: "easee:A", SessionID: "session-1", SessionWh: 1000, EnergyAt: at, PowerAt: at} + m.ObserveSample("garage", s) + m.SetCurrentSoC("garage", .76) + at = at.Add(5 * time.Second) + s.PowerAt = at + s.SessionWhUnavailable = true + s.SessionWh = 0 + m.ObserveSample("garage", s) + st, _ := m.State("garage") + if st.CurrentSoC != .76 || st.SoCRetention != "session" { + t.Fatal(st) + } + s.SessionWhUnavailable = false + s.SessionWh = 900 + s.EnergyAt = at.Add(-time.Minute) + m.ObserveSample("garage", s) + st, _ = m.State("garage") + if st.CurrentSoC != .76 || st.SoCRetention != "session" { + t.Fatal(st) + } + // A fresh lower counter is a real reset and must drop the previous anchor. + s.EnergyAt = at + m.ObserveSample("garage", s) + st, _ = m.State("garage") + if st.SoCSource != "assumed" { + t.Fatal(st) + } +} + +func TestSessionEnergyOldCounterDoesNotLoseTrimmedPower(t *testing.T) { + start := time.Now().Add(-4 * time.Hour) + e := &sessionEnergy{} + for sec := 0; sec <= 3*3600; sec += 5 { + at := start.Add(time.Duration(sec) * time.Second) + got := e.observe(EVSample{PowerW: 3600, PowerAt: at, SessionWh: 1000, EnergyAt: start}, at) + if want := 1000 + float64(sec); math.Abs(got-want) > 1e-8 { + t.Fatalf("at %ds got %v want %v", sec, got, want) + } + } +} + +func TestSessionProgressPersistsBetweenMinuteBoundaries(t *testing.T) { + store := &sessionMemory{data: map[string]string{}} + m := sessionManager(store, "garage", "charger") + at := time.Now().Add(-time.Hour).Truncate(time.Second) + m.SetNowFn(func() time.Time { return at }) + s := EVSample{Connected: true, RequestActive: true, DeviceID: "easee:A", SessionID: "session-1", SessionWh: 1000, EnergyAt: at, PowerW: 6900, PowerAt: at} + m.ObserveSample("garage", s) + m.SetCurrentSoC("garage", .76) + for i := 0; i < 17; i++ { + at = at.Add(5 * time.Second) + s.PowerAt = at + m.ObserveSample("garage", s) + } + before, _ := m.State("garage") + m = sessionManager(store, "garage", "charger") + at = at.Add(3 * time.Second) + m.SetNowFn(func() time.Time { return at }) + m.ObserveSample("garage", s) + after, _ := m.State("garage") + if before.CurrentSoC != after.CurrentSoC || after.SoCRetention != "session" { + t.Fatalf("restart forgot measured progress: before=%+v after=%+v", before, after) + } +} + +func TestFirstCounterAfterConfirmationDoesNotAddPastEnergy(t *testing.T) { + store := &sessionMemory{data: map[string]string{}} + m := sessionManager(store, "garage", "charger") + at := time.Now().Add(-time.Hour).Truncate(time.Second) + m.SetNowFn(func() time.Time { return at }) + s := EVSample{Connected: true, RequestActive: true, DeviceID: "easee:A", SessionID: "session-1", SessionWhUnavailable: true, PowerAt: at, PowerW: 3600} + m.ObserveSample("garage", s) + m.SetCurrentSoC("garage", .76) + for i := 0; i < 6; i++ { + at = at.Add(5 * time.Second) + s.PowerAt = at + m.ObserveSample("garage", s) + } + s.SessionWhUnavailable = false + s.SessionWh, s.EnergyAt = 1030, at + m.ObserveSample("garage", s) + before, _ := m.State("garage") + if want := .76 + 30*.9/60000; math.Abs(before.CurrentSoC-want) > 1e-9 || before.SoCRetention != "session" { + t.Fatalf("late counter changed user correction: %+v want %.8f", before, want) + } + m = sessionManager(store, "garage", "charger") + m.SetNowFn(func() time.Time { return at }) + m.ObserveSample("garage", s) + after, _ := m.State("garage") + if before.CurrentSoC != after.CurrentSoC || after.SoCRetention != "session" { + t.Fatalf("late counter was not saved: before=%+v after=%+v", before, after) + } +} + +func TestLateFirstCounterDoesNotConsumeCurrentSlotBudget(t *testing.T) { + start := time.Now().Add(-time.Hour).Truncate(time.Second) + c := &Controller{} + cfg := Config{ID: "garage", DriverName: "easee"} + for sec := 0; sec <= 120; sec += 5 { + at := start.Add(time.Duration(sec) * time.Second) + s := EVSample{Connected: true, DeviceID: "easee:A", SessionID: "session-1", PowerW: 3600, PowerAt: at, SessionWhUnavailable: true} + if sec >= 90 { + s.SessionWhUnavailable = false + s.SessionWh, s.EnergyAt = 1060, start.Add(time.Minute) + } + c.observeEnergy(cfg, s, at) + got, missing := c.energySince("garage", start, at) + if math.Abs(got-float64(sec)) > 1e-9 || missing != 0 { + t.Fatalf("t=%d counted %v Wh, missing %v seconds", sec, got, missing) + } + } +} diff --git a/go/internal/loadpoint/session_sqlite_test.go b/go/internal/loadpoint/session_sqlite_test.go index 96fee5ae..ae037177 100644 --- a/go/internal/loadpoint/session_sqlite_test.go +++ b/go/internal/loadpoint/session_sqlite_test.go @@ -5,6 +5,7 @@ import ( "math" "path/filepath" "testing" + "time" "github.com/srcfl/ftw/go/internal/loadpoint" "github.com/srcfl/ftw/go/internal/state" @@ -49,3 +50,44 @@ func TestConfirmedBatteryLevelSurvivesDatabaseCloseAndReopen(t *testing.T) { }) } } + +func TestDelayedCounterProgressSurvivesDatabaseReopen(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.db") + store, err := state.Open(path) + if err != nil { + t.Fatal(err) + } + newManager := func() *loadpoint.Manager { + m := loadpoint.NewManager() + m.Load([]loadpoint.Config{{ID: "garage", DriverName: "charger", VehicleCapacityWh: 75000}}) + m.SetSessionStore(store) + return m + } + at := time.Now().Add(-time.Hour).Truncate(time.Second) + m := newManager() + m.SetNowFn(func() time.Time { return at }) + sample := loadpoint.EVSample{Connected: true, RequestActive: true, DeviceID: "easee:TEST", SessionID: "open-1", SessionWh: 1000, EnergyAt: at, PowerW: 6900, PowerAt: at} + m.ObserveSample("garage", sample) + m.SetCurrentSoC("garage", .76) + for i := 0; i < 17; i++ { + at = at.Add(5 * time.Second) + sample.PowerAt = at + m.ObserveSample("garage", sample) + } + before, _ := m.State("garage") + if err := store.Close(); err != nil { + t.Fatal(err) + } + store, err = state.Open(path) + if err != nil { + t.Fatal(err) + } + defer store.Close() + m = newManager() + m.SetNowFn(func() time.Time { return at }) + m.ObserveSample("garage", sample) + after, _ := m.State("garage") + if before.CurrentSoC != after.CurrentSoC || after.SoCRetention != "session" { + t.Fatalf("before=%+v after=%+v", before, after) + } +} diff --git a/go/internal/loadpoint/session_state.go b/go/internal/loadpoint/session_state.go index 35ad2f37..5398c2db 100644 --- a/go/internal/loadpoint/session_state.go +++ b/go/internal/loadpoint/session_state.go @@ -20,13 +20,15 @@ type SessionStore interface { } type savedSession struct { - Version int `json:"version"` - DeviceID string `json:"device_id"` - SessionID string `json:"session_id"` - AnchorSoC float64 `json:"anchor_soc"` - ConfirmedAtWh float64 `json:"confirmed_at_wh"` - CapacityWh float64 `json:"capacity_wh"` - CompletionNotified bool `json:"completion_notified,omitempty"` + Version int `json:"version"` + DeviceID string `json:"device_id"` + SessionID string `json:"session_id"` + AnchorSoC float64 `json:"anchor_soc"` + ConfirmedAtWh float64 `json:"confirmed_at_wh"` + CapacityWh float64 `json:"capacity_wh"` + EstimatedWh *float64 `json:"estimated_wh,omitempty"` + EstimatedAt time.Time `json:"estimated_at,omitempty"` + CompletionNotified bool `json:"completion_notified,omitempty"` } func sessionKey(deviceID string) string { @@ -51,6 +53,18 @@ func (m *Manager) SetSessionStore(store SessionStore) { // restart and change after disconnect; missing or ambiguous IDs are empty. // Endpoint addresses, YAML names and timestamps invented by core are not IDs. func (m *Manager) ObserveSession(id string, pluggedIn bool, powerW, deliveredWh float64, requestActive bool, deviceID, sessionID string) { + now := m.now() + m.ObserveSample(id, EVSample{PowerAt: now, EnergyAt: now, Connected: pluggedIn, PowerW: powerW, SessionWh: deliveredWh, RequestActive: requestActive, DeviceID: deviceID, SessionID: sessionID}) +} + +// ObserveSample retains measurement time and missing-counter state from the +// driver. Missing data cannot reset a session or erase a confirmed level. +func (m *Manager) ObserveSample(id string, sample EVSample) { + pluggedIn, powerW, deliveredWh, requestActive, deviceID, sessionID := sample.Connected, sample.PowerW, sample.SessionWh, sample.RequestActive, sample.DeviceID, sample.SessionID + if sample.PowerUnavailable { + powerW = 0 + requestActive = true + } m.sessionMu.Lock() var fired []events.Event var bus *events.Bus @@ -65,7 +79,7 @@ func (m *Manager) ObserveSession(id string, pluggedIn bool, powerW, deliveredWh if strings.HasPrefix(deviceID, "ep:") { deviceID = "" } - if !finite(deliveredWh) || deliveredWh < 0 { + if !sample.SessionWhUnavailable && (!finite(deliveredWh) || deliveredWh < 0) { return } @@ -76,7 +90,7 @@ func (m *Manager) ObserveSession(id string, pluggedIn bool, powerW, deliveredWh return } previousDevice, previousSession := lp.sessionDeviceID, lp.sessionID - regressed := pluggedIn && lp.pluggedIn && deliveredWh < lp.deliveredWhSession + regressed := pluggedIn && lp.pluggedIn && lp.energy != nil && lp.energy.counterRegressed(sample) firstSessionProof := deviceID != "" && previousDevice == deviceID && previousSession == "" && sessionID != "" && lp.pluggedIn && pluggedIn && !regressed changed := previousDevice != deviceID || (previousSession != sessionID && !firstSessionProof) @@ -86,6 +100,7 @@ func (m *Manager) ObserveSession(id string, pluggedIn bool, powerW, deliveredWh m.nextSessionGeneration++ lp.sessionGeneration = m.nextSessionGeneration lp.pluggedIn = false + lp.energy = nil lp.chargingSteadySince = time.Time{} lp.stoppedSince = time.Time{} lp.steadyRunArmed = false @@ -97,6 +112,25 @@ func (m *Manager) ObserveSession(id string, pluggedIn bool, powerW, deliveredWh } } lp.sessionDeviceID, lp.sessionID = deviceID, sessionID + if lp.energy == nil { + lp.energy = &sessionEnergy{} + } + if !pluggedIn { + lp.energy = &sessionEnergy{} + } + counterWasKnown := lp.energy.counterKnown + deliveredWh = lp.energy.observe(sample, m.now()) + if !counterWasKnown && lp.energy.counterKnown && lp.socConfirmed && lp.pluggedIn && lp.VehicleCapacityWh > 0 { + // The first counter may include energy from before the owner entered + // a level. Keep that correction while joining the measured time line. + baseline := lp.energy.counterWh - lp.energy.integralAt(lp.energy.counterAt) + lp.sessionPluginSoC -= baseline * DefaultChargeEfficiency / lp.VehicleCapacityWh + } + lp.powerAt = sample.PowerAt + if lp.powerAt.IsZero() { + lp.powerAt = m.now() + } + lp.powerUnavailable = sample.PowerUnavailable confirmed := lp.socConfirmed && lp.pluggedIn m.mu.Unlock() @@ -112,20 +146,24 @@ func (m *Manager) ObserveSession(id string, pluggedIn bool, powerW, deliveredWh } } var restore *savedSession - if pluggedIn && !confirmed && !regressed && deviceID != "" && sessionID != "" && m.sessionStore != nil { + if pluggedIn && !confirmed && !regressed && !sample.SessionWhUnavailable && deviceID != "" && sessionID != "" && m.sessionStore != nil { if raw, ok := m.sessionStore.LoadConfig(sessionKey(deviceID)); ok { var saved savedSession if json.Unmarshal([]byte(raw), &saved) == nil && (saved.Version == 1 || saved.Version == 2) && saved.DeviceID == deviceID && saved.SessionID == sessionID && finite(saved.AnchorSoC) && finite(saved.ConfirmedAtWh) && saved.ConfirmedAtWh >= 0 && - deliveredWh >= saved.ConfirmedAtWh && finite(saved.CapacityWh) && saved.CapacityWh > 0 { + sample.SessionWh >= saved.ConfirmedAtWh && finite(saved.CapacityWh) && saved.CapacityWh > 0 { efficiency := DefaultChargeEfficiency if saved.Version == 1 { efficiency = 1 } - atConfirmation := saved.AnchorSoC + saved.ConfirmedAtWh*efficiency/saved.CapacityWh + confirmationWh := saved.ConfirmedAtWh + if saved.EstimatedWh != nil && !saved.EstimatedAt.IsZero() && finite(*saved.EstimatedWh) && *saved.EstimatedWh >= confirmationWh { + confirmationWh = *saved.EstimatedWh + } + atConfirmation := saved.AnchorSoC + confirmationWh*efficiency/saved.CapacityWh // Preserve the last confirmed level when migrating the old AC-only estimate. - saved.AnchorSoC = atConfirmation - saved.ConfirmedAtWh*DefaultChargeEfficiency/saved.CapacityWh + saved.AnchorSoC = atConfirmation - confirmationWh*DefaultChargeEfficiency/saved.CapacityWh if atConfirmation >= 0 && atConfirmation <= 1 { restore = &saved } @@ -136,6 +174,11 @@ func (m *Manager) ObserveSession(id string, pluggedIn bool, powerW, deliveredWh m.mu.Lock() lp = m.byID[id] if restore != nil && lp.pluggedIn && lp.VehicleCapacityWh == restore.CapacityWh { + if restore.EstimatedWh != nil && finite(*restore.EstimatedWh) && *restore.EstimatedWh >= restore.ConfirmedAtWh && !restore.EstimatedAt.IsZero() { + lp.energy.floorWh, lp.energy.floorAt = *restore.EstimatedWh, restore.EstimatedAt + deliveredWh = lp.energy.observe(sample, m.now()) + lp.deliveredWhSession = deliveredWh + } lp.sessionPluginSoC = restore.AnchorSoC lp.currentSoC = estimateSoC(restore.AnchorSoC, deliveredWh, restore.CapacityWh) lp.socConfirmed = true @@ -144,7 +187,14 @@ func (m *Manager) ObserveSession(id string, pluggedIn bool, powerW, deliveredWh } else if !lp.socConfirmed || deviceID == "" || sessionID == "" || m.sessionStore == nil { lp.socRetention = "unavailable" } + // Save each change inferred from power: the cloud counter can remain + // behind through an arbitrary restart. Cached readings do not write. + saveProgress := lp.socConfirmed && lp.sessionID != "" && lp.energy.counterKnown && + (lp.socRetention != "session" || (lp.energy.source == "power" && lp.deliveredWhSession != lp.lastSavedEnergyWh)) m.mu.Unlock() + if saveProgress { + m.persistSession(id) + } if firstSessionProof && confirmed { // The driver can first verify a session when charging starts. Preserve // the level the owner entered while waiting and now make it durable. @@ -166,7 +216,18 @@ func (m *Manager) persistSession(id string) { record := savedSession{Version: 2, DeviceID: lp.sessionDeviceID, SessionID: lp.sessionID, AnchorSoC: lp.sessionPluginSoC, ConfirmedAtWh: lp.deliveredWhSession, CapacityWh: lp.VehicleCapacityWh, CompletionNotified: lp.completionNotified} - eligible := lp.pluggedIn && lp.socConfirmed && record.DeviceID != "" && record.SessionID != "" && + if lp.energy != nil && lp.energy.counterKnown { + record.ConfirmedAtWh = lp.energy.counterWh + if lp.deliveredWhSession > lp.energy.counterWh { + value := lp.deliveredWhSession + record.EstimatedWh = &value + record.EstimatedAt = lp.energy.floorAt + if n := len(lp.energy.points); n > 0 && lp.energy.points[n-1].at.After(record.EstimatedAt) { + record.EstimatedAt = lp.energy.points[n-1].at + } + } + } + eligible := lp.pluggedIn && lp.socConfirmed && lp.energy != nil && lp.energy.counterKnown && record.DeviceID != "" && record.SessionID != "" && finite(record.AnchorSoC) && finite(record.ConfirmedAtWh) && record.ConfirmedAtWh >= 0 && finite(record.CapacityWh) && record.CapacityWh > 0 m.mu.RUnlock() @@ -184,6 +245,9 @@ func (m *Manager) persistSession(id string) { m.mu.Lock() if lp := m.byID[id]; lp != nil { lp.socRetention = retention + if retention == "session" { + lp.lastSavedEnergyWh = lp.deliveredWhSession + } } m.mu.Unlock() } From 9a8516647d5f2851f1262d7ebd1611ab4130f028 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 13 Sep 2026 16:34:12 +0200 Subject: [PATCH 2/7] fix(ev): follow source cadence and replan after restored state --- .changeset/ev-counter-time-and-session.md | 2 + drivers/BUNDLED_SOURCE.json | 2 +- go/cmd/ftw/ev_observation.go | 5 +- go/cmd/ftw/ev_observation_test.go | 15 +++++ go/internal/loadpoint/controller.go | 1 + go/internal/loadpoint/controller_energy.go | 5 +- go/internal/loadpoint/session_energy.go | 37 +++++++++-- go/internal/loadpoint/session_energy_test.go | 40 ++++++++++++ go/internal/loadpoint/session_state.go | 6 +- go/internal/mpc/service.go | 13 +++- go/internal/mpc/service_ev_divergence.go | 65 +++++++++++++++++++ go/internal/mpc/service_ev_divergence_test.go | 42 ++++++++++++ 12 files changed, 219 insertions(+), 14 deletions(-) create mode 100644 go/internal/mpc/service_ev_divergence.go create mode 100644 go/internal/mpc/service_ev_divergence_test.go diff --git a/.changeset/ev-counter-time-and-session.md b/.changeset/ev-counter-time-and-session.md index 18c36f80..b929f44e 100644 --- a/.changeset/ev-counter-time-and-session.md +++ b/.changeset/ev-counter-time-and-session.md @@ -3,3 +3,5 @@ --- Keep charger measurement times when estimating EV energy. Use fresh power between delayed session-counter updates, reconcile overlapping energy once, and retain the estimate through a verified session restart. Missing or older counters no longer reset a confirmed battery level. Expose the estimate source and measurement ages. + +Match Easee pauses to the current vendor session even when sessionEnd is populated. Bound power estimates to its reporting cadence. Replan when a restored EV level differs from the active plan. diff --git a/drivers/BUNDLED_SOURCE.json b/drivers/BUNDLED_SOURCE.json index bf0c5b07..c587d6c4 100644 --- a/drivers/BUNDLED_SOURCE.json +++ b/drivers/BUNDLED_SOURCE.json @@ -17,7 +17,7 @@ "for coverage. Run scripts/sync-bundled-drivers.sh to update." ], "repository": "srcfl/device-drivers", - "commit": "f016c67c4c04cd350a342e55a7683ec5790482ea", + "commit": "8e8b48f604fc98111fb611187d96403e539f745e", "source_dir": "drivers/lua", "drivers": [ "ambibox_v2x", "ctek", "ctek_hybrid", "ctek_v2", "deye", "easee_cloud", diff --git a/go/cmd/ftw/ev_observation.go b/go/cmd/ftw/ev_observation.go index 1d653a08..30080ca1 100644 --- a/go/cmd/ftw/ev_observation.go +++ b/go/cmd/ftw/ev_observation.go @@ -27,6 +27,7 @@ func currentEVSample(r *telemetry.DerReading, health *telemetry.DriverHealth, wa RequestActive *bool `json:"request_active"` SessionID string `json:"session_id"` PowerAt string `json:"power_observed_at"` + PowerMaxAgeS int `json:"power_max_age_s"` EnergyAt string `json:"energy_observed_at"` } if json.Unmarshal(r.Data, &d) != nil || (d.SessionWh != nil && *d.SessionWh < 0) { @@ -43,7 +44,7 @@ func currentEVSample(r *telemetry.DerReading, health *telemetry.DriverHealth, wa active = *d.RequestActive } sample := loadpoint.EVSample{ConnectionGeneration: d.ConnectionGeneration, PowerW: r.RawW, - PowerAt: r.UpdatedAt, Connected: *d.Connected, RequestActive: active, DeviceID: deviceID, SessionID: d.SessionID, + PowerAt: r.UpdatedAt, PowerMaxAge: time.Duration(min(max(d.PowerMaxAgeS, 0), 180)) * time.Second, Connected: *d.Connected, RequestActive: active, DeviceID: deviceID, SessionID: d.SessionID, SessionWhUnavailable: d.SessionWh == nil} if d.SessionWh != nil { sample.SessionWh = *d.SessionWh @@ -51,7 +52,7 @@ func currentEVSample(r *telemetry.DerReading, health *telemetry.DriverHealth, wa if d.PowerAt != "" { at, err := time.Parse(time.RFC3339Nano, d.PowerAt) sample.PowerAt = at - sample.PowerUnavailable = err != nil || at.After(now.Add(time.Second)) || (now.Sub(at) > 30*time.Second && r.RawW > 0) + sample.PowerUnavailable = err != nil || at.After(now.Add(time.Second)) || (now.Sub(at) > sample.PowerWindow() && r.RawW > 0) } if d.EnergyAt != "" { at, err := time.Parse(time.RFC3339Nano, d.EnergyAt) diff --git a/go/cmd/ftw/ev_observation_test.go b/go/cmd/ftw/ev_observation_test.go index 1d95e742..383a1700 100644 --- a/go/cmd/ftw/ev_observation_test.go +++ b/go/cmd/ftw/ev_observation_test.go @@ -53,3 +53,18 @@ func TestEVObservationKeepsSourceTimesAndMissingCounter(t *testing.T) { t.Fatalf("old vendor power became fresh on receipt: %+v", s) } } + +func TestEVSourceCadenceDoesNotExtendTransportWatchdog(t *testing.T) { + now := time.Now().Truncate(time.Second) + data, _ := json.Marshal(map[string]any{"connected": true, "session_wh": 1000, "power_observed_at": now.Add(-2 * time.Minute).Format(time.RFC3339), "power_max_age_s": 180}) + r := &telemetry.DerReading{UpdatedAt: now, RawW: 6900, Data: data} + health := &telemetry.DriverHealth{Status: telemetry.StatusOk} + sample, ok := currentEVSample(r, health, time.Minute, now, false, "charger") + if !ok || sample.PowerUnavailable || sample.PowerMaxAge != 3*time.Minute { + t.Fatalf("source cadence rejected: %+v", sample) + } + r.UpdatedAt = now.Add(-2 * time.Minute) + if _, ok := currentEVSample(r, health, time.Minute, now, false, "charger"); ok { + t.Fatal("source cadence bypassed transport watchdog") + } +} diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index 4f6e821a..0dd20808 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -450,6 +450,7 @@ type EVSample struct { SessionWhUnavailable bool PowerUnavailable bool PowerAt time.Time + PowerMaxAge time.Duration EnergyAt time.Time Connected bool RequestActive bool diff --git a/go/internal/loadpoint/controller_energy.go b/go/internal/loadpoint/controller_energy.go index 32b3a043..f4c3496d 100644 --- a/go/internal/loadpoint/controller_energy.go +++ b/go/internal/loadpoint/controller_energy.go @@ -41,6 +41,9 @@ func (c *Controller) observeEnergy(cfg Config, sample EVSample, now time.Time) { measuredAt = sample.EnergyAt } } + if sample.PowerMaxAge > 0 && !sample.PowerUnavailable && now.Sub(sample.PowerAt) <= sample.PowerWindow() { + measuredAt = now + } if measuredAt.After(now) { return } @@ -50,7 +53,7 @@ func (c *Controller) observeEnergy(cfg Config, sample EVSample, now time.Time) { return } counterAdvanced := !sample.SessionWhUnavailable && sample.SessionWh > e.last.SessionWh && (sample.EnergyAt.IsZero() || sample.EnergyAt.After(previous.at)) - if measuredAt.Sub(previous.at) > 30*time.Second && !counterAdvanced { + if measuredAt.Sub(previous.at) > sample.PowerWindow() && !counterAdvanced { e.points = nil } } diff --git a/go/internal/loadpoint/session_energy.go b/go/internal/loadpoint/session_energy.go index ebcbcba1..8c93d772 100644 --- a/go/internal/loadpoint/session_energy.go +++ b/go/internal/loadpoint/session_energy.go @@ -2,6 +2,16 @@ package loadpoint import "time" +// PowerWindow bounds the source's declared reporting cadence. Easee can leave +// power unchanged for two minutes; Core permits one extra minute of margin. +// This does not extend the separate transport/driver watchdog. +func (s EVSample) PowerWindow() time.Duration { + if s.PowerMaxAge <= 0 { + return 30 * time.Second + } + return min(s.PowerMaxAge, 3*time.Minute) +} + // A counter and power can describe different moments. Keep the counter's // timestamp so its eventual catch-up replaces, rather than adds to, the // power measured over the same interval. @@ -14,6 +24,7 @@ type sessionEnergy struct { counterWh float64 counterAt time.Time counterKnown bool + coverageAt time.Time points []sessionPowerPoint floorWh float64 floorAt time.Time @@ -36,6 +47,9 @@ func (e *sessionEnergy) integralAt(at time.Time) float64 { next := e.points[i+1] return p.wh + (next.wh-p.wh)*at.Sub(p.at).Seconds()/next.at.Sub(p.at).Seconds() } + if !e.coverageAt.IsZero() && at.After(p.at) { + return p.wh + p.w*float64(max(0, min(at.UnixNano(), e.coverageAt.UnixNano())-p.at.UnixNano()))/float64(time.Hour) + } return p.wh } if len(e.points) > 0 { @@ -49,18 +63,31 @@ func (e *sessionEnergy) observe(s EVSample, now time.Time) float64 { if powerAt.IsZero() { powerAt = now } - if !s.PowerUnavailable && finite(s.PowerW) && !powerAt.After(now.Add(time.Second)) && now.Sub(powerAt) <= 30*time.Second { + if !s.PowerUnavailable && finite(s.PowerW) && !powerAt.After(now.Add(time.Second)) && now.Sub(powerAt) <= s.PowerWindow() { n := len(e.points) if n == 0 { e.points = append(e.points, sessionPowerPoint{at: powerAt, w: max(0, s.PowerW)}) } else if powerAt.After(e.points[n-1].at) { prev := e.points[n-1] wh := prev.wh - if dt := powerAt.Sub(prev.at); dt <= 30*time.Second { + if dt := powerAt.Sub(prev.at); dt <= s.PowerWindow() { wh += prev.w * dt.Hours() + } else if e.coverageAt.After(prev.at) { + // Retain an estimate already made while the source was valid, + // without filling the later unobserved gap. + wh += prev.w * e.coverageAt.Sub(prev.at).Hours() } e.points = append(e.points, sessionPowerPoint{at: powerAt, w: max(0, s.PowerW), wh: wh}) } + + if n := len(e.points); n > 0 { + e.coverageAt = e.points[n-1].at + if s.PowerMaxAge > 0 && powerAt.Equal(e.points[n-1].at) && now.After(powerAt) { + // The driver declares a bounded reporting interval. Between + // its source updates this is explicitly power-estimated energy. + e.coverageAt = now + } + } } counterAt := s.EnergyAt if counterAt.IsZero() { @@ -72,7 +99,7 @@ func (e *sessionEnergy) observe(s EVSample, now time.Time) float64 { } total := 0.0 if len(e.points) > 0 { - total = e.points[len(e.points)-1].wh + total = e.integralAt(e.coverageAt) } e.source = "unavailable" estimate := total @@ -97,8 +124,8 @@ func (e *sessionEnergy) observe(s EVSample, now time.Time) float64 { if len(e.points) > 2048 || (len(e.points) > 2 && e.points[1].at.Before(now.Add(-2*time.Hour))) { // Retain accumulated work before trimming the time line. An overdue // counter must not make the oldest measured energy disappear. - if last := e.points[len(e.points)-1]; !e.counterKnown || e.counterAt.Before(e.points[1].at) { - e.floorWh, e.floorAt = estimate, last.at + if !e.counterKnown || e.counterAt.Before(e.points[1].at) { + e.floorWh, e.floorAt = estimate, e.coverageAt } } for len(e.points) > 2048 || (len(e.points) > 2 && e.points[1].at.Before(now.Add(-2*time.Hour))) { diff --git a/go/internal/loadpoint/session_energy_test.go b/go/internal/loadpoint/session_energy_test.go index 9b406434..51a1b058 100644 --- a/go/internal/loadpoint/session_energy_test.go +++ b/go/internal/loadpoint/session_energy_test.go @@ -196,3 +196,43 @@ func TestLateFirstCounterDoesNotConsumeCurrentSlotBudget(t *testing.T) { } } } + +func TestSlowPowerCadenceUsesBoundedEstimateBetweenSourceUpdates(t *testing.T) { + start := time.Now().Add(-time.Hour) + e := &sessionEnergy{} + s := EVSample{PowerW: 7103, PowerAt: start, SessionWh: 1000, EnergyAt: start, PowerMaxAge: 3 * time.Minute} + e.observe(s, start) + // Each poll advances the power estimate while preserving the source time. + for sec := 5; sec < 115; sec += 5 { + if got := e.observe(s, start.Add(time.Duration(sec)*time.Second)); math.Abs(got-(1000+7103*float64(sec)/3600)) > 1e-8 { + t.Fatal(got) + } + } + s.PowerAt = start.Add(115 * time.Second) + got := e.observe(s, s.PowerAt) + if want := 1000 + 7103*115.0/3600; math.Abs(got-want) > 1e-8 { + t.Fatalf("got %v want %v", got, want) + } + s.PowerAt = start.Add(10 * time.Minute) + if next := e.observe(s, s.PowerAt); next != got { + t.Fatalf("long gap invented charge: %v -> %v", got, next) + } +} + +func TestPowerEstimateExpiresAndCounterCatchupDoesNotDuplicate(t *testing.T) { + start := time.Now().Add(-time.Hour) + e := &sessionEnergy{} + sample := EVSample{PowerW: 3600, PowerAt: start, PowerMaxAge: 3 * time.Minute, SessionWh: 1000, EnergyAt: start} + for sec := 0; sec <= 240; sec += 5 { + at := start.Add(time.Duration(sec) * time.Second) + got := e.observe(sample, at) + if want := 1000 + float64(min(sec, 180)); math.Abs(got-want) > 1e-8 { + t.Fatalf("at %ds got %v want %v", sec, got, want) + } + } + sample.SessionWh = 1180 + sample.EnergyAt = start.Add(180 * time.Second) + if got := e.observe(sample, start.Add(240*time.Second)); got != 1180 { + t.Fatal(got) + } +} diff --git a/go/internal/loadpoint/session_state.go b/go/internal/loadpoint/session_state.go index 5398c2db..8af1218e 100644 --- a/go/internal/loadpoint/session_state.go +++ b/go/internal/loadpoint/session_state.go @@ -188,7 +188,7 @@ func (m *Manager) ObserveSample(id string, sample EVSample) { lp.socRetention = "unavailable" } // Save each change inferred from power: the cloud counter can remain - // behind through an arbitrary restart. Cached readings do not write. + // behind through an arbitrary restart. Unchanged estimates do not write. saveProgress := lp.socConfirmed && lp.sessionID != "" && lp.energy.counterKnown && (lp.socRetention != "session" || (lp.energy.source == "power" && lp.deliveredWhSession != lp.lastSavedEnergyWh)) m.mu.Unlock() @@ -222,8 +222,8 @@ func (m *Manager) persistSession(id string) { value := lp.deliveredWhSession record.EstimatedWh = &value record.EstimatedAt = lp.energy.floorAt - if n := len(lp.energy.points); n > 0 && lp.energy.points[n-1].at.After(record.EstimatedAt) { - record.EstimatedAt = lp.energy.points[n-1].at + if lp.energy.coverageAt.After(record.EstimatedAt) { + record.EstimatedAt = lp.energy.coverageAt } } } diff --git a/go/internal/mpc/service.go b/go/internal/mpc/service.go index 2e8c2c72..9065ecd9 100644 --- a/go/internal/mpc/service.go +++ b/go/internal/mpc/service.go @@ -965,8 +965,12 @@ func (s *Service) loop(ctx context.Context) { t := time.NewTicker(interval) defer t.Stop() var reactiveTick <-chan time.Time - if s.ReactiveInterval > 0 && (s.PVDivergenceWh > 0 || s.LoadDivergenceWh > 0) { - rt := time.NewTicker(s.ReactiveInterval) + reactiveInterval := s.ReactiveInterval + if reactiveInterval <= 0 && (s.Loadpoints != nil || s.Loadpoint != nil) { + reactiveInterval = 5 * time.Second + } + if reactiveInterval > 0 && (s.PVDivergenceWh > 0 || s.LoadDivergenceWh > 0 || s.Loadpoints != nil || s.Loadpoint != nil) { + rt := time.NewTicker(reactiveInterval) defer rt.Stop() reactiveTick = rt.C } @@ -999,6 +1003,7 @@ func (s *Service) loop(ctx context.Context) { func (s *Service) checkDivergence(ctx context.Context) { s.mu.RLock() plan := s.last + params := s.lastParams last := s.lastReplanAt s.mu.RUnlock() if plan == nil || len(plan.Actions) == 0 { @@ -1007,6 +1012,10 @@ func (s *Service) checkDivergence(ctx context.Context) { if time.Since(last) < s.MinReplanGap { return } + if s.loadpointStateDiverged(plan, params, time.Now()) { + s.replan(ctx, "loadpoint_soc_changed") + return + } // Find the slot covering now. nowMs := time.Now().UnixMilli() var slot *Action diff --git a/go/internal/mpc/service_ev_divergence.go b/go/internal/mpc/service_ev_divergence.go new file mode 100644 index 00000000..9f294040 --- /dev/null +++ b/go/internal/mpc/service_ev_divergence.go @@ -0,0 +1,65 @@ +package mpc + +import ( + "math" + "time" +) + +// A restored level or charging outside the plan changes the remaining duty. +// Reuse the existing reactive loop and cooldown, including when PV/load +// divergence checks are off. No new user setting is needed. +func (s *Service) loadpointStateDiverged(plan *Plan, params Params, now time.Time) bool { + var current []*LoadpointSpec + slotLen := 15 + if len(plan.Actions) > 0 && plan.Actions[0].SlotLenMin > 0 { + slotLen = plan.Actions[0].SlotLenMin + } + if s.Loadpoints != nil { + current = s.Loadpoints(slotLen) + } else if s.Loadpoint != nil { + if lp := s.Loadpoint(slotLen); lp != nil { + current = []*LoadpointSpec{lp} + } + } else { + return false + } + previous := params.activeLoadpoints() + for _, lp := range current { + if !lp.active() { + continue + } + var initial *LoadpointSpec + for _, old := range previous { + if old.ID == lp.ID { + initial = old + break + } + } + if initial == nil { + return true + } + expected := initial.InitialSoC + efficiency := initial.ChargeEfficiency + if efficiency <= 0 { + efficiency = .9 + } + for _, a := range plan.Actions { + start, end := a.ExecutionStart(), a.SlotStartMs+int64(a.SlotLenMin)*60000 + elapsed := min(now.UnixMilli(), end) - start + if elapsed <= 0 { + continue + } + watts := a.LoadpointPowerW[lp.ID] + if len(a.LoadpointPowerW) == 0 && len(previous) == 1 { + watts = a.LoadpointW + } + expected += max(0, watts) * float64(elapsed) / 3600000 * efficiency / initial.CapacityWh + } + // Two percentage points avoid replanning for rounding and normal meter + // delay. A larger restoration/correction must not wait fifteen minutes. + if math.Abs(lp.InitialSoC-min(1, expected)) > .02 { + return true + } + } + return false +} diff --git a/go/internal/mpc/service_ev_divergence_test.go b/go/internal/mpc/service_ev_divergence_test.go new file mode 100644 index 00000000..cf8e5049 --- /dev/null +++ b/go/internal/mpc/service_ev_divergence_test.go @@ -0,0 +1,42 @@ +package mpc + +import ( + "context" + "testing" + "time" +) + +func TestRestoredEVLevelTriggersReplanWithoutPVDivergence(t *testing.T) { + s, _ := buildTestService(t, 0, 500) + s.PVDivergenceWh, s.LoadDivergenceWh = 0, 0 + old := &LoadpointSpec{ID: "garage", Levels: 11, SoCMax: 1, CapacityWh: 75000, InitialSoC: .33, PluggedIn: true, TargetSoC: .8, TargetSlotIdx: 3, MaxChargeW: 11000, ChargeEfficiency: .9} + s.lastParams.Loadpoints = []*LoadpointSpec{old} + current := *old + current.InitialSoC = .776 + s.Loadpoints = func(int) []*LoadpointSpec { return []*LoadpointSpec{¤t} } + s.checkDivergence(context.Background()) + if s.lastReason != "loadpoint_soc_changed" || len(s.lastParams.Loadpoints) != 1 || s.lastParams.Loadpoints[0].InitialSoC != .776 { + t.Fatalf("restoration did not reach planner: reason=%s params=%+v", s.lastReason, s.lastParams.Loadpoints) + } +} + +func TestEVLevelDivergenceAccountsForPlannedDelivery(t *testing.T) { + start := time.Now().Truncate(time.Minute) + initial := &LoadpointSpec{ID: "garage", Levels: 11, SoCMax: 1, CapacityWh: 60000, InitialSoC: .50, PluggedIn: true, TargetSoC: .8, MaxChargeW: 11000, ChargeEfficiency: .9} + current := *initial + current.InitialSoC += 11000 * .25 * .9 / 60000 + s := &Service{Loadpoints: func(int) []*LoadpointSpec { return []*LoadpointSpec{¤t} }} + p := Params{Loadpoints: []*LoadpointSpec{initial}} + plan := &Plan{Actions: []Action{{SlotStartMs: start.UnixMilli(), SlotLenMin: 15, LoadpointPowerW: map[string]float64{"garage": 11000}}}} + if s.loadpointStateDiverged(plan, p, start.Add(15*time.Minute)) { + t.Fatal("normal planned delivery caused another replan") + } + current.InitialSoC += .01 + if s.loadpointStateDiverged(plan, p, start.Add(15*time.Minute)) { + t.Fatal("small meter error caused another replan") + } + current.InitialSoC += .03 + if !s.loadpointStateDiverged(plan, p, start.Add(15*time.Minute)) { + t.Fatal("larger correction did not trigger replan") + } +} From c663b6bf8a3513d14d0e458b9dc5331e8c0e0dfb Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 13 Sep 2026 16:47:16 +0200 Subject: [PATCH 3/7] fix(ev): retain progress when power observations arrive out of order --- go/internal/loadpoint/session_energy.go | 2 +- go/internal/loadpoint/session_energy_test.go | 22 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/go/internal/loadpoint/session_energy.go b/go/internal/loadpoint/session_energy.go index 8c93d772..a466a4f6 100644 --- a/go/internal/loadpoint/session_energy.go +++ b/go/internal/loadpoint/session_energy.go @@ -80,7 +80,7 @@ func (e *sessionEnergy) observe(s EVSample, now time.Time) float64 { e.points = append(e.points, sessionPowerPoint{at: powerAt, w: max(0, s.PowerW), wh: wh}) } - if n := len(e.points); n > 0 { + if n := len(e.points); n > 0 && !powerAt.Before(e.points[n-1].at) { e.coverageAt = e.points[n-1].at if s.PowerMaxAge > 0 && powerAt.Equal(e.points[n-1].at) && now.After(powerAt) { // The driver declares a bounded reporting interval. Between diff --git a/go/internal/loadpoint/session_energy_test.go b/go/internal/loadpoint/session_energy_test.go index 51a1b058..f562bf98 100644 --- a/go/internal/loadpoint/session_energy_test.go +++ b/go/internal/loadpoint/session_energy_test.go @@ -236,3 +236,25 @@ func TestPowerEstimateExpiresAndCounterCatchupDoesNotDuplicate(t *testing.T) { t.Fatal(got) } } + +func TestOutOfOrderPowerDoesNotEraseEstimatedProgress(t *testing.T) { + start := time.Now().Add(-time.Hour) + e := &sessionEnergy{} + s := EVSample{PowerW: 3600, PowerAt: start, PowerMaxAge: 3 * time.Minute, SessionWh: 1000, EnergyAt: start} + e.observe(s, start) + s.PowerAt = start.Add(30 * time.Second) + e.observe(s, s.PowerAt) + if got := e.observe(s, start.Add(time.Minute)); got != 1060 { + t.Fatal(got) + } + // An older response can arrive after the latest source reading. It must + // neither erase the estimate nor replace the latest measured power. + s.PowerAt, s.PowerW = start, 11000 + if got := e.observe(s, start.Add(65*time.Second)); got != 1060 { + t.Fatalf("older power erased progress: %v", got) + } + s.PowerAt, s.PowerW = start.Add(30*time.Second), 3600 + if got := e.observe(s, start.Add(70*time.Second)); got != 1070 { + t.Fatalf("latest power did not resume correctly: %v", got) + } +} From ab4c77d1c41ed4c8b67ef0cd2138a68f4d660843 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 13 Sep 2026 16:50:39 +0200 Subject: [PATCH 4/7] fix(web): explain when the EV charge target is reached --- .changeset/ev-counter-time-and-session.md | 2 ++ web/app.js | 8 ++++++++ web/ev-plan-status.test.mjs | 17 +++++++++++++++++ 3 files changed, 27 insertions(+) diff --git a/.changeset/ev-counter-time-and-session.md b/.changeset/ev-counter-time-and-session.md index b929f44e..633fccca 100644 --- a/.changeset/ev-counter-time-and-session.md +++ b/.changeset/ev-counter-time-and-session.md @@ -5,3 +5,5 @@ Keep charger measurement times when estimating EV energy. Use fresh power between delayed session-counter updates, reconcile overlapping energy once, and retain the estimate through a verified session restart. Missing or older counters no longer reset a confirmed battery level. Expose the estimate source and measurement ages. Match Easee pauses to the current vendor session even when sessionEnd is populated. Bound power estimates to its reporting cadence. Replan when a restored EV level differs from the active plan. + +Show when a stopped charge has reached its target, and distinguish an estimated battery level from one reported by the car. diff --git a/web/app.js b/web/app.js index 302bdbc8..c959b196 100644 --- a/web/app.js +++ b/web/app.js @@ -2836,6 +2836,14 @@ if (lp.commanded_reason === "fuse_limit") { text += " Rate is limited by the main fuse right now."; } + } else if (lp.commanded_known && lp.commanded_w === 0 && !lp.power_unavailable && + typeof lp.target_soc === "number" && lp.target_soc > 0 && lp.target_soc <= 1 && + typeof lp.current_soc === "number" && lp.current_soc >= lp.target_soc && lp.current_soc <= 1 && + (lp.soc_source === "vehicle" || lp.soc_source === "inferred")) { + text = "Charge target reached (" + Math.round(lp.target_soc * 100) + "%)."; + text += lp.soc_source === "vehicle" + ? " Battery level reported by the car." + : " Battery level is estimated by FTW; check the car to confirm."; } else if (lp.charging_declined) { text = "The car stopped asking for charge. Check its charge limit or schedule. This does not confirm the battery is full."; } else if (lp.commanded_known && lp.commanded_w > 0) { diff --git a/web/ev-plan-status.test.mjs b/web/ev-plan-status.test.mjs index 8d7855ed..cb4c05e8 100644 --- a/web/ev-plan-status.test.mjs +++ b/web/ev-plan-status.test.mjs @@ -51,3 +51,20 @@ test('pending and failed plans do not pretend that charging windows are ready', assert.doesNotMatch(failed, /Updating|Charging planned/); assert.equal(planStatus({ ...lp, manual_active: true, plan_pending: true }, {}).textContent, 'Paused by you.'); }); + +test('reached goal explains a stopped charge and names the level source', () => { + const lp = { plugged_in: true, charger: { available: true }, current_power_w: 0, + commanded_known: true, commanded_w: 0, target_soc: .8, current_soc: .8014, + soc_source: 'inferred', schedule: { soc: .8 } }; + const estimated = planStatus(lp, {}).textContent; + assert.match(estimated, /Charge target reached \(80%\).*estimated by FTW/); + assert.doesNotMatch(estimated, /No charge window|Choose Charge now/); + assert.match(planStatus({ ...lp, soc_source: 'vehicle' }, {}).textContent, /reported by the car/); + for (const change of [ + { soc_source: 'assumed' }, { current_soc: .79 }, { target_soc: null }, + { power_unavailable: true }, { charger: { known: true, available: false } }, + { current_power_w: 6900 }, { commanded_w: 6900 }, { commanded_known: false }, + ]) { + assert.doesNotMatch(planStatus({ ...lp, ...change }, {}).textContent, /target reached/); + } +}); From 0799cb7bfe4a53a500e41403b15817d8423570a4 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 13 Sep 2026 16:58:41 +0200 Subject: [PATCH 5/7] chore(drivers): pin the signed-off Easee fix Signed-off-by: Fredrik Ahlgren --- drivers/BUNDLED_SOURCE.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/drivers/BUNDLED_SOURCE.json b/drivers/BUNDLED_SOURCE.json index c587d6c4..1b68cd13 100644 --- a/drivers/BUNDLED_SOURCE.json +++ b/drivers/BUNDLED_SOURCE.json @@ -17,7 +17,7 @@ "for coverage. Run scripts/sync-bundled-drivers.sh to update." ], "repository": "srcfl/device-drivers", - "commit": "8e8b48f604fc98111fb611187d96403e539f745e", + "commit": "2086b64f06c40e6277edc37d907512010b6c1871", "source_dir": "drivers/lua", "drivers": [ "ambibox_v2x", "ctek", "ctek_hybrid", "ctek_v2", "deye", "easee_cloud", From d721bd6e3f2611c439ace90260e3dba7840a300d Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 13 Sep 2026 17:07:33 +0200 Subject: [PATCH 6/7] fix(drivers): bundle Easee outage and session quota guards Signed-off-by: Fredrik Ahlgren --- drivers/BUNDLED_SOURCE.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/drivers/BUNDLED_SOURCE.json b/drivers/BUNDLED_SOURCE.json index 1b68cd13..7b874527 100644 --- a/drivers/BUNDLED_SOURCE.json +++ b/drivers/BUNDLED_SOURCE.json @@ -17,7 +17,7 @@ "for coverage. Run scripts/sync-bundled-drivers.sh to update." ], "repository": "srcfl/device-drivers", - "commit": "2086b64f06c40e6277edc37d907512010b6c1871", + "commit": "d44fda113f171b5824ecbe178073731dfcf1c031", "source_dir": "drivers/lua", "drivers": [ "ambibox_v2x", "ctek", "ctek_hybrid", "ctek_v2", "deye", "easee_cloud", From 67439f4a47bd0dd29a984fe2b48ce32465c710bf Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 13 Sep 2026 17:22:37 +0200 Subject: [PATCH 7/7] fix(ev): bound duty dispatch and checkpoint charging progress Signed-off-by: Fredrik Ahlgren --- .changeset/ev-counter-time-and-session.md | 2 + go/internal/loadpoint/controller.go | 14 +++-- go/internal/loadpoint/controller_energy.go | 18 +++--- .../loadpoint/controller_energy_test.go | 56 +++++++++++++++++ go/internal/loadpoint/loadpoint.go | 2 + go/internal/loadpoint/session_energy_test.go | 60 ++++++++++++++++++- go/internal/loadpoint/session_sqlite_test.go | 3 + go/internal/loadpoint/session_state.go | 11 +++- go/internal/mpc/service_ev_divergence.go | 21 ++++--- go/internal/mpc/service_ev_divergence_test.go | 29 +++++++++ web/app.js | 3 + web/ev-plan-status.test.mjs | 7 +++ 12 files changed, 200 insertions(+), 26 deletions(-) diff --git a/.changeset/ev-counter-time-and-session.md b/.changeset/ev-counter-time-and-session.md index 633fccca..5b7b78ec 100644 --- a/.changeset/ev-counter-time-and-session.md +++ b/.changeset/ev-counter-time-and-session.md @@ -7,3 +7,5 @@ Keep charger measurement times when estimating EV energy. Use fresh power betwee Match Easee pauses to the current vendor session even when sessionEnd is populated. Bound power estimates to its reporting cadence. Replan when a restored EV level differs from the active plan. Show when a stopped charge has reached its target, and distinguish an estimated battery level from one reported by the car. + +Pause dispatch when charger power is unavailable and retain spent pulse energy across recovery. Compare EV progress with the allowed duty curve. Bound progress checkpoints to 30 seconds or 30 Wh, with immediate saves for stops and user corrections. diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index 0dd20808..d77f4393 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -1567,7 +1567,7 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, d c.manager.SetSurplusWithheld(lpCfg.ID, selfWithheld) c.manager.ObserveSample(lpCfg.ID, sample) c.restoreManualHoldForSession(lpCfg.ID) - c.evaluateBatteryBoost(lpCfg.ID, now, sample.Connected, dispatchAllowed) + c.evaluateBatteryBoost(lpCfg.ID, now, sample.Connected, dispatchAllowed && !sample.PowerUnavailable) if !sample.Connected { delete(c.resumeOffers, lpCfg.ID) c.resetSurplusSession(lpCfg.ID) @@ -1588,14 +1588,14 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, d // outcome here; driverActuationTracker.update owns the timed retry. return } - if !dispatchAllowed { + if !dispatchAllowed || sample.PowerUnavailable { // The observation above is deliberately retained: dashboards, SoC - // inference and plug/unplug state must stay live while the site-meter + // inference and plug/unplug state must stay live while a measurement // safety gate is closed. Do not advance manual-hold completion timers // or auto-wake state while we are the reason current is withheld; a // persistent hold or schedule must resume normally after recovery. // The outcome is deliberately not reported to dispatchOutcome: this - // is core withdrawing under a stale site meter, not core actuating, + // is core withdrawing under stale measurements, not core actuating, // and the staleness tracker already owns that transition. A charger // that refuses the standdown must not be excluded for it — the fault // being handled is the meter's. @@ -1605,7 +1605,11 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, d if hold, held := c.GetManualHold(lpCfg.ID, now); held { manualUpdatedAt = hold.UpdatedAt } - c.manager.setCommandedForManual(lpCfg.ID, 0, "site_meter_stale", manualUpdatedAt) + reason := "site_meter_stale" + if dispatchAllowed && sample.PowerUnavailable { + reason = "charger_power_stale" + } + c.manager.setCommandedForManual(lpCfg.ID, 0, reason, manualUpdatedAt) payload, err := json.Marshal(map[string]any{ "action": "ev_set_current", "power_w": 0, diff --git a/go/internal/loadpoint/controller_energy.go b/go/internal/loadpoint/controller_energy.go index f4c3496d..bc38df97 100644 --- a/go/internal/loadpoint/controller_energy.go +++ b/go/internal/loadpoint/controller_energy.go @@ -13,22 +13,27 @@ type energyPoint struct { type meteredEnergy struct { driver, device, session string generation uint64 - last EVSample meter sessionEnergy points []energyPoint } // observeEnergy uses the charger counter when available, otherwise integrates // successive fresh power readings. It never applies today's power to an entire -// elapsed slot. A transport/session change or an unmeasured gap breaks coverage. +// elapsed slot. A transport/session change resets delivery. Measurement gaps +// add no assumed charge and cannot erase energy already spent in the slot. func (c *Controller) observeEnergy(cfg Config, sample EVSample, now time.Time) { if c.energySamples == nil { c.energySamples = make(map[string]*meteredEnergy) } - if !sample.Connected || sample.ConnectionUnknown || sample.PowerUnavailable || math.IsNaN(sample.PowerW) || math.IsInf(sample.PowerW, 0) { + if !sample.Connected || sample.ConnectionUnknown { delete(c.energySamples, cfg.ID) return } + if sample.PowerUnavailable || math.IsNaN(sample.PowerW) || math.IsInf(sample.PowerW, 0) { + // Dispatch pauses without measurements. Retain already spent energy so + // recovery cannot repeat the same pulse while its counter is delayed. + return + } e := c.energySamples[cfg.ID] if e == nil || e.driver != cfg.DriverName || e.device != sample.DeviceID || e.session != sample.SessionID || e.generation != sample.ConnectionGeneration || e.meter.counterRegressed(sample) { e = &meteredEnergy{driver: cfg.DriverName, device: sample.DeviceID, session: sample.SessionID, generation: sample.ConnectionGeneration} @@ -52,10 +57,8 @@ func (c *Controller) observeEnergy(cfg Config, sample EVSample, now time.Time) { if !measuredAt.After(previous.at) { return } - counterAdvanced := !sample.SessionWhUnavailable && sample.SessionWh > e.last.SessionWh && (sample.EnergyAt.IsZero() || sample.EnergyAt.After(previous.at)) - if measuredAt.Sub(previous.at) > sample.PowerWindow() && !counterAdvanced { - e.points = nil - } + // sessionEnergy leaves a measurement gap unintegrated. Keep its known + // delivery before the gap rather than reopening an already spent budget. } counterWasKnown := e.meter.counterKnown wh := e.meter.observe(sample, now) @@ -68,7 +71,6 @@ func (c *Controller) observeEnergy(cfg Config, sample EVSample, now time.Time) { } } e.points = append(e.points, energyPoint{at: measuredAt, wh: wh}) - e.last = sample // Keep one boundary reading for a two-hour slot, with a hard cap for // callers ticking faster than production's five-second loop. for len(e.points) > 2048 || (len(e.points) > 2 && e.points[1].at.Before(now.Add(-2*time.Hour))) { diff --git a/go/internal/loadpoint/controller_energy_test.go b/go/internal/loadpoint/controller_energy_test.go index 12b71ce6..abb1ee7d 100644 --- a/go/internal/loadpoint/controller_energy_test.go +++ b/go/internal/loadpoint/controller_energy_test.go @@ -95,3 +95,59 @@ func TestDutyPlanCommandsOnPowerThenStopsAtEnergyBudget(t *testing.T) { t.Fatalf("spent budget still commands %.0f", got) } } + +func TestDutyDispatchStopsForUnavailablePowerAndKeepsSpentBudget(t *testing.T) { + start := time.Now().Truncate(time.Minute) + cfg := Config{ID: "garage", DriverName: "easee", MinChargeW: 4140, MaxChargeW: 11000} + dir := &Directive{SlotStart: start, SlotEnd: start.Add(15 * time.Minute), LoadpointEnergyWh: map[string]float64{"garage": 100}, LoadpointMaxPowerW: map[string]float64{"garage": 11000}} + samples := map[string]EVSample{"easee": {Connected: true, RequestActive: true, SessionWh: 1000, EnergyAt: start, PowerAt: start}} + sender := &fakeSender{} + c := newTestController(t, []Config{cfg}, dir, samples, sender) + c.Tick(context.Background(), start) + // A fresh counter proves that the whole pulse has been delivered. + s := samples["easee"] + s.SessionWh, s.EnergyAt, s.PowerAt = 1100, start.Add(35*time.Second), start.Add(35*time.Second) + samples["easee"] = s + c.Tick(context.Background(), s.PowerAt) + for sec := 40; sec <= 600; sec += 5 { + s.PowerUnavailable = true + samples["easee"] = s + c.Tick(context.Background(), start.Add(time.Duration(sec)*time.Second)) + cmd, ok := lastSetCurrent(sender.calls) + if !ok || cmd.power != 0 { + t.Fatalf("missing power resumed a spent pulse at %ds: %+v", sec, cmd) + } + } + s.PowerUnavailable, s.PowerAt = false, start.Add(605*time.Second) + samples["easee"] = s + c.Tick(context.Background(), s.PowerAt) + if cmd, _ := lastSetCurrent(sender.calls); cmd.power != 0 { + t.Fatalf("recovery forgot the spent budget: %+v", cmd) + } +} + +func TestUnavailablePowerPausesAndRetainsManualCharge(t *testing.T) { + start := time.Now().Truncate(time.Minute) + cfg := Config{ID: "garage", DriverName: "easee", MinChargeW: 4140, MaxChargeW: 11000} + samples := map[string]EVSample{"easee": {Connected: true, RequestActive: true, DeviceID: "easee:A", SessionID: "session-1", SessionWh: 1000}} + sender := &fakeSender{} + c := newTestController(t, []Config{cfg}, nil, samples, sender) + c.Tick(context.Background(), start) + c.SetManualHold(cfg.ID, ManualHold{PowerW: 11000, Persistent: true}) + s := samples["easee"] + s.PowerUnavailable = true + samples["easee"] = s + c.Tick(context.Background(), start.Add(5*time.Minute)) + if cmd, _ := lastSetCurrent(sender.calls); cmd.power != 0 { + t.Fatalf("unavailable power did not pause manual charge: %+v", cmd) + } + if _, held := c.GetManualHold(cfg.ID, start.Add(5*time.Minute)); !held { + t.Fatal("stale measurements erased the manual request") + } + s.PowerUnavailable = false + samples["easee"] = s + c.Tick(context.Background(), start.Add(5*time.Minute+5*time.Second)) + if cmd, _ := lastSetCurrent(sender.calls); cmd.power <= 0 { + t.Fatalf("recovered measurements did not resume the request: %+v", cmd) + } +} diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index f7efaf63..6699f466 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -343,6 +343,7 @@ type loadpointRuntime struct { powerAt time.Time powerUnavailable bool lastSavedEnergyWh float64 + lastSavedEnergyAt time.Time sessionDeviceID string sessionID string socRetention string @@ -553,6 +554,7 @@ func (m *Manager) Load(cfgs []Config) { lp.powerAt = existing.powerAt lp.powerUnavailable = existing.powerUnavailable lp.lastSavedEnergyWh = existing.lastSavedEnergyWh + lp.lastSavedEnergyAt = existing.lastSavedEnergyAt lp.targetSoC = existing.targetSoC lp.targetTime = existing.targetTime lp.updatedAtMs = existing.updatedAtMs diff --git a/go/internal/loadpoint/session_energy_test.go b/go/internal/loadpoint/session_energy_test.go index f562bf98..8dd99d52 100644 --- a/go/internal/loadpoint/session_energy_test.go +++ b/go/internal/loadpoint/session_energy_test.go @@ -125,7 +125,7 @@ func TestSessionEnergyOldCounterDoesNotLoseTrimmedPower(t *testing.T) { } } -func TestSessionProgressPersistsBetweenMinuteBoundaries(t *testing.T) { +func TestSessionProgressCheckpointBoundsRestartGap(t *testing.T) { store := &sessionMemory{data: map[string]string{}} m := sessionManager(store, "garage", "charger") at := time.Now().Add(-time.Hour).Truncate(time.Second) @@ -144,8 +144,14 @@ func TestSessionProgressPersistsBetweenMinuteBoundaries(t *testing.T) { m.SetNowFn(func() time.Time { return at }) m.ObserveSample("garage", s) after, _ := m.State("garage") - if before.CurrentSoC != after.CurrentSoC || after.SoCRetention != "session" { - t.Fatalf("restart forgot measured progress: before=%+v after=%+v", before, after) + if math.Abs(before.DeliveredWhSession-after.DeliveredWhSession) >= 30 || after.SoCRetention != "session" { + t.Fatalf("restart exceeded checkpoint bound: before=%+v after=%+v", before, after) + } + s.SessionWh, s.EnergyAt = before.DeliveredWhSession, s.PowerAt + m.ObserveSample("garage", s) + after, _ = m.State("garage") + if math.Abs(before.CurrentSoC-after.CurrentSoC) > 1e-9 { + t.Fatalf("counter catch-up lost progress: before=%+v after=%+v", before, after) } } @@ -258,3 +264,51 @@ func TestOutOfOrderPowerDoesNotEraseEstimatedProgress(t *testing.T) { t.Fatalf("latest power did not resume correctly: %v", got) } } + +type countedSessionStore struct { + *sessionMemory + writes int +} + +func (s *countedSessionStore) SaveConfig(key, value string) error { + s.writes++ + return s.sessionMemory.SaveConfig(key, value) +} + +func TestSessionCheckpointsBoundWritesAndSaveStoppedProgress(t *testing.T) { + store := &countedSessionStore{sessionMemory: &sessionMemory{data: map[string]string{}}} + m := sessionManager(store, "garage", "charger") + start := time.Now().Add(-time.Hour) + at := start + m.SetNowFn(func() time.Time { return at }) + s := EVSample{Connected: true, RequestActive: true, DeviceID: "easee:A", SessionID: "session-1", SessionWh: 1000, EnergyAt: start, PowerW: 3600, PowerAt: start} + m.ObserveSample("garage", s) + m.SetCurrentSoC("garage", .76) + for sec := 2; sec <= 180; sec += 2 { + at, s.PowerAt = start.Add(time.Duration(sec)*time.Second), start.Add(time.Duration(sec)*time.Second) + m.ObserveSample("garage", s) + } + if store.writes > 8 { + t.Fatalf("90 control ticks wrote %d checkpoints", store.writes) + } + at = at.Add(2 * time.Second) + s.PowerAt, s.PowerW = at, 0 + m.ObserveSample("garage", s) + before, _ := m.State("garage") + writes := store.writes + for i := 0; i < 30; i++ { + at = at.Add(2 * time.Second) + s.PowerAt = at + m.ObserveSample("garage", s) + } + if store.writes != writes { + t.Fatal("unchanged stopped energy kept writing checkpoints") + } + m = sessionManager(store, "garage", "charger") + m.SetNowFn(func() time.Time { return at }) + m.ObserveSample("garage", s) + after, _ := m.State("garage") + if before.CurrentSoC != after.CurrentSoC || after.SoCRetention != "session" { + t.Fatalf("stop did not save final progress: before=%+v after=%+v", before, after) + } +} diff --git a/go/internal/loadpoint/session_sqlite_test.go b/go/internal/loadpoint/session_sqlite_test.go index ae037177..dcea18e5 100644 --- a/go/internal/loadpoint/session_sqlite_test.go +++ b/go/internal/loadpoint/session_sqlite_test.go @@ -74,6 +74,9 @@ func TestDelayedCounterProgressSurvivesDatabaseReopen(t *testing.T) { sample.PowerAt = at m.ObserveSample("garage", sample) } + // A stopped charge checkpoints immediately, including between periodic saves. + sample.PowerW = 0 + m.ObserveSample("garage", sample) before, _ := m.State("garage") if err := store.Close(); err != nil { t.Fatal(err) diff --git a/go/internal/loadpoint/session_state.go b/go/internal/loadpoint/session_state.go index 8af1218e..748c13e0 100644 --- a/go/internal/loadpoint/session_state.go +++ b/go/internal/loadpoint/session_state.go @@ -187,10 +187,14 @@ func (m *Manager) ObserveSample(id string, sample EVSample) { } else if !lp.socConfirmed || deviceID == "" || sessionID == "" || m.sessionStore == nil { lp.socRetention = "unavailable" } - // Save each change inferred from power: the cloud counter can remain - // behind through an arbitrary restart. Unchanged estimates do not write. + // Bound unsaved progress without writing state.db on every control tick. + // At 75 kWh, 30 Wh is 0.036 percentage points after charging loss. A stop, + // user correction or failed save still gets an immediate checkpoint. + progressChanged := lp.deliveredWhSession != lp.lastSavedEnergyWh + checkpointDue := math.Abs(lp.deliveredWhSession-lp.lastSavedEnergyWh) >= 30 || + m.now().Sub(lp.lastSavedEnergyAt) >= 30*time.Second || lp.currentPowerW == 0 saveProgress := lp.socConfirmed && lp.sessionID != "" && lp.energy.counterKnown && - (lp.socRetention != "session" || (lp.energy.source == "power" && lp.deliveredWhSession != lp.lastSavedEnergyWh)) + (lp.socRetention != "session" || (lp.energy.source == "power" && progressChanged && checkpointDue)) m.mu.Unlock() if saveProgress { m.persistSession(id) @@ -247,6 +251,7 @@ func (m *Manager) persistSession(id string) { lp.socRetention = retention if retention == "session" { lp.lastSavedEnergyWh = lp.deliveredWhSession + lp.lastSavedEnergyAt = m.now() } } m.mu.Unlock() diff --git a/go/internal/mpc/service_ev_divergence.go b/go/internal/mpc/service_ev_divergence.go index 9f294040..6d0db01c 100644 --- a/go/internal/mpc/service_ev_divergence.go +++ b/go/internal/mpc/service_ev_divergence.go @@ -1,9 +1,6 @@ package mpc -import ( - "math" - "time" -) +import "time" // A restored level or charging outside the plan changes the remaining duty. // Reuse the existing reactive loop and cooldown, including when PV/load @@ -38,7 +35,7 @@ func (s *Service) loadpointStateDiverged(plan *Plan, params Params, now time.Tim if initial == nil { return true } - expected := initial.InitialSoC + expectedMin, expectedMax := initial.InitialSoC, initial.InitialSoC efficiency := initial.ChargeEfficiency if efficiency <= 0 { efficiency = .9 @@ -53,11 +50,21 @@ func (s *Service) loadpointStateDiverged(plan *Plan, params Params, now time.Tim if len(a.LoadpointPowerW) == 0 && len(previous) == 1 { watts = a.LoadpointW } - expected += max(0, watts) * float64(elapsed) / 3600000 * efficiency / initial.CapacityWh + lowWh := max(0, watts) * float64(elapsed) / 3600000 + highWh := lowWh + if peak := a.LoadpointMaxPowerW[lp.ID]; peak > 0 { + // A duty plan budgets average watts but executes a legal peak. + // Allow any placement of that pulse within the remaining slot. + budgetWh := max(0, watts) * float64(end-start) / 3600000 + highWh = min(budgetWh, peak*float64(elapsed)/3600000) + lowWh = max(0, budgetWh-peak*float64(end-start-elapsed)/3600000) + } + expectedMin += lowWh * efficiency / initial.CapacityWh + expectedMax += highWh * efficiency / initial.CapacityWh } // Two percentage points avoid replanning for rounding and normal meter // delay. A larger restoration/correction must not wait fifteen minutes. - if math.Abs(lp.InitialSoC-min(1, expected)) > .02 { + if lp.InitialSoC < min(1, expectedMin)-.02 || lp.InitialSoC > min(1, expectedMax)+.02 { return true } } diff --git a/go/internal/mpc/service_ev_divergence_test.go b/go/internal/mpc/service_ev_divergence_test.go index cf8e5049..ead2bbf6 100644 --- a/go/internal/mpc/service_ev_divergence_test.go +++ b/go/internal/mpc/service_ev_divergence_test.go @@ -40,3 +40,32 @@ func TestEVLevelDivergenceAccountsForPlannedDelivery(t *testing.T) { t.Fatal("larger correction did not trigger replan") } } + +func TestEVLevelDivergenceAcceptsDutyExecutionRange(t *testing.T) { + start := time.Now().Truncate(time.Hour) + initial := &LoadpointSpec{ID: "garage", Levels: 11, SoCMax: 1, CapacityWh: 60000, InitialSoC: .5, PluggedIn: true, TargetSoC: .8, MaxChargeW: 11000, ChargeEfficiency: .9} + current := *initial + s := &Service{Loadpoints: func(int) []*LoadpointSpec { return []*LoadpointSpec{¤t} }} + p := Params{Loadpoints: []*LoadpointSpec{initial}} + for _, offset := range []time.Duration{0, 30 * time.Minute} { + a := Action{SlotStartMs: start.UnixMilli(), ExecutionStartMs: start.Add(offset).UnixMilli(), SlotLenMin: 60, LoadpointPowerW: map[string]float64{"garage": 5500}, LoadpointMaxPowerW: map[string]float64{"garage": 11000}} + plan := &Plan{Actions: []Action{a}} + for _, fraction := range []float64{0, .25, .5, .75, 1} { + duration := time.Hour - offset + elapsed := time.Duration(float64(duration) * fraction) + budget := 5500 * duration.Hours() + front := min(budget, 11000*elapsed.Hours()) + back := max(0, budget-11000*(duration-elapsed).Hours()) + for _, wh := range []float64{front, back} { + current.InitialSoC = .5 + wh*.9/60000 + if s.loadpointStateDiverged(plan, p, start.Add(offset+elapsed)) { + t.Fatalf("legal duty charge triggered replan: offset=%v elapsed=%v Wh=%v", offset, elapsed, wh) + } + } + current.InitialSoC = .5 + front*.9/60000 + .03 + if !s.loadpointStateDiverged(plan, p, start.Add(offset+elapsed)) { + t.Fatal("correction outside the duty range did not replan") + } + } + } +} diff --git a/web/app.js b/web/app.js index c959b196..7d1246a0 100644 --- a/web/app.js +++ b/web/app.js @@ -2822,6 +2822,9 @@ ? "Charger status is out of date. FTW cannot confirm whether the car is charging." : "Waiting for the charger's first status report."; tone = "var(--text)"; + } else if (lp.power_unavailable) { + text = "Paused: charger power data is out of date. Charging resumes when readings recover."; + tone = "var(--text)"; } else if (lp.manual_active) { // The same sentence as the charge controls, so the charger's own reason is // never hidden behind "manual charge is running". diff --git a/web/ev-plan-status.test.mjs b/web/ev-plan-status.test.mjs index cb4c05e8..5243b1b3 100644 --- a/web/ev-plan-status.test.mjs +++ b/web/ev-plan-status.test.mjs @@ -68,3 +68,10 @@ test('reached goal explains a stopped charge and names the level source', () => assert.doesNotMatch(planStatus({ ...lp, ...change }, {}).textContent, /target reached/); } }); + +test('unavailable charger power explains the pause without declaring completion', () => { + const text = planStatus({ plugged_in: true, power_unavailable: true, + charger: { available: true }, manual_active: true }, {}).textContent; + assert.match(text, /Paused: charger power data is out of date/); + assert.doesNotMatch(text, /target reached|Charging on plan/); +});