Migrate zone to use collection helpers (#30774)

This commit is contained in:
Paulus Schoutsen 2020-01-22 12:36:25 -08:00 committed by GitHub
parent 4c27d6b9aa
commit 0fba9e44ed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
47 changed files with 762 additions and 1209 deletions

View File

@ -271,8 +271,17 @@ async def async_handle_waypoint(hass, name_base, waypoint):
return return
zone = zone_comp.Zone( zone = zone_comp.Zone(
hass, pretty_name, lat, lon, rad, zone_comp.ICON_IMPORT, False {
zone_comp.CONF_NAME: pretty_name,
zone_comp.CONF_LATITUDE: lat,
zone_comp.CONF_LONGITUDE: lon,
zone_comp.CONF_RADIUS: rad,
zone_comp.CONF_ICON: zone_comp.ICON_IMPORT,
zone_comp.CONF_PASSIVE: False,
},
False,
) )
zone.hass = hass
zone.entity_id = entity_id zone.entity_id = entity_id
await zone.async_update_ha_state() await zone.async_update_ha_state()

View File

@ -1,21 +0,0 @@
{
"config": {
"error": {
"name_exists": "\u0418\u043c\u0435\u0442\u043e \u0432\u0435\u0447\u0435 \u0441\u044a\u0449\u0435\u0441\u0442\u0432\u0443\u0432\u0430"
},
"step": {
"init": {
"data": {
"icon": "\u0418\u043a\u043e\u043d\u0430",
"latitude": "\u0428\u0438\u0440\u0438\u043d\u0430",
"longitude": "\u0414\u044a\u043b\u0436\u0438\u043d\u0430",
"name": "\u0418\u043c\u0435",
"passive": "\u041f\u0430\u0441\u0438\u0432\u043d\u0430",
"radius": "\u0420\u0430\u0434\u0438\u0443\u0441"
},
"title": "\u041e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u0442\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0438\u0442\u0435 \u043d\u0430 \u0437\u043e\u043d\u0430\u0442\u0430"
}
},
"title": "\u0417\u043e\u043d\u0430"
}
}

View File

@ -1,21 +0,0 @@
{
"config": {
"error": {
"name_exists": "El nom ja existeix"
},
"step": {
"init": {
"data": {
"icon": "Icona",
"latitude": "Latitud",
"longitude": "Longitud",
"name": "Nom",
"passive": "Passiu",
"radius": "Radi"
},
"title": "Definici\u00f3 dels par\u00e0metres de la zona"
}
},
"title": "Zona"
}
}

View File

@ -1,21 +0,0 @@
{
"config": {
"error": {
"name_exists": "N\u00e1zev ji\u017e existuje"
},
"step": {
"init": {
"data": {
"icon": "Ikona",
"latitude": "Zem\u011bpisn\u00e1 \u0161\u00ed\u0159ka",
"longitude": "Zem\u011bpisn\u00e1 d\u00e9lka",
"name": "N\u00e1zev",
"passive": "Pasivn\u00ed",
"radius": "Polom\u011br"
},
"title": "Definujte parametry z\u00f3ny"
}
},
"title": "Z\u00f3na"
}
}

View File

@ -1,21 +0,0 @@
{
"config": {
"error": {
"name_exists": "Enw eisoes yn bodoli"
},
"step": {
"init": {
"data": {
"icon": "Eicon",
"latitude": "Lledred",
"longitude": "Hydred",
"name": "Enw",
"passive": "Goddefol",
"radius": "Radiws"
},
"title": "Ddiffinio paramedrau parth"
}
},
"title": "Parth"
}
}

View File

@ -1,21 +0,0 @@
{
"config": {
"error": {
"name_exists": "Navnet findes allerede"
},
"step": {
"init": {
"data": {
"icon": "Ikon",
"latitude": "Breddegrad",
"longitude": "L\u00e6ngdegrad",
"name": "Navn",
"passive": "Passiv",
"radius": "Radius"
},
"title": "Definer zoneparametre"
}
},
"title": "Zone"
}
}

View File

@ -1,21 +0,0 @@
{
"config": {
"error": {
"name_exists": "Name existiert bereits"
},
"step": {
"init": {
"data": {
"icon": "Symbol",
"latitude": "Breitengrad",
"longitude": "L\u00e4ngengrad",
"name": "Name",
"passive": "Passiv",
"radius": "Radius"
},
"title": "Definiere die Zonenparameter"
}
},
"title": "Zone"
}
}

View File

@ -1,21 +0,0 @@
{
"config": {
"error": {
"name_exists": "Name already exists"
},
"step": {
"init": {
"data": {
"icon": "Icon",
"latitude": "Latitude",
"longitude": "Longitude",
"name": "Name",
"passive": "Passive",
"radius": "Radius"
},
"title": "Define zone parameters"
}
},
"title": "Zone"
}
}

View File

@ -1,21 +0,0 @@
{
"config": {
"error": {
"name_exists": "El nombre ya existe"
},
"step": {
"init": {
"data": {
"icon": "Icono",
"latitude": "Latitud",
"longitude": "Longitud",
"name": "Nombre",
"passive": "Pasivo",
"radius": "Radio"
},
"title": "Definir par\u00e1metros de zona"
}
},
"title": "Zona"
}
}

View File

@ -1,21 +0,0 @@
{
"config": {
"error": {
"name_exists": "El nombre ya existe"
},
"step": {
"init": {
"data": {
"icon": "Icono",
"latitude": "Latitud",
"longitude": "Longitud",
"name": "Nombre",
"passive": "Pasivo",
"radius": "Radio"
},
"title": "Definir par\u00e1metros de la zona"
}
},
"title": "Zona"
}
}

View File

@ -1,16 +0,0 @@
{
"config": {
"step": {
"init": {
"data": {
"icon": "Ikoon",
"latitude": "Laius",
"longitude": "Pikkus",
"name": "Nimi",
"radius": "Raadius"
},
"title": "M\u00e4\u00e4ra tsooni parameetrid"
}
}
}
}

View File

@ -1,21 +0,0 @@
{
"config": {
"error": {
"name_exists": "Ce nom est d\u00e9j\u00e0 utilis\u00e9"
},
"step": {
"init": {
"data": {
"icon": "Ic\u00f4ne",
"latitude": "Latitude",
"longitude": "Longitude",
"name": "Nom",
"passive": "Passif",
"radius": "Rayon"
},
"title": "D\u00e9finir les param\u00e8tres de la zone"
}
},
"title": "Zone"
}
}

View File

@ -1,21 +0,0 @@
{
"config": {
"error": {
"name_exists": "\u05d4\u05e9\u05dd \u05db\u05d1\u05e8 \u05e7\u05d9\u05d9\u05dd"
},
"step": {
"init": {
"data": {
"icon": "\u05e1\u05de\u05dc",
"latitude": "\u05e7\u05d5 \u05e8\u05d5\u05d7\u05d1",
"longitude": "\u05e7\u05d5 \u05d0\u05d5\u05e8\u05da",
"name": "\u05e9\u05dd",
"passive": "\u05e4\u05e1\u05d9\u05d1\u05d9",
"radius": "\u05e8\u05d3\u05d9\u05d5\u05e1"
},
"title": "\u05d4\u05d2\u05d3\u05e8 \u05e4\u05e8\u05de\u05d8\u05e8\u05d9\u05dd \u05e9\u05dc \u05d0\u05d6\u05d5\u05e8"
}
},
"title": "\u05d0\u05d6\u05d5\u05e8"
}
}

View File

@ -1,21 +0,0 @@
{
"config": {
"error": {
"name_exists": "Ime ve\u0107 postoji"
},
"step": {
"init": {
"data": {
"icon": "Ikona",
"latitude": "Zemljopisna \u0161irina",
"longitude": "Zemljopisna du\u017eina",
"name": "Ime",
"passive": "Pasivno",
"radius": "Radijus"
},
"title": "Definirajte parametre zone"
}
},
"title": "Zona"
}
}

View File

@ -1,21 +0,0 @@
{
"config": {
"error": {
"name_exists": "A n\u00e9v m\u00e1r l\u00e9tezik"
},
"step": {
"init": {
"data": {
"icon": "Ikon",
"latitude": "Sz\u00e9less\u00e9g",
"longitude": "Hossz\u00fas\u00e1g",
"name": "N\u00e9v",
"passive": "Passz\u00edv",
"radius": "Sug\u00e1r"
},
"title": "Z\u00f3na param\u00e9terek megad\u00e1sa"
}
},
"title": "Z\u00f3na"
}
}

View File

@ -1,21 +0,0 @@
{
"config": {
"error": {
"name_exists": "Nama sudah ada"
},
"step": {
"init": {
"data": {
"icon": "Ikon",
"latitude": "Lintang",
"longitude": "Garis bujur",
"name": "Nama",
"passive": "Pasif",
"radius": "Radius"
},
"title": "Tentukan parameter zona"
}
},
"title": "Zona"
}
}

View File

@ -1,21 +0,0 @@
{
"config": {
"error": {
"name_exists": "Il nome \u00e8 gi\u00e0 esistente"
},
"step": {
"init": {
"data": {
"icon": "Icona",
"latitude": "Latitudine",
"longitude": "Longitudine",
"name": "Nome",
"passive": "Passiva",
"radius": "Raggio"
},
"title": "Imposta i parametri della zona"
}
},
"title": "Zona"
}
}

View File

@ -1,13 +0,0 @@
{
"config": {
"step": {
"init": {
"data": {
"latitude": "\u7def\u5ea6",
"longitude": "\u7d4c\u5ea6",
"name": "\u540d\u524d"
}
}
}
}
}

View File

@ -1,21 +0,0 @@
{
"config": {
"error": {
"name_exists": "\uc774\ub984\uc774 \uc774\ubbf8 \uc874\uc7ac\ud569\ub2c8\ub2e4"
},
"step": {
"init": {
"data": {
"icon": "\uc544\uc774\ucf58",
"latitude": "\uc704\ub3c4",
"longitude": "\uacbd\ub3c4",
"name": "\uc774\ub984",
"passive": "\uc790\ub3d9\ud654 \uc804\uc6a9",
"radius": "\ubc18\uacbd"
},
"title": "\uad6c\uc5ed \uc124\uc815"
}
},
"title": "\uad6c\uc5ed"
}
}

View File

@ -1,21 +0,0 @@
{
"config": {
"error": {
"name_exists": "Numm g\u00ebtt et schonn"
},
"step": {
"init": {
"data": {
"icon": "Ikone",
"latitude": "Breedegrad",
"longitude": "L\u00e4ngegrad",
"name": "Numm",
"passive": "Passif",
"radius": "Radius"
},
"title": "D\u00e9fin\u00e9iert Zone Parameter"
}
},
"title": "Zone"
}
}

View File

@ -1,21 +0,0 @@
{
"config": {
"error": {
"name_exists": "Naam bestaat al"
},
"step": {
"init": {
"data": {
"icon": "Pictogram",
"latitude": "Breedtegraad",
"longitude": "Lengtegraad",
"name": "Naam",
"passive": "Passief",
"radius": "Straal"
},
"title": "Definieer zone parameters"
}
},
"title": "Zone"
}
}

View File

@ -1,21 +0,0 @@
{
"config": {
"error": {
"name_exists": "Namnet eksisterar allereie"
},
"step": {
"init": {
"data": {
"icon": "Ikon",
"latitude": "Breiddegrad",
"longitude": "Lengdegrad",
"name": "Namn",
"passive": "Passiv",
"radius": "Radius"
},
"title": "Definer soneparameterar"
}
},
"title": "Sone"
}
}

View File

@ -1,21 +0,0 @@
{
"config": {
"error": {
"name_exists": "Navnet eksisterer allerede"
},
"step": {
"init": {
"data": {
"icon": "Ikon",
"latitude": "Breddegrad",
"longitude": "Lengdegrad",
"name": "Navn",
"passive": "Passiv",
"radius": "Radius"
},
"title": "Definer sone parametere"
}
},
"title": "Sone"
}
}

View File

@ -1,21 +0,0 @@
{
"config": {
"error": {
"name_exists": "Nazwa ju\u017c istnieje"
},
"step": {
"init": {
"data": {
"icon": "Ikona",
"latitude": "Szeroko\u015b\u0107 geograficzna",
"longitude": "D\u0142ugo\u015b\u0107 geograficzna",
"name": "Nazwa",
"passive": "Pasywnie",
"radius": "Promie\u0144"
},
"title": "Zdefiniuj parametry strefy"
}
},
"title": "Strefa"
}
}

View File

@ -1,21 +0,0 @@
{
"config": {
"error": {
"name_exists": "O nome j\u00e1 existe"
},
"step": {
"init": {
"data": {
"icon": "\u00cdcone",
"latitude": "Latitude",
"longitude": "Longitude",
"name": "Nome",
"passive": "Passivo",
"radius": "Raio"
},
"title": "Definir par\u00e2metros da zona"
}
},
"title": "Zona"
}
}

View File

@ -1,21 +0,0 @@
{
"config": {
"error": {
"name_exists": "Nome j\u00e1 existente"
},
"step": {
"init": {
"data": {
"icon": "\u00cdcone",
"latitude": "Latitude",
"longitude": "Longitude",
"name": "Nome",
"passive": "Passivo",
"radius": "Raio"
},
"title": "Definir os par\u00e2metros da zona"
}
},
"title": "Zona"
}
}

View File

@ -1,21 +0,0 @@
{
"config": {
"error": {
"name_exists": "\u042d\u0442\u043e \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u0443\u0436\u0435 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f."
},
"step": {
"init": {
"data": {
"icon": "\u0417\u043d\u0430\u0447\u043e\u043a",
"latitude": "\u0428\u0438\u0440\u043e\u0442\u0430",
"longitude": "\u0414\u043e\u043b\u0433\u043e\u0442\u0430",
"name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435",
"passive": "\u041f\u0430\u0441\u0441\u0438\u0432\u043d\u0430\u044f",
"radius": "\u0420\u0430\u0434\u0438\u0443\u0441"
},
"title": "\u0417\u043e\u043d\u0430"
}
},
"title": "\u0417\u043e\u043d\u0430"
}
}

View File

@ -1,21 +0,0 @@
{
"config": {
"error": {
"name_exists": "Ime \u017ee obstaja"
},
"step": {
"init": {
"data": {
"icon": "Ikona",
"latitude": "Zemljepisna \u0161irina",
"longitude": "Zemljepisna dol\u017eina",
"name": "Ime",
"passive": "Pasivno",
"radius": "Radij"
},
"title": "Dolo\u010dite parametre obmo\u010dja"
}
},
"title": "Obmo\u010dje"
}
}

View File

@ -1,21 +0,0 @@
{
"config": {
"error": {
"name_exists": "Namnet finns redan"
},
"step": {
"init": {
"data": {
"icon": "Ikon",
"latitude": "Latitud",
"longitude": "Longitud",
"name": "Namn",
"passive": "Passiv",
"radius": "Radie"
},
"title": "Definiera zonparametrar"
}
},
"title": "Zon"
}
}

View File

@ -1,17 +0,0 @@
{
"config": {
"error": {
"name_exists": "\u0e21\u0e35\u0e0a\u0e37\u0e48\u0e2d\u0e19\u0e35\u0e49\u0e2d\u0e22\u0e39\u0e48\u0e41\u0e25\u0e49\u0e27"
},
"step": {
"init": {
"data": {
"latitude": "\u0e40\u0e2a\u0e49\u0e19\u0e23\u0e38\u0e49\u0e07",
"longitude": "\u0e40\u0e2a\u0e49\u0e19\u0e41\u0e27\u0e07",
"name": "\u0e0a\u0e37\u0e48\u0e2d"
}
}
},
"title": "\u0e42\u0e0b\u0e19"
}
}

View File

@ -1,21 +0,0 @@
{
"config": {
"error": {
"name_exists": "\u0406\u043c'\u044f \u0432\u0436\u0435 \u0456\u0441\u043d\u0443\u0454"
},
"step": {
"init": {
"data": {
"icon": "\u0406\u043a\u043e\u043d\u043a\u0430",
"latitude": "\u0428\u0438\u0440\u043e\u0442\u0430",
"longitude": "\u0414\u043e\u0432\u0433\u043e\u0442\u0430",
"name": "\u041d\u0430\u0437\u0432\u0430",
"passive": "\u041f\u0430\u0441\u0438\u0432\u043d\u0438\u0439",
"radius": "\u0420\u0430\u0434\u0456\u0443\u0441"
},
"title": "\u0412\u0438\u0437\u043d\u0430\u0447\u0435\u043d\u043d\u044f \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0456\u0432 \u0437\u043e\u043d\u0438"
}
},
"title": "\u0417\u043e\u043d\u0430"
}
}

View File

@ -1,21 +0,0 @@
{
"config": {
"error": {
"name_exists": "T\u00ean \u0111\u00e3 t\u1ed3n t\u1ea1i"
},
"step": {
"init": {
"data": {
"icon": "Bi\u1ec3u t\u01b0\u1ee3ng",
"latitude": "V\u0129 \u0111\u1ed9",
"longitude": "Kinh \u0111\u1ed9",
"name": "T\u00ean",
"passive": "Th\u1ee5 \u0111\u1ed9ng",
"radius": "B\u00e1n k\u00ednh"
},
"title": "X\u00e1c \u0111\u1ecbnh tham s\u1ed1 v\u00f9ng"
}
},
"title": "V\u00f9ng"
}
}

View File

@ -1,21 +0,0 @@
{
"config": {
"error": {
"name_exists": "\u540d\u79f0\u5df2\u5b58\u5728"
},
"step": {
"init": {
"data": {
"icon": "\u56fe\u6807",
"latitude": "\u7eac\u5ea6",
"longitude": "\u7ecf\u5ea6",
"name": "\u540d\u79f0",
"passive": "\u88ab\u52a8",
"radius": "\u534a\u5f84"
},
"title": "\u5b9a\u4e49\u533a\u57df\u76f8\u5173\u53d8\u91cf"
}
},
"title": "\u533a\u57df"
}
}

View File

@ -1,21 +0,0 @@
{
"config": {
"error": {
"name_exists": "\u8a72\u540d\u7a31\u5df2\u5b58\u5728"
},
"step": {
"init": {
"data": {
"icon": "\u5716\u793a",
"latitude": "\u7def\u5ea6",
"longitude": "\u7d93\u5ea6",
"name": "\u540d\u7a31",
"passive": "\u88ab\u52d5",
"radius": "\u534a\u5f91"
},
"title": "\u5b9a\u7fa9\u5340\u57df\u53c3\u6578"
}
},
"title": "\u5340\u57df"
}
}

View File

@ -1,36 +1,41 @@
"""Support for the definition of zones.""" """Support for the definition of zones."""
import logging import logging
from typing import Set, cast from typing import Dict, List, Optional, cast
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import ( from homeassistant.const import (
ATTR_EDITABLE,
ATTR_HIDDEN,
ATTR_LATITUDE, ATTR_LATITUDE,
ATTR_LONGITUDE, ATTR_LONGITUDE,
CONF_ICON, CONF_ICON,
CONF_ID,
CONF_LATITUDE, CONF_LATITUDE,
CONF_LONGITUDE, CONF_LONGITUDE,
CONF_NAME, CONF_NAME,
CONF_RADIUS, CONF_RADIUS,
EVENT_CORE_CONFIG_UPDATE, EVENT_CORE_CONFIG_UPDATE,
SERVICE_RELOAD,
)
from homeassistant.core import Event, HomeAssistant, ServiceCall, State, callback
from homeassistant.helpers import (
collection,
config_validation as cv,
entity,
entity_component,
entity_registry,
service,
storage,
) )
from homeassistant.core import State, callback
from homeassistant.helpers import config_per_platform
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import async_generate_entity_id
from homeassistant.loader import bind_hass from homeassistant.loader import bind_hass
from homeassistant.util import slugify
from homeassistant.util.location import distance from homeassistant.util.location import distance
from .config_flow import configured_zones
from .const import ATTR_PASSIVE, ATTR_RADIUS, CONF_PASSIVE, DOMAIN, HOME_ZONE from .const import ATTR_PASSIVE, ATTR_RADIUS, CONF_PASSIVE, DOMAIN, HOME_ZONE
from .zone import Zone
# mypy: allow-untyped-calls, allow-untyped-defs
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
DEFAULT_NAME = "Unnamed zone"
DEFAULT_PASSIVE = False DEFAULT_PASSIVE = False
DEFAULT_RADIUS = 100 DEFAULT_RADIUS = 100
@ -40,29 +45,47 @@ ENTITY_ID_HOME = ENTITY_ID_FORMAT.format(HOME_ZONE)
ICON_HOME = "mdi:home" ICON_HOME = "mdi:home"
ICON_IMPORT = "mdi:import" ICON_IMPORT = "mdi:import"
# The config that zone accepts is the same as if it has platforms. CREATE_FIELDS = {
PLATFORM_SCHEMA = vol.Schema( vol.Required(CONF_NAME): cv.string,
{ vol.Required(CONF_LATITUDE): cv.latitude,
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Required(CONF_LONGITUDE): cv.longitude,
vol.Required(CONF_LATITUDE): cv.latitude, vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS): vol.Coerce(float),
vol.Required(CONF_LONGITUDE): cv.longitude, vol.Optional(CONF_PASSIVE, default=DEFAULT_PASSIVE): cv.boolean,
vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS): vol.Coerce(float), vol.Optional(CONF_ICON): cv.icon,
vol.Optional(CONF_PASSIVE, default=DEFAULT_PASSIVE): cv.boolean, }
vol.Optional(CONF_ICON): cv.icon,
},
UPDATE_FIELDS = {
vol.Optional(CONF_NAME): cv.string,
vol.Optional(CONF_LATITUDE): cv.latitude,
vol.Optional(CONF_LONGITUDE): cv.longitude,
vol.Optional(CONF_RADIUS): vol.Coerce(float),
vol.Optional(CONF_PASSIVE): cv.boolean,
vol.Optional(CONF_ICON): cv.icon,
}
CONFIG_SCHEMA = vol.Schema(
{vol.Optional(DOMAIN): vol.All(cv.ensure_list, [vol.Schema(CREATE_FIELDS)])},
extra=vol.ALLOW_EXTRA, extra=vol.ALLOW_EXTRA,
) )
RELOAD_SERVICE_SCHEMA = vol.Schema({})
STORAGE_KEY = DOMAIN
STORAGE_VERSION = 1
@bind_hass @bind_hass
def async_active_zone(hass, latitude, longitude, radius=0): def async_active_zone(
hass: HomeAssistant, latitude: float, longitude: float, radius: int = 0
) -> Optional[State]:
"""Find the active zone for given latitude, longitude. """Find the active zone for given latitude, longitude.
This method must be run in the event loop. This method must be run in the event loop.
""" """
# Sort entity IDs so that we are deterministic if equal distance to 2 zones # Sort entity IDs so that we are deterministic if equal distance to 2 zones
zones = ( zones = (
hass.states.get(entity_id) cast(State, hass.states.get(entity_id))
for entity_id in sorted(hass.states.async_entity_ids(DOMAIN)) for entity_id in sorted(hass.states.async_entity_ids(DOMAIN))
) )
@ -80,6 +103,9 @@ def async_active_zone(hass, latitude, longitude, radius=0):
zone.attributes[ATTR_LONGITUDE], zone.attributes[ATTR_LONGITUDE],
) )
if zone_dist is None:
continue
within_zone = zone_dist - radius < zone.attributes[ATTR_RADIUS] within_zone = zone_dist - radius < zone.attributes[ATTR_RADIUS]
closer_zone = closest is None or zone_dist < min_dist # type: ignore closer_zone = closest is None or zone_dist < min_dist # type: ignore
smaller_zone = ( smaller_zone = (
@ -95,79 +121,227 @@ def async_active_zone(hass, latitude, longitude, radius=0):
return closest return closest
async def async_setup(hass, config): def in_zone(zone: State, latitude: float, longitude: float, radius: float = 0) -> bool:
"""Set up configured zones as well as Home Assistant zone if necessary.""" """Test if given latitude, longitude is in given zone.
hass.data[DOMAIN] = {}
entities: Set[str] = set()
zone_entries = configured_zones(hass)
for _, entry in config_per_platform(config, DOMAIN):
if slugify(entry[CONF_NAME]) not in zone_entries:
zone = Zone(
hass,
entry[CONF_NAME],
entry[CONF_LATITUDE],
entry[CONF_LONGITUDE],
entry.get(CONF_RADIUS),
entry.get(CONF_ICON),
entry.get(CONF_PASSIVE),
)
zone.entity_id = async_generate_entity_id(
ENTITY_ID_FORMAT, entry[CONF_NAME], entities
)
hass.async_create_task(zone.async_update_ha_state())
entities.add(zone.entity_id)
if ENTITY_ID_HOME in entities or HOME_ZONE in zone_entries: Async friendly.
return True """
zone_dist = distance(
zone = Zone( latitude,
hass, longitude,
hass.config.location_name, zone.attributes[ATTR_LATITUDE],
hass.config.latitude, zone.attributes[ATTR_LONGITUDE],
hass.config.longitude,
DEFAULT_RADIUS,
ICON_HOME,
False,
) )
zone.entity_id = ENTITY_ID_HOME
hass.async_create_task(zone.async_update_ha_state()) if zone_dist is None or zone.attributes[ATTR_RADIUS] is None:
return False
return zone_dist - radius < cast(float, zone.attributes[ATTR_RADIUS])
class ZoneStorageCollection(collection.StorageCollection):
"""Zone collection stored in storage."""
CREATE_SCHEMA = vol.Schema(CREATE_FIELDS)
UPDATE_SCHEMA = vol.Schema(UPDATE_FIELDS)
async def _process_create_data(self, data: Dict) -> Dict:
"""Validate the config is valid."""
return cast(Dict, self.CREATE_SCHEMA(data))
@callback @callback
def core_config_updated(_): def _get_suggested_id(self, info: Dict) -> str:
"""Suggest an ID based on the config."""
return cast(str, info[CONF_NAME])
async def _update_data(self, data: dict, update_data: Dict) -> Dict:
"""Return a new updated data object."""
update_data = self.UPDATE_SCHEMA(update_data)
return {**data, **update_data}
class IDLessCollection(collection.ObservableCollection):
"""A collection without IDs."""
counter = 0
async def async_load(self, data: List[dict]) -> None:
"""Load the collection. Overrides existing data."""
for item_id in list(self.data):
await self.notify_change(collection.CHANGE_REMOVED, item_id, None)
self.data.clear()
for item in data:
self.counter += 1
item_id = f"fakeid-{self.counter}"
self.data[item_id] = item
await self.notify_change(collection.CHANGE_ADDED, item_id, item)
async def async_setup(hass: HomeAssistant, config: Dict) -> bool:
"""Set up configured zones as well as Home Assistant zone if necessary."""
component = entity_component.EntityComponent(_LOGGER, DOMAIN, hass)
id_manager = collection.IDManager()
yaml_collection = IDLessCollection(
logging.getLogger(f"{__name__}.yaml_collection"), id_manager
)
collection.attach_entity_component_collection(
component, yaml_collection, lambda conf: Zone(conf, False)
)
storage_collection = ZoneStorageCollection(
storage.Store(hass, STORAGE_VERSION, STORAGE_KEY),
logging.getLogger(f"{__name__}_storage_collection"),
id_manager,
)
collection.attach_entity_component_collection(
component, storage_collection, lambda conf: Zone(conf, True)
)
if DOMAIN in config:
await yaml_collection.async_load(config[DOMAIN])
await storage_collection.async_load()
collection.StorageCollectionWebsocket(
storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS
).async_setup(hass)
async def _collection_changed(
change_type: str, item_id: str, config: Optional[Dict]
) -> None:
"""Handle a collection change: clean up entity registry on removals."""
if change_type != collection.CHANGE_REMOVED:
return
ent_reg = await entity_registry.async_get_registry(hass)
ent_reg.async_remove(
cast(str, ent_reg.async_get_entity_id(DOMAIN, DOMAIN, item_id))
)
storage_collection.async_add_listener(_collection_changed)
async def reload_service_handler(service_call: ServiceCall) -> None:
"""Remove all zones and load new ones from config."""
conf = await component.async_prepare_reload(skip_reset=True)
if conf is None:
return
await yaml_collection.async_load(conf[DOMAIN])
service.async_register_admin_service(
hass,
DOMAIN,
SERVICE_RELOAD,
reload_service_handler,
schema=RELOAD_SERVICE_SCHEMA,
)
if component.get_entity("zone.home"):
return True
home_zone = Zone(_home_conf(hass), True,)
home_zone.entity_id = ENTITY_ID_HOME
await component.async_add_entities([home_zone]) # type: ignore
async def core_config_updated(_: Event) -> None:
"""Handle core config updated.""" """Handle core config updated."""
zone.name = hass.config.location_name await home_zone.async_update_config(_home_conf(hass))
zone.latitude = hass.config.latitude
zone.longitude = hass.config.longitude
zone.async_write_ha_state()
hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, core_config_updated) hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, core_config_updated)
hass.data[DOMAIN] = storage_collection
return True return True
async def async_setup_entry(hass, config_entry): @callback
def _home_conf(hass: HomeAssistant) -> Dict:
"""Return the home zone config."""
return {
CONF_NAME: hass.config.location_name,
CONF_LATITUDE: hass.config.latitude,
CONF_LONGITUDE: hass.config.longitude,
CONF_RADIUS: DEFAULT_RADIUS,
CONF_ICON: ICON_HOME,
CONF_PASSIVE: False,
}
async def async_setup_entry(
hass: HomeAssistant, config_entry: config_entries.ConfigEntry
) -> bool:
"""Set up zone as config entry.""" """Set up zone as config entry."""
entry = config_entry.data storage_collection = cast(ZoneStorageCollection, hass.data[DOMAIN])
name = entry[CONF_NAME]
zone = Zone( data = dict(config_entry.data)
hass, data.setdefault(CONF_PASSIVE, DEFAULT_PASSIVE)
name, data.setdefault(CONF_RADIUS, DEFAULT_RADIUS)
entry[CONF_LATITUDE],
entry[CONF_LONGITUDE], await storage_collection.async_create_item(data)
entry.get(CONF_RADIUS, DEFAULT_RADIUS),
entry.get(CONF_ICON), hass.async_create_task(hass.config_entries.async_remove(config_entry.entry_id))
entry.get(CONF_PASSIVE, DEFAULT_PASSIVE),
)
zone.entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, name, None, hass)
hass.async_create_task(zone.async_update_ha_state())
hass.data[DOMAIN][slugify(name)] = zone
return True return True
async def async_unload_entry(hass, config_entry): async def async_unload_entry(
"""Unload a config entry.""" hass: HomeAssistant, config_entry: config_entries.ConfigEntry
zones = hass.data[DOMAIN] ) -> bool:
name = slugify(config_entry.data[CONF_NAME]) """Will be called once we remove it."""
zone = zones.pop(name)
await zone.async_remove()
return True return True
class Zone(entity.Entity):
"""Representation of a Zone."""
def __init__(self, config: Dict, editable: bool):
"""Initialize the zone."""
self._config = config
self._editable = editable
self._attrs: Optional[Dict] = None
self._generate_attrs()
@property
def state(self) -> str:
"""Return the state property really does nothing for a zone."""
return "zoning"
@property
def name(self) -> str:
"""Return name."""
return cast(str, self._config[CONF_NAME])
@property
def unique_id(self) -> Optional[str]:
"""Return unique ID."""
return self._config.get(CONF_ID)
@property
def icon(self) -> Optional[str]:
"""Return the icon if any."""
return self._config.get(CONF_ICON)
@property
def state_attributes(self) -> Optional[Dict]:
"""Return the state attributes of the zone."""
return self._attrs
async def async_update_config(self, config: Dict) -> None:
"""Handle when the config is updated."""
self._config = config
self._generate_attrs()
self.async_write_ha_state()
@callback
def _generate_attrs(self) -> None:
"""Generate new attrs based on config."""
self._attrs = {
ATTR_HIDDEN: True,
ATTR_LATITUDE: self._config[CONF_LATITUDE],
ATTR_LONGITUDE: self._config[CONF_LONGITUDE],
ATTR_RADIUS: self._config[CONF_RADIUS],
ATTR_PASSIVE: self._config[CONF_PASSIVE],
ATTR_EDITABLE: self._editable,
}

