1 """Support for the definition of zones."""
3 from __future__
import annotations
5 from collections.abc
import Callable, Iterable
7 from operator
import attrgetter
9 from typing
import Any, Self, cast
11 import voluptuous
as vol
13 from homeassistant
import config_entries
25 EVENT_CORE_CONFIG_UPDATE,
34 EventStateChangedData,
42 config_validation
as cv,
52 from .const
import ATTR_PASSIVE, ATTR_RADIUS, CONF_PASSIVE, DOMAIN, HOME_ZONE
54 _LOGGER = logging.getLogger(__name__)
56 DEFAULT_PASSIVE =
False
59 ENTITY_ID_FORMAT =
"zone.{}"
60 ENTITY_ID_HOME = ENTITY_ID_FORMAT.format(HOME_ZONE)
62 ICON_HOME =
"mdi:home"
63 ICON_IMPORT =
"mdi:import"
65 CREATE_FIELDS: VolDictType = {
66 vol.Required(CONF_NAME): cv.string,
67 vol.Required(CONF_LATITUDE): cv.latitude,
68 vol.Required(CONF_LONGITUDE): cv.longitude,
69 vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS): vol.Coerce(float),
70 vol.Optional(CONF_PASSIVE, default=DEFAULT_PASSIVE): cv.boolean,
71 vol.Optional(CONF_ICON): cv.icon,
75 UPDATE_FIELDS: VolDictType = {
76 vol.Optional(CONF_NAME): cv.string,
77 vol.Optional(CONF_LATITUDE): cv.latitude,
78 vol.Optional(CONF_LONGITUDE): cv.longitude,
79 vol.Optional(CONF_RADIUS): vol.Coerce(float),
80 vol.Optional(CONF_PASSIVE): cv.boolean,
81 vol.Optional(CONF_ICON): cv.icon,
86 """Test if the user has the default config value from adding "zone:"."""
87 if isinstance(value, dict)
and len(value) == 0:
90 raise vol.Invalid(
"Not a default value")
93 CONFIG_SCHEMA = vol.Schema(
95 vol.Optional(DOMAIN, default=[]): vol.Any(
96 vol.All(cv.ensure_list, [vol.Schema(CREATE_FIELDS)]),
100 extra=vol.ALLOW_EXTRA,
103 RELOAD_SERVICE_SCHEMA = vol.Schema({})
107 ENTITY_ID_SORTER = attrgetter(
"entity_id")
109 ZONE_ENTITY_IDS =
"zone_entity_ids"
114 hass: HomeAssistant, latitude: float, longitude: float, radius: int = 0
116 """Find the active zone for given latitude, longitude.
118 This method must be run in the event loop.
121 min_dist: float = sys.maxsize
122 closest: State |
None =
None
125 zone_entity_ids: Iterable[str] = hass.data.get(ZONE_ENTITY_IDS, ())
127 for entity_id
in zone_entity_ids:
129 not (zone := hass.states.get(entity_id))
131 or zone.state == STATE_UNAVAILABLE
133 or (zone_attrs := zone.attributes).
get(ATTR_PASSIVE)
139 zone_attrs[ATTR_LATITUDE],
140 zone_attrs[ATTR_LONGITUDE],
146 or not (zone_dist - (zone_radius := zone_attrs[ATTR_RADIUS]) < radius)
155 zone_dist == min_dist
and zone_radius < closest.attributes[ATTR_RADIUS]
170 """Set up track of entity IDs for zones."""
171 zone_entity_ids: list[str] = hass.states.async_entity_ids(DOMAIN)
172 hass.data[ZONE_ENTITY_IDS] = zone_entity_ids
175 def _async_add_zone_entity_id(
176 event_: Event[EventStateChangedData],
178 """Add zone entity ID."""
179 zone_entity_ids.append(event_.data[
"entity_id"])
180 zone_entity_ids.sort()
183 def _async_remove_zone_entity_id(
184 event_: Event[EventStateChangedData],
186 """Remove zone entity ID."""
187 zone_entity_ids.remove(event_.data[
"entity_id"])
189 event.async_track_state_added_domain(hass, DOMAIN, _async_add_zone_entity_id)
190 event.async_track_state_removed_domain(hass, DOMAIN, _async_remove_zone_entity_id)
193 def in_zone(zone: State, latitude: float, longitude: float, radius: float = 0) -> bool:
194 """Test if given latitude, longitude is in given zone.
198 if zone.state == STATE_UNAVAILABLE:
204 zone.attributes[ATTR_LATITUDE],
205 zone.attributes[ATTR_LONGITUDE],
208 if zone_dist
is None or zone.attributes[ATTR_RADIUS]
is None:
210 return zone_dist - radius < cast(float, zone.attributes[ATTR_RADIUS])
214 """Zone collection stored in storage."""
216 CREATE_SCHEMA = vol.Schema(CREATE_FIELDS)
217 UPDATE_SCHEMA = vol.Schema(UPDATE_FIELDS)
220 """Validate the config is valid."""
225 """Suggest an ID based on the config."""
226 return cast(str, info[CONF_NAME])
229 """Return a new updated data object."""
231 return {**item, **update_data}
234 async
def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
235 """Set up configured zones as well as Home Assistant zone if necessary."""
238 component = entity_component.EntityComponent[Zone](_LOGGER, DOMAIN, hass)
239 id_manager = collection.IDManager()
241 yaml_collection = collection.IDLessCollection(
242 logging.getLogger(f
"{__name__}.yaml_collection"), id_manager
244 collection.sync_entity_lifecycle(
245 hass, DOMAIN, DOMAIN, component, yaml_collection, Zone
249 storage.Store(hass, STORAGE_VERSION, STORAGE_KEY),
252 collection.sync_entity_lifecycle(
253 hass, DOMAIN, DOMAIN, component, storage_collection, Zone
257 await yaml_collection.async_load(config[DOMAIN])
259 await storage_collection.async_load()
261 collection.DictStorageCollectionWebsocket(
262 storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS
265 async
def reload_service_handler(service_call: ServiceCall) ->
None:
266 """Remove all zones and load new ones from config."""
267 conf = await component.async_prepare_reload(skip_reset=
True)
270 await yaml_collection.async_load(conf[DOMAIN])
272 service.async_register_admin_service(
276 reload_service_handler,
277 schema=RELOAD_SERVICE_SCHEMA,
280 if component.get_entity(
"zone.home"):
284 home_zone.entity_id = ENTITY_ID_HOME
285 await component.async_add_entities([home_zone])
287 async
def core_config_updated(_: Event) ->
None:
288 """Handle core config updated."""
289 await home_zone.async_update_config(
_home_conf(hass))
291 hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, core_config_updated)
293 hass.data[DOMAIN] = storage_collection
300 """Return the home zone config."""
302 CONF_NAME: hass.config.location_name,
303 CONF_LATITUDE: hass.config.latitude,
304 CONF_LONGITUDE: hass.config.longitude,
305 CONF_RADIUS: hass.config.radius,
306 CONF_ICON: ICON_HOME,
314 """Set up zone as config entry."""
315 storage_collection = cast(ZoneStorageCollection, hass.data[DOMAIN])
317 data =
dict(config_entry.data)
318 data.setdefault(CONF_PASSIVE, DEFAULT_PASSIVE)
319 data.setdefault(CONF_RADIUS, DEFAULT_RADIUS)
321 await storage_collection.async_create_item(data)
323 hass.async_create_task(
324 hass.config_entries.async_remove(config_entry.entry_id), eager_start=
True
333 """Will be called once we remove it."""
337 class Zone(collection.CollectionEntity):
338 """Representation of a Zone."""
341 _attr_should_poll =
False
344 """Initialize the zone."""
347 self._attrs: dict |
None =
None
348 self._remove_listener: Callable[[],
None] |
None =
None
353 """Set the attributes from the config."""
355 name: str = config[CONF_NAME]
363 """Return entity instance initialized from storage."""
366 zone._generate_attrs()
371 """Return entity instance initialized from yaml."""
373 zone.editable =
False
374 zone._generate_attrs()
379 """Return the state property really does nothing for a zone."""
383 """Handle when the config is updated."""
384 if self.
_config_config == config:
389 self.async_write_ha_state()
393 person_entity_id = evt.data[
"entity_id"]
395 cur_count = len(persons_in_zone)
397 persons_in_zone.add(person_entity_id)
398 elif person_entity_id
in persons_in_zone:
399 persons_in_zone.remove(person_entity_id)
401 if len(persons_in_zone) != cur_count:
403 self.async_write_ha_state()
406 """Run when entity about to be added to hass."""
408 person_domain =
"person"
411 for state
in self.hass.states.async_all(person_domain)
416 self.async_on_remove(
417 event.async_track_state_change_filtered(
419 event.TrackStates(
False, set(), {person_domain}),
426 """Generate new attrs based on config."""
428 ATTR_LATITUDE: self.
_config_config[CONF_LATITUDE],
429 ATTR_LONGITUDE: self.
_config_config[CONF_LONGITUDE],
430 ATTR_RADIUS: self.
_config_config[CONF_RADIUS],
431 ATTR_PASSIVE: self.
_config_config[CONF_PASSIVE],
433 ATTR_EDITABLE: self.
editableeditable,
438 """Return if given state is in zone."""
449 or (state.state == STATE_HOME
and self.
entity_identity_id == ENTITY_ID_HOME)
str _get_suggested_id(self, dict info)
dict _process_create_data(self, dict data)
dict _update_data(self, dict item, dict update_data)
None _generate_attrs(self)
None async_added_to_hass(self)
bool _state_is_in_zone(self, State|None state)
None __init__(self, ConfigType config)
Self from_yaml(cls, ConfigType config)
_attr_extra_state_attributes
None _person_state_change_listener(self, Event[EventStateChangedData] evt)
None _set_attrs_from_config(self)
Self from_storage(cls, ConfigType config)
None async_update_config(self, ConfigType config)
web.Response get(self, web.Request request, str config_key)
Any empty_value(Any value)
bool async_setup_entry(HomeAssistant hass, config_entries.ConfigEntry config_entry)
State|None async_active_zone(HomeAssistant hass, float latitude, float longitude, int radius=0)
bool in_zone(State zone, float latitude, float longitude, float radius=0)
bool async_unload_entry(HomeAssistant hass, config_entries.ConfigEntry config_entry)
dict _home_conf(HomeAssistant hass)
None async_setup_track_zone_entity_ids(HomeAssistant hass)
bool async_setup(HomeAssistant hass, ConfigType config)
def distance(hass, *args)