Zone component config entry support ()

* 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
pull/14083/merge
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.loader import bind_hass
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.exceptions import HomeAssistantError
from homeassistant.helpers import config_per_platform, discovery
@ -541,7 +542,7 @@ class Device(Entity):
elif self.location_name:
self._state = self.location_name
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)
if zone_state is None:
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.components.device_tracker import (
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
import homeassistant.helpers.config_validation as cv
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 @@
"""
Support for the definition of zones.
"""Component entity and functionality."""
For more details about this component, please refer to the documentation at
https://home-assistant.io/components/zone/
"""
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.const import ATTR_HIDDEN, ATTR_LATITUDE, ATTR_LONGITUDE
from homeassistant.helpers.entity import Entity
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.location import distance
_LOGGER = logging.getLogger(__name__)
from .const import DOMAIN
ATTR_PASSIVE = 'passive'
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'
# 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
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]
@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):
"""Representation of a Zone."""

View File

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

View File

@ -393,8 +393,8 @@ def zone(hass, zone_ent, entity):
if latitude is None or longitude is None:
return False
return zone_cmp.in_zone(zone_ent, latitude, longitude,
entity.attributes.get(ATTR_GPS_ACCURACY, 0))
return zone_cmp.zone.in_zone(zone_ent, latitude, longitude,
entity.attributes.get(ATTR_GPS_ACCURACY, 0))
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."""
import unittest
from unittest.mock import Mock
from homeassistant import setup
from homeassistant.components import zone
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):
@ -20,18 +52,17 @@ class TestComponentZone(unittest.TestCase):
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 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)
assert 'test_home' in self.hass.data[zone.DOMAIN]
def test_setup(self):
"""Test setup."""
"""Test a successful setup."""
info = {
'name': 'Test Zone',
'latitude': 32.880837,
@ -39,16 +70,61 @@ class TestComponentZone(unittest.TestCase):
'radius': 250,
'passive': True
}
assert setup.setup_component(self.hass, zone.DOMAIN, {
'zone': info
})
assert setup.setup_component(self.hass, zone.DOMAIN, {'zone': info})
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']
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):
"""Test active and passive zones."""
@ -64,7 +140,7 @@ class TestComponentZone(unittest.TestCase):
]
})
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
def test_active_zone_skips_passive_zones_2(self):
@ -80,7 +156,7 @@ class TestComponentZone(unittest.TestCase):
]
})
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
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
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
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'),
latitude, longitude)
assert zone.zone.in_zone(self.hass.states.get('zone.passive_zone'),
latitude, longitude)