@@ -867,11 +867,24 @@ def periodic_update_all(self) -> list[str]:
867867 Falls back to last_power_kw for sessions without V×A data (e.g. DC, or AC
868868 vehicles that only report charging.power).
869869
870- Gated by session.last_external_power_update: if no real MQTT-driven power
871- update has arrived within STALE_EXTERNAL_POWER_SECONDS, replay is skipped
872- and cached power values are cleared. This prevents phantom energy
873- accumulation when BMW keeps reporting CHARGING but stops pushing power
874- data (e.g. EVSE stopped externally but BMW still reports session active).
870+ Two replay paths with different freshness rules:
871+
872+ 1. V×A recompute. When the session has positive voltage and current,
873+ treat the recomputed power as a real reading and refresh the
874+ freshness marker. Stable AC charging can go 30+ min between BMW
875+ MQTT updates because the values do not change, and value-changed
876+ filtering means even API polls do not always flow back through
877+ update_ac_charging_data. The heartbeat is then the only thing
878+ keeping the prediction live, so it has to mark fresh itself.
879+ Self-protecting: when an EVSE stops, BMW reports V=0/I=0 and
880+ _calc_ac_power_kw returns None, dropping into the gated fallback.
881+
882+ 2. Cached last_power_kw fallback (no live V×A). Here we cannot tell
883+ ongoing charging apart from a session BMW still flags as active
884+ after an external EVSE stop, so the freshness gate clears the
885+ scalar once STALE_EXTERNAL_POWER_SECONDS has passed without a
886+ real external power update. This is the path that issue #355
887+ guards against; #372 was caused by path 1 being gated as well.
875888
876889 Returns:
877890 List of VINs that had their prediction updated
@@ -883,34 +896,36 @@ def periodic_update_all(self) -> list[str]:
883896 if not session :
884897 continue
885898
886- # Freshness gate: skip replay if external power data is stale.
899+ # Path 1: live V×A (AC sessions only). DC is excluded because a
900+ # session that flipped AC → DC mid-life can carry stale V×A and
901+ # _calc_ac_power_kw would compute a meaningless number for it.
902+ if session .charging_method != "DC" :
903+ power_kw = _calc_ac_power_kw (session )
904+ if power_kw is not None :
905+ self .update_power_reading (vin , power_kw , aux_power_kw = session .last_aux_kw )
906+ updated_vins .append (vin )
907+ continue
908+
909+ # Path 2: cached last_power_kw fallback. Gate on freshness so a
910+ # session BMW keeps flagging as active after an external EVSE
911+ # stop cannot inflate the prediction indefinitely (issue #355).
887912 if (
888913 session .last_external_power_update is None
889914 or now - session .last_external_power_update > STALE_EXTERNAL_POWER_SECONDS
890915 ):
891- # Clear cached power so get_predicted_soc extrapolation also stops.
892- # V×A values are left intact: if BMW resumes pushing fresh data,
893- # update_ac_charging_data will refresh them and re-arm the gate.
894916 if session .last_power_kw > 0 :
895917 age = (now - session .last_external_power_update ) if session .last_external_power_update else - 1
896918 _LOGGER .debug (
897- "Heartbeat stale for %s (age=%.0fs) — clearing last_power_kw to freeze prediction" ,
919+ "Heartbeat stale for %s (age=%.0fs), clearing last_power_kw to freeze prediction" ,
898920 redact_vin (vin ),
899921 age ,
900922 )
901923 session .last_power_kw = 0.0
902924 continue
903925
904- # Prefer V×A recalculation for AC sessions
905- power_kw = _calc_ac_power_kw (session )
906- if power_kw is not None :
907- self .update_power_reading (vin , power_kw , aux_power_kw = session .last_aux_kw , mark_fresh = False )
908- updated_vins .append (vin )
909-
910- # Fallback: use last known power for AC sessions without V×A data.
911- # DC excluded — power tapers during charging, so replaying stale
926+ # DC excluded: power tapers during charging, so replaying stale
912927 # power would overestimate energy.
913- elif session .last_power_kw > 0 and session .charging_method != "DC" :
928+ if session .last_power_kw > 0 and session .charging_method != "DC" :
914929 self .update_power_reading (
915930 vin , session .last_power_kw , aux_power_kw = session .last_aux_kw , mark_fresh = False
916931 )
0 commit comments