Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 52 additions & 3 deletions custom_components/smart_icons/injector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
)
113 changes: 113 additions & 0 deletions tests/test_injector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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