mirror of
https://github.com/home-assistant/core.git
synced 2026-05-23 09:15:45 +02:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fc0ae68f3c | |||
| f19a392d71 |
@@ -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]],
|
||||
|
||||
Reference in New Issue
Block a user