Zone component config entry support (#14059)

* Initial commit

* Add error handling to config flow
Change unique identifyer to name
Clean up hound comments

* Ensure hass home zone is created with correct entity id
Fix failing tests

* Fix rest of tests

* Move zone tests to zone folder
Create config flow tests

* Add possibility to unload entry

* Use hass.data instead of globas

* Don't calculate configures zones every loop iteration

* No need to know about home zone during setup of entry

* Only use name as title

* Don't cache hass home zone

* Add new tests for setup and setup entry

* Break out functionality from init to zone.py

* Make hass home zone be created directly

* Make sure that config flow doesn't override hass home zone

* A newline was missing in const

* Configured zones shall not be imported
Removed config flow import functionality
Improved tests
This commit is contained in:
Kane610 2018-04-26 23:59:22 +02:00 committed by Paulus Schoutsen
parent f5de2b9e5b
commit 4b06392442
13 changed files with 351 additions and 83 deletions

View File

@ -15,6 +15,7 @@ from homeassistant.setup import async_prepare_setup_platform
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.loader import bind_hass from homeassistant.loader import bind_hass
from homeassistant.components import group, zone from homeassistant.components import group, zone
from homeassistant.components.zone.zone import async_active_zone
from homeassistant.config import load_yaml_config_file, async_log_exception from homeassistant.config import load_yaml_config_file, async_log_exception
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_per_platform, discovery from homeassistant.helpers import config_per_platform, discovery
@ -541,7 +542,7 @@ class Device(Entity):
elif self.location_name: elif self.location_name:
self._state = self.location_name self._state = self.location_name
elif self.gps is not None and self.source_type == SOURCE_TYPE_GPS: elif self.gps is not None and self.source_type == SOURCE_TYPE_GPS:
zone_state = zone.async_active_zone( zone_state = async_active_zone(
self.hass, self.gps[0], self.gps[1], self.gps_accuracy) self.hass, self.gps[0], self.gps[1], self.gps_accuracy)
if zone_state is None: if zone_state is None:
self._state = STATE_NOT_HOME self._state = STATE_NOT_HOME

View File

@ -13,7 +13,7 @@ import voluptuous as vol
from homeassistant.const import CONF_USERNAME, CONF_PASSWORD from homeassistant.const import CONF_USERNAME, CONF_PASSWORD
from homeassistant.components.device_tracker import ( from homeassistant.components.device_tracker import (
PLATFORM_SCHEMA, DOMAIN, ATTR_ATTRIBUTES, ENTITY_ID_FORMAT, DeviceScanner) PLATFORM_SCHEMA, DOMAIN, ATTR_ATTRIBUTES, ENTITY_ID_FORMAT, DeviceScanner)
from homeassistant.components.zone import active_zone from homeassistant.components.zone.zone import active_zone
from homeassistant.helpers.event import track_utc_time_change from homeassistant.helpers.event import track_utc_time_change
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.util import slugify from homeassistant.util import slugify

View File

@ -0,0 +1,21 @@
{
"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

@ -0,0 +1,93 @@
"""
Support for the definition of zones.
For more details about this component, please refer to the documentation at
https://home-assistant.io/components/zone/
"""
import logging
import voluptuous as vol
import homeassistant.helpers.config_validation as cv
from homeassistant.const import (
CONF_NAME, CONF_LATITUDE, CONF_LONGITUDE, CONF_ICON, CONF_RADIUS)
from homeassistant.helpers import config_per_platform
from homeassistant.helpers.entity import async_generate_entity_id
from homeassistant.util import slugify
from .config_flow import configured_zones
from .const import CONF_PASSIVE, DOMAIN, HOME_ZONE
from .zone import Zone
_LOGGER = logging.getLogger(__name__)
DEFAULT_NAME = 'Unnamed zone'
DEFAULT_PASSIVE = False
DEFAULT_RADIUS = 100
ENTITY_ID_FORMAT = 'zone.{}'
ENTITY_ID_HOME = ENTITY_ID_FORMAT.format(HOME_ZONE)
ICON_HOME = 'mdi:home'
ICON_IMPORT = 'mdi:import'
# The config that zone accepts is the same as if it has platforms.
PLATFORM_SCHEMA = vol.Schema({
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Required(CONF_LATITUDE): cv.latitude,
vol.Required(CONF_LONGITUDE): cv.longitude,
vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS): vol.Coerce(float),
vol.Optional(CONF_PASSIVE, default=DEFAULT_PASSIVE): cv.boolean,
vol.Optional(CONF_ICON): cv.icon,
}, extra=vol.ALLOW_EXTRA)
async def async_setup(hass, config):
"""Setup configured zones as well as home assistant zone if necessary."""
if DOMAIN not in hass.data:
hass.data[DOMAIN] = {}
zone_entries = configured_zones(hass)
for _, entry in config_per_platform(config, DOMAIN):
name = slugify(entry[CONF_NAME])
if 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], None, hass)
hass.async_add_job(zone.async_update_ha_state())
hass.data[DOMAIN][name] = zone
if HOME_ZONE not in hass.data[DOMAIN] and HOME_ZONE not in zone_entries:
name = hass.config.location_name
zone = Zone(hass, name, hass.config.latitude, hass.config.longitude,
DEFAULT_RADIUS, ICON_HOME, False)
zone.entity_id = ENTITY_ID_HOME
hass.async_add_job(zone.async_update_ha_state())
hass.data[DOMAIN][slugify(name)] = zone
return True
async def async_setup_entry(hass, config_entry):
"""Set up zone as config entry."""
entry = config_entry.data
name = entry[CONF_NAME]
zone = Zone(hass, 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, name, None, hass)
hass.async_add_job(zone.async_update_ha_state())
hass.data[DOMAIN][slugify(name)] = zone
return True
async def async_unload_entry(hass, config_entry):
"""Unload a config entry."""
zones = hass.data[DOMAIN]
name = slugify(config_entry.data[CONF_NAME])
zone = zones.pop(name)
await zone.async_remove()
return True

View File

@ -0,0 +1,56 @@
"""Config flow to configure zone component."""
import voluptuous as vol
import homeassistant.helpers.config_validation as cv
from homeassistant import config_entries, data_entry_flow
from homeassistant.const import (
CONF_NAME, CONF_LATITUDE, CONF_LONGITUDE, CONF_ICON, CONF_RADIUS)
from homeassistant.core import callback
from homeassistant.util import slugify
from .const import CONF_PASSIVE, DOMAIN, HOME_ZONE
@callback
def configured_zones(hass):
"""Return a set of the configured hosts."""
return set((slugify(entry.data[CONF_NAME])) for
entry in hass.config_entries.async_entries(DOMAIN))
@config_entries.HANDLERS.register(DOMAIN)
class ZoneFlowHandler(data_entry_flow.FlowHandler):
"""Zone config flow."""
VERSION = 1
def __init__(self):
"""Initialize zone configuration flow."""
pass
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

@ -0,0 +1,5 @@
"""Constants for the zone component."""
CONF_PASSIVE = 'passive'
DOMAIN = 'zone'
HOME_ZONE = 'home'

View File

@ -0,0 +1,21 @@
{
"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,54 +1,18 @@
""" """Component entity and functionality."""
Support for the definition of zones.
For more details about this component, please refer to the documentation at from homeassistant.const import ATTR_HIDDEN, ATTR_LATITUDE, ATTR_LONGITUDE
https://home-assistant.io/components/zone/ from homeassistant.helpers.entity import Entity
"""
import asyncio
import logging
import voluptuous as vol
import homeassistant.helpers.config_validation as cv
from homeassistant.const import (
ATTR_HIDDEN, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_NAME, CONF_LATITUDE,
CONF_LONGITUDE, CONF_ICON, CONF_RADIUS)
from homeassistant.loader import bind_hass from homeassistant.loader import bind_hass
from homeassistant.helpers import config_per_platform
from homeassistant.helpers.entity import Entity, async_generate_entity_id
from homeassistant.util.async_ import run_callback_threadsafe from homeassistant.util.async_ import run_callback_threadsafe
from homeassistant.util.location import distance from homeassistant.util.location import distance
_LOGGER = logging.getLogger(__name__) from .const import DOMAIN
ATTR_PASSIVE = 'passive' ATTR_PASSIVE = 'passive'
ATTR_RADIUS = 'radius' ATTR_RADIUS = 'radius'
CONF_PASSIVE = 'passive'
DEFAULT_NAME = 'Unnamed zone'
DEFAULT_PASSIVE = False
DEFAULT_RADIUS = 100
DOMAIN = 'zone'
ENTITY_ID_FORMAT = 'zone.{}'
ENTITY_ID_HOME = ENTITY_ID_FORMAT.format('home')
ICON_HOME = 'mdi:home'
ICON_IMPORT = 'mdi:import'
STATE = 'zoning' STATE = 'zoning'
# The config that zone accepts is the same as if it has platforms.
PLATFORM_SCHEMA = vol.Schema({
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Required(CONF_LATITUDE): cv.latitude,
vol.Required(CONF_LONGITUDE): cv.longitude,
vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS): vol.Coerce(float),
vol.Optional(CONF_PASSIVE, default=DEFAULT_PASSIVE): cv.boolean,
vol.Optional(CONF_ICON): cv.icon,
}, extra=vol.ALLOW_EXTRA)
@bind_hass @bind_hass
def active_zone(hass, latitude, longitude, radius=0): def active_zone(hass, latitude, longitude, radius=0):
@ -104,32 +68,6 @@ def in_zone(zone, latitude, longitude, radius=0):
return zone_dist - radius < zone.attributes[ATTR_RADIUS] return zone_dist - radius < zone.attributes[ATTR_RADIUS]
@asyncio.coroutine
def async_setup(hass, config):
"""Set up the zone."""
entities = set()
tasks = []
for _, entry in config_per_platform(config, DOMAIN):
name = entry.get(CONF_NAME)
zone = Zone(hass, 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, name, entities)
tasks.append(zone.async_update_ha_state())
entities.add(zone.entity_id)
if ENTITY_ID_HOME not in entities:
zone = Zone(hass, hass.config.location_name,
hass.config.latitude, hass.config.longitude,
DEFAULT_RADIUS, ICON_HOME, False)
zone.entity_id = ENTITY_ID_HOME
tasks.append(zone.async_update_ha_state())
yield from asyncio.wait(tasks, loop=hass.loop)
return True
class Zone(Entity): class Zone(Entity):
"""Representation of a Zone.""" """Representation of a Zone."""

View File

@ -129,6 +129,7 @@ HANDLERS = Registry()
FLOWS = [ FLOWS = [
'deconz', 'deconz',
'hue', 'hue',
'zone',
] ]

View File

@ -393,8 +393,8 @@ def zone(hass, zone_ent, entity):
if latitude is None or longitude is None: if latitude is None or longitude is None:
return False return False
return zone_cmp.in_zone(zone_ent, latitude, longitude, return zone_cmp.zone.in_zone(zone_ent, latitude, longitude,
entity.attributes.get(ATTR_GPS_ACCURACY, 0)) entity.attributes.get(ATTR_GPS_ACCURACY, 0))
def zone_from_config(config, config_validation=True): def zone_from_config(config, config_validation=True):

View File

@ -0,0 +1 @@
"""Tests for the zone component."""

View File

@ -0,0 +1,55 @@
"""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_NAME, CONF_LATITUDE, CONF_LONGITUDE, CONF_ICON, 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,10 +1,42 @@
"""Test zone component.""" """Test zone component."""
import unittest import unittest
from unittest.mock import Mock
from homeassistant import setup from homeassistant import setup
from homeassistant.components import zone from homeassistant.components import zone
from tests.common import get_test_home_assistant from tests.common import get_test_home_assistant
from tests.common import MockConfigEntry
async def test_setup_entry_successful(hass):
"""Test setup entry is successful."""
entry = Mock()
entry.data = {
zone.CONF_NAME: 'Test Zone',
zone.CONF_LATITUDE: 1.1,
zone.CONF_LONGITUDE: -2.2,
zone.CONF_RADIUS: 250,
zone.CONF_RADIUS: True
}
hass.data[zone.DOMAIN] = {}
assert await zone.async_setup_entry(hass, entry) is True
assert 'test_zone' in hass.data[zone.DOMAIN]
async def test_unload_entry_successful(hass):
"""Test unload entry is successful."""
entry = Mock()
entry.data = {
zone.CONF_NAME: 'Test Zone',
zone.CONF_LATITUDE: 1.1,
zone.CONF_LONGITUDE: -2.2
}
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): class TestComponentZone(unittest.TestCase):
@ -20,18 +52,17 @@ class TestComponentZone(unittest.TestCase):
def test_setup_no_zones_still_adds_home_zone(self): def test_setup_no_zones_still_adds_home_zone(self):
"""Test if no config is passed in we still get the home zone.""" """Test if no config is passed in we still get the home zone."""
assert setup.setup_component(self.hass, zone.DOMAIN, assert setup.setup_component(self.hass, zone.DOMAIN, {'zone': None})
{'zone': None})
assert len(self.hass.states.entity_ids('zone')) == 1 assert len(self.hass.states.entity_ids('zone')) == 1
state = self.hass.states.get('zone.home') state = self.hass.states.get('zone.home')
assert self.hass.config.location_name == state.name assert self.hass.config.location_name == state.name
assert self.hass.config.latitude == state.attributes['latitude'] assert self.hass.config.latitude == state.attributes['latitude']
assert self.hass.config.longitude == state.attributes['longitude'] assert self.hass.config.longitude == state.attributes['longitude']
assert not state.attributes.get('passive', False) assert not state.attributes.get('passive', False)
assert 'test_home' in self.hass.data[zone.DOMAIN]
def test_setup(self): def test_setup(self):
"""Test setup.""" """Test a successful setup."""
info = { info = {
'name': 'Test Zone', 'name': 'Test Zone',
'latitude': 32.880837, 'latitude': 32.880837,
@ -39,16 +70,61 @@ class TestComponentZone(unittest.TestCase):
'radius': 250, 'radius': 250,
'passive': True 'passive': True
} }
assert setup.setup_component(self.hass, zone.DOMAIN, { assert setup.setup_component(self.hass, zone.DOMAIN, {'zone': info})
'zone': info
})
assert len(self.hass.states.entity_ids('zone')) == 2
state = self.hass.states.get('zone.test_zone') state = self.hass.states.get('zone.test_zone')
assert info['name'] == state.name assert info['name'] == state.name
assert info['latitude'] == state.attributes['latitude'] assert info['latitude'] == state.attributes['latitude']
assert info['longitude'] == state.attributes['longitude'] assert info['longitude'] == state.attributes['longitude']
assert info['radius'] == state.attributes['radius'] assert info['radius'] == state.attributes['radius']
assert info['passive'] == state.attributes['passive'] assert info['passive'] == state.attributes['passive']
assert 'test_zone' in self.hass.data[zone.DOMAIN]
assert 'test_home' in self.hass.data[zone.DOMAIN]
def test_setup_zone_skips_home_zone(self):
"""Test that zone named Home should override hass home zone."""
info = {
'name': 'Home',
'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.home')
assert info['name'] == state.name
assert 'home' in self.hass.data[zone.DOMAIN]
assert 'test_home' not in self.hass.data[zone.DOMAIN]
def test_setup_registered_zone_skips_home_zone(self):
"""Test that config entry named home should override hass home zone."""
entry = MockConfigEntry(domain=zone.DOMAIN, data={
zone.CONF_NAME: 'home'
})
entry.add_to_hass(self.hass)
assert setup.setup_component(self.hass, zone.DOMAIN, {'zone': None})
assert len(self.hass.states.entity_ids('zone')) == 0
assert not self.hass.data[zone.DOMAIN]
def test_setup_registered_zone_skips_configured_zone(self):
"""Test if config entry will override configured zone."""
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
assert 'test_zone' not in self.hass.data[zone.DOMAIN]
assert 'test_home' in self.hass.data[zone.DOMAIN]
def test_active_zone_skips_passive_zones(self): def test_active_zone_skips_passive_zones(self):
"""Test active and passive zones.""" """Test active and passive zones."""
@ -64,7 +140,7 @@ class TestComponentZone(unittest.TestCase):
] ]
}) })
self.hass.block_till_done() self.hass.block_till_done()
active = zone.active_zone(self.hass, 32.880600, -117.237561) active = zone.zone.active_zone(self.hass, 32.880600, -117.237561)
assert active is None assert active is None
def test_active_zone_skips_passive_zones_2(self): def test_active_zone_skips_passive_zones_2(self):
@ -80,7 +156,7 @@ class TestComponentZone(unittest.TestCase):
] ]
}) })
self.hass.block_till_done() self.hass.block_till_done()
active = zone.active_zone(self.hass, 32.880700, -117.237561) active = zone.zone.active_zone(self.hass, 32.880700, -117.237561)
assert 'zone.active_zone' == active.entity_id assert 'zone.active_zone' == active.entity_id
def test_active_zone_prefers_smaller_zone_if_same_distance(self): def test_active_zone_prefers_smaller_zone_if_same_distance(self):
@ -104,7 +180,7 @@ class TestComponentZone(unittest.TestCase):
] ]
}) })
active = zone.active_zone(self.hass, latitude, longitude) active = zone.zone.active_zone(self.hass, latitude, longitude)
assert 'zone.small_zone' == active.entity_id assert 'zone.small_zone' == active.entity_id
def test_active_zone_prefers_smaller_zone_if_same_distance_2(self): def test_active_zone_prefers_smaller_zone_if_same_distance_2(self):
@ -122,7 +198,7 @@ class TestComponentZone(unittest.TestCase):
] ]
}) })
active = zone.active_zone(self.hass, latitude, longitude) active = zone.zone.active_zone(self.hass, latitude, longitude)
assert 'zone.smallest_zone' == active.entity_id assert 'zone.smallest_zone' == active.entity_id
def test_in_zone_works_for_passive_zones(self): def test_in_zone_works_for_passive_zones(self):
@ -141,5 +217,5 @@ class TestComponentZone(unittest.TestCase):
] ]
}) })
assert zone.in_zone(self.hass.states.get('zone.passive_zone'), assert zone.zone.in_zone(self.hass.states.get('zone.passive_zone'),
latitude, longitude) latitude, longitude)