View File

@ -1,75 +1,13 @@
"""Config flow to configure zone component.""" """Config flow to configure zone component.
from typing import Set
import voluptuous as vol
This is no longer in use. This file is around so that existing
config entries will remain to be loaded and then automatically
migrated to the storage collection.
"""
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.const import (
CONF_ICON,
CONF_LATITUDE,
CONF_LONGITUDE,
CONF_NAME,
CONF_RADIUS,
)
from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import HomeAssistantType
from homeassistant.util import slugify
from .const import CONF_PASSIVE, DOMAIN, HOME_ZONE from .const import DOMAIN # noqa # pylint:disable=unused-import
# mypy: allow-untyped-defs, no-check-untyped-defs
@callback class ZoneConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
def configured_zones(hass: HomeAssistantType) -> Set[str]: """Stub zone config flow class."""
"""Return a set of the configured zones."""
return set(
(slugify(entry.data[CONF_NAME]))
for entry in (
hass.config_entries.async_entries(DOMAIN) if hass.config_entries else []
)
)
@config_entries.HANDLERS.register(DOMAIN)
class ZoneFlowHandler(config_entries.ConfigFlow):
"""Zone config flow."""
VERSION = 1
def __init__(self):
"""Initialize zone configuration flow."""
pass
async def async_step_user(self, user_input=None):
"""Handle a flow initialized by the user."""
return await self.async_step_init(user_input)
async def async_step_init(self, user_input=None):
"""Handle a flow start."""
errors = {}
if user_input is not None:
name = slugify(user_input[CONF_NAME])
if name not in configured_zones(self.hass) and name != HOME_ZONE:
return self.async_create_entry(
title=user_input[CONF_NAME], data=user_input
)
errors["base"] = "name_exists"
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Required(CONF_NAME): str,
vol.Required(CONF_LATITUDE): cv.latitude,
vol.Required(CONF_LONGITUDE): cv.longitude,
vol.Optional(CONF_RADIUS): vol.Coerce(float),
vol.Optional(CONF_ICON): str,
vol.Optional(CONF_PASSIVE): bool,
}
),
errors=errors,
)

View File

@ -1,7 +1,7 @@
{ {
"domain": "zone", "domain": "zone",
"name": "Zone", "name": "Zone",
"config_flow": true, "config_flow": false,
"documentation": "https://www.home-assistant.io/integrations/zone", "documentation": "https://www.home-assistant.io/integrations/zone",
"requirements": [], "requirements": [],
"dependencies": [], "dependencies": [],

View File

@ -1,21 +0,0 @@
{
"config": {
"title": "Zone",
"step": {
"init": {
"title": "Define zone parameters",
"data": {
"name": "Name",
"latitude": "Latitude",
"longitude": "Longitude",
"radius": "Radius",
"passive": "Passive",
"icon": "Icon"
}
}
},
"error": {
"name_exists": "Name already exists"
}
}
}

View File

@ -1,71 +0,0 @@
"""Zone entity and functionality."""
from typing import cast
from homeassistant.const import ATTR_HIDDEN, ATTR_LATITUDE, ATTR_LONGITUDE
from homeassistant.core import State
from homeassistant.helpers.entity import Entity
from homeassistant.util.location import distance
from .const import ATTR_PASSIVE, ATTR_RADIUS
STATE = "zoning"
# mypy: allow-untyped-defs
def in_zone(zone: State, latitude: float, longitude: float, radius: float = 0) -> bool:
"""Test if given latitude, longitude is in given zone.
Async friendly.
"""
zone_dist = distance(
latitude,
longitude,
zone.attributes[ATTR_LATITUDE],
zone.attributes[ATTR_LONGITUDE],
)
if zone_dist is None or zone.attributes[ATTR_RADIUS] is None:
return False
return zone_dist - radius < cast(float, zone.attributes[ATTR_RADIUS])
class Zone(Entity):
"""Representation of a Zone."""
name = None
def __init__(self, hass, name, latitude, longitude, radius, icon, passive):
"""Initialize the zone."""
self.hass = hass
self.name = name
self.latitude = latitude
self.longitude = longitude
self._radius = radius
self._icon = icon
self._passive = passive
@property
def state(self):
"""Return the state property really does nothing for a zone."""
return STATE
@property
def icon(self):
"""Return the icon if any."""
return self._icon
@property
def state_attributes(self):
"""Return the state attributes of the zone."""
data = {
ATTR_HIDDEN: True,
ATTR_LATITUDE: self.latitude,
ATTR_LONGITUDE: self.longitude,
ATTR_RADIUS: self._radius,
}
if self._passive:
data[ATTR_PASSIVE] = self._passive
return data

View File

@ -98,6 +98,5 @@ FLOWS = [
"wled", "wled",
"wwlln", "wwlln",
"zha", "zha",
"zone",
"zwave" "zwave"
] ]

View File

@ -114,7 +114,7 @@ class ObservableCollection(ABC):
class YamlCollection(ObservableCollection): class YamlCollection(ObservableCollection):
"""Offer a fake CRUD interface on top of static YAML.""" """Offer a collection based on static data."""
async def async_load(self, data: List[dict]) -> None: async def async_load(self, data: List[dict]) -> None:
"""Load the YAML collection. Overrides existing data.""" """Load the YAML collection. Overrides existing data."""
@ -133,7 +133,7 @@ class YamlCollection(ObservableCollection):
event = CHANGE_ADDED event = CHANGE_ADDED
self.data[item_id] = item self.data[item_id] = item
await self.notify_change(event, item[CONF_ID], item) await self.notify_change(event, item_id, item)
for item_id in old_ids: for item_id in old_ids:
self.data.pop(item_id) self.data.pop(item_id)
@ -246,7 +246,7 @@ def attach_entity_component_collection(
"""Handle a collection change.""" """Handle a collection change."""
if change_type == CHANGE_ADDED: if change_type == CHANGE_ADDED:
entity = create_entity(cast(dict, config)) entity = create_entity(cast(dict, config))
await entity_component.async_add_entities([entity]) await entity_component.async_add_entities([entity]) # type: ignore
entities[item_id] = entity entities[item_id] = entity
return return

View File

@ -473,7 +473,7 @@ def zone(
if latitude is None or longitude is None: if latitude is None or longitude is None:
return False return False
return zone_cmp.zone.in_zone( return zone_cmp.in_zone(
zone_ent, latitude, longitude, entity.attributes.get(ATTR_GPS_ACCURACY, 0) zone_ent, latitude, longitude, entity.attributes.get(ATTR_GPS_ACCURACY, 0)
) )

View File

@ -284,7 +284,7 @@ class Entity(ABC):
self._async_write_ha_state() self._async_write_ha_state()
@callback @callback
def async_write_ha_state(self): def async_write_ha_state(self) -> None:
"""Write the state to the state machine.""" """Write the state to the state machine."""
if self.hass is None: if self.hass is None:
raise RuntimeError(f"Attribute hass is None for {self}") raise RuntimeError(f"Attribute hass is None for {self}")
@ -294,7 +294,7 @@ class Entity(ABC):
f"No entity id specified for entity {self.name}" f"No entity id specified for entity {self.name}"
) )
self._async_write_ha_state() self._async_write_ha_state() # type: ignore
@callback @callback
def _async_write_ha_state(self): def _async_write_ha_state(self):

View File

@ -3,6 +3,8 @@ import asyncio
from datetime import timedelta from datetime import timedelta
from itertools import chain from itertools import chain
import logging import logging
from types import ModuleType
from typing import Dict, Optional, cast
from homeassistant import config as conf_util from homeassistant import config as conf_util
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@ -13,6 +15,7 @@ from homeassistant.helpers import (
config_per_platform, config_per_platform,
config_validation as cv, config_validation as cv,
discovery, discovery,
entity,
service, service,
) )
from homeassistant.loader import async_get_integration, bind_hass from homeassistant.loader import async_get_integration, bind_hass
@ -38,15 +41,15 @@ async def async_update_entity(hass: HomeAssistant, entity_id: str) -> None:
) )
return return
entity = entity_comp.get_entity(entity_id) entity_obj = entity_comp.get_entity(entity_id)
if entity is None: if entity_obj is None:
logging.getLogger(__name__).warning( logging.getLogger(__name__).warning(
"Forced update failed. Entity %s not found.", entity_id "Forced update failed. Entity %s not found.", entity_id
) )
return return
await entity.async_update_ha_state(True) await entity_obj.async_update_ha_state(True)
class EntityComponent: class EntityComponent:
@ -59,7 +62,13 @@ class EntityComponent:
- Listen for discovery events for platforms related to the domain. - Listen for discovery events for platforms related to the domain.
""" """
def __init__(self, logger, domain, hass, scan_interval=DEFAULT_SCAN_INTERVAL): def __init__(
self,
logger: logging.Logger,
domain: str,
hass: HomeAssistant,
scan_interval: timedelta = DEFAULT_SCAN_INTERVAL,
):
"""Initialize an entity component.""" """Initialize an entity component."""
self.logger = logger self.logger = logger
self.hass = hass self.hass = hass
@ -68,7 +77,9 @@ class EntityComponent:
self.config = None self.config = None
self._platforms = {domain: self._async_init_entity_platform(domain, None)} self._platforms: Dict[str, EntityPlatform] = {
domain: self._async_init_entity_platform(domain, None)
}
self.async_add_entities = self._platforms[domain].async_add_entities self.async_add_entities = self._platforms[domain].async_add_entities
self.add_entities = self._platforms[domain].add_entities self.add_entities = self._platforms[domain].add_entities
@ -81,12 +92,12 @@ class EntityComponent:
platform.entities.values() for platform in self._platforms.values() platform.entities.values() for platform in self._platforms.values()
) )
def get_entity(self, entity_id): def get_entity(self, entity_id: str) -> Optional[entity.Entity]:
"""Get an entity.""" """Get an entity."""
for platform in self._platforms.values(): for platform in self._platforms.values():
entity = platform.entities.get(entity_id) entity_obj = cast(Optional[entity.Entity], platform.entities.get(entity_id))
if entity is not None: if entity_obj is not None:
return entity return entity_obj
return None return None
def setup(self, config): def setup(self, config):
@ -237,7 +248,7 @@ class EntityComponent:
if entity_id in platform.entities: if entity_id in platform.entities:
await platform.async_remove_entity(entity_id) await platform.async_remove_entity(entity_id)
async def async_prepare_reload(self, *, skip_reset=False): async def async_prepare_reload(self, *, skip_reset: bool = False) -> Optional[dict]:
"""Prepare reloading this entity component. """Prepare reloading this entity component.
This method must be run in the event loop. This method must be run in the event loop.
@ -250,25 +261,30 @@ class EntityComponent:
integration = await async_get_integration(self.hass, self.domain) integration = await async_get_integration(self.hass, self.domain)
conf = await conf_util.async_process_component_config( processed_conf = await conf_util.async_process_component_config(
self.hass, conf, integration self.hass, conf, integration
) )
if conf is None: if processed_conf is None:
return None return None
if not skip_reset: if not skip_reset:
await self._async_reset() await self._async_reset()
return conf
return processed_conf
def _async_init_entity_platform( def _async_init_entity_platform(
self, platform_type, platform, scan_interval=None, entity_namespace=None self,
): platform_type: str,
platform: Optional[ModuleType],
scan_interval: Optional[timedelta] = None,
entity_namespace: Optional[str] = None,
) -> EntityPlatform:
"""Initialize an entity platform.""" """Initialize an entity platform."""
if scan_interval is None: if scan_interval is None:
scan_interval = self.scan_interval scan_interval = self.scan_interval
return EntityPlatform( return EntityPlatform( # type: ignore
hass=self.hass, hass=self.hass,
logger=self.logger, logger=self.logger,
domain=self.domain, domain=self.domain,

View File

@ -14,6 +14,7 @@ class TestProximity(unittest.TestCase):
def setUp(self): def setUp(self):
"""Set up things to be run when tests are started.""" """Set up things to be run when tests are started."""
self.hass = get_test_home_assistant() self.hass = get_test_home_assistant()
self.hass.config.components.add("zone")
self.hass.states.set( self.hass.states.set(
"zone.home", "zone.home",
"zoning", "zoning",
@ -211,7 +212,7 @@ class TestProximity(unittest.TestCase):
self.hass.block_till_done() self.hass.block_till_done()
state = self.hass.states.get("proximity.home") state = self.hass.states.get("proximity.home")
assert state.attributes.get("nearest") == "test1" assert state.attributes.get("nearest") == "test1"
assert state.attributes.get("dir_of_travel") == "towards" assert state.attributes.get("dir_of_travel") == "away_from"
def test_device_tracker_test1_awaycloser(self): def test_device_tracker_test1_awaycloser(self):
"""Test for tracker state away closer.""" """Test for tracker state away closer."""
@ -245,7 +246,7 @@ class TestProximity(unittest.TestCase):
self.hass.block_till_done() self.hass.block_till_done()
state = self.hass.states.get("proximity.home") state = self.hass.states.get("proximity.home")
assert state.attributes.get("nearest") == "test1" assert state.attributes.get("nearest") == "test1"
assert state.attributes.get("dir_of_travel") == "away_from" assert state.attributes.get("dir_of_travel") == "towards"
def test_all_device_trackers_in_ignored_zone(self): def test_all_device_trackers_in_ignored_zone(self):
"""Test for tracker in ignored zone.""" """Test for tracker in ignored zone."""

View File

@ -1,60 +0,0 @@
"""Tests for zone config flow."""
from homeassistant.components.zone import config_flow
from homeassistant.components.zone.const import CONF_PASSIVE, DOMAIN, HOME_ZONE
from homeassistant.const import (
CONF_ICON,
CONF_LATITUDE,
CONF_LONGITUDE,
CONF_NAME,
CONF_RADIUS,
)
from tests.common import MockConfigEntry
async def test_flow_works(hass):
"""Test that config flow works."""
flow = config_flow.ZoneFlowHandler()
flow.hass = hass
result = await flow.async_step_init(
user_input={
CONF_NAME: "Name",
CONF_LATITUDE: "1.1",
CONF_LONGITUDE: "2.2",
CONF_RADIUS: "100",
CONF_ICON: "mdi:home",
CONF_PASSIVE: True,
}
)
assert result["type"] == "create_entry"
assert result["title"] == "Name"
assert result["data"] == {
CONF_NAME: "Name",
CONF_LATITUDE: "1.1",
CONF_LONGITUDE: "2.2",
CONF_RADIUS: "100",
CONF_ICON: "mdi:home",
CONF_PASSIVE: True,
}
async def test_flow_requires_unique_name(hass):
"""Test that config flow verifies that each zones name is unique."""
MockConfigEntry(domain=DOMAIN, data={CONF_NAME: "Name"}).add_to_hass(hass)
flow = config_flow.ZoneFlowHandler()
flow.hass = hass
result = await flow.async_step_init(user_input={CONF_NAME: "Name"})
assert result["errors"] == {"base": "name_exists"}
async def test_flow_requires_name_different_from_home(hass):
"""Test that config flow verifies that each zones name is unique."""
flow = config_flow.ZoneFlowHandler()
flow.hass = hass
result = await flow.async_step_init(user_input={CONF_NAME: HOME_ZONE})
assert result["errors"] == {"base": "name_exists"}

View File

@ -1,229 +1,224 @@
"""Test zone component.""" """Test zone component."""
from asynctest import patch
import unittest import pytest
from unittest.mock import Mock
from homeassistant import setup from homeassistant import setup
from homeassistant.components import zone from homeassistant.components import zone
from homeassistant.components.zone import DOMAIN
from homeassistant.const import (
ATTR_EDITABLE,
ATTR_FRIENDLY_NAME,
ATTR_ICON,
ATTR_NAME,
SERVICE_RELOAD,
)
from homeassistant.core import Context
from homeassistant.exceptions import Unauthorized
from homeassistant.helpers import entity_registry
from tests.common import MockConfigEntry, get_test_home_assistant from tests.common import MockConfigEntry
async def test_setup_entry_successful(hass): @pytest.fixture
"""Test setup entry is successful.""" def storage_setup(hass, hass_storage):
entry = Mock() """Storage setup."""
entry.data = {
zone.CONF_NAME: "Test Zone", async def _storage(items=None, config=None):
zone.CONF_LATITUDE: 1.1, if items is None:
zone.CONF_LONGITUDE: -2.2, hass_storage[DOMAIN] = {
zone.CONF_RADIUS: True, "key": DOMAIN,
"version": 1,
"data": {
"items": [
{
"id": "from_storage",
"name": "from storage",
"latitude": 1,
"longitude": 2,
"radius": 3,
"passive": False,
"icon": "mdi:from-storage",
}
]
},
}
else:
hass_storage[DOMAIN] = {
"key": DOMAIN,
"version": 1,
"data": {"items": items},
}
if config is None:
config = {}
return await setup.async_setup_component(hass, DOMAIN, config)
return _storage
async def test_setup_no_zones_still_adds_home_zone(hass):
"""Test if no config is passed in we still get the home zone."""
assert await setup.async_setup_component(hass, zone.DOMAIN, {"zone": None})
assert len(hass.states.async_entity_ids("zone")) == 1
state = hass.states.get("zone.home")
assert hass.config.location_name == state.name
assert hass.config.latitude == state.attributes["latitude"]
assert hass.config.longitude == state.attributes["longitude"]
assert not state.attributes.get("passive", False)
async def test_setup(hass):
"""Test a successful setup."""
info = {
"name": "Test Zone",
"latitude": 32.880837,
"longitude": -117.237561,
"radius": 250,
"passive": True,
} }
hass.data[zone.DOMAIN] = {} assert await setup.async_setup_component(hass, zone.DOMAIN, {"zone": info})
assert await zone.async_setup_entry(hass, entry) is True
assert "test_zone" in hass.data[zone.DOMAIN] assert len(hass.states.async_entity_ids("zone")) == 2
state = hass.states.get("zone.test_zone")
assert info["name"] == state.name
assert info["latitude"] == state.attributes["latitude"]
assert info["longitude"] == state.attributes["longitude"]
assert info["radius"] == state.attributes["radius"]
assert info["passive"] == state.attributes["passive"]
async def test_unload_entry_successful(hass): async def test_setup_zone_skips_home_zone(hass):
"""Test unload entry is successful.""" """Test that zone named Home should override hass home zone."""
entry = Mock() info = {"name": "Home", "latitude": 1.1, "longitude": -2.2}
entry.data = { assert await setup.async_setup_component(hass, zone.DOMAIN, {"zone": info})
zone.CONF_NAME: "Test Zone",
zone.CONF_LATITUDE: 1.1, assert len(hass.states.async_entity_ids("zone")) == 1
zone.CONF_LONGITUDE: -2.2, state = hass.states.get("zone.home")
} assert info["name"] == state.name
hass.data[zone.DOMAIN] = {}
assert await zone.async_setup_entry(hass, entry) is True
assert await zone.async_unload_entry(hass, entry) is True
assert not hass.data[zone.DOMAIN]
class TestComponentZone(unittest.TestCase): async def test_setup_name_can_be_same_on_multiple_zones(hass):
"""Test the zone component.""" """Test that zone named Home should override hass home zone."""
info = {"name": "Test Zone", "latitude": 1.1, "longitude": -2.2}
assert await setup.async_setup_component(hass, zone.DOMAIN, {"zone": [info, info]})
assert len(hass.states.async_entity_ids("zone")) == 3
def setUp(self): # pylint: disable=invalid-name
"""Set up things to be run when tests are started."""
self.hass = get_test_home_assistant()
def tearDown(self): # pylint: disable=invalid-name async def test_active_zone_skips_passive_zones(hass):
"""Stop down everything that was started.""" """Test active and passive zones."""
self.hass.stop() assert await setup.async_setup_component(
hass,
zone.DOMAIN,
{
"zone": [
{
"name": "Passive Zone",
"latitude": 32.880600,
"longitude": -117.237561,
"radius": 250,
"passive": True,
}
]
},
)
await hass.async_block_till_done()
active = zone.async_active_zone(hass, 32.880600, -117.237561)
assert active is None
def test_setup_no_zones_still_adds_home_zone(self):
"""Test if no config is passed in we still get the home zone."""
assert setup.setup_component(self.hass, zone.DOMAIN, {"zone": None})
assert len(self.hass.states.entity_ids("zone")) == 1
state = self.hass.states.get("zone.home")
assert self.hass.config.location_name == state.name
assert self.hass.config.latitude == state.attributes["latitude"]
assert self.hass.config.longitude == state.attributes["longitude"]
assert not state.attributes.get("passive", False)
def test_setup(self): async def test_active_zone_skips_passive_zones_2(hass):
"""Test a successful setup.""" """Test active and passive zones."""
info = { assert await setup.async_setup_component(
"name": "Test Zone", hass,
"latitude": 32.880837, zone.DOMAIN,
"longitude": -117.237561, {
"radius": 250, "zone": [
"passive": True, {
} "name": "Active Zone",
assert setup.setup_component(self.hass, zone.DOMAIN, {"zone": info}) "latitude": 32.880800,
"longitude": -117.237561,
"radius": 500,
}
]
},
)
await hass.async_block_till_done()
active = zone.async_active_zone(hass, 32.880700, -117.237561)
assert "zone.active_zone" == active.entity_id
assert len(self.hass.states.entity_ids("zone")) == 2
state = self.hass.states.get("zone.test_zone")
assert info["name"] == state.name
assert info["latitude"] == state.attributes["latitude"]
assert info["longitude"] == state.attributes["longitude"]
assert info["radius"] == state.attributes["radius"]
assert info["passive"] == state.attributes["passive"]
def test_setup_zone_skips_home_zone(self): async def test_active_zone_prefers_smaller_zone_if_same_distance(hass):
"""Test that zone named Home should override hass home zone.""" """Test zone size preferences."""
info = {"name": "Home", "latitude": 1.1, "longitude": -2.2} latitude = 32.880600
assert setup.setup_component(self.hass, zone.DOMAIN, {"zone": info}) longitude = -117.237561
assert await setup.async_setup_component(
hass,
zone.DOMAIN,
{
"zone": [
{
"name": "Small Zone",
"latitude": latitude,
"longitude": longitude,
"radius": 250,
},
{
"name": "Big Zone",
"latitude": latitude,
"longitude": longitude,
"radius": 500,
},
]
},
)
assert len(self.hass.states.entity_ids("zone")) == 1 active = zone.async_active_zone(hass, latitude, longitude)
state = self.hass.states.get("zone.home") assert "zone.small_zone" == active.entity_id
assert info["name"] == state.name
def test_setup_name_can_be_same_on_multiple_zones(self):
"""Test that zone named Home should override hass home zone."""
info = {"name": "Test Zone", "latitude": 1.1, "longitude": -2.2}
assert setup.setup_component(self.hass, zone.DOMAIN, {"zone": [info, info]})
assert len(self.hass.states.entity_ids("zone")) == 3
def test_setup_registered_zone_skips_home_zone(self): async def test_active_zone_prefers_smaller_zone_if_same_distance_2(hass):
"""Test that config entry named home should override hass home zone.""" """Test zone size preferences."""
entry = MockConfigEntry(domain=zone.DOMAIN, data={zone.CONF_NAME: "home"}) latitude = 32.880600
entry.add_to_hass(self.hass) longitude = -117.237561
assert setup.setup_component(self.hass, zone.DOMAIN, {"zone": None}) assert await setup.async_setup_component(
assert len(self.hass.states.entity_ids("zone")) == 0 hass,
zone.DOMAIN,
{
"zone": [
{
"name": "Smallest Zone",
"latitude": latitude,
"longitude": longitude,
"radius": 50,
}
]
},
)
def test_setup_registered_zone_skips_configured_zone(self): active = zone.async_active_zone(hass, latitude, longitude)
"""Test if config entry will override configured zone.""" assert "zone.smallest_zone" == active.entity_id
entry = MockConfigEntry(domain=zone.DOMAIN, data={zone.CONF_NAME: "Test Zone"})
entry.add_to_hass(self.hass)
info = {"name": "Test Zone", "latitude": 1.1, "longitude": -2.2}
assert setup.setup_component(self.hass, zone.DOMAIN, {"zone": info})
assert len(self.hass.states.entity_ids("zone")) == 1
state = self.hass.states.get("zone.test_zone")
assert not state
def test_active_zone_skips_passive_zones(self): async def test_in_zone_works_for_passive_zones(hass):
"""Test active and passive zones.""" """Test working in passive zones."""
assert setup.setup_component( latitude = 32.880600
self.hass, longitude = -117.237561
zone.DOMAIN, assert await setup.async_setup_component(
{ hass,
"zone": [ zone.DOMAIN,
{ {
"name": "Passive Zone", "zone": [
"latitude": 32.880600, {
"longitude": -117.237561, "name": "Passive Zone",
"radius": 250, "latitude": latitude,
"passive": True, "longitude": longitude,
} "radius": 250,
] "passive": True,
}, }
) ]
self.hass.block_till_done() },
active = zone.async_active_zone(self.hass, 32.880600, -117.237561) )
assert active is None
def test_active_zone_skips_passive_zones_2(self): assert zone.in_zone(hass.states.get("zone.passive_zone"), latitude, longitude)
"""Test active and passive zones."""
assert setup.setup_component(
self.hass,
zone.DOMAIN,
{
"zone": [
{
"name": "Active Zone",
"latitude": 32.880800,
"longitude": -117.237561,
"radius": 500,
}
]
},
)
self.hass.block_till_done()
active = zone.async_active_zone(self.hass, 32.880700, -117.237561)
assert "zone.active_zone" == active.entity_id
def test_active_zone_prefers_smaller_zone_if_same_distance(self):
"""Test zone size preferences."""
latitude = 32.880600
longitude = -117.237561
assert setup.setup_component(
self.hass,
zone.DOMAIN,
{
"zone": [
{
"name": "Small Zone",
"latitude": latitude,
"longitude": longitude,
"radius": 250,
},
{
"name": "Big Zone",
"latitude": latitude,
"longitude": longitude,
"radius": 500,
},
]
},
)
active = zone.async_active_zone(self.hass, latitude, longitude)
assert "zone.small_zone" == active.entity_id
def test_active_zone_prefers_smaller_zone_if_same_distance_2(self):
"""Test zone size preferences."""
latitude = 32.880600
longitude = -117.237561
assert setup.setup_component(
self.hass,
zone.DOMAIN,
{
"zone": [
{
"name": "Smallest Zone",
"latitude": latitude,
"longitude": longitude,
"radius": 50,
}
]
},
)
active = zone.async_active_zone(self.hass, latitude, longitude)
assert "zone.smallest_zone" == active.entity_id
def test_in_zone_works_for_passive_zones(self):
"""Test working in passive zones."""
latitude = 32.880600
longitude = -117.237561
assert setup.setup_component(
self.hass,
zone.DOMAIN,
{
"zone": [
{
"name": "Passive Zone",
"latitude": latitude,
"longitude": longitude,
"radius": 250,
"passive": True,
}
]
},
)
assert zone.zone.in_zone(
self.hass.states.get("zone.passive_zone"), latitude, longitude
)
async def test_core_config_update(hass): async def test_core_config_update(hass):
@ -243,3 +238,252 @@ async def test_core_config_update(hass):
assert home_updated.name == "Updated Name" assert home_updated.name == "Updated Name"
assert home_updated.attributes["latitude"] == 10 assert home_updated.attributes["latitude"] == 10
assert home_updated.attributes["longitude"] == 20 assert home_updated.attributes["longitude"] == 20
async def test_reload(hass, hass_admin_user, hass_read_only_user):
"""Test reload service."""
count_start = len(hass.states.async_entity_ids())
ent_reg = await entity_registry.async_get_registry(hass)
assert await setup.async_setup_component(
hass,
DOMAIN,
{
DOMAIN: [
{"name": "yaml 1", "latitude": 1, "longitude": 2},
{"name": "yaml 2", "latitude": 3, "longitude": 4},
],
},
)
assert count_start + 3 == len(hass.states.async_entity_ids())
state_1 = hass.states.get("zone.yaml_1")
state_2 = hass.states.get("zone.yaml_2")
state_3 = hass.states.get("zone.yaml_3")
assert state_1 is not None
assert state_1.attributes["latitude"] == 1
assert state_1.attributes["longitude"] == 2
assert state_2 is not None
assert state_2.attributes["latitude"] == 3
assert state_2.attributes["longitude"] == 4
assert state_3 is None
assert len(ent_reg.entities) == 0
with patch(
"homeassistant.config.load_yaml_config_file",
autospec=True,
return_value={
DOMAIN: [
{"name": "yaml 2", "latitude": 3, "longitude": 4},
{"name": "yaml 3", "latitude": 5, "longitude": 6},
]
},
):
with pytest.raises(Unauthorized):
await hass.services.async_call(
DOMAIN,
SERVICE_RELOAD,
blocking=True,
context=Context(user_id=hass_read_only_user.id),
)
await hass.services.async_call(
DOMAIN,
SERVICE_RELOAD,
blocking=True,
context=Context(user_id=hass_admin_user.id),
)
await hass.async_block_till_done()
assert count_start + 3 == len(hass.states.async_entity_ids())
state_1 = hass.states.get("zone.yaml_1")
state_2 = hass.states.get("zone.yaml_2")
state_3 = hass.states.get("zone.yaml_3")
assert state_1 is None
assert state_2 is not None
assert state_2.attributes["latitude"] == 3
assert state_2.attributes["longitude"] == 4
assert state_3 is not None
assert state_3.attributes["latitude"] == 5
assert state_3.attributes["longitude"] == 6
async def test_load_from_storage(hass, storage_setup):
"""Test set up from storage."""
assert await storage_setup()
state = hass.states.get(f"{DOMAIN}.from_storage")
assert state.state == "zoning"
assert state.name == "from storage"
assert state.attributes.get(ATTR_EDITABLE)
async def test_editable_state_attribute(hass, storage_setup):
"""Test editable attribute."""
assert await storage_setup(
config={DOMAIN: [{"name": "yaml option", "latitude": 3, "longitude": 4}]}
)
state = hass.states.get(f"{DOMAIN}.from_storage")
assert state.state == "zoning"
assert state.attributes.get(ATTR_FRIENDLY_NAME) == "from storage"
assert state.attributes.get(ATTR_EDITABLE)
state = hass.states.get(f"{DOMAIN}.yaml_option")
assert state.state == "zoning"
assert not state.attributes.get(ATTR_EDITABLE)
async def test_ws_list(hass, hass_ws_client, storage_setup):
"""Test listing via WS."""
assert await storage_setup(
config={DOMAIN: [{"name": "yaml option", "latitude": 3, "longitude": 4}]}
)
client = await hass_ws_client(hass)
await client.send_json({"id": 6, "type": f"{DOMAIN}/list"})
resp = await client.receive_json()
assert resp["success"]
storage_ent = "from_storage"
yaml_ent = "from_yaml"
result = {item["id"]: item for item in resp["result"]}
assert len(result) == 1
assert storage_ent in result
assert yaml_ent not in result
assert result[storage_ent][ATTR_NAME] == "from storage"
async def test_ws_delete(hass, hass_ws_client, storage_setup):
"""Test WS delete cleans up entity registry."""
assert await storage_setup()
input_id = "from_storage"
input_entity_id = f"{DOMAIN}.{input_id}"
ent_reg = await entity_registry.async_get_registry(hass)
state = hass.states.get(input_entity_id)
assert state is not None
assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None
client = await hass_ws_client(hass)
await client.send_json(
{"id": 6, "type": f"{DOMAIN}/delete", f"{DOMAIN}_id": f"{input_id}"}
)
resp = await client.receive_json()
assert resp["success"]
state = hass.states.get(input_entity_id)
assert state is None
assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None
async def test_update(hass, hass_ws_client, storage_setup):
"""Test updating min/max updates the state."""
items = [
{
"id": "from_storage",
"name": "from storage",
"latitude": 1,
"longitude": 2,
"radius": 3,
"passive": False,
}
]
assert await storage_setup(items)
input_id = "from_storage"
input_entity_id = f"{DOMAIN}.{input_id}"
ent_reg = await entity_registry.async_get_registry(hass)
state = hass.states.get(input_entity_id)
assert state.attributes["latitude"] == 1
assert state.attributes["longitude"] == 2
assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None
client = await hass_ws_client(hass)
await client.send_json(
{
"id": 6,
"type": f"{DOMAIN}/update",
f"{DOMAIN}_id": f"{input_id}",
"latitude": 3,
"longitude": 4,
"passive": True,
}
)
resp = await client.receive_json()
assert resp["success"]
state = hass.states.get(input_entity_id)
assert state.attributes["latitude"] == 3
assert state.attributes["longitude"] == 4
assert state.attributes["passive"] is True
async def test_ws_create(hass, hass_ws_client, storage_setup):
"""Test create WS."""
assert await storage_setup(items=[])
input_id = "new_input"
input_entity_id = f"{DOMAIN}.{input_id}"
ent_reg = await entity_registry.async_get_registry(hass)
state = hass.states.get(input_entity_id)
assert state is None
assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None
client = await hass_ws_client(hass)
await client.send_json(
{
"id": 6,
"type": f"{DOMAIN}/create",
"name": "New Input",
"latitude": 3,
"longitude": 4,
"passive": True,
}
)
resp = await client.receive_json()
assert resp["success"]
state = hass.states.get(input_entity_id)
assert state.state == "zoning"
assert state.attributes["latitude"] == 3
assert state.attributes["longitude"] == 4
assert state.attributes["passive"] is True
async def test_import_config_entry(hass):
"""Test we import config entry and then delete it."""
entry = MockConfigEntry(
domain="zone",
data={
"name": "from config entry",
"latitude": 1,
"longitude": 2,
"radius": 3,
"passive": False,
"icon": "mdi:from-config-entry",
},
)
entry.add_to_hass(hass)
assert await setup.async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
assert len(hass.config_entries.async_entries()) == 0
state = hass.states.get("zone.from_config_entry")
assert state is not None
assert state.attributes[zone.ATTR_LATITUDE] == 1
assert state.attributes[zone.ATTR_LONGITUDE] == 2
assert state.attributes[zone.ATTR_RADIUS] == 3
assert state.attributes[zone.ATTR_PASSIVE] is False
assert state.attributes[ATTR_ICON] == "mdi:from-config-entry"