From 3261ad2075d0194d5a1ca6058e3153579ca18458 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20He=C3=9F?= Date: Thu, 19 Mar 2026 18:13:12 +0100 Subject: [PATCH] Easee: fix stale power/current readings when charger goes offline (#28362) --- charger/easee.go | 23 +++++++++++++++++ charger/easee_test.go | 58 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/charger/easee.go b/charger/easee.go index 9b32e4e50..e580047d0 100644 --- a/charger/easee.go +++ b/charger/easee.go @@ -40,6 +40,13 @@ import ( "golang.org/x/oauth2" ) +// observationTimeout is the maximum age of the most recently received charger +// observation before power and current readings are considered stale and zeroed. +// The Easee charger sends a thermal heartbeat every ~15 minutes when idle, so +// 20 minutes gives a comfortable margin above that while staying well below the +// time a user would expect stale data to linger. +const observationTimeout = 20 * time.Minute + // Easee charger implementation type Easee struct { *request.Helper @@ -69,6 +76,7 @@ type Easee struct { expectedOrphans map[easee.ObservationID]int obsC chan easee.Observation obsTime map[easee.ObservationID]time.Time + lastObsReceived time.Time startDone func() } @@ -350,6 +358,12 @@ func (c *Easee) ProductUpdate(i json.RawMessage) { c.obsTime[res.ID] = res.Timestamp + // Update liveness timestamp for observations with a fresh charger-side timestamp. + // Stale cloud replay on restart has old timestamps and must not refresh this. + if time.Since(res.Timestamp) < observationTimeout { + c.lastObsReceived = time.Now() + } + switch res.ID { case easee.USER_IDTOKEN: c.rfid = res.Value @@ -749,6 +763,10 @@ func (c *Easee) CurrentPower() (float64, error) { c.mux.RLock() defer c.mux.RUnlock() + if time.Since(c.lastObsReceived) > observationTimeout { + return 0, nil + } + return c.currentPower, nil } @@ -767,6 +785,11 @@ var _ api.PhaseCurrents = (*Easee)(nil) func (c *Easee) Currents() (float64, float64, float64, error) { c.mux.RLock() defer c.mux.RUnlock() + + if time.Since(c.lastObsReceived) > observationTimeout { + return 0, 0, 0, nil + } + return c.currentL1, c.currentL2, c.currentL3, nil } diff --git a/charger/easee_test.go b/charger/easee_test.go index 3c6b19943..63c6a89e4 100644 --- a/charger/easee_test.go +++ b/charger/easee_test.go @@ -560,6 +560,28 @@ func TestEasee_registerExpectedOrphan_multipleRegistrations(t *testing.T) { assert.False(t, e.consumeExpectedOrphan(easee.CIRCUIT_MAX_CURRENT_P1)) } +func TestProductUpdate_updatesLastObsReceived_freshTimestamp(t *testing.T) { + e := newEasee() + assert.True(t, e.lastObsReceived.IsZero()) + + // Observation with a fresh charger-side timestamp (seconds ago) + now := time.Now().UTC().Truncate(0) + e.ProductUpdate(createPayload(easee.TOTAL_POWER, now, easee.Double, "3.5")) + + assert.False(t, e.lastObsReceived.IsZero()) + assert.WithinDuration(t, time.Now(), e.lastObsReceived, 5*time.Second) +} + +func TestProductUpdate_doesNotUpdateLastObsReceived_staleTimestamp(t *testing.T) { + e := newEasee() + + // Observation with a charger-side timestamp older than observationTimeout + stale := time.Now().UTC().Add(-(observationTimeout + time.Minute)) + e.ProductUpdate(createPayload(easee.TOTAL_POWER, stale, easee.Double, "3.5")) + + assert.True(t, e.lastObsReceived.IsZero(), "stale replay must not update lastObsReceived") +} + func TestEasee_Phases1p3p_registersExpectedOrphan(t *testing.T) { const siteID = 12345 const circuitID = 67890 @@ -600,3 +622,39 @@ func TestEasee_Phases1p3p_registersExpectedOrphan(t *testing.T) { e.cmdMu.Unlock() assert.Equal(t, 1, count, "expected orphan should be registered before the POST") } + +func TestLivenessCheck_staleObservations(t *testing.T) { + e := newEasee() + e.opMode = easee.ModeCharging + e.currentPower = 7280 + e.currentL1, e.currentL2, e.currentL3 = 16, 16, 16 + e.lastObsReceived = time.Now().Add(-(observationTimeout + time.Minute)) + + power, err := e.CurrentPower() + assert.NoError(t, err) + assert.Equal(t, float64(0), power, "expired observations: CurrentPower must return 0W") + + l1, l2, l3, err := e.Currents() + assert.NoError(t, err) + assert.Equal(t, float64(0), l1) + assert.Equal(t, float64(0), l2) + assert.Equal(t, float64(0), l3) +} + +func TestLivenessCheck_freshObservations(t *testing.T) { + e := newEasee() + e.opMode = easee.ModeCharging + e.currentPower = 7280 + e.currentL1, e.currentL2, e.currentL3 = 16, 16, 16 + e.lastObsReceived = time.Now() + + power, err := e.CurrentPower() + assert.NoError(t, err) + assert.Equal(t, float64(7280), power) + + l1, l2, l3, err := e.Currents() + assert.NoError(t, err) + assert.Equal(t, float64(16), l1) + assert.Equal(t, float64(16), l2) + assert.Equal(t, float64(16), l3) +}