From 0fb6237667a4349d0f4db93556d225c90d6fadbb Mon Sep 17 00:00:00 2001 From: John Pettitt Date: Tue, 26 May 2026 18:05:06 -0700 Subject: [PATCH] feat(injector): exclude smart_icons attrs from recorder persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass `state_info` with `unrecorded_attributes` on every async_set call so HA's recorder strips `smart_icons_color` and `smart_icons_background` before persisting state-changed events to the state_attributes table. The state_changed event still fires — the painter needs it for live updates — but the database-side payload shrinks, and adjacent state_attributes rows deduplicate better since the "real" entity attributes change less often than our decoration writes. Implementation notes: - `_SMART_ICONS_UNRECORDED_ATTRS` declares the keys to exclude. `ATTR_ICON` is intentionally NOT included — it's HA's standard glyph attribute that other integrations and templates may legitimately change, so recording its history is correct. - `_state_info_excluding_smart_icons` merges our exclusion set with the target's existing `unrecorded_attributes` (if any). Some integrations declare their own (e.g. a sensor that excludes `last_reset` from history); a bare override would re-enable recording of attrs the integration author chose to exclude. The merge preserves both. - Both write paths (`_apply_target` and `_release_target`) carry the same state_info — uniform contract; the recorder hint travels with every write the injector produces. This is a recorder-disk optimization, not a memory fix — it does not affect the in-memory event bus traffic, only the persisted row payload size and deduplication. Tests: - Asserts state_info carries our two attribute keys on a normal apply write. - Asserts the merge preserves the entity-owner's existing unrecorded_attributes declaration when present. - Asserts state_info also travels with the release write (consistency of contract). Co-Authored-By: Claude Opus 4.7 --- custom_components/smart_icons/injector.py | 55 ++++++++++- tests/test_injector.py | 113 ++++++++++++++++++++++ 2 files changed, 165 insertions(+), 3 deletions(-) diff --git a/custom_components/smart_icons/injector.py b/custom_components/smart_icons/injector.py index 68c9665..629a0a9 100644 --- a/custom_components/smart_icons/injector.py +++ b/custom_components/smart_icons/injector.py @@ -38,6 +38,18 @@ - Calling `hass.states.async_set` fires `state_changed`, which can wake automations that trigger on raw events. Most automations trigger on state transitions, not attribute changes; we accept the rare edge. + +Recorder hint: +- Every `async_set` call carries a `state_info` declaring + `smart_icons_color` and `smart_icons_background` as unrecorded + attributes (see `_state_info_excluding_smart_icons`). The recorder + reads this and strips those keys from the JSON it persists, so our + decoration writes don't bloat the state_attributes table on large + installs with broad glob rules. The `state_changed` event still + fires (the painter needs it); only the database-side payload + shrinks. The merge with any existing `unrecorded_attributes` is + load-bearing — entities with their own exclusions (e.g. sensor's + `last_reset`) would otherwise have those clobbered. """ from __future__ import annotations @@ -47,7 +59,8 @@ from typing import Any from homeassistant.const import EVENT_STATE_CHANGED -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, State, callback +from homeassistant.helpers.entity import StateInfo from homeassistant.helpers.event import async_track_state_change_event from .const import ATTR_ICON, ATTR_SMART_ICONS_BACKGROUND, ATTR_SMART_ICONS_COLOR @@ -57,6 +70,32 @@ _GLOB_CHARS = ("*", "?", "[") +# Attribute keys the injector writes that aren't worth recording. The +# recorder reads `state.state_info["unrecorded_attributes"]` and strips +# matching keys from the JSON it persists to the state_attributes table +# (see homeassistant/components/recorder/db_schema.py). Excluding our +# decoration attrs cuts a lot of recorder noise on large installs with +# broad glob rules — every state_changed event still fires (the painter +# needs it), but the recorder's stored row shrinks and adjacent +# state_attributes dedupe better. `ATTR_ICON` is HA's standard glyph +# attribute that other integrations and templates legitimately change, +# so we leave it recordable. +_SMART_ICONS_UNRECORDED_ATTRS: frozenset[str] = frozenset( + {ATTR_SMART_ICONS_COLOR, ATTR_SMART_ICONS_BACKGROUND} +) + + +def _state_info_excluding_smart_icons(current: State) -> StateInfo: + """Merge the entity's existing `unrecorded_attributes` (if any) with + our smart-icons-specific keys. Preserving the entity-owner's + declarations matters: some integrations declare their own + unrecorded attrs (e.g. `last_reset` on a sensor), and a bare + `state_info` from our `async_set` would clobber them, suddenly + recording attrs the integration author chose to exclude.""" + existing = current.state_info or {} + existing_unrec = existing.get("unrecorded_attributes") or frozenset() + return {"unrecorded_attributes": existing_unrec | _SMART_ICONS_UNRECORDED_ATTRS} + def _is_glob(entry: str) -> bool: return any(c in entry for c in _GLOB_CHARS) @@ -452,7 +491,12 @@ def _apply_target(self, target: str) -> None: if not changed: return - self._hass.states.async_set(target, current.state, new_attrs) + self._hass.states.async_set( + target, + current.state, + new_attrs, + state_info=_state_info_excluding_smart_icons(current), + ) self._injected_targets.add(target) @callback @@ -484,4 +528,9 @@ def _release_target(self, target: str) -> None: new_attrs.pop(ATTR_SMART_ICONS_BACKGROUND, None) if icon_is_ours: new_attrs.pop(ATTR_ICON, None) - self._hass.states.async_set(target, current.state, new_attrs) + self._hass.states.async_set( + target, + current.state, + new_attrs, + state_info=_state_info_excluding_smart_icons(current), + ) diff --git a/tests/test_injector.py b/tests/test_injector.py index 0798e08..f665cb2 100644 --- a/tests/test_injector.py +++ b/tests/test_injector.py @@ -849,3 +849,116 @@ async def test_injector_does_not_clobber_source_overwritten_icon( state = hass.states.get("light.kitchen") assert state.attributes[ATTR_ICON] == "mdi:source-owned-icon" + + +async def test_injector_marks_smart_icons_attrs_as_unrecorded( + hass, config_entry # noqa: ARG001 +): + """Each paint write should carry a state_info that tells the + recorder to skip `smart_icons_color` and `smart_icons_background`. + The state_changed event still fires (the painter needs it) but the + recorder strips those keys from the JSON it persists, reducing + state_attributes-table bloat on installs with broad glob rules.""" + hass.states.async_set("input_select.scene", "movie") + hass.states.async_set("light.kitchen", "on") + store = hass.data[DOMAIN][DATA_STORE] + + await store.async_upsert( + { + "target": "light.kitchen", + "source": "input_select.scene", + "mode": "mapping", + "mapping": { + "movie": { + "color": "#ffff00", + "background_color": "#43a047", + }, + }, + } + ) + await hass.async_block_till_done() + + state = hass.states.get("light.kitchen") + assert state.state_info is not None + unrec = state.state_info.get("unrecorded_attributes", frozenset()) + assert ATTR_SMART_ICONS_COLOR in unrec + assert ATTR_SMART_ICONS_BACKGROUND in unrec + # ATTR_ICON is HA's standard mechanism — other integrations may + # legitimately change it, so we leave it recordable. + assert ATTR_ICON not in unrec + + +async def test_injector_preserves_entity_owners_unrecorded_attrs( + hass, config_entry # noqa: ARG001 +): + """When an entity declares its own unrecorded_attributes (e.g. a + sensor that excludes `last_reset` from history), our `async_set` + must MERGE our exclusion set with the entity's existing one rather + than replacing it — otherwise we'd silently re-enable recording + of attributes the entity author chose to exclude. + """ + from homeassistant.helpers.entity import StateInfo + + # Simulate an entity that has its own unrecorded_attributes set. + entity_unrec = frozenset({"some_owner_attr"}) + entity_state_info: StateInfo = {"unrecorded_attributes": entity_unrec} + hass.states.async_set( + "light.kitchen", + "on", + state_info=entity_state_info, + ) + hass.states.async_set("input_select.scene", "movie") + store = hass.data[DOMAIN][DATA_STORE] + + await store.async_upsert( + { + "target": "light.kitchen", + "source": "input_select.scene", + "mode": "mapping", + "mapping": {"movie": {"color": "#ffff00"}}, + } + ) + await hass.async_block_till_done() + + state = hass.states.get("light.kitchen") + unrec = state.state_info.get("unrecorded_attributes", frozenset()) + # The entity's existing declaration survives our overwrite. + assert "some_owner_attr" in unrec + # Our exclusions are added on top. + assert ATTR_SMART_ICONS_COLOR in unrec + assert ATTR_SMART_ICONS_BACKGROUND in unrec + + +async def test_injector_marks_unrecorded_attrs_on_release_too( + hass, config_entry # noqa: ARG001 +): + """The release path also carries the state_info hint. Strictly the + release write doesn't include our attrs in the new payload, so the + recorder hint is redundant for that specific row — but keeping the + hint uniform across both write paths makes the contract simpler + and matches what subsequent re-applies would write.""" + hass.states.async_set("input_select.scene", "movie") + hass.states.async_set("light.kitchen", "on") + store = hass.data[DOMAIN][DATA_STORE] + + rule = await store.async_upsert( + { + "target": "light.kitchen", + "source": "input_select.scene", + "mode": "mapping", + "mapping": {"movie": {"color": "#ffff00"}}, + } + ) + await hass.async_block_till_done() + + await store.async_delete(rule.id) + await hass.async_block_till_done() + + state = hass.states.get("light.kitchen") + # Color attribute is cleared on release. + assert ATTR_SMART_ICONS_COLOR not in state.attributes + # state_info is still set — recorder hint travels with every write. + assert state.state_info is not None + unrec = state.state_info.get("unrecorded_attributes", frozenset()) + assert ATTR_SMART_ICONS_COLOR in unrec + assert ATTR_SMART_ICONS_BACKGROUND in unrec