From d73a4e1ed50fa4b324ee961e1cf14178588e5704 Mon Sep 17 00:00:00 2001 From: Tim van Cann Date: Sat, 6 Jun 2020 16:37:31 +0200 Subject: [PATCH] Add Avri config flow (#34288) * Add config flow to Avri integration * Add config flow validation * Update .coveragerc * Start adding config flow tests * Fix failing test * Fix pylint * Update homeassistant/components/avri/config_flow.py Co-authored-by: J. Nick Koston * Update homeassistant/components/avri/config_flow.py Co-authored-by: J. Nick Koston * Fix import order * Code review comments * Update homeassistant/components/avri/sensor.py Co-authored-by: J. Nick Koston * Remove device information Co-authored-by: J. Nick Koston --- .coveragerc | 1 + .../components/avri/.translations/en.json | 24 ++++++ .../components/avri/.translations/nl.json | 24 ++++++ homeassistant/components/avri/__init__.py | 62 ++++++++++++++ homeassistant/components/avri/config_flow.py | 74 +++++++++++++++++ homeassistant/components/avri/const.py | 8 ++ homeassistant/components/avri/manifest.json | 12 ++- homeassistant/components/avri/sensor.py | 69 ++++++---------- homeassistant/components/avri/strings.json | 24 ++++++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 6 ++ script/setup | 2 +- tests/components/avri/__init__.py | 1 + tests/components/avri/test_config_flow.py | 80 +++++++++++++++++++ 15 files changed, 344 insertions(+), 47 deletions(-) create mode 100644 homeassistant/components/avri/.translations/en.json create mode 100644 homeassistant/components/avri/.translations/nl.json create mode 100644 homeassistant/components/avri/config_flow.py create mode 100644 homeassistant/components/avri/const.py create mode 100644 homeassistant/components/avri/strings.json create mode 100644 tests/components/avri/__init__.py create mode 100644 tests/components/avri/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index d7ee4145ef4..aae365530d9 100644 --- a/.coveragerc +++ b/.coveragerc @@ -68,6 +68,7 @@ omit = homeassistant/components/aurora_abb_powerone/sensor.py homeassistant/components/avea/light.py homeassistant/components/avion/light.py + homeassistant/components/avri/const.py homeassistant/components/avri/sensor.py homeassistant/components/azure_event_hub/* homeassistant/components/azure_service_bus/* diff --git a/homeassistant/components/avri/.translations/en.json b/homeassistant/components/avri/.translations/en.json new file mode 100644 index 00000000000..83cd4232d42 --- /dev/null +++ b/homeassistant/components/avri/.translations/en.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "This address is already configured." + }, + "error": { + "invalid_country_code": "Unknown 2 letter country code.", + "invalid_house_number": "Invalid house number." + }, + "step": { + "user": { + "data": { + "country_code": "2 Letter country code", + "house_number": "House number", + "house_number_extension": "House number extension", + "zip_code": "Zip code" + }, + "description": "Enter your address", + "title": "Avri" + } + } + }, + "title": "Avri" +} \ No newline at end of file diff --git a/homeassistant/components/avri/.translations/nl.json b/homeassistant/components/avri/.translations/nl.json new file mode 100644 index 00000000000..22798b09689 --- /dev/null +++ b/homeassistant/components/avri/.translations/nl.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Dit adres is reeds geconfigureerd." + }, + "error": { + "invalid_country_code": "Onbekende landcode", + "invalid_house_number": "Ongeldig huisnummer." + }, + "step": { + "user": { + "data": { + "country_code": "2 Letter landcode", + "house_number": "Huisnummer", + "house_number_extension": "Huisnummer toevoeging", + "zip_code": "Postcode" + }, + "description": "Vul je adres in.", + "title": "Avri" + } + } + }, + "title": "Avri" +} \ No newline at end of file diff --git a/homeassistant/components/avri/__init__.py b/homeassistant/components/avri/__init__.py index 4d99b2ed0e4..3165b6ee87a 100644 --- a/homeassistant/components/avri/__init__.py +++ b/homeassistant/components/avri/__init__.py @@ -1 +1,63 @@ """The avri component.""" +import asyncio +from datetime import timedelta +import logging + +from avri.api import Avri + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import ( + CONF_COUNTRY_CODE, + CONF_HOUSE_NUMBER, + CONF_HOUSE_NUMBER_EXTENSION, + CONF_ZIP_CODE, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = ["sensor"] +SCAN_INTERVAL = timedelta(hours=4) + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Avri component.""" + hass.data[DOMAIN] = {} + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Avri from a config entry.""" + client = Avri( + postal_code=entry.data[CONF_ZIP_CODE], + house_nr=entry.data[CONF_HOUSE_NUMBER], + house_nr_extension=entry.data.get(CONF_HOUSE_NUMBER_EXTENSION), + country_code=entry.data[CONF_COUNTRY_CODE], + ) + + hass.data[DOMAIN][entry.entry_id] = client + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/avri/config_flow.py b/homeassistant/components/avri/config_flow.py new file mode 100644 index 00000000000..d6f9dbf7b62 --- /dev/null +++ b/homeassistant/components/avri/config_flow.py @@ -0,0 +1,74 @@ +"""Config flow for Avri component.""" +import pycountry +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_ID + +from .const import ( + CONF_COUNTRY_CODE, + CONF_HOUSE_NUMBER, + CONF_HOUSE_NUMBER_EXTENSION, + CONF_ZIP_CODE, + DEFAULT_COUNTRY_CODE, +) +from .const import DOMAIN # pylint:disable=unused-import + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_ZIP_CODE): str, + vol.Required(CONF_HOUSE_NUMBER): int, + vol.Optional(CONF_HOUSE_NUMBER_EXTENSION): str, + vol.Optional(CONF_COUNTRY_CODE, default=DEFAULT_COUNTRY_CODE): str, + } +) + + +class AvriConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Avri config flow.""" + + VERSION = 1 + + async def _show_setup_form(self, errors=None): + """Show the setup form to the user.""" + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors or {}, + ) + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + if user_input is None: + return await self._show_setup_form() + + zip_code = user_input[CONF_ZIP_CODE].replace(" ", "").upper() + + errors = {} + if user_input[CONF_HOUSE_NUMBER] <= 0: + errors[CONF_HOUSE_NUMBER] = "invalid_house_number" + return await self._show_setup_form(errors) + if not pycountry.countries.get(alpha_2=user_input[CONF_COUNTRY_CODE]): + errors[CONF_COUNTRY_CODE] = "invalid_country_code" + return await self._show_setup_form(errors) + + unique_id = ( + f"{zip_code}" + f" " + f"{user_input[CONF_HOUSE_NUMBER]}" + f'{user_input.get(CONF_HOUSE_NUMBER_EXTENSION, "")}' + ) + + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=unique_id, + data={ + CONF_ID: unique_id, + CONF_ZIP_CODE: zip_code, + CONF_HOUSE_NUMBER: user_input[CONF_HOUSE_NUMBER], + CONF_HOUSE_NUMBER_EXTENSION: user_input.get( + CONF_HOUSE_NUMBER_EXTENSION, "" + ), + CONF_COUNTRY_CODE: user_input[CONF_COUNTRY_CODE], + }, + ) diff --git a/homeassistant/components/avri/const.py b/homeassistant/components/avri/const.py new file mode 100644 index 00000000000..dab3491b356 --- /dev/null +++ b/homeassistant/components/avri/const.py @@ -0,0 +1,8 @@ +"""Constants for the Avri integration.""" +CONF_COUNTRY_CODE = "country_code" +CONF_ZIP_CODE = "zip_code" +CONF_HOUSE_NUMBER = "house_number" +CONF_HOUSE_NUMBER_EXTENSION = "house_number_extension" +DOMAIN = "avri" +ICON = "mdi:trash-can-outline" +DEFAULT_COUNTRY_CODE = "NL" diff --git a/homeassistant/components/avri/manifest.json b/homeassistant/components/avri/manifest.json index 41be3251b10..8a418bfb7bd 100644 --- a/homeassistant/components/avri/manifest.json +++ b/homeassistant/components/avri/manifest.json @@ -2,6 +2,12 @@ "domain": "avri", "name": "Avri", "documentation": "https://www.home-assistant.io/integrations/avri", - "requirements": ["avri-api==0.1.7"], - "codeowners": ["@timvancann"] -} + "requirements": [ + "avri-api==0.1.7", + "pycountry==19.8.18" + ], + "codeowners": [ + "@timvancann" + ], + "config_flow": true +} \ No newline at end of file diff --git a/homeassistant/components/avri/sensor.py b/homeassistant/components/avri/sensor.py index a221147f065..a4931004a92 100644 --- a/homeassistant/components/avri/sensor.py +++ b/homeassistant/components/avri/sensor.py @@ -1,45 +1,25 @@ """Support for Avri waste curbside collection pickup.""" -from datetime import timedelta import logging from avri.api import Avri, AvriException -import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_NAME +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ID, DEVICE_CLASS_TIMESTAMP from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import HomeAssistantType + +from .const import DOMAIN, ICON _LOGGER = logging.getLogger(__name__) -CONF_COUNTRY_CODE = "country_code" -CONF_ZIP_CODE = "zip_code" -CONF_HOUSE_NUMBER = "house_number" -CONF_HOUSE_NUMBER_EXTENSION = "house_number_extension" -DEFAULT_NAME = "avri" -ICON = "mdi:trash-can-outline" -SCAN_INTERVAL = timedelta(hours=4) -DEFAULT_COUNTRY_CODE = "NL" - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_ZIP_CODE): cv.string, - vol.Required(CONF_HOUSE_NUMBER): cv.positive_int, - vol.Optional(CONF_HOUSE_NUMBER_EXTENSION): cv.string, - vol.Optional(CONF_COUNTRY_CODE, default=DEFAULT_COUNTRY_CODE): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - } -) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: """Set up the Avri Waste platform.""" - client = Avri( - postal_code=config[CONF_ZIP_CODE], - house_nr=config[CONF_HOUSE_NUMBER], - house_nr_extension=config.get(CONF_HOUSE_NUMBER_EXTENSION), - country_code=config[CONF_COUNTRY_CODE], - ) + client = hass.data[DOMAIN][entry.entry_id] + integration_id = entry.data[CONF_ID] try: each_upcoming = client.upcoming_of_each() @@ -47,22 +27,23 @@ def setup_platform(hass, config, add_entities, discovery_info=None): raise PlatformNotReady from ex else: entities = [ - AvriWasteUpcoming(config[CONF_NAME], client, upcoming.name) + AvriWasteUpcoming(client, upcoming.name, integration_id) for upcoming in each_upcoming ] - add_entities(entities, True) + async_add_entities(entities, True) class AvriWasteUpcoming(Entity): """Avri Waste Sensor.""" - def __init__(self, name: str, client: Avri, waste_type: str): + def __init__(self, client: Avri, waste_type: str, integration_id: str): """Initialize the sensor.""" self._waste_type = waste_type - self._name = f"{name}_{self._waste_type}" + self._name = f"{self._waste_type}".title() self._state = None self._client = client self._state_available = False + self._integration_id = integration_id @property def name(self): @@ -72,13 +53,7 @@ class AvriWasteUpcoming(Entity): @property def unique_id(self) -> str: """Return a unique ID.""" - return ( - f"{self._waste_type}" - f"-{self._client.country_code}" - f"-{self._client.postal_code}" - f"-{self._client.house_nr}" - f"-{self._client.house_nr_extension}" - ) + return (f"{self._integration_id}" f"-{self._waste_type}").replace(" ", "") @property def state(self): @@ -90,13 +65,21 @@ class AvriWasteUpcoming(Entity): """Return True if entity is available.""" return self._state_available + @property + def device_class(self): + """Return the device class of the sensor.""" + return DEVICE_CLASS_TIMESTAMP + @property def icon(self): """Icon to use in the frontend.""" return ICON - def update(self): - """Update device state.""" + async def async_update(self): + """Update the data.""" + if not self.enabled: + return + try: pickup_events = self._client.upcoming_of_each() except AvriException as ex: diff --git a/homeassistant/components/avri/strings.json b/homeassistant/components/avri/strings.json new file mode 100644 index 00000000000..9c7af6e47f2 --- /dev/null +++ b/homeassistant/components/avri/strings.json @@ -0,0 +1,24 @@ +{ + "title": "Avri", + "config": { + "abort": { + "already_configured": "This address is already configured." + }, + "error": { + "invalid_house_number": "Invalid house number.", + "invalid_country_code": "Unknown 2 letter country code." + }, + "step": { + "user": { + "data": { + "zip_code": "Zip code", + "house_number": "House number", + "house_number_extension": "House number extension", + "country_code": "2 Letter country code" + }, + "description": "Enter your address", + "title": "Avri" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 4d59a6d9bc2..d3d8b3ba929 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -17,6 +17,7 @@ FLOWS = [ "ambient_station", "atag", "august", + "avri", "axis", "blebox", "blink", diff --git a/requirements_all.txt b/requirements_all.txt index 850dd4f31bd..559c4c74f49 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1259,6 +1259,9 @@ pycomfoconnect==0.3 # homeassistant.components.coolmaster pycoolmasternet==0.0.4 +# homeassistant.components.avri +pycountry==19.8.18 + # homeassistant.components.microsoft pycsspeechtts==1.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 21e0e3e6745..5c848996850 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -146,6 +146,9 @@ async-upnp-client==0.14.13 # homeassistant.components.stream av==8.0.2 +# homeassistant.components.avri +avri-api==0.1.7 + # homeassistant.components.axis axis==29 @@ -544,6 +547,9 @@ pychromecast==6.0.0 # homeassistant.components.coolmaster pycoolmasternet==0.0.4 +# homeassistant.components.avri +pycountry==19.8.18 + # homeassistant.components.daikin pydaikin==2.1.1 diff --git a/script/setup b/script/setup index a8c9d628115..0076d70a7f0 100755 --- a/script/setup +++ b/script/setup @@ -8,4 +8,4 @@ cd "$(dirname "$0")/.." script/bootstrap pre-commit install -pip3 install -e . +pip install -e . diff --git a/tests/components/avri/__init__.py b/tests/components/avri/__init__.py new file mode 100644 index 00000000000..c5212855038 --- /dev/null +++ b/tests/components/avri/__init__.py @@ -0,0 +1 @@ +"""Tests for the Avri integration.""" diff --git a/tests/components/avri/test_config_flow.py b/tests/components/avri/test_config_flow.py new file mode 100644 index 00000000000..291f7669ebd --- /dev/null +++ b/tests/components/avri/test_config_flow.py @@ -0,0 +1,80 @@ +"""Test the Avri config flow.""" +from asynctest import patch + +from homeassistant import config_entries, setup +from homeassistant.components.avri.const import DOMAIN + + +async def test_form(hass): + """Test we get the form.""" + await setup.async_setup_component(hass, "avri", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + "homeassistant.components.avri.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "zip_code": "1234AB", + "house_number": 42, + "house_number_extension": "", + "country_code": "NL", + }, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "1234AB 42" + assert result2["data"] == { + "id": "1234AB 42", + "zip_code": "1234AB", + "house_number": 42, + "house_number_extension": "", + "country_code": "NL", + } + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_house_number(hass): + """Test we handle invalid house number.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "zip_code": "1234AB", + "house_number": -1, + "house_number_extension": "", + "country_code": "NL", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"house_number": "invalid_house_number"} + + +async def test_form_invalid_country_code(hass): + """Test we handle invalid county code.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "zip_code": "1234AB", + "house_number": 42, + "house_number_extension": "", + "country_code": "foo", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"country_code": "invalid_country_code"}