fix: gate entity registration on field presence by default

_is_included now checks that an entity's field exists in the resource
rep before registering it, preventing phantom entities when optional
fields are absent on a shared resource. Explicit exists_fn still takes
priority for cases requiring custom logic. All platforms use the shared
helper; select.py's inline duplicate removed.
This commit is contained in:
Marc Billow
2026-06-30 16:59:45 -05:00
parent da573a725f
commit 9ea4ddd3a3
8 changed files with 29 additions and 25 deletions
@@ -10,7 +10,7 @@ from .ocf.registry.entities import BinarySensorDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity
from .entity import LocalThingsEntity, _is_included
async def async_setup_entry(
@@ -22,7 +22,7 @@ async def async_setup_entry(
async_add_entities(
LocalThingsBinarySensor(coordinator, b)
for b in coordinator.bound
if isinstance(b.desc, BinarySensorDesc)
if isinstance(b.desc, BinarySensorDesc) and _is_included(b, coordinator)
)
+2 -2
View File
@@ -10,7 +10,7 @@ from .ocf.registry.entities import ButtonDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity
from .entity import LocalThingsEntity, _is_included
async def async_setup_entry(
@@ -22,7 +22,7 @@ async def async_setup_entry(
async_add_entities(
LocalThingsButton(coordinator, b)
for b in coordinator.bound
if isinstance(b.desc, ButtonDesc)
if isinstance(b.desc, ButtonDesc) and _is_included(b, coordinator)
)
+15
View File
@@ -14,6 +14,21 @@ from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
def _is_included(bound: BoundEntity, coordinator: 'LocalThingsCoordinator') -> bool:
"""Return False if the entity should not be registered for this device.
Explicit exists_fn takes priority. Otherwise, if the entity has a field,
require that field to be present in the resource rep so that optional
fields on shared resources don't create phantom entities.
"""
rep = coordinator.last_resources.get(bound.href) or {}
if bound.desc.exists_fn is not None:
return bound.desc.exists_fn(rep)
if bound.desc.field:
return bound.desc.field in rep
return True # rep_fn or no-field entities (ButtonDesc) are always included
def _derive_name(state_key: str) -> str:
"""Turn a snake_case state key into a title-cased display name.
+2 -2
View File
@@ -10,7 +10,7 @@ from .ocf.registry.entities import NumberDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity
from .entity import LocalThingsEntity, _is_included
async def async_setup_entry(
@@ -22,7 +22,7 @@ async def async_setup_entry(
async_add_entities(
LocalThingsNumber(coordinator, b)
for b in coordinator.bound
if isinstance(b.desc, NumberDesc)
if isinstance(b.desc, NumberDesc) and _is_included(b, coordinator)
)
+2 -13
View File
@@ -10,7 +10,7 @@ from .ocf.registry.entities import SelectDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity
from .entity import LocalThingsEntity, _is_included
async def async_setup_entry(
@@ -19,21 +19,10 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id]
def _include(b) -> bool:
if not isinstance(b.desc, SelectDesc):
return False
# Skip at registration time if exists_fn says the entity isn't present
if b.desc.exists_fn is not None:
rep = coordinator.last_resources.get(b.href) or {}
if not b.desc.exists_fn(rep):
return False
return True
async_add_entities(
LocalThingsSelect(coordinator, b)
for b in coordinator.bound
if _include(b)
if isinstance(b.desc, SelectDesc) and _is_included(b, coordinator)
)
+2 -2
View File
@@ -10,7 +10,7 @@ from .ocf.registry.entities import SensorDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity
from .entity import LocalThingsEntity, _is_included
async def async_setup_entry(
@@ -22,7 +22,7 @@ async def async_setup_entry(
async_add_entities(
LocalThingsSensor(coordinator, b)
for b in coordinator.bound
if isinstance(b.desc, SensorDesc)
if isinstance(b.desc, SensorDesc) and _is_included(b, coordinator)
)
+2 -2
View File
@@ -10,7 +10,7 @@ from .ocf.registry.entities import SwitchDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity
from .entity import LocalThingsEntity, _is_included
async def async_setup_entry(
@@ -22,7 +22,7 @@ async def async_setup_entry(
async_add_entities(
LocalThingsSwitch(coordinator, b)
for b in coordinator.bound
if isinstance(b.desc, SwitchDesc)
if isinstance(b.desc, SwitchDesc) and _is_included(b, coordinator)
)
+2 -2
View File
@@ -12,7 +12,7 @@ from .ocf.registry.entities import TimeDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity
from .entity import LocalThingsEntity, _is_included
async def async_setup_entry(
@@ -24,7 +24,7 @@ async def async_setup_entry(
async_add_entities(
LocalThingsTime(coordinator, b)
for b in coordinator.bound
if isinstance(b.desc, TimeDesc)
if isinstance(b.desc, TimeDesc) and _is_included(b, coordinator)
)