Easee: fix stale power/current readings when charger goes offline (#28362)

This commit is contained in:
Michael Heß 2026-03-19 18:13:12 +01:00 • committed by GitHub
parent 708b1c88e2
commit 3261ad2075
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 81 additions and 0 deletions

View file

@ -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
}

View file

@ -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)
}