Proximity: Fix/improve matching against trackers with in_zones attributes (#172602)

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Erik Montnemery <erik@montnemery.com>
This commit is contained in:
Keith Buck
2026-07-03 18:15:29 +00:00
committed by Franck Nijhof
co-authored by Copilot Autofix powered by AI Erik Montnemery
parent 368f160558
commit fe498d0374
2 changed files with 187 additions and 4 deletions
@@ -5,14 +5,17 @@ from dataclasses import dataclass
import logging
from typing import cast, override
from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN
from homeassistant.components.device_tracker import ATTR_IN_ZONES
from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN, ENTITY_ID_HOME
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_FRIENDLY_NAME,
ATTR_LATITUDE,
ATTR_LONGITUDE,
ATTR_NAME,
CONF_UNIT_OF_MEASUREMENT,
CONF_ZONE,
STATE_HOME,
)
from homeassistant.core import (
Event,
@@ -80,7 +83,6 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]):
self.tracked_entities: list[str] = config_entry.data[CONF_TRACKED_ENTITIES]
self.tolerance: int = config_entry.data[CONF_TOLERANCE]
self.proximity_zone_id: str = config_entry.data[CONF_ZONE]
self.proximity_zone_name: str = self.proximity_zone_id.split(".")[-1]
self.unit_of_measurement: str = config_entry.data.get(
CONF_UNIT_OF_MEASUREMENT, hass.config.units.length_unit
)
@@ -141,6 +143,40 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]):
},
)
def _device_in_zone(self, zone: State, device: State) -> bool:
"""Return whether the tracked entity is currently in the proximity zone."""
# Modern entity-based trackers and person entities always report zone
# membership authoritatively in the ``in_zones`` attribute (a list of zone
# entity IDs), so a present, empty list genuinely means "in no zone".
# The state-based fallback below is a temporary shim for two deprecated
# producers whose ``in_zones`` cannot be trusted as authoritative:
# - Legacy (non-entity) device trackers omit ``in_zones`` entirely
# (deprecated, removed in HA Core 2027.5).
# - Trackers using the deprecated ``location_name`` report an empty
# ``in_zones`` while their state still names their location
# (deprecated, removed in HA Core 2027.7).
# For both, an empty or absent ``in_zones`` does not imply "in no zone", so we
# fall back to matching the device state against the zone's friendly name
# (what a tracker's state is set to for non-home zones), plus an explicit
# home-zone check. Once both deprecations are gone, ``in_zones`` is
# authoritative for every tracker and this method should reduce to the
# membership check alone; the fallback must be removed, as second-guessing an
# empty list would then be incorrect.
if in_zones := device.attributes.get(ATTR_IN_ZONES):
return zone.entity_id in in_zones
# Remove once legacy device trackers (2027.5) and location_name (2027.7)
# are gone, see detailed comment above
zone_friendly_name = zone.attributes.get(ATTR_FRIENDLY_NAME)
return (
zone_friendly_name is not None
and device.state.lower() == zone_friendly_name.lower()
) or (device.state == STATE_HOME and zone.entity_id == ENTITY_ID_HOME)
def _calc_distance_to_zone(
self,
zone: State,
@@ -148,7 +184,7 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]):
latitude: float | None,
longitude: float | None,
) -> int | None:
if device.state.lower() == self.proximity_zone_name.lower():
if self._device_in_zone(zone, device):
_LOGGER.debug(
"%s: %s in zone -> distance=0",
self.name,
@@ -190,7 +226,7 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]):
new_latitude: float | None,
new_longitude: float | None,
) -> str | None:
if device.state.lower() == self.proximity_zone_name.lower():
if self._device_in_zone(zone, device):
_LOGGER.debug(
"%s: %s in zone -> direction_of_travel=arrived",
self.name,
+147
View File
@@ -1,5 +1,7 @@
"""The tests for the Proximity component."""
from typing import Any
import pytest
from homeassistant.components.proximity.const import (
@@ -329,6 +331,151 @@ async def test_device_trackers_in_zone(hass: HomeAssistant) -> None:
assert state.state == "arrived"
@pytest.mark.parametrize(
"tracker_attributes",
[
pytest.param({"in_zones": ["zone.work_office"]}, id="in_zones_attribute"),
pytest.param({"in_zones": []}, id="empty_in_zones_fallback"),
pytest.param({}, id="friendly_name_fallback"),
],
)
async def test_device_tracker_in_non_home_zone(
hass: HomeAssistant,
tracker_attributes: dict[str, Any],
) -> None:
"""Test that a tracker in a non-home zone reports arrived.
Regression test for zones whose friendly name (which the tracker reports as
its state) differs from the zone entity id slug, e.g. names with spaces or
mixed case. The tracker is placed far from the zone centre so that only the
zone-membership check, not the distance calculation, can yield "arrived".
"""
hass.states.async_set(
"zone.work_office",
"zoning",
{
"friendly_name": "Work Office",
"latitude": 2.3,
"longitude": 1.3,
"radius": 10,
},
)
await async_setup_single_entry(
hass, "zone.work_office", ["device_tracker.test1"], [], 1
)
hass.states.async_set(
"device_tracker.test1",
"Work Office",
{
"friendly_name": "test1",
"latitude": 20.1,
"longitude": 10.1,
**tracker_attributes,
},
)
await hass.async_block_till_done()
state = hass.states.get("sensor.home_nearest_device")
assert state.state == "test1"
entity_base_name = "sensor.home_test1"
state = hass.states.get(f"{entity_base_name}_distance")
assert state.state == "0"
state = hass.states.get(f"{entity_base_name}_direction_of_travel")
assert state.state == "arrived"
async def test_overlapping_zones_in_zones_reports_arrived(
hass: HomeAssistant,
) -> None:
"""Test overlapping proximity zones both report arrived via in_zones.
Regression test for trackers that report membership in multiple overlapping
zones via the in_zones attribute while their state only reflects one zone.
"""
hass.states.async_set(
"zone.backyard",
"zoning",
{
"friendly_name": "Backyard",
"latitude": 2.1,
"longitude": 1.1,
"radius": 10,
},
)
home_config = MockConfigEntry(
domain=DOMAIN,
title="Home",
data={
CONF_ZONE: "zone.home",
CONF_TRACKED_ENTITIES: ["device_tracker.test1"],
CONF_IGNORED_ZONES: [],
CONF_TOLERANCE: 1,
},
)
backyard_config = MockConfigEntry(
domain=DOMAIN,
title="Backyard",
data={
CONF_ZONE: "zone.backyard",
CONF_TRACKED_ENTITIES: ["device_tracker.test1"],
CONF_IGNORED_ZONES: [],
CONF_TOLERANCE: 1,
},
)
home_config.add_to_hass(hass)
assert await hass.config_entries.async_setup(home_config.entry_id)
backyard_config.add_to_hass(hass)
assert await hass.config_entries.async_setup(backyard_config.entry_id)
await hass.async_block_till_done()
hass.states.async_set(
"device_tracker.test1",
"home",
{
"friendly_name": "test1",
"latitude": 20.1,
"longitude": 10.1,
"in_zones": ["zone.home", "zone.backyard"],
},
)
await hass.async_block_till_done()
state = hass.states.get("sensor.home_test1_direction_of_travel")
assert state.state == "arrived"
state = hass.states.get("sensor.backyard_test1_direction_of_travel")
assert state.state == "arrived"
async def test_legacy_device_tracker_home_with_empty_in_zones(
hass: HomeAssistant,
) -> None:
"""Test legacy tracker with empty in_zones and home state reports arrived.
Regression test for legacy device trackers that do not populate in_zones
but still report their state as home when inside the home zone.
"""
await async_setup_single_entry(hass, "zone.home", ["device_tracker.test1"], [], 1)
hass.states.async_set(
"device_tracker.test1",
"home",
{
"friendly_name": "test1",
"latitude": 20.1,
"longitude": 10.1,
"in_zones": [],
},
)
await hass.async_block_till_done()
state = hass.states.get("sensor.home_test1_direction_of_travel")
assert state.state == "arrived"
async def test_device_tracker_test1_awayfurther_than_test2_first_test1(
hass: HomeAssistant, config_zones
) -> None: