Compare commits

...

1 Commits

Author SHA1 Message Date
Erik 1d58d51e51 Add entity option to associate scanner tracker with any zone 2026-05-25 18:29:11 +02:00
5 changed files with 461 additions and 7 deletions
@@ -31,6 +31,7 @@ from .const import ( # noqa: F401
ATTR_LOCATION_NAME,
ATTR_MAC,
ATTR_SOURCE_TYPE,
CONF_ASSOCIATED_ZONE,
CONF_CONSIDER_HOME,
CONF_NEW_DEVICE_DEFAULTS,
CONF_SCAN_INTERVAL,
@@ -17,8 +17,19 @@ from homeassistant.const import (
STATE_NOT_HOME,
EntityCategory,
)
from homeassistant.core import Event, HomeAssistant, State, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.core import (
CALLBACK_TYPE,
Event,
EventStateChangedData,
HomeAssistant,
State,
callback,
)
from homeassistant.helpers import (
device_registry as dr,
entity_registry as er,
issue_registry as ir,
)
from homeassistant.helpers.device_registry import (
DeviceInfo,
EventDeviceRegistryUpdatedData,
@@ -27,6 +38,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.entity_platform import EntityPlatform
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.typing import StateType
from homeassistant.util.hass_dict import HassKey
@@ -36,6 +48,7 @@ from .const import (
ATTR_IP,
ATTR_MAC,
ATTR_SOURCE_TYPE,
CONF_ASSOCIATED_ZONE,
CONNECTED_DEVICE_REGISTERED,
DOMAIN,
LOGGER,
@@ -343,14 +356,116 @@ class BaseScannerEntity(BaseTrackerEntity):
addresses being used to identify the device.
"""
_scanner_option_associated_zone: str = zone.ENTITY_ID_HOME
_scanner_option_associated_zone_unsub: CALLBACK_TYPE | None = None
async def async_internal_added_to_hass(self) -> None:
"""Call when the scanner entity is added to hass."""
await super().async_internal_added_to_hass()
if not self.registry_entry:
return
self._async_read_entity_options()
async def async_internal_will_remove_from_hass(self) -> None:
"""Call when the scanner entity is about to be removed from hass."""
if self._scanner_option_associated_zone_unsub is not None:
self._scanner_option_associated_zone_unsub()
self._scanner_option_associated_zone_unsub = None
self._async_clear_associated_zone_issue()
await super().async_internal_will_remove_from_hass()
@callback
def async_registry_entry_updated(self) -> None:
"""Run when the entity registry entry has been updated."""
self._async_read_entity_options()
@callback
def _async_read_entity_options(self) -> None:
"""Read entity options from the entity registry.
Called when the entity registry entry has been updated and before the
scanner entity is added to the state machine.
"""
assert self.registry_entry
if (scanner_options := self.registry_entry.options.get(DOMAIN)) and (
associated_zone := scanner_options.get(CONF_ASSOCIATED_ZONE)
):
new_zone = associated_zone
else:
new_zone = zone.ENTITY_ID_HOME
if new_zone == self._scanner_option_associated_zone:
return
# Tear down tracking for the previous zone.
if self._scanner_option_associated_zone_unsub is not None:
self._scanner_option_associated_zone_unsub()
self._scanner_option_associated_zone_unsub = None
self._async_clear_associated_zone_issue()
self._scanner_option_associated_zone = new_zone
# zone.home is always present so no tracking or issue handling needed.
if new_zone == zone.ENTITY_ID_HOME:
return
self._scanner_option_associated_zone_unsub = async_track_state_change_event(
self.hass, new_zone, self._async_associated_zone_state_changed
)
if self.hass.states.get(new_zone) is None:
self._async_create_associated_zone_issue()
@callback
def _async_associated_zone_state_changed(
self, event: Event[EventStateChangedData]
) -> None:
"""Open or clear the repair issue when the associated zone appears or disappears."""
if event.data["new_state"] is None:
self._async_create_associated_zone_issue()
else:
self._async_clear_associated_zone_issue()
self.async_write_ha_state()
@callback
def _async_create_associated_zone_issue(self) -> None:
"""Create a repair issue prompting the user to reconfigure the scanner."""
ir.async_create_issue(
self.hass,
DOMAIN,
self._associated_zone_issue_id,
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key="associated_zone_missing",
translation_placeholders={
"entity_id": self.entity_id,
"zone": self._scanner_option_associated_zone,
},
)
@callback
def _async_clear_associated_zone_issue(self) -> None:
"""Clear the associated-zone-missing repair issue if it exists."""
ir.async_delete_issue(self.hass, DOMAIN, self._associated_zone_issue_id)
@property
def _associated_zone_issue_id(self) -> str:
"""Return the issue id for the associated-zone-missing repair."""
return f"associated_zone_missing_{self.entity_id}"
@property
def state(self) -> str | None:
"""Return the state of the device."""
if self.is_connected is None:
return None
if self.is_connected:
if not self.is_connected:
return STATE_NOT_HOME
associated_zone = self._scanner_option_associated_zone
if associated_zone == zone.ENTITY_ID_HOME:
return STATE_HOME
return STATE_NOT_HOME
if zone_state := self.hass.states.get(associated_zone):
return zone_state.name
# Configured zone has been removed; state is unknown.
return None
@property
def is_connected(self) -> bool | None:
@@ -367,9 +482,18 @@ class BaseScannerEntity(BaseTrackerEntity):
if not self.is_connected:
return attr
associated_zone = self._scanner_option_associated_zone
# If the configured zone has been removed, in_zones stays empty so the
# attribute does not claim membership in a zone that no longer exists.
if (
associated_zone != zone.ENTITY_ID_HOME
and self.hass.states.get(associated_zone) is None
):
return attr
attr[ATTR_IN_ZONES] = [
zone.ENTITY_ID_HOME,
*zone.async_get_enclosing_zones(self.hass, zone.ENTITY_ID_HOME),
associated_zone,
*zone.async_get_enclosing_zones(self.hass, associated_zone),
]
return attr
@@ -36,6 +36,8 @@ DEFAULT_CONSIDER_HOME: Final = timedelta(seconds=180)
CONF_NEW_DEVICE_DEFAULTS: Final = "new_device_defaults"
CONF_ASSOCIATED_ZONE: Final = "associated_zone"
ATTR_ATTRIBUTES: Final = "attributes"
ATTR_BATTERY: Final = "battery"
ATTR_DEV_ID: Final = "dev_id"
@@ -44,6 +44,12 @@
}
}
},
"issues": {
"associated_zone_missing": {
"description": "The scanner entity `{entity_id}` is associated with the zone `{zone}`, but that zone has been removed.\n\nTo fix this, reconfigure the scanner to use a different zone or recreate the missing zone.",
"title": "Scanner is associated with a removed zone"
}
},
"services": {
"see": {
"description": "Manually update the records of a seen legacy device tracker in the known_devices.yaml file.",
@@ -11,6 +11,7 @@ from homeassistant.components.device_tracker import (
ATTR_IP,
ATTR_MAC,
ATTR_SOURCE_TYPE,
CONF_ASSOCIATED_ZONE,
DOMAIN,
SourceType,
)
@@ -35,7 +36,11 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers import (
device_registry as dr,
entity_registry as er,
issue_registry as ir,
)
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -893,6 +898,322 @@ async def test_base_scanner_entity_in_zones_when_connected(
}
@pytest.mark.parametrize("unique_id", ["unique_scanner"])
async def test_base_scanner_entity_associated_zone_option(
hass: HomeAssistant,
config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
entity_id: str,
base_scanner_entity: MockBaseScannerEntity,
) -> None:
"""Test the associated_zone entity option overrides which zone in_zones reports.
The scanner reports being connected to a non-default zone; state and in_zones
must follow the configured zone, and a zone enclosing the configured one is
included in in_zones too.
"""
hass.states.async_set(
"zone.home",
"0",
{ATTR_LATITUDE: 50.0, ATTR_LONGITUDE: 60.0, ATTR_RADIUS: 1000},
)
hass.states.async_set(
"zone.kitchen",
"0",
{ATTR_LATITUDE: 50.0, ATTR_LONGITUDE: 60.0, ATTR_RADIUS: 50},
)
await hass.async_block_till_done()
base_scanner_entity._connected = True
config_entry = await create_mock_platform(hass, config_entry, [base_scanner_entity])
assert config_entry.state is ConfigEntryState.LOADED
# Default: no option set -> associated with zone.home.
entity_state = hass.states.get(entity_id)
assert entity_state
assert entity_state.state == STATE_HOME
assert entity_state.attributes[ATTR_IN_ZONES] == ["zone.home"]
# Set the option -> associated_zone replaces zone.home; zone.home now shows
# up via the enclosing-zones lookup.
entity_registry.async_update_entity_options(
entity_id,
DOMAIN,
{CONF_ASSOCIATED_ZONE: "zone.kitchen"},
)
await hass.async_block_till_done()
assert base_scanner_entity._scanner_option_associated_zone == "zone.kitchen"
entity_state = hass.states.get(entity_id)
assert entity_state
# zone.kitchen is the configured zone -> state is the zone's name.
assert entity_state.state == "kitchen"
assert entity_state.attributes[ATTR_IN_ZONES] == ["zone.kitchen", "zone.home"]
# Clearing the option falls back to zone.home.
entity_registry.async_update_entity_options(entity_id, DOMAIN, None)
await hass.async_block_till_done()
assert base_scanner_entity._scanner_option_associated_zone == "zone.home"
entity_state = hass.states.get(entity_id)
assert entity_state
assert entity_state.state == STATE_HOME
assert entity_state.attributes[ATTR_IN_ZONES] == ["zone.home"]
@pytest.mark.parametrize("unique_id", ["unique_scanner"])
async def test_base_scanner_entity_associated_zone_removed_after_set(
hass: HomeAssistant,
config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
issue_registry: ir.IssueRegistry,
entity_id: str,
base_scanner_entity: MockBaseScannerEntity,
) -> None:
"""Test scanner state and repair issue when associated zone is removed.
When the user picks a zone via the associated_zone option and then deletes
that zone, the scanner falls back to ``state == "unknown"`` and a repair
issue is opened prompting the user to reconfigure.
"""
hass.states.async_set(
"zone.home",
"0",
{ATTR_LATITUDE: 50.0, ATTR_LONGITUDE: 60.0, ATTR_RADIUS: 1000},
)
hass.states.async_set(
"zone.kitchen",
"0",
{ATTR_LATITUDE: 50.0, ATTR_LONGITUDE: 60.0, ATTR_RADIUS: 50},
)
await hass.async_block_till_done()
base_scanner_entity._connected = True
config_entry = await create_mock_platform(hass, config_entry, [base_scanner_entity])
assert config_entry.state is ConfigEntryState.LOADED
entity_registry.async_update_entity_options(
entity_id,
DOMAIN,
{CONF_ASSOCIATED_ZONE: "zone.kitchen"},
)
await hass.async_block_till_done()
# Sanity check before removal.
entity_state = hass.states.get(entity_id)
assert entity_state
assert entity_state.state == "kitchen"
assert entity_state.attributes[ATTR_IN_ZONES] == ["zone.kitchen", "zone.home"]
issue_id = f"associated_zone_missing_{entity_id}"
assert issue_registry.async_get_issue(DOMAIN, issue_id) is None
# Remove the associated zone.
hass.states.async_remove("zone.kitchen")
await hass.async_block_till_done()
entity_state = hass.states.get(entity_id)
assert entity_state
assert entity_state.state == STATE_UNKNOWN
assert entity_state.attributes[ATTR_IN_ZONES] == []
issue = issue_registry.async_get_issue(DOMAIN, issue_id)
assert issue is not None
assert issue.severity is ir.IssueSeverity.WARNING
assert issue.translation_key == "associated_zone_missing"
assert issue.translation_placeholders == {
"entity_id": entity_id,
"zone": "zone.kitchen",
}
# Restore the zone -> issue is cleared, state recovers.
hass.states.async_set(
"zone.kitchen",
"0",
{ATTR_LATITUDE: 50.0, ATTR_LONGITUDE: 60.0, ATTR_RADIUS: 50},
)
await hass.async_block_till_done()
entity_state = hass.states.get(entity_id)
assert entity_state
assert entity_state.state == "kitchen"
assert entity_state.attributes[ATTR_IN_ZONES] == ["zone.kitchen", "zone.home"]
assert issue_registry.async_get_issue(DOMAIN, issue_id) is None
@pytest.mark.parametrize("unique_id", ["unique_scanner"])
async def test_base_scanner_entity_associated_zone_missing_at_setup(
hass: HomeAssistant,
config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
issue_registry: ir.IssueRegistry,
entity_id: str,
base_scanner_entity: MockBaseScannerEntity,
) -> None:
"""Test repair issue is created when the configured zone is missing at setup."""
hass.states.async_set(
"zone.home",
"0",
{ATTR_LATITUDE: 50.0, ATTR_LONGITUDE: 60.0, ATTR_RADIUS: 1000},
)
await hass.async_block_till_done()
# Pre-register the entity option pointing at a zone that does not exist.
entity_registry.async_get_or_create(
DOMAIN,
TEST_DOMAIN,
base_scanner_entity.unique_id,
suggested_object_id="entity1",
)
entity_registry.async_update_entity_options(
entity_id,
DOMAIN,
{CONF_ASSOCIATED_ZONE: "zone.never_existed"},
)
base_scanner_entity._connected = True
config_entry = await create_mock_platform(hass, config_entry, [base_scanner_entity])
assert config_entry.state is ConfigEntryState.LOADED
entity_state = hass.states.get(entity_id)
assert entity_state
assert entity_state.state == STATE_UNKNOWN
assert entity_state.attributes[ATTR_IN_ZONES] == []
issue_id = f"associated_zone_missing_{entity_id}"
issue = issue_registry.async_get_issue(DOMAIN, issue_id)
assert issue is not None
assert issue.translation_placeholders == {
"entity_id": entity_id,
"zone": "zone.never_existed",
}
@pytest.mark.parametrize("unique_id", ["unique_scanner"])
async def test_base_scanner_entity_associated_zone_issue_cleared_on_option_change(
hass: HomeAssistant,
config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
issue_registry: ir.IssueRegistry,
entity_id: str,
base_scanner_entity: MockBaseScannerEntity,
) -> None:
"""Test the repair issue is cleared when the user clears the option."""
hass.states.async_set(
"zone.home",
"0",
{ATTR_LATITUDE: 50.0, ATTR_LONGITUDE: 60.0, ATTR_RADIUS: 1000},
)
await hass.async_block_till_done()
base_scanner_entity._connected = True
config_entry = await create_mock_platform(hass, config_entry, [base_scanner_entity])
assert config_entry.state is ConfigEntryState.LOADED
entity_registry.async_update_entity_options(
entity_id,
DOMAIN,
{CONF_ASSOCIATED_ZONE: "zone.never_existed"},
)
await hass.async_block_till_done()
issue_id = f"associated_zone_missing_{entity_id}"
assert issue_registry.async_get_issue(DOMAIN, issue_id) is not None
# Clearing the option restores the default and clears the repair issue.
entity_registry.async_update_entity_options(entity_id, DOMAIN, None)
await hass.async_block_till_done()
assert issue_registry.async_get_issue(DOMAIN, issue_id) is None
entity_state = hass.states.get(entity_id)
assert entity_state
assert entity_state.state == STATE_HOME
@pytest.mark.parametrize("unique_id", ["unique_scanner"])
async def test_base_scanner_entity_associated_zone_issue_cleared_on_unload(
hass: HomeAssistant,
config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
issue_registry: ir.IssueRegistry,
entity_id: str,
base_scanner_entity: MockBaseScannerEntity,
) -> None:
"""Test the repair issue is cleared when the entity is removed from hass."""
hass.states.async_set(
"zone.home",
"0",
{ATTR_LATITUDE: 50.0, ATTR_LONGITUDE: 60.0, ATTR_RADIUS: 1000},
)
await hass.async_block_till_done()
base_scanner_entity._connected = True
config_entry = await create_mock_platform(hass, config_entry, [base_scanner_entity])
assert config_entry.state is ConfigEntryState.LOADED
entity_registry.async_update_entity_options(
entity_id,
DOMAIN,
{CONF_ASSOCIATED_ZONE: "zone.never_existed"},
)
await hass.async_block_till_done()
issue_id = f"associated_zone_missing_{entity_id}"
assert issue_registry.async_get_issue(DOMAIN, issue_id) is not None
assert await hass.config_entries.async_unload(config_entry.entry_id)
await hass.async_block_till_done()
assert issue_registry.async_get_issue(DOMAIN, issue_id) is None
@pytest.mark.parametrize("unique_id", ["unique_scanner"])
async def test_base_scanner_entity_associated_zone_option_set_before_add(
hass: HomeAssistant,
config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
entity_id: str,
base_scanner_entity: MockBaseScannerEntity,
) -> None:
"""Test associated_zone option set before the entity is added is honored."""
hass.states.async_set(
"zone.home",
"0",
{ATTR_LATITUDE: 50.0, ATTR_LONGITUDE: 60.0, ATTR_RADIUS: 1000},
)
hass.states.async_set(
"zone.kitchen",
"0",
{ATTR_LATITUDE: 50.0, ATTR_LONGITUDE: 60.0, ATTR_RADIUS: 50},
)
await hass.async_block_till_done()
# Pre-register the entity with the option set before the platform is set up.
entity_registry.async_get_or_create(
DOMAIN,
TEST_DOMAIN,
base_scanner_entity.unique_id,
suggested_object_id="entity1",
)
entity_registry.async_update_entity_options(
entity_id,
DOMAIN,
{CONF_ASSOCIATED_ZONE: "zone.kitchen"},
)
base_scanner_entity._connected = True
config_entry = await create_mock_platform(hass, config_entry, [base_scanner_entity])
assert config_entry.state is ConfigEntryState.LOADED
assert base_scanner_entity._scanner_option_associated_zone == "zone.kitchen"
entity_state = hass.states.get(entity_id)
assert entity_state
assert entity_state.state == "kitchen"
assert entity_state.attributes[ATTR_IN_ZONES] == ["zone.kitchen", "zone.home"]
@pytest.mark.parametrize(
("ip_address", "mac_address", "hostname"),
[("0.0.0.0", "ad:de:ef:be:ed:fe", "test.hostname.org")],