Add last_reported to state reported event data (#148932)

This commit is contained in:
Erik Montnemery 2025-07-18 13:53:30 +02:00 committed by GitHub
parent 277241c4d3
commit 1743766d17
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 109 additions and 49 deletions

View File

@ -320,7 +320,12 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
# changed state, then we know it will still be zero. # changed state, then we know it will still be zero.
return return
schedule_max_sub_interval_exceeded(new_state) schedule_max_sub_interval_exceeded(new_state)
calc_derivative(new_state, new_state.state, event.data["old_last_reported"]) calc_derivative(
new_state,
new_state.state,
event.data["last_reported"],
event.data["old_last_reported"],
)
@callback @callback
def on_state_changed(event: Event[EventStateChangedData]) -> None: def on_state_changed(event: Event[EventStateChangedData]) -> None:
@ -334,19 +339,27 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
schedule_max_sub_interval_exceeded(new_state) schedule_max_sub_interval_exceeded(new_state)
old_state = event.data["old_state"] old_state = event.data["old_state"]
if old_state is not None: if old_state is not None:
calc_derivative(new_state, old_state.state, old_state.last_reported) calc_derivative(
new_state,
old_state.state,
new_state.last_updated,
old_state.last_reported,
)
else: else:
# On first state change from none, update availability # On first state change from none, update availability
self.async_write_ha_state() self.async_write_ha_state()
def calc_derivative( def calc_derivative(
new_state: State, old_value: str, old_last_reported: datetime new_state: State,
old_value: str,
new_timestamp: datetime,
old_timestamp: datetime,
) -> None: ) -> None:
"""Handle the sensor state changes.""" """Handle the sensor state changes."""
if not _is_decimal_state(old_value): if not _is_decimal_state(old_value):
if self._last_valid_state_time: if self._last_valid_state_time:
old_value = self._last_valid_state_time[0] old_value = self._last_valid_state_time[0]
old_last_reported = self._last_valid_state_time[1] old_timestamp = self._last_valid_state_time[1]
else: else:
# Sensor becomes valid for the first time, just keep the restored value # Sensor becomes valid for the first time, just keep the restored value
self.async_write_ha_state() self.async_write_ha_state()
@ -358,12 +371,10 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
"" if unit is None else unit "" if unit is None else unit
) )
self._prune_state_list(new_state.last_reported) self._prune_state_list(new_timestamp)
try: try:
elapsed_time = ( elapsed_time = (new_timestamp - old_timestamp).total_seconds()
new_state.last_reported - old_last_reported
).total_seconds()
delta_value = Decimal(new_state.state) - Decimal(old_value) delta_value = Decimal(new_state.state) - Decimal(old_value)
new_derivative = ( new_derivative = (
delta_value delta_value
@ -392,12 +403,10 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
return return
# add latest derivative to the window list # add latest derivative to the window list
self._state_list.append( self._state_list.append((old_timestamp, new_timestamp, new_derivative))
(old_last_reported, new_state.last_reported, new_derivative)
)
self._last_valid_state_time = ( self._last_valid_state_time = (
new_state.state, new_state.state,
new_state.last_reported, new_timestamp,
) )
# If outside of time window just report derivative (is the same as modeling it in the window), # If outside of time window just report derivative (is the same as modeling it in the window),
@ -405,9 +414,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
if elapsed_time > self._time_window: if elapsed_time > self._time_window:
derivative = new_derivative derivative = new_derivative
else: else:
derivative = self._calc_derivative_from_state_list( derivative = self._calc_derivative_from_state_list(new_timestamp)
new_state.last_reported
)
self._write_native_value(derivative) self._write_native_value(derivative)
source_state = self.hass.states.get(self._sensor_source_id) source_state = self.hass.states.get(self._sensor_source_id)

View File

@ -463,7 +463,7 @@ class IntegrationSensor(RestoreSensor):
) -> None: ) -> None:
"""Handle sensor state update when sub interval is configured.""" """Handle sensor state update when sub interval is configured."""
self._integrate_on_state_update_with_max_sub_interval( self._integrate_on_state_update_with_max_sub_interval(
None, event.data["old_state"], event.data["new_state"] None, None, event.data["old_state"], event.data["new_state"]
) )
@callback @callback
@ -472,13 +472,17 @@ class IntegrationSensor(RestoreSensor):
) -> None: ) -> None:
"""Handle sensor state report when sub interval is configured.""" """Handle sensor state report when sub interval is configured."""
self._integrate_on_state_update_with_max_sub_interval( self._integrate_on_state_update_with_max_sub_interval(
event.data["old_last_reported"], None, event.data["new_state"] event.data["old_last_reported"],
event.data["last_reported"],
None,
event.data["new_state"],
) )
@callback @callback
def _integrate_on_state_update_with_max_sub_interval( def _integrate_on_state_update_with_max_sub_interval(
self, self,
old_last_reported: datetime | None, old_timestamp: datetime | None,
new_timestamp: datetime | None,
old_state: State | None, old_state: State | None,
new_state: State | None, new_state: State | None,
) -> None: ) -> None:
@ -489,7 +493,9 @@ class IntegrationSensor(RestoreSensor):
""" """
self._cancel_max_sub_interval_exceeded_callback() self._cancel_max_sub_interval_exceeded_callback()
try: try:
self._integrate_on_state_change(old_last_reported, old_state, new_state) self._integrate_on_state_change(
old_timestamp, new_timestamp, old_state, new_state
)
self._last_integration_trigger = _IntegrationTrigger.StateEvent self._last_integration_trigger = _IntegrationTrigger.StateEvent
self._last_integration_time = datetime.now(tz=UTC) self._last_integration_time = datetime.now(tz=UTC)
finally: finally:
@ -503,7 +509,7 @@ class IntegrationSensor(RestoreSensor):
) -> None: ) -> None:
"""Handle sensor state change.""" """Handle sensor state change."""
return self._integrate_on_state_change( return self._integrate_on_state_change(
None, event.data["old_state"], event.data["new_state"] None, None, event.data["old_state"], event.data["new_state"]
) )
@callback @callback
@ -512,12 +518,16 @@ class IntegrationSensor(RestoreSensor):
) -> None: ) -> None:
"""Handle sensor state report.""" """Handle sensor state report."""
return self._integrate_on_state_change( return self._integrate_on_state_change(
event.data["old_last_reported"], None, event.data["new_state"] event.data["old_last_reported"],
event.data["last_reported"],
None,
event.data["new_state"],
) )
def _integrate_on_state_change( def _integrate_on_state_change(
self, self,
old_last_reported: datetime | None, old_timestamp: datetime | None,
new_timestamp: datetime | None,
old_state: State | None, old_state: State | None,
new_state: State | None, new_state: State | None,
) -> None: ) -> None:
@ -531,16 +541,17 @@ class IntegrationSensor(RestoreSensor):
if old_state: if old_state:
# state has changed, we recover old_state from the event # state has changed, we recover old_state from the event
new_timestamp = new_state.last_updated
old_state_state = old_state.state old_state_state = old_state.state
old_last_reported = old_state.last_reported old_timestamp = old_state.last_reported
else: else:
# event state reported without any state change # first state or event state reported without any state change
old_state_state = new_state.state old_state_state = new_state.state
self._attr_available = True self._attr_available = True
self._derive_and_set_attributes_from_state(new_state) self._derive_and_set_attributes_from_state(new_state)
if old_last_reported is None and old_state is None: if old_timestamp is None and old_state is None:
self.async_write_ha_state() self.async_write_ha_state()
return return
@ -551,11 +562,12 @@ class IntegrationSensor(RestoreSensor):
return return
if TYPE_CHECKING: if TYPE_CHECKING:
assert old_last_reported is not None assert new_timestamp is not None
assert old_timestamp is not None
elapsed_seconds = Decimal( elapsed_seconds = Decimal(
(new_state.last_reported - old_last_reported).total_seconds() (new_timestamp - old_timestamp).total_seconds()
if self._last_integration_trigger == _IntegrationTrigger.StateEvent if self._last_integration_trigger == _IntegrationTrigger.StateEvent
else (new_state.last_reported - self._last_integration_time).total_seconds() else (new_timestamp - self._last_integration_time).total_seconds()
) )
area = self._method.calculate_area_with_two_states(elapsed_seconds, *states) area = self._method.calculate_area_with_two_states(elapsed_seconds, *states)

View File

@ -727,12 +727,11 @@ class StatisticsSensor(SensorEntity):
def _async_handle_new_state( def _async_handle_new_state(
self, self,
reported_state: State | None, reported_state: State,
timestamp: float,
) -> None: ) -> None:
"""Handle the sensor state changes.""" """Handle the sensor state changes."""
if (new_state := reported_state) is None: self._add_state_to_queue(reported_state, timestamp)
return
self._add_state_to_queue(new_state)
self._async_purge_update_and_schedule() self._async_purge_update_and_schedule()
if self._preview_callback: if self._preview_callback:
@ -747,14 +746,18 @@ class StatisticsSensor(SensorEntity):
self, self,
event: Event[EventStateChangedData], event: Event[EventStateChangedData],
) -> None: ) -> None:
self._async_handle_new_state(event.data["new_state"]) if (new_state := event.data["new_state"]) is None:
return
self._async_handle_new_state(new_state, new_state.last_updated_timestamp)
@callback @callback
def _async_stats_sensor_state_report_listener( def _async_stats_sensor_state_report_listener(
self, self,
event: Event[EventStateReportedData], event: Event[EventStateReportedData],
) -> None: ) -> None:
self._async_handle_new_state(event.data["new_state"]) self._async_handle_new_state(
event.data["new_state"], event.data["last_reported"].timestamp()
)
async def _async_stats_sensor_startup(self) -> None: async def _async_stats_sensor_startup(self) -> None:
"""Add listener and get recorded state. """Add listener and get recorded state.
@ -785,7 +788,9 @@ class StatisticsSensor(SensorEntity):
"""Register callbacks.""" """Register callbacks."""
await self._async_stats_sensor_startup() await self._async_stats_sensor_startup()
def _add_state_to_queue(self, new_state: State) -> None: def _add_state_to_queue(
self, new_state: State, last_reported_timestamp: float
) -> None:
"""Add the state to the queue.""" """Add the state to the queue."""
# Attention: it is not safe to store the new_state object, # Attention: it is not safe to store the new_state object,
@ -805,7 +810,7 @@ class StatisticsSensor(SensorEntity):
self.states.append(new_state.state == "on") self.states.append(new_state.state == "on")
else: else:
self.states.append(float(new_state.state)) self.states.append(float(new_state.state))
self.ages.append(new_state.last_reported_timestamp) self.ages.append(last_reported_timestamp)
self._attr_extra_state_attributes[STAT_SOURCE_VALUE_VALID] = True self._attr_extra_state_attributes[STAT_SOURCE_VALUE_VALID] = True
except ValueError: except ValueError:
self._attr_extra_state_attributes[STAT_SOURCE_VALUE_VALID] = False self._attr_extra_state_attributes[STAT_SOURCE_VALUE_VALID] = False
@ -1062,7 +1067,7 @@ class StatisticsSensor(SensorEntity):
self._fetch_states_from_database self._fetch_states_from_database
): ):
for state in reversed(states): for state in reversed(states):
self._add_state_to_queue(state) self._add_state_to_queue(state, state.last_reported_timestamp)
self._calculate_state_attributes(state) self._calculate_state_attributes(state)
self._async_purge_update_and_schedule() self._async_purge_update_and_schedule()

View File

@ -157,7 +157,6 @@ class EventStateEventData(TypedDict):
"""Base class for EVENT_STATE_CHANGED and EVENT_STATE_REPORTED data.""" """Base class for EVENT_STATE_CHANGED and EVENT_STATE_REPORTED data."""
entity_id: str entity_id: str
new_state: State | None
class EventStateChangedData(EventStateEventData): class EventStateChangedData(EventStateEventData):
@ -166,6 +165,7 @@ class EventStateChangedData(EventStateEventData):
A state changed event is fired when on state write the state is changed. A state changed event is fired when on state write the state is changed.
""" """
new_state: State | None
old_state: State | None old_state: State | None
@ -175,6 +175,8 @@ class EventStateReportedData(EventStateEventData):
A state reported event is fired when on state write the state is unchanged. A state reported event is fired when on state write the state is unchanged.
""" """
last_reported: datetime.datetime
new_state: State
old_last_reported: datetime.datetime old_last_reported: datetime.datetime
@ -1749,18 +1751,38 @@ class CompressedState(TypedDict):
class State: class State:
"""Object to represent a state within the state machine. """Object to represent a state within the state machine."""
entity_id: the entity that is represented. entity_id: str
state: the state of the entity """The entity that is represented by the state."""
attributes: extra information on entity and state domain: str
last_changed: last time the state was changed. """Domain of the entity that is represented by the state."""
last_reported: last time the state was reported. object_id: str
last_updated: last time the state or attributes were changed. """object_id: Object id of this state."""
context: Context in which it was created state: str
domain: Domain of this state. """The state of the entity."""
object_id: Object id of this state. attributes: ReadOnlyDict[str, Any]
"""Extra information on entity and state"""
last_changed: datetime.datetime
"""Last time the state was changed."""
last_reported: datetime.datetime
"""Last time the state was reported.
Note: When the state is set and neither the state nor attributes are
changed, the existing state will be mutated with an updated last_reported.
When handling a state change event, the last_reported attribute of the old
state will not be modified and can safely be used. The last_reported attribute
of the new state may be modified and the last_updated attribute should be used
instead.
When handling a state report event, the last_reported attribute may be
modified and last_reported from the event data should be used instead.
""" """
last_updated: datetime.datetime
"""Last time the state or attributes were changed."""
context: Context
"""Context in which the state was created."""
__slots__ = ( __slots__ = (
"_cache", "_cache",
@ -1841,7 +1863,20 @@ class State:
@under_cached_property @under_cached_property
def last_reported_timestamp(self) -> float: def last_reported_timestamp(self) -> float:
"""Timestamp of last report.""" """Timestamp of last report.
Note: When the state is set and neither the state nor attributes are
changed, the existing state will be mutated with an updated last_reported.
When handling a state change event, the last_reported_timestamp attribute
of the old state will not be modified and can safely be used. The
last_reported_timestamp attribute of the new state may be modified and the
last_updated_timestamp attribute should be used instead.
When handling a state report event, the last_reported_timestamp attribute may
be modified and last_reported from the event data should be used instead.
"""
return self.last_reported.timestamp() return self.last_reported.timestamp()
@under_cached_property @under_cached_property
@ -2340,6 +2375,7 @@ class StateMachine:
EVENT_STATE_REPORTED, EVENT_STATE_REPORTED,
{ {
"entity_id": entity_id, "entity_id": entity_id,
"last_reported": now,
"old_last_reported": old_last_reported, "old_last_reported": old_last_reported,
"new_state": old_state, "new_state": old_state,
}, },