From 9d4b5ee58d7a12602f176cc8ea85118f4d91d3f0 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 14 Jul 2019 12:30:23 +0200 Subject: [PATCH] Add Twente Milieu integration (#25129) * Adds Twente Milieu integration * Addresses flake8 warnings * Adds required test deps * Fixes path typo in coveragerc * dispatcher_send -> async_dispatcher_send Signed-off-by: Franck Nijhof * Removes not needed __init__ Signed-off-by: Franck Nijhof * Remove explicitly setting None default value on get call Signed-off-by: Franck Nijhof * Correct typo in comment Signed-off-by: Franck Nijhof * Clean storage for only the unloaded entry Signed-off-by: Franck Nijhof * asyncio.wait on updating all integrations Signed-off-by: Franck Nijhof * Use string formatting Signed-off-by: Franck Nijhof * Set a more sane SCAN_INTERVAL Signed-off-by: Franck Nijhof * Small refactor around services Signed-off-by: Franck Nijhof * Small styling correction * Extract update logic into own function Signed-off-by: Franck Nijhof * Addresses flake8 warnings --- .coveragerc | 2 + CODEOWNERS | 1 + .../twentemilieu/.translations/en.json | 23 +++ .../components/twentemilieu/__init__.py | 103 ++++++++++++ .../components/twentemilieu/config_flow.py | 84 ++++++++++ .../components/twentemilieu/const.py | 9 + .../components/twentemilieu/manifest.json | 13 ++ .../components/twentemilieu/sensor.py | 154 ++++++++++++++++++ .../components/twentemilieu/services.yaml | 6 + .../components/twentemilieu/strings.json | 23 +++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + script/gen_requirements_all.py | 1 + tests/components/twentemilieu/__init__.py | 1 + .../twentemilieu/test_config_flow.py | 112 +++++++++++++ 16 files changed, 539 insertions(+) create mode 100644 homeassistant/components/twentemilieu/.translations/en.json create mode 100644 homeassistant/components/twentemilieu/__init__.py create mode 100644 homeassistant/components/twentemilieu/config_flow.py create mode 100644 homeassistant/components/twentemilieu/const.py create mode 100644 homeassistant/components/twentemilieu/manifest.json create mode 100644 homeassistant/components/twentemilieu/sensor.py create mode 100644 homeassistant/components/twentemilieu/services.yaml create mode 100644 homeassistant/components/twentemilieu/strings.json create mode 100644 tests/components/twentemilieu/__init__.py create mode 100644 tests/components/twentemilieu/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 940235c9028..cd58828c4d3 100644 --- a/.coveragerc +++ b/.coveragerc @@ -649,6 +649,8 @@ omit = homeassistant/components/transmission/* homeassistant/components/travisci/sensor.py homeassistant/components/tuya/* + homeassistant/components/twentemilieu/const.py + homeassistant/components/twentemilieu/sensor.py homeassistant/components/twilio_call/notify.py homeassistant/components/twilio_sms/notify.py homeassistant/components/twitch/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 66d2b4ef8f5..808618e31b6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -271,6 +271,7 @@ homeassistant/components/traccar/* @ludeeus homeassistant/components/tradfri/* @ggravlingen homeassistant/components/trafikverket_train/* @endor-force homeassistant/components/tts/* @robbiet480 +homeassistant/components/twentemilieu/* @frenck homeassistant/components/twilio_call/* @robbiet480 homeassistant/components/twilio_sms/* @robbiet480 homeassistant/components/unifi/* @kane610 diff --git a/homeassistant/components/twentemilieu/.translations/en.json b/homeassistant/components/twentemilieu/.translations/en.json new file mode 100644 index 00000000000..deabeeeb835 --- /dev/null +++ b/homeassistant/components/twentemilieu/.translations/en.json @@ -0,0 +1,23 @@ +{ + "config": { + "title": "Twente Milieu", + "step": { + "user": { + "title": "Twente Milieu", + "description": "Set up Twente Milieu providing waste collection information on your address.", + "data": { + "post_code": "Postal code", + "house_number": "House number", + "house_letter": "House letter/additional" + } + } + }, + "error": { + "connection_error": "Failed to connect.", + "invalid_address": "Address not found in Twente Milieu service area." + }, + "abort": { + "address_already_set_up": "Address already set up." + } + } +} diff --git a/homeassistant/components/twentemilieu/__init__.py b/homeassistant/components/twentemilieu/__init__.py new file mode 100644 index 00000000000..760c80c1452 --- /dev/null +++ b/homeassistant/components/twentemilieu/__init__.py @@ -0,0 +1,103 @@ +"""Support for Twente Milieu.""" +import asyncio +from datetime import timedelta +import logging +from typing import Optional + +from twentemilieu import TwenteMilieu +import voluptuous as vol + +from homeassistant.components.twentemilieu.const import ( + CONF_HOUSE_LETTER, + CONF_HOUSE_NUMBER, + CONF_POST_CODE, + DATA_UPDATE, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ID +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.typing import ConfigType, HomeAssistantType + +SCAN_INTERVAL = timedelta(seconds=3600) + +_LOGGER = logging.getLogger(__name__) + +SERVICE_UPDATE = "update" +SERVICE_SCHEMA = vol.Schema({vol.Optional(CONF_ID): cv.string}) + + +async def _update_twentemilieu( + hass: HomeAssistantType, + unique_id: Optional[str] +) -> None: + """Update Twente Milieu.""" + if unique_id is not None: + twentemilieu = hass.data[DOMAIN].get(unique_id) + if twentemilieu is not None: + await twentemilieu.update() + async_dispatcher_send(hass, DATA_UPDATE, unique_id) + else: + tasks = [] + for twentemilieu in hass.data[DOMAIN].values(): + tasks.append(twentemilieu.update()) + await asyncio.wait(tasks) + + for uid in hass.data[DOMAIN]: + async_dispatcher_send(hass, DATA_UPDATE, uid) + + +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: + """Set up the Twente Milieu components.""" + async def update(call) -> None: + """Service call to manually update the data.""" + unique_id = call.data.get(CONF_ID) + await _update_twentemilieu(hass, unique_id) + + hass.services.async_register( + DOMAIN, SERVICE_UPDATE, update, schema=SERVICE_SCHEMA + ) + + return True + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry +) -> bool: + """Set up Twente Milieu from a config entry.""" + session = async_get_clientsession(hass) + twentemilieu = TwenteMilieu( + post_code=entry.data[CONF_POST_CODE], + house_number=entry.data[CONF_HOUSE_NUMBER], + house_letter=entry.data[CONF_HOUSE_LETTER], + session=session, + ) + + unique_id = entry.data[CONF_ID] + hass.data.setdefault(DOMAIN, {})[unique_id] = twentemilieu + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "sensor") + ) + + async def _interval_update(now=None) -> None: + """Update Twente Milieu data.""" + await _update_twentemilieu(hass, unique_id) + + async_track_time_interval(hass, _interval_update, SCAN_INTERVAL) + + return True + + +async def async_unload_entry( + hass: HomeAssistantType, entry: ConfigEntry +) -> bool: + """Unload Twente Milieu config entry.""" + await hass.config_entries.async_forward_entry_unload(entry, "sensor") + + del hass.data[DOMAIN][entry.data[CONF_ID]] + + return True diff --git a/homeassistant/components/twentemilieu/config_flow.py b/homeassistant/components/twentemilieu/config_flow.py new file mode 100644 index 00000000000..8c2180ca0dd --- /dev/null +++ b/homeassistant/components/twentemilieu/config_flow.py @@ -0,0 +1,84 @@ +"""Config flow to configure the Twente Milieu integration.""" +import logging + +from twentemilieu import ( + TwenteMilieu, + TwenteMilieuAddressError, + TwenteMilieuConnectionError, +) +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.twentemilieu.const import ( + CONF_HOUSE_LETTER, + CONF_HOUSE_NUMBER, + CONF_POST_CODE, + DOMAIN, +) +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_ID +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +_LOGGER = logging.getLogger(__name__) + + +@config_entries.HANDLERS.register(DOMAIN) +class TwenteMilieuFlowHandler(ConfigFlow): + """Handle a Twente Milieu config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + 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=vol.Schema( + { + vol.Required(CONF_POST_CODE): str, + vol.Required(CONF_HOUSE_NUMBER): str, + vol.Optional(CONF_HOUSE_LETTER): str, + } + ), + errors=errors or {}, + ) + + async def async_step_user(self, user_input=None): + """Handle a flow initiated by the user.""" + if user_input is None: + return await self._show_setup_form(user_input) + + errors = {} + + session = async_get_clientsession(self.hass) + + twentemilieu = TwenteMilieu( + post_code=user_input[CONF_POST_CODE], + house_number=user_input[CONF_HOUSE_NUMBER], + house_letter=user_input.get(CONF_HOUSE_LETTER), + session=session, + ) + + try: + unique_id = await twentemilieu.unique_id() + except TwenteMilieuConnectionError: + errors["base"] = "connection_error" + return await self._show_setup_form(errors) + except TwenteMilieuAddressError: + errors["base"] = "invalid_address" + return await self._show_setup_form(errors) + + entries = self._async_current_entries() + for entry in entries: + if entry.data[CONF_ID] == unique_id: + return self.async_abort(reason="address_already_set_up") + + return self.async_create_entry( + title=unique_id, + data={ + CONF_ID: unique_id, + CONF_POST_CODE: user_input[CONF_POST_CODE], + CONF_HOUSE_NUMBER: user_input[CONF_HOUSE_NUMBER], + CONF_HOUSE_LETTER: user_input.get(CONF_HOUSE_LETTER), + }, + ) diff --git a/homeassistant/components/twentemilieu/const.py b/homeassistant/components/twentemilieu/const.py new file mode 100644 index 00000000000..30f770efd25 --- /dev/null +++ b/homeassistant/components/twentemilieu/const.py @@ -0,0 +1,9 @@ +"""Constants for the Twente Milieu integration.""" + +DOMAIN = "twentemilieu" + +DATA_UPDATE = "twentemilieu_update" + +CONF_POST_CODE = "post_code" +CONF_HOUSE_NUMBER = "house_number" +CONF_HOUSE_LETTER = "house_letter" diff --git a/homeassistant/components/twentemilieu/manifest.json b/homeassistant/components/twentemilieu/manifest.json new file mode 100644 index 00000000000..d1acf936a24 --- /dev/null +++ b/homeassistant/components/twentemilieu/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "twentemilieu", + "name": "Twente Milieu", + "config_flow": true, + "documentation": "https://www.home-assistant.io/components/twentemilieu", + "requirements": [ + "twentemilieu==0.1.0" + ], + "dependencies": [], + "codeowners": [ + "@frenck" + ] +} \ No newline at end of file diff --git a/homeassistant/components/twentemilieu/sensor.py b/homeassistant/components/twentemilieu/sensor.py new file mode 100644 index 00000000000..189a544ca8c --- /dev/null +++ b/homeassistant/components/twentemilieu/sensor.py @@ -0,0 +1,154 @@ +"""Support for Twente Milieu sensors.""" +import logging +from typing import Any, Dict + +from twentemilieu import ( + WASTE_TYPE_NON_RECYCLABLE, + WASTE_TYPE_ORGANIC, + WASTE_TYPE_PAPER, + WASTE_TYPE_PLASTIC, + TwenteMilieu, + TwenteMilieuConnectionError, +) + +from homeassistant.components.twentemilieu.const import DATA_UPDATE, DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ID +from homeassistant.core import callback +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import HomeAssistantType + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: + """Set up Twente Milieu sensor based on a config entry.""" + twentemilieu = hass.data[DOMAIN][entry.data[CONF_ID]] + + try: + await twentemilieu.update() + except TwenteMilieuConnectionError as exception: + raise PlatformNotReady from exception + + sensors = [ + TwenteMilieuSensor( + twentemilieu, + unique_id=entry.data[CONF_ID], + name="{} Waste Pickup".format(WASTE_TYPE_NON_RECYCLABLE), + waste_type=WASTE_TYPE_NON_RECYCLABLE, + icon="mdi:delete-empty", + ), + TwenteMilieuSensor( + twentemilieu, + unique_id=entry.data[CONF_ID], + name="{} Waste Pickup".format(WASTE_TYPE_ORGANIC), + waste_type=WASTE_TYPE_ORGANIC, + icon="mdi:delete-empty", + ), + TwenteMilieuSensor( + twentemilieu, + unique_id=entry.data[CONF_ID], + name="{} Waste Pickup".format(WASTE_TYPE_PAPER), + waste_type=WASTE_TYPE_PAPER, + icon="mdi:delete-empty", + ), + TwenteMilieuSensor( + twentemilieu, + unique_id=entry.data[CONF_ID], + name="{} Waste Pickup".format(WASTE_TYPE_PLASTIC), + waste_type=WASTE_TYPE_PLASTIC, + icon="mdi:delete-empty", + ), + ] + + async_add_entities(sensors, True) + + +class TwenteMilieuSensor(Entity): + """Defines a Twente Milieu sensor.""" + + def __init__( + self, + twentemilieu: TwenteMilieu, + unique_id: str, + name: str, + waste_type: str, + icon: str, + ) -> None: + """Initialize the Twente Milieu entity.""" + self._available = True + self._unique_id = unique_id + self._icon = icon + self._name = name + self._twentemilieu = twentemilieu + self._waste_type = waste_type + self._unsub_dispatcher = None + + self._state = None + + @property + def name(self) -> str: + """Return the name of the entity.""" + return self._name + + @property + def icon(self) -> str: + """Return the mdi icon of the entity.""" + return self._icon + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + + @property + def unique_id(self) -> str: + """Return the unique ID for this sensor.""" + return "{}_{}_{}".format(DOMAIN, self._unique_id, self._waste_type) + + @property + def should_poll(self) -> bool: + """Return the polling requirement of the entity.""" + return False + + async def async_added_to_hass(self) -> None: + """Connect to dispatcher listening for entity data notifications.""" + self._unsub_dispatcher = async_dispatcher_connect( + self.hass, DATA_UPDATE, self._schedule_immediate_update + ) + + async def async_will_remove_from_hass(self) -> None: + """Disconnect from update signal.""" + self._unsub_dispatcher() + + @callback + def _schedule_immediate_update(self, unique_id: str) -> None: + """Schedule an immediate update of the entity.""" + if unique_id == self._unique_id: + self.async_schedule_update_ha_state(True) + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + async def async_update(self) -> None: + """Update Twente Milieu entity.""" + next_pickup = await self._twentemilieu.next_pickup(self._waste_type) + if next_pickup is not None: + self._state = next_pickup.date().isoformat() + + @property + def device_info(self) -> Dict[str, Any]: + """Return device information about Twente Milieu.""" + return { + "identifiers": {(DOMAIN, self._unique_id)}, + "name": "Twente Milieu", + "manufacturer": "Twente Milieu", + } diff --git a/homeassistant/components/twentemilieu/services.yaml b/homeassistant/components/twentemilieu/services.yaml new file mode 100644 index 00000000000..7a5b1db301d --- /dev/null +++ b/homeassistant/components/twentemilieu/services.yaml @@ -0,0 +1,6 @@ +update: + description: Update all entities with fresh data from Twente Milieu + fields: + id: + description: Specific unique address ID to update + example: 1300012345 diff --git a/homeassistant/components/twentemilieu/strings.json b/homeassistant/components/twentemilieu/strings.json new file mode 100644 index 00000000000..b94b7146d2c --- /dev/null +++ b/homeassistant/components/twentemilieu/strings.json @@ -0,0 +1,23 @@ +{ + "config": { + "title": "Twente Milieu", + "step": { + "user": { + "title": "Twente Milieu", + "description": "Set up Twente Milieu providing waste collection information on your address.", + "data": { + "post_code": "Postal code", + "house_number": "House number", + "house_letter": "House letter/additional" + } + } + }, + "error": { + "connection_error": "Failed to connect.", + "invalid_address": "Address not found in Twente Milieu service area." + }, + "abort": { + "address_exists": "Address already set up." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 4a2cfcf5009..c8d8f262a86 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -52,6 +52,7 @@ FLOWS = [ "toon", "tplink", "tradfri", + "twentemilieu", "twilio", "unifi", "upnp", diff --git a/requirements_all.txt b/requirements_all.txt index f990576a61a..34235fcc8ef 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1830,6 +1830,9 @@ transmissionrpc==0.11 # homeassistant.components.tuya tuyaha==0.0.2 +# homeassistant.components.twentemilieu +twentemilieu==0.1.0 + # homeassistant.components.twilio twilio==6.19.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 61b218be7b5..6fcdc9f3fe9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -358,6 +358,9 @@ statsd==3.2.1 # homeassistant.components.toon toonapilib==3.2.4 +# homeassistant.components.twentemilieu +twentemilieu==0.1.0 + # homeassistant.components.uvc uvcclient==0.11.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index fc8656f0333..ae4c3591164 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -148,6 +148,7 @@ TEST_REQUIREMENTS = ( 'srpenergy', 'statsd', 'toonapilib', + 'twentemilieu', 'uvcclient', 'vsure', 'warrant', diff --git a/tests/components/twentemilieu/__init__.py b/tests/components/twentemilieu/__init__.py new file mode 100644 index 00000000000..d26f05978ac --- /dev/null +++ b/tests/components/twentemilieu/__init__.py @@ -0,0 +1 @@ +"""Tests for the Twente Milieu integration.""" diff --git a/tests/components/twentemilieu/test_config_flow.py b/tests/components/twentemilieu/test_config_flow.py new file mode 100644 index 00000000000..5a583761eb6 --- /dev/null +++ b/tests/components/twentemilieu/test_config_flow.py @@ -0,0 +1,112 @@ +"""Tests for the Twente Milieu config flow.""" +import aiohttp + +from homeassistant import data_entry_flow +from homeassistant.components.twentemilieu import config_flow +from homeassistant.components.twentemilieu.const import ( + CONF_HOUSE_LETTER, + CONF_HOUSE_NUMBER, + CONF_POST_CODE, + DOMAIN, +) +from homeassistant.const import CONF_ID + +from tests.common import MockConfigEntry + +FIXTURE_USER_INPUT = { + CONF_ID: "12345", + CONF_POST_CODE: "1234AB", + CONF_HOUSE_NUMBER: "1", + CONF_HOUSE_LETTER: "A", +} + + +async def test_show_set_form(hass): + """Test that the setup form is served.""" + flow = config_flow.TwenteMilieuFlowHandler() + flow.hass = hass + result = await flow.async_step_user(user_input=None) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + +async def test_connection_error(hass, aioclient_mock): + """Test we show user form on Twente Milieu connection error.""" + aioclient_mock.post( + "https://wasteapi.2go-mobile.com/api/FetchAdress", + exc=aiohttp.ClientError, + ) + + flow = config_flow.TwenteMilieuFlowHandler() + flow.hass = hass + result = await flow.async_step_user(user_input=FIXTURE_USER_INPUT) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "connection_error"} + + +async def test_invalid_address(hass, aioclient_mock): + """Test we show user form on Twente Milieu invalid address error.""" + aioclient_mock.post( + "https://wasteapi.2go-mobile.com/api/FetchAdress", + json={"dataList": []}, + headers={"Content-Type": "application/json"}, + ) + + flow = config_flow.TwenteMilieuFlowHandler() + flow.hass = hass + result = await flow.async_step_user(user_input=FIXTURE_USER_INPUT) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_address"} + + +async def test_address_already_set_up(hass, aioclient_mock): + """Test we abort if address has already been set up.""" + MockConfigEntry( + domain=DOMAIN, data=FIXTURE_USER_INPUT, title="12345" + ).add_to_hass(hass) + + aioclient_mock.post( + "https://wasteapi.2go-mobile.com/api/FetchAdress", + json={"dataList": [{"UniqueId": "12345"}]}, + headers={"Content-Type": "application/json"}, + ) + + flow = config_flow.TwenteMilieuFlowHandler() + flow.hass = hass + result = await flow.async_step_user(user_input=FIXTURE_USER_INPUT) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "address_already_set_up" + + +async def test_full_flow_implementation(hass, aioclient_mock): + """Test registering an integration and finishing flow works.""" + aioclient_mock.post( + "https://wasteapi.2go-mobile.com/api/FetchAdress", + json={"dataList": [{"UniqueId": "12345"}]}, + headers={"Content-Type": "application/json"}, + ) + + flow = config_flow.TwenteMilieuFlowHandler() + flow.hass = hass + result = await flow.async_step_user(user_input=None) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await flow.async_step_user(user_input=FIXTURE_USER_INPUT) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "12345" + assert result["data"][CONF_POST_CODE] == FIXTURE_USER_INPUT[CONF_POST_CODE] + assert ( + result["data"][CONF_HOUSE_NUMBER] + == FIXTURE_USER_INPUT[CONF_HOUSE_NUMBER] + ) + assert ( + result["data"][CONF_HOUSE_LETTER] + == FIXTURE_USER_INPUT[CONF_HOUSE_LETTER] + )