mirror of
https://github.com/home-assistant/core.git
synced 2026-08-24 10:13:52 -05:00
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:
co-authored by
Copilot Autofix powered by AI
Erik Montnemery
parent
368f160558
commit
fe498d0374
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user