From 91c2c5951da5073c9f32d1966b2a5c3acdf12eca Mon Sep 17 00:00:00 2001 From: Sab44 <64696149+Sab44@users.noreply.github.com> Date: Wed, 18 Feb 2026 12:54:11 +0100 Subject: [PATCH 1/4] detect unit migration in recorder --- homeassistant/components/sensor/recorder.py | 42 +++++++++++++++++++-- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 78c5719510aa36..cd4d16c4e35ca5 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -94,6 +94,8 @@ class _StatisticsConfig: # Keep track of entities for which a warning about unsupported unit has been logged WARN_UNSUPPORTED_UNIT: HassKey[set[str]] = HassKey(f"{DOMAIN}_warn_unsupported_unit") WARN_UNSTABLE_UNIT: HassKey[set[str]] = HassKey(f"{DOMAIN}_warn_unstable_unit") +# Keep track of entities for which a change in unit has been observed +SEEN_CHANGED_UNIT: HassKey[set[str]] = HassKey(f"{DOMAIN}_seen_changed_unit") # Keep track of entities for which a warning about statistics mean algorithm change has been logged WARN_STATISTICS_MEAN_CHANGED: HassKey[set[str]] = HassKey( f"{DOMAIN}_warn_statistics_mean_change" @@ -279,11 +281,11 @@ def _normalize_states( """Normalize units.""" state_unit: str | None = None statistics_unit: str | None - state_unit = fstates[0][1].attributes.get(ATTR_UNIT_OF_MEASUREMENT) - device_class = fstates[0][1].attributes.get(ATTR_DEVICE_CLASS) + state_unit = fstates[-1][1].attributes.get(ATTR_UNIT_OF_MEASUREMENT) + device_class = fstates[-1][1].attributes.get(ATTR_DEVICE_CLASS) old_metadata = old_metadatas[entity_id][1] if entity_id in old_metadatas else None if not old_metadata: - # We've not seen this sensor before, the first valid state determines the unit + # We've not seen this sensor before, the most recent valid state determines the unit # used for statistics statistics_unit = state_unit unit_class = _get_unit_class(device_class, state_unit) @@ -302,11 +304,29 @@ def _normalize_states( # and the unit in the state, so we can use the new unit class unit_class = new_unit_class + states_by_unit = [ + list(state) + for _, state in itertools.groupby( + fstates, key=lambda x: x[1].attributes.get(ATTR_UNIT_OF_MEASUREMENT) + ) + ] + if not (converter := _get_unit_converter(unit_class)): # The unit used by this sensor doesn't support unit conversion all_units = _get_units(fstates) if not _equivalent_units(all_units): + if SEEN_CHANGED_UNIT not in hass.data: + hass.data[SEEN_CHANGED_UNIT] = set() + + if ( + len(states_by_unit) == 2 + and state_unit == statistics_unit + and entity_id not in hass.data[SEEN_CHANGED_UNIT] + ): + hass.data[SEEN_CHANGED_UNIT].add(entity_id) + return unit_class, state_unit, states_by_unit[1] + if WARN_UNSTABLE_UNIT not in hass.data: hass.data[WARN_UNSTABLE_UNIT] = set() if entity_id not in hass.data[WARN_UNSTABLE_UNIT]: @@ -332,7 +352,7 @@ def _normalize_states( if state_unit != statistics_unit: unit_class = _get_unit_class( - fstates[0][1].attributes.get(ATTR_DEVICE_CLASS), + fstates[-1][1].attributes.get(ATTR_DEVICE_CLASS), state_unit, ) return unit_class, state_unit, fstates @@ -341,11 +361,22 @@ def _normalize_states( convert: Callable[[float], float] | None = None last_unit: str | None | UndefinedType = UNDEFINED valid_units = converter.VALID_UNITS + is_unit_migration: bool = False for fstate, state in fstates: state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) # Exclude states with unsupported unit from statistics if state_unit not in valid_units: + if SEEN_CHANGED_UNIT not in hass.data: + hass.data[SEEN_CHANGED_UNIT] = set() + + if ( + len(states_by_unit) == 2 + and entity_id not in hass.data[SEEN_CHANGED_UNIT] + ): + is_unit_migration = True + continue + if WARN_UNSUPPORTED_UNIT not in hass.data: hass.data[WARN_UNSUPPORTED_UNIT] = set() if entity_id not in hass.data[WARN_UNSUPPORTED_UNIT]: @@ -379,6 +410,9 @@ def _normalize_states( valid_fstates.append((fstate, state)) + if is_unit_migration: + hass.data[SEEN_CHANGED_UNIT].add(entity_id) + return unit_class, statistics_unit, valid_fstates From b2209cbd6c8c2d6a4533a43a4847425d5adcb0cc Mon Sep 17 00:00:00 2001 From: Sab44 <64696149+Sab44@users.noreply.github.com> Date: Wed, 18 Feb 2026 20:45:43 +0100 Subject: [PATCH 2/4] simplify unit migration detection * requires new unit to belong to a unit_class --- homeassistant/components/sensor/recorder.py | 24 ++++++--------------- 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index cd4d16c4e35ca5..0a1b0d33507753 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -304,29 +304,11 @@ def _normalize_states( # and the unit in the state, so we can use the new unit class unit_class = new_unit_class - states_by_unit = [ - list(state) - for _, state in itertools.groupby( - fstates, key=lambda x: x[1].attributes.get(ATTR_UNIT_OF_MEASUREMENT) - ) - ] - if not (converter := _get_unit_converter(unit_class)): # The unit used by this sensor doesn't support unit conversion all_units = _get_units(fstates) if not _equivalent_units(all_units): - if SEEN_CHANGED_UNIT not in hass.data: - hass.data[SEEN_CHANGED_UNIT] = set() - - if ( - len(states_by_unit) == 2 - and state_unit == statistics_unit - and entity_id not in hass.data[SEEN_CHANGED_UNIT] - ): - hass.data[SEEN_CHANGED_UNIT].add(entity_id) - return unit_class, state_unit, states_by_unit[1] - if WARN_UNSTABLE_UNIT not in hass.data: hass.data[WARN_UNSTABLE_UNIT] = set() if entity_id not in hass.data[WARN_UNSTABLE_UNIT]: @@ -362,6 +344,12 @@ def _normalize_states( last_unit: str | None | UndefinedType = UNDEFINED valid_units = converter.VALID_UNITS is_unit_migration: bool = False + states_by_unit: list[list[tuple[float, State]]] = [ + list(states) + for _, states in itertools.groupby( + fstates, key=lambda x: x[1].attributes.get(ATTR_UNIT_OF_MEASUREMENT) + ) + ] for fstate, state in fstates: state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) From 1a4ca1b468872a05aadc147e24358d2c04391b72 Mon Sep 17 00:00:00 2001 From: Sab44 <64696149+Sab44@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:00:36 +0100 Subject: [PATCH 3/4] refactor; drop states before looping --- homeassistant/components/sensor/recorder.py | 45 +++++++++++---------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 0a1b0d33507753..8ab86467a95f8f 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -282,6 +282,7 @@ def _normalize_states( state_unit: str | None = None statistics_unit: str | None state_unit = fstates[-1][1].attributes.get(ATTR_UNIT_OF_MEASUREMENT) + all_units = _get_units(fstates) device_class = fstates[-1][1].attributes.get(ATTR_DEVICE_CLASS) old_metadata = old_metadatas[entity_id][1] if entity_id in old_metadatas else None if not old_metadata: @@ -307,7 +308,6 @@ def _normalize_states( if not (converter := _get_unit_converter(unit_class)): # The unit used by this sensor doesn't support unit conversion - all_units = _get_units(fstates) if not _equivalent_units(all_units): if WARN_UNSTABLE_UNIT not in hass.data: hass.data[WARN_UNSTABLE_UNIT] = set() @@ -339,32 +339,36 @@ def _normalize_states( ) return unit_class, state_unit, fstates + valid_units = converter.VALID_UNITS + + if any(unit not in valid_units for unit in all_units): + # Potential unit migration, drop states with unexpected units once + states_by_unit: list[list[tuple[float, State]]] = [ + list(states) + for _, states in itertools.groupby( + fstates, key=lambda x: x[1].attributes.get(ATTR_UNIT_OF_MEASUREMENT) + ) + ] + + if SEEN_CHANGED_UNIT not in hass.data: + hass.data[SEEN_CHANGED_UNIT] = set() + + if ( + len(states_by_unit) == 2 + and state_unit == statistics_unit + and entity_id not in hass.data[SEEN_CHANGED_UNIT] + ): + hass.data[SEEN_CHANGED_UNIT].add(entity_id) + fstates = states_by_unit[1] + valid_fstates: list[tuple[float, State]] = [] convert: Callable[[float], float] | None = None last_unit: str | None | UndefinedType = UNDEFINED - valid_units = converter.VALID_UNITS - is_unit_migration: bool = False - states_by_unit: list[list[tuple[float, State]]] = [ - list(states) - for _, states in itertools.groupby( - fstates, key=lambda x: x[1].attributes.get(ATTR_UNIT_OF_MEASUREMENT) - ) - ] for fstate, state in fstates: state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) # Exclude states with unsupported unit from statistics if state_unit not in valid_units: - if SEEN_CHANGED_UNIT not in hass.data: - hass.data[SEEN_CHANGED_UNIT] = set() - - if ( - len(states_by_unit) == 2 - and entity_id not in hass.data[SEEN_CHANGED_UNIT] - ): - is_unit_migration = True - continue - if WARN_UNSUPPORTED_UNIT not in hass.data: hass.data[WARN_UNSUPPORTED_UNIT] = set() if entity_id not in hass.data[WARN_UNSUPPORTED_UNIT]: @@ -398,9 +402,6 @@ def _normalize_states( valid_fstates.append((fstate, state)) - if is_unit_migration: - hass.data[SEEN_CHANGED_UNIT].add(entity_id) - return unit_class, statistics_unit, valid_fstates From 27a39ac62eaca7dc6384de2f9db4ec92bb5a610e Mon Sep 17 00:00:00 2001 From: Sab44 <64696149+Sab44@users.noreply.github.com> Date: Thu, 19 Feb 2026 08:00:13 +0100 Subject: [PATCH 4/4] refactor; move warnings for unsupported unit --- homeassistant/components/sensor/recorder.py | 25 ++++++++++++--------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 8ab86467a95f8f..8499b610659e34 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -342,7 +342,6 @@ def _normalize_states( valid_units = converter.VALID_UNITS if any(unit not in valid_units for unit in all_units): - # Potential unit migration, drop states with unexpected units once states_by_unit: list[list[tuple[float, State]]] = [ list(states) for _, states in itertools.groupby( @@ -358,17 +357,11 @@ def _normalize_states( and state_unit == statistics_unit and entity_id not in hass.data[SEEN_CHANGED_UNIT] ): + # Potential unit migration, silently drop states with unexpected units once hass.data[SEEN_CHANGED_UNIT].add(entity_id) fstates = states_by_unit[1] - - valid_fstates: list[tuple[float, State]] = [] - convert: Callable[[float], float] | None = None - last_unit: str | None | UndefinedType = UNDEFINED - - for fstate, state in fstates: - state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - # Exclude states with unsupported unit from statistics - if state_unit not in valid_units: + else: + # Exclude states with unsupported unit from statistics if WARN_UNSUPPORTED_UNIT not in hass.data: hass.data[WARN_UNSUPPORTED_UNIT] = set() if entity_id not in hass.data[WARN_UNSUPPORTED_UNIT]: @@ -386,8 +379,18 @@ def _normalize_states( statistics_unit, LINK_DEV_STATISTICS, ) - continue + fstates = [ + (fstate, state) + for fstate, state in fstates + if state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) in valid_units + ] + valid_fstates: list[tuple[float, State]] = [] + convert: Callable[[float], float] | None = None + last_unit: str | None | UndefinedType = UNDEFINED + + for fstate, state in fstates: + state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) if state_unit != last_unit: # The unit of measurement has changed since the last state change # recreate the converter factory