Compare commits

...

2 Commits

Author SHA1 Message Date
Erik fc0ae68f3c Add in_zones property to mobile_app device tracker 2026-05-22 08:00:01 +02:00
Erik f19a392d71 Add property in_zones to TrackerEntity 2026-05-21 21:02:51 +02:00
5 changed files with 348 additions and 11 deletions
@@ -207,6 +207,7 @@ class TrackerEntityDescription(EntityDescription, frozen_or_thawed=True):
CACHED_TRACKER_PROPERTIES_WITH_ATTR_ = {
"in_zones",
"latitude",
"location_accuracy",
"location_name",
@@ -220,6 +221,7 @@ class TrackerEntity(
"""Base class for a tracked device."""
entity_description: TrackerEntityDescription
_attr_in_zones: list[str] | None = None
_attr_latitude: float | None = None
_attr_location_accuracy: float = 0
_attr_location_name: str | None = None
@@ -239,6 +241,14 @@ class TrackerEntity(
"""All updates need to be written to the state machine if we're not polling."""
return not self.should_poll
@cached_property
def in_zones(self) -> list[str] | None:
"""Return the entity_id of zones the device is currently in.
Ignored if latitude and longitude are both set.
"""
return self._attr_in_zones
@cached_property
def location_accuracy(self) -> float:
"""Return the location accuracy of the device.
@@ -270,8 +280,9 @@ class TrackerEntity(
self.hass, self.latitude, self.longitude, self.location_accuracy
)
else:
self.__active_zone = None
self.__in_zones = None
zones = self.in_zones
self.__active_zone = None if not zones else self.hass.states.get(zones[0])
self.__in_zones = zones
super()._async_write_ha_state()
@property
@@ -280,7 +291,9 @@ class TrackerEntity(
if self.location_name is not None:
return self.location_name
if self.latitude is not None and self.longitude is not None:
if (
self.latitude is not None and self.longitude is not None
) or self.__in_zones is not None:
zone_state = self.__active_zone
if zone_state is None:
state = STATE_NOT_HOME
@@ -296,11 +309,10 @@ class TrackerEntity(
@property
def state_attributes(self) -> dict[str, Any]:
"""Return the device state attributes."""
attr: dict[str, Any] = {ATTR_IN_ZONES: []}
attr: dict[str, Any] = {ATTR_IN_ZONES: self.__in_zones or []}
attr.update(super().state_attributes)
if self.latitude is not None and self.longitude is not None:
attr[ATTR_IN_ZONES] = self.__in_zones or []
attr[ATTR_LATITUDE] = self.latitude
attr[ATTR_LONGITUDE] = self.longitude
attr[ATTR_GPS_ACCURACY] = self.location_accuracy
@@ -10,10 +10,12 @@ import voluptuous as vol
from homeassistant.components.device_tracker import (
ATTR_BATTERY,
ATTR_GPS,
ATTR_IN_ZONES,
ATTR_LOCATION_NAME,
TrackerEntity,
)
from homeassistant.components.zone import (
DOMAIN as ZONE_DOMAIN,
ENTITY_ID_FORMAT as ZONE_ENTITY_ID_FORMAT,
HOME_ZONE,
)
@@ -59,6 +61,7 @@ LOCATION_UPDATE_SCHEMA = vol.All(
vol.Optional(ATTR_ALTITUDE): vol.Coerce(float),
vol.Optional(ATTR_COURSE): cv.positive_int,
vol.Optional(ATTR_VERTICAL_ACCURACY): cv.positive_int,
vol.Optional(ATTR_IN_ZONES): cv.entities_domain(ZONE_DOMAIN),
},
),
)
@@ -126,6 +129,11 @@ class MobileAppEntity(TrackerEntity, RestoreEntity):
return attrs
@property
def in_zones(self) -> list[str] | None:
"""Return the zones the device is currently in."""
return self._data.get(ATTR_IN_ZONES)
@property
def location_accuracy(self) -> float:
"""Return the gps accuracy of the device."""
@@ -150,6 +158,11 @@ class MobileAppEntity(TrackerEntity, RestoreEntity):
@property
def location_name(self) -> str | None:
"""Return a location name for the current location of the device."""
if ATTR_IN_ZONES in self._data:
# New app sends in_zones as well as location_name. Prioritize in_zones
# and only use location_name for backwards compatibility with old
# app versions.
return None
if location_name := self._data.get(ATTR_LOCATION_NAME):
if location_name == HOME_ZONE:
return STATE_HOME
@@ -146,6 +146,7 @@ class MockTrackerEntity(TrackerEntity):
def __init__(
self,
battery_level: int | None = None,
in_zones: list[str] | None = None,
location_name: str | None = None,
latitude: float | None = None,
longitude: float | None = None,
@@ -153,6 +154,7 @@ class MockTrackerEntity(TrackerEntity):
) -> None:
"""Initialize entity."""
self._battery_level = battery_level
self._in_zones = in_zones
self._location_name = location_name
self._latitude = latitude
self._longitude = longitude
@@ -171,6 +173,11 @@ class MockTrackerEntity(TrackerEntity):
"""Return the source type, eg gps or router, of the device."""
return SourceType.GPS
@property
def in_zones(self) -> list[str] | None:
"""Return the entity_id of zones the device is currently in."""
return self._in_zones
@property
def location_name(self) -> str | None:
"""Return a location name for the current location of the device."""
@@ -198,6 +205,12 @@ def battery_level_fixture() -> int | None:
return None
@pytest.fixture(name="in_zones")
def in_zones_fixture() -> list[str] | None:
"""Return the in_zones value of the entity for the test."""
return None
@pytest.fixture(name="location_name")
def location_name_fixture() -> str | None:
"""Return the location_name of the entity for the test."""
@@ -226,6 +239,7 @@ def accuracy_fixture() -> float:
def tracker_entity_fixture(
entity_id: str,
battery_level: int | None,
in_zones: list[str] | None,
location_name: str | None,
latitude: float | None,
longitude: float | None,
@@ -234,6 +248,7 @@ def tracker_entity_fixture(
"""Create a test tracker entity."""
entity = MockTrackerEntity(
battery_level=battery_level,
in_zones=in_zones,
location_name=location_name,
latitude=latitude,
longitude=longitude,
@@ -464,6 +479,7 @@ async def test_load_unload_entry_tracker(
@pytest.mark.parametrize(
(
"battery_level",
"in_zones",
"location_name",
"latitude",
"longitude",
@@ -471,7 +487,8 @@ async def test_load_unload_entry_tracker(
"expected_attributes",
),
[
(
pytest.param(
None,
None,
None,
1.0,
@@ -484,8 +501,10 @@ async def test_load_unload_entry_tracker(
ATTR_LATITUDE: 1.0,
ATTR_LONGITUDE: 2.0,
},
id="lat_long_no_zone",
),
(
pytest.param(
None,
None,
None,
50.0,
@@ -498,8 +517,10 @@ async def test_load_unload_entry_tracker(
ATTR_LATITUDE: 50.0,
ATTR_LONGITUDE: 60.0,
},
id="lat_long_home",
),
(
pytest.param(
None,
None,
None,
-50.0,
@@ -512,8 +533,10 @@ async def test_load_unload_entry_tracker(
ATTR_LATITUDE: -50.0,
ATTR_LONGITUDE: -60.0,
},
id="lat_long_other_zone",
),
(
pytest.param(
None,
None,
"zen_zone",
None,
@@ -523,8 +546,10 @@ async def test_load_unload_entry_tracker(
ATTR_SOURCE_TYPE: SourceType.GPS,
ATTR_IN_ZONES: [],
},
id="location_name",
),
(
pytest.param(
None,
None,
None,
None,
@@ -534,18 +559,102 @@ async def test_load_unload_entry_tracker(
ATTR_SOURCE_TYPE: SourceType.GPS,
ATTR_IN_ZONES: [],
},
id="no_location",
),
(
pytest.param(
100,
None,
None,
None,
None,
STATE_UNKNOWN,
{
ATTR_BATTERY_LEVEL: 100,
ATTR_SOURCE_TYPE: SourceType.GPS,
ATTR_IN_ZONES: [],
},
id="battery_only",
),
pytest.param(
None,
["zone.home"],
None,
None,
None,
STATE_HOME,
{
ATTR_SOURCE_TYPE: SourceType.GPS,
ATTR_IN_ZONES: ["zone.home"],
},
id="in_zones_home",
),
pytest.param(
None,
["zone.other_zone"],
None,
None,
None,
"other zone",
{
ATTR_SOURCE_TYPE: SourceType.GPS,
ATTR_IN_ZONES: ["zone.other_zone"],
},
id="in_zones_other_zone",
),
pytest.param(
None,
["zone.other_zone", "zone.other_zone_larger"],
None,
None,
None,
"other zone",
{
ATTR_SOURCE_TYPE: SourceType.GPS,
ATTR_IN_ZONES: ["zone.other_zone", "zone.other_zone_larger"],
},
id="in_zones_multiple",
),
pytest.param(
None,
[],
None,
None,
None,
STATE_NOT_HOME,
{
ATTR_SOURCE_TYPE: SourceType.GPS,
ATTR_IN_ZONES: [],
},
id="in_zones_empty",
),
pytest.param(
None,
["zone.home"],
None,
1.0,
2.0,
STATE_NOT_HOME,
{
ATTR_SOURCE_TYPE: SourceType.GPS,
ATTR_GPS_ACCURACY: 0,
ATTR_IN_ZONES: [],
ATTR_LATITUDE: 1.0,
ATTR_LONGITUDE: 2.0,
},
id="in_zones_ignored_when_lat_long_set",
),
pytest.param(
None,
["zone.home"],
"zen_zone",
None,
None,
"zen_zone",
{
ATTR_SOURCE_TYPE: SourceType.GPS,
ATTR_IN_ZONES: ["zone.home"],
},
id="location_name_wins_over_in_zones",
),
],
)
@@ -674,6 +783,7 @@ def test_tracker_entity() -> None:
"""Test coverage for base TrackerEntity class."""
entity = TrackerEntity()
assert entity.source_type is SourceType.GPS
assert entity.in_zones is None
assert entity.latitude is None
assert entity.longitude is None
assert entity.location_name is None
@@ -138,6 +138,51 @@ async def setup_zone(hass: HomeAssistant) -> None:
},
"School",
),
# Send in_zones only - first zone determines state
(
{"in_zones": ["zone.home"]},
{"in_zones": ["zone.home"]},
"home",
),
(
{"in_zones": ["zone.office"]},
{"in_zones": ["zone.office"]},
"Office",
),
(
{"in_zones": ["zone.home", "zone.office"]},
{"in_zones": ["zone.home", "zone.office"]},
"home",
),
# Empty in_zones list - not_home
(
{"in_zones": []},
{"in_zones": []},
"not_home",
),
# in_zones + location_name: in_zones wins, location_name ignored
(
{"in_zones": ["zone.office"], "location_name": "home"},
{"in_zones": ["zone.office"]},
"Office",
),
# in_zones with empty list still suppresses location_name
(
{"in_zones": [], "location_name": "home"},
{"in_zones": []},
"not_home",
),
# in_zones + gps: gps wins, in_zones recomputed from coordinates
(
{"gps": [10, 20], "in_zones": ["zone.school"]},
{
"latitude": 10,
"longitude": 20,
"gps_accuracy": 30,
"in_zones": ["zone.home"],
},
"home",
),
],
)
async def test_sending_location(
@@ -341,6 +386,32 @@ async def test_restoring_location(
}
},
),
# in_zones only
(
{"in_zones": ["zone.office"]},
"Office",
{
"friendly_name": "Test 1",
"source_type": "gps",
"battery_level": 40,
"altitude": 50.0,
"course": 60,
"speed": 70,
"vertical_accuracy": 80,
"in_zones": ["zone.office"],
},
{
"data": {
"gps_accuracy": 30,
"battery": 40,
"altitude": 50.0,
"course": 60,
"speed": 70,
"vertical_accuracy": 80,
"in_zones": ["zone.office"],
}
},
),
],
)
async def test_saving_state(
@@ -454,6 +525,42 @@ async def test_saving_state(
"in_zones": [],
},
),
# Last update was an in_zones list (no coords)
(
{
"in_zones": ["zone.office"],
"battery": 40,
"altitude": 50.0,
"course": 60,
"speed": 70,
"vertical_accuracy": 80,
},
"Office",
{
"friendly_name": "Test 1",
"source_type": "gps",
"battery_level": 40,
"altitude": 50.0,
"course": 60,
"speed": 70,
"vertical_accuracy": 80,
"in_zones": ["zone.office"],
},
),
# Empty in_zones list - not_home
(
{
"in_zones": [],
"battery": 40,
},
"not_home",
{
"friendly_name": "Test 1",
"source_type": "gps",
"battery_level": 40,
"in_zones": [],
},
),
],
)
async def test_restoring_state(
@@ -602,6 +709,8 @@ async def test_restoring_state_legacy_fallback(
{"battery": -1},
# gps_accuracy rejected by cv.positive_float
{"gps_accuracy": "not-a-number"},
# in_zones contains a non-zone entity_id
{"in_zones": ["sensor.foo"]},
],
)
async def test_restoring_state_invalid_extra_data(
@@ -833,6 +833,99 @@ async def test_webhook_update_location_with_location_name(
assert state.state == STATE_NOT_HOME
async def test_webhook_update_location_with_in_zones(
hass: HomeAssistant,
create_registrations: tuple[dict[str, Any], dict[str, Any]],
webhook_client: TestClient,
) -> None:
"""Test that in_zones can be set via the update_location webhook."""
with patch(
"homeassistant.config.load_yaml_config_file",
autospec=True,
return_value={
ZONE_DOMAIN: [
{
"name": "zone_name",
"latitude": 1.23,
"longitude": -4.56,
"radius": 200,
"icon": "mdi:test-tube",
},
]
},
):
await hass.services.async_call(ZONE_DOMAIN, "reload", blocking=True)
resp = await webhook_client.post(
f"/api/webhook/{create_registrations[1]['webhook_id']}",
json={
"type": "update_location",
"data": {"in_zones": ["zone.zone_name"]},
},
)
assert resp.status == HTTPStatus.OK
state = hass.states.get("device_tracker.test_1_2")
assert state is not None
assert state.state == "zone_name"
assert state.attributes["in_zones"] == ["zone.zone_name"]
# Empty list reports not_home
resp = await webhook_client.post(
f"/api/webhook/{create_registrations[1]['webhook_id']}",
json={
"type": "update_location",
"data": {"in_zones": []},
},
)
assert resp.status == HTTPStatus.OK
state = hass.states.get("device_tracker.test_1_2")
assert state is not None
assert state.state == STATE_NOT_HOME
assert state.attributes["in_zones"] == []
async def test_webhook_update_location_in_zones_rejects_non_zone_entity(
hass: HomeAssistant,
create_registrations: tuple[dict[str, Any], dict[str, Any]],
webhook_client: TestClient,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that in_zones rejects entity_ids outside the zone domain."""
# First, set a valid state so we can verify it isn't overwritten by the
# rejected payload.
resp = await webhook_client.post(
f"/api/webhook/{create_registrations[1]['webhook_id']}",
json={
"type": "update_location",
"data": {"location_name": STATE_HOME},
},
)
assert resp.status == HTTPStatus.OK
state = hass.states.get("device_tracker.test_1_2")
assert state is not None
assert state.state == STATE_HOME
# Send a payload with an invalid in_zones entry; the webhook responds OK
# but the payload is dropped.
resp = await webhook_client.post(
f"/api/webhook/{create_registrations[1]['webhook_id']}",
json={
"type": "update_location",
"data": {"in_zones": ["sensor.not_a_zone"]},
},
)
assert resp.status == HTTPStatus.OK
assert "Received invalid webhook payload" in caplog.text
state = hass.states.get("device_tracker.test_1_2")
assert state is not None
assert state.state == STATE_HOME
async def test_webhook_enable_encryption(
hass: HomeAssistant,
create_registrations: tuple[dict[str, Any], dict[str, Any]],