From 303ab36c544c813221cc67326c15343a99a3e3ab Mon Sep 17 00:00:00 2001 From: corneyl Date: Thu, 22 Apr 2021 07:21:56 +0200 Subject: [PATCH] Add Picnic integration (#47507) Co-authored-by: Paulus Schoutsen Co-authored-by: @tkdrob --- CODEOWNERS | 1 + homeassistant/components/picnic/__init__.py | 59 +++ .../components/picnic/config_flow.py | 119 +++++ homeassistant/components/picnic/const.py | 118 +++++ .../components/picnic/coordinator.py | 151 +++++++ homeassistant/components/picnic/manifest.json | 9 + homeassistant/components/picnic/sensor.py | 109 +++++ homeassistant/components/picnic/strings.json | 22 + .../components/picnic/translations/en.json | 22 + .../components/picnic/translations/nl.json | 22 + homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/picnic/__init__.py | 1 + tests/components/picnic/test_config_flow.py | 124 ++++++ tests/components/picnic/test_sensor.py | 407 ++++++++++++++++++ 16 files changed, 1171 insertions(+) create mode 100644 homeassistant/components/picnic/__init__.py create mode 100644 homeassistant/components/picnic/config_flow.py create mode 100644 homeassistant/components/picnic/const.py create mode 100644 homeassistant/components/picnic/coordinator.py create mode 100644 homeassistant/components/picnic/manifest.json create mode 100644 homeassistant/components/picnic/sensor.py create mode 100644 homeassistant/components/picnic/strings.json create mode 100644 homeassistant/components/picnic/translations/en.json create mode 100644 homeassistant/components/picnic/translations/nl.json create mode 100644 tests/components/picnic/__init__.py create mode 100644 tests/components/picnic/test_config_flow.py create mode 100644 tests/components/picnic/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index a2ab0082cac..6d044f4d06b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -356,6 +356,7 @@ homeassistant/components/persistent_notification/* @home-assistant/core homeassistant/components/philips_js/* @elupus homeassistant/components/pi4ioe5v9xxxx/* @antonverburg homeassistant/components/pi_hole/* @fabaff @johnluetke @shenxn +homeassistant/components/picnic/* @corneyl homeassistant/components/pilight/* @trekky12 homeassistant/components/plaato/* @JohNan homeassistant/components/plex/* @jjlawren diff --git a/homeassistant/components/picnic/__init__.py b/homeassistant/components/picnic/__init__.py new file mode 100644 index 00000000000..003111088e1 --- /dev/null +++ b/homeassistant/components/picnic/__init__.py @@ -0,0 +1,59 @@ +"""The Picnic integration.""" +import asyncio + +from python_picnic_api import PicnicAPI + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant + +from .const import CONF_API, CONF_COORDINATOR, CONF_COUNTRY_CODE, DOMAIN +from .coordinator import PicnicUpdateCoordinator + +PLATFORMS = ["sensor"] + + +def create_picnic_client(entry: ConfigEntry): + """Create an instance of the PicnicAPI client.""" + return PicnicAPI( + auth_token=entry.data.get(CONF_ACCESS_TOKEN), + country_code=entry.data.get(CONF_COUNTRY_CODE), + ) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Picnic from a config entry.""" + picnic_client = await hass.async_add_executor_job(create_picnic_client, entry) + picnic_coordinator = PicnicUpdateCoordinator(hass, picnic_client, entry) + + # Fetch initial data so we have data when entities subscribe + await picnic_coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = { + CONF_API: picnic_client, + CONF_COORDINATOR: picnic_coordinator, + } + + 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/picnic/config_flow.py b/homeassistant/components/picnic/config_flow.py new file mode 100644 index 00000000000..0252e7caca5 --- /dev/null +++ b/homeassistant/components/picnic/config_flow.py @@ -0,0 +1,119 @@ +"""Config flow for Picnic integration.""" +import logging +from typing import Tuple + +from python_picnic_api import PicnicAPI +from python_picnic_api.session import PicnicAuthError +import requests +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_PASSWORD, CONF_USERNAME + +from .const import ( # pylint: disable=unused-import + CONF_COUNTRY_CODE, + COUNTRY_CODES, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_COUNTRY_CODE, default=COUNTRY_CODES[0]): vol.In( + COUNTRY_CODES + ), + } +) + + +class PicnicHub: + """Hub class to test user authentication.""" + + @staticmethod + def authenticate(username, password, country_code) -> Tuple[str, dict]: + """Test if we can authenticate with the Picnic API.""" + picnic = PicnicAPI(username, password, country_code) + return picnic.session.auth_token, picnic.get_user() + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + hub = PicnicHub() + + try: + auth_token, user_data = await hass.async_add_executor_job( + hub.authenticate, + data[CONF_USERNAME], + data[CONF_PASSWORD], + data[CONF_COUNTRY_CODE], + ) + except requests.exceptions.ConnectionError as error: + raise CannotConnect from error + except PicnicAuthError as error: + raise InvalidAuth from error + + # Return the validation result + address = ( + f'{user_data["address"]["street"]} {user_data["address"]["house_number"]}' + + f'{user_data["address"]["house_number_ext"]}' + ) + return auth_token, { + "title": address, + "unique_id": user_data["user_id"], + } + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Picnic.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + errors = {} + + try: + auth_token, info = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + # Set the unique id and abort if it already exists + await self.async_set_unique_id(info["unique_id"]) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=info["title"], + data={ + CONF_ACCESS_TOKEN: auth_token, + CONF_COUNTRY_CODE: user_input[CONF_COUNTRY_CODE], + }, + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/picnic/const.py b/homeassistant/components/picnic/const.py new file mode 100644 index 00000000000..18a62589732 --- /dev/null +++ b/homeassistant/components/picnic/const.py @@ -0,0 +1,118 @@ +"""Constants for the Picnic integration.""" +from homeassistant.const import CURRENCY_EURO, DEVICE_CLASS_TIMESTAMP + +DOMAIN = "picnic" + +CONF_API = "api" +CONF_COORDINATOR = "coordinator" +CONF_COUNTRY_CODE = "country_code" + +COUNTRY_CODES = ["NL", "DE", "BE"] +ATTRIBUTION = "Data provided by Picnic" +ADDRESS = "address" +CART_DATA = "cart_data" +SLOT_DATA = "slot_data" +LAST_ORDER_DATA = "last_order_data" + +SENSOR_CART_ITEMS_COUNT = "cart_items_count" +SENSOR_CART_TOTAL_PRICE = "cart_total_price" +SENSOR_SELECTED_SLOT_START = "selected_slot_start" +SENSOR_SELECTED_SLOT_END = "selected_slot_end" +SENSOR_SELECTED_SLOT_MAX_ORDER_TIME = "selected_slot_max_order_time" +SENSOR_SELECTED_SLOT_MIN_ORDER_VALUE = "selected_slot_min_order_value" +SENSOR_LAST_ORDER_SLOT_START = "last_order_slot_start" +SENSOR_LAST_ORDER_SLOT_END = "last_order_slot_end" +SENSOR_LAST_ORDER_STATUS = "last_order_status" +SENSOR_LAST_ORDER_ETA_START = "last_order_eta_start" +SENSOR_LAST_ORDER_ETA_END = "last_order_eta_end" +SENSOR_LAST_ORDER_DELIVERY_TIME = "last_order_delivery_time" +SENSOR_LAST_ORDER_TOTAL_PRICE = "last_order_total_price" + +SENSOR_TYPES = { + SENSOR_CART_ITEMS_COUNT: { + "icon": "mdi:format-list-numbered", + "data_type": CART_DATA, + "state": lambda cart: cart.get("total_count", 0), + }, + SENSOR_CART_TOTAL_PRICE: { + "unit": CURRENCY_EURO, + "icon": "mdi:currency-eur", + "default_enabled": True, + "data_type": CART_DATA, + "state": lambda cart: cart.get("total_price", 0) / 100, + }, + SENSOR_SELECTED_SLOT_START: { + "class": DEVICE_CLASS_TIMESTAMP, + "icon": "mdi:calendar-start", + "default_enabled": True, + "data_type": SLOT_DATA, + "state": lambda slot: slot.get("window_start"), + }, + SENSOR_SELECTED_SLOT_END: { + "class": DEVICE_CLASS_TIMESTAMP, + "icon": "mdi:calendar-end", + "default_enabled": True, + "data_type": SLOT_DATA, + "state": lambda slot: slot.get("window_end"), + }, + SENSOR_SELECTED_SLOT_MAX_ORDER_TIME: { + "class": DEVICE_CLASS_TIMESTAMP, + "icon": "mdi:clock-alert-outline", + "default_enabled": True, + "data_type": SLOT_DATA, + "state": lambda slot: slot.get("cut_off_time"), + }, + SENSOR_SELECTED_SLOT_MIN_ORDER_VALUE: { + "unit": CURRENCY_EURO, + "icon": "mdi:currency-eur", + "default_enabled": True, + "data_type": SLOT_DATA, + "state": lambda slot: slot["minimum_order_value"] / 100 + if slot.get("minimum_order_value") + else None, + }, + SENSOR_LAST_ORDER_SLOT_START: { + "class": DEVICE_CLASS_TIMESTAMP, + "icon": "mdi:calendar-start", + "data_type": LAST_ORDER_DATA, + "state": lambda last_order: last_order.get("slot", {}).get("window_start"), + }, + SENSOR_LAST_ORDER_SLOT_END: { + "class": DEVICE_CLASS_TIMESTAMP, + "icon": "mdi:calendar-end", + "data_type": LAST_ORDER_DATA, + "state": lambda last_order: last_order.get("slot", {}).get("window_end"), + }, + SENSOR_LAST_ORDER_STATUS: { + "icon": "mdi:list-status", + "data_type": LAST_ORDER_DATA, + "state": lambda last_order: last_order.get("status"), + }, + SENSOR_LAST_ORDER_ETA_START: { + "class": DEVICE_CLASS_TIMESTAMP, + "icon": "mdi:clock-start", + "default_enabled": True, + "data_type": LAST_ORDER_DATA, + "state": lambda last_order: last_order.get("eta", {}).get("start"), + }, + SENSOR_LAST_ORDER_ETA_END: { + "class": DEVICE_CLASS_TIMESTAMP, + "icon": "mdi:clock-end", + "default_enabled": True, + "data_type": LAST_ORDER_DATA, + "state": lambda last_order: last_order.get("eta", {}).get("end"), + }, + SENSOR_LAST_ORDER_DELIVERY_TIME: { + "class": DEVICE_CLASS_TIMESTAMP, + "icon": "mdi:timeline-clock", + "default_enabled": True, + "data_type": LAST_ORDER_DATA, + "state": lambda last_order: last_order.get("delivery_time", {}).get("start"), + }, + SENSOR_LAST_ORDER_TOTAL_PRICE: { + "unit": CURRENCY_EURO, + "icon": "mdi:cash-marker", + "data_type": LAST_ORDER_DATA, + "state": lambda last_order: last_order.get("total_price", 0) / 100, + }, +} diff --git a/homeassistant/components/picnic/coordinator.py b/homeassistant/components/picnic/coordinator.py new file mode 100644 index 00000000000..a4660344aaf --- /dev/null +++ b/homeassistant/components/picnic/coordinator.py @@ -0,0 +1,151 @@ +"""Coordinator to fetch data from the Picnic API.""" +import copy +from datetime import timedelta +import logging + +import async_timeout +from python_picnic_api import PicnicAPI +from python_picnic_api.session import PicnicAuthError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ADDRESS, CART_DATA, LAST_ORDER_DATA, SLOT_DATA + + +class PicnicUpdateCoordinator(DataUpdateCoordinator): + """The coordinator to fetch data from the Picnic API at a set interval.""" + + def __init__( + self, + hass: HomeAssistant, + picnic_api_client: PicnicAPI, + config_entry: ConfigEntry, + ): + """Initialize the coordinator with the given Picnic API client.""" + self.picnic_api_client = picnic_api_client + self.config_entry = config_entry + self._user_address = None + + logger = logging.getLogger(__name__) + super().__init__( + hass, + logger, + name="Picnic coordinator", + update_interval=timedelta(minutes=30), + ) + + async def _async_update_data(self) -> dict: + """Fetch data from API endpoint.""" + try: + # Note: asyncio.TimeoutError and aiohttp.ClientError are already + # handled by the data update coordinator. + async with async_timeout.timeout(10): + data = await self.hass.async_add_executor_job(self.fetch_data) + + # Update the auth token in the config entry if applicable + self._update_auth_token() + + # Return the fetched data + return data + except ValueError as error: + raise UpdateFailed(f"API response was malformed: {error}") from error + except PicnicAuthError as error: + raise ConfigEntryAuthFailed from error + + def fetch_data(self): + """Fetch the data from the Picnic API and return a flat dict with only needed sensor data.""" + # Fetch from the API and pre-process the data + cart = self.picnic_api_client.get_cart() + last_order = self._get_last_order() + + if not cart or not last_order: + raise UpdateFailed("API response doesn't contain expected data.") + + slot_data = self._get_slot_data(cart) + + return { + ADDRESS: self._get_address(), + CART_DATA: cart, + SLOT_DATA: slot_data, + LAST_ORDER_DATA: last_order, + } + + def _get_address(self): + """Get the address that identifies the Picnic service.""" + if self._user_address is None: + address = self.picnic_api_client.get_user()["address"] + self._user_address = f'{address["street"]} {address["house_number"]}{address["house_number_ext"]}' + + return self._user_address + + @staticmethod + def _get_slot_data(cart: dict) -> dict: + """Get the selected slot, if it's explicitly selected.""" + selected_slot = cart.get("selected_slot", {}) + available_slots = cart.get("delivery_slots", []) + + if selected_slot.get("state") == "EXPLICIT": + slot_data = filter( + lambda slot: slot.get("slot_id") == selected_slot.get("slot_id"), + available_slots, + ) + if slot_data: + return next(slot_data) + + return {} + + def _get_last_order(self) -> dict: + """Get data of the last order from the list of deliveries.""" + # Get the deliveries + deliveries = self.picnic_api_client.get_deliveries(summary=True) + if not deliveries: + return {} + + # Determine the last order + last_order = copy.deepcopy(deliveries[0]) + + # Get the position details if the order is not delivered yet + delivery_position = {} + if not last_order.get("delivery_time"): + try: + delivery_position = self.picnic_api_client.get_delivery_position( + last_order["delivery_id"] + ) + except ValueError: + # No information yet can mean an empty response + pass + + # Determine the ETA, if available, the one from the delivery position API is more precise + # but it's only available shortly before the actual delivery. + last_order["eta"] = delivery_position.get( + "eta_window", last_order.get("eta2", {}) + ) + + # Determine the total price by adding up the total price of all sub-orders + total_price = 0 + for order in last_order.get("orders", []): + total_price += order.get("total_price", 0) + + # Sanitise the object + last_order["total_price"] = total_price + last_order.setdefault("delivery_time", {}) + if "eta2" in last_order: + del last_order["eta2"] + + # Make a copy because some references are local + return last_order + + @callback + def _update_auth_token(self): + """Set the updated authentication token.""" + updated_token = self.picnic_api_client.session.auth_token + if self.config_entry.data.get(CONF_ACCESS_TOKEN) != updated_token: + # Create an updated data dict + data = {**self.config_entry.data, CONF_ACCESS_TOKEN: updated_token} + + # Update the config entry + self.hass.config_entries.async_update_entry(self.config_entry, data=data) diff --git a/homeassistant/components/picnic/manifest.json b/homeassistant/components/picnic/manifest.json new file mode 100644 index 00000000000..757f2ef24ad --- /dev/null +++ b/homeassistant/components/picnic/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "picnic", + "name": "Picnic", + "config_flow": true, + "iot_class": "cloud_polling", + "documentation": "https://www.home-assistant.io/integrations/picnic", + "requirements": ["python-picnic-api==1.1.0"], + "codeowners": ["@corneyl"] +} \ No newline at end of file diff --git a/homeassistant/components/picnic/sensor.py b/homeassistant/components/picnic/sensor.py new file mode 100644 index 00000000000..d3778003646 --- /dev/null +++ b/homeassistant/components/picnic/sensor.py @@ -0,0 +1,109 @@ +"""Definition of Picnic sensors.""" + +from typing import Any, Optional + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_ATTRIBUTION +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import ADDRESS, ATTRIBUTION, CONF_COORDINATOR, DOMAIN, SENSOR_TYPES + + +async def async_setup_entry( + hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities +): + """Set up Picnic sensor entries.""" + picnic_coordinator = hass.data[DOMAIN][config_entry.entry_id][CONF_COORDINATOR] + + # Add an entity for each sensor type + async_add_entities( + PicnicSensor(picnic_coordinator, config_entry, sensor_type, props) + for sensor_type, props in SENSOR_TYPES.items() + ) + + return True + + +class PicnicSensor(CoordinatorEntity): + """The CoordinatorEntity subclass representing Picnic sensors.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator[Any], + config_entry: ConfigEntry, + sensor_type, + properties, + ): + """Init a Picnic sensor.""" + super().__init__(coordinator) + + self.sensor_type = sensor_type + self.properties = properties + self.entity_id = f"sensor.picnic_{sensor_type}" + self._service_unique_id = config_entry.unique_id + + @property + def unit_of_measurement(self) -> Optional[str]: + """Return the unit this state is expressed in.""" + return self.properties.get("unit") + + @property + def unique_id(self) -> Optional[str]: + """Return a unique ID.""" + return f"{self._service_unique_id}.{self.sensor_type}" + + @property + def name(self) -> Optional[str]: + """Return the name of the entity.""" + return self._to_capitalized_name(self.sensor_type) + + @property + def state(self) -> StateType: + """Return the state of the entity.""" + data_set = self.coordinator.data.get(self.properties["data_type"], {}) + return self.properties["state"](data_set) + + @property + def device_class(self) -> Optional[str]: + """Return the class of this device, from component DEVICE_CLASSES.""" + return self.properties.get("class") + + @property + def icon(self) -> Optional[str]: + """Return the icon to use in the frontend, if any.""" + return self.properties["icon"] + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self.coordinator.last_update_success and self.state is not None + + @property + def entity_registry_enabled_default(self) -> bool: + """Return if the entity should be enabled when first added to the entity registry.""" + return self.properties.get("default_enabled", False) + + @property + def extra_state_attributes(self): + """Return the sensor specific state attributes.""" + return {ATTR_ATTRIBUTION: ATTRIBUTION} + + @property + def device_info(self): + """Return device info.""" + return { + "identifiers": {(DOMAIN, self._service_unique_id)}, + "manufacturer": "Picnic", + "model": self._service_unique_id, + "name": f"Picnic: {self.coordinator.data[ADDRESS]}", + "entry_type": "service", + } + + @staticmethod + def _to_capitalized_name(name: str) -> str: + return name.replace("_", " ").capitalize() diff --git a/homeassistant/components/picnic/strings.json b/homeassistant/components/picnic/strings.json new file mode 100644 index 00000000000..d43a91fbb0c --- /dev/null +++ b/homeassistant/components/picnic/strings.json @@ -0,0 +1,22 @@ +{ + "title": "Picnic", + "config": { + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "country_code": "Country code" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/picnic/translations/en.json b/homeassistant/components/picnic/translations/en.json new file mode 100644 index 00000000000..2732abe8adc --- /dev/null +++ b/homeassistant/components/picnic/translations/en.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Picnic integration is already configured" + }, + "error": { + "cannot_connect": "Failed to connect to Picnic server", + "invalid_auth": "Invalid credentials", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Username", + "country_code": "County code" + } + } + } + }, + "title": "Picnic" +} \ No newline at end of file diff --git a/homeassistant/components/picnic/translations/nl.json b/homeassistant/components/picnic/translations/nl.json new file mode 100644 index 00000000000..78879f10b61 --- /dev/null +++ b/homeassistant/components/picnic/translations/nl.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Picnic integratie is al geconfigureerd" + }, + "error": { + "cannot_connect": "Kan niet verbinden met Picnic server", + "invalid_auth": "Verkeerde gebruikersnaam/wachtwoord", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "password": "Wachtwoord", + "username": "Gebruikersnaam", + "country_code": "Landcode" + } + } + } + }, + "title": "Picnic" +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 151b95a8f20..f4bb23d698c 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -179,6 +179,7 @@ FLOWS = [ "panasonic_viera", "philips_js", "pi_hole", + "picnic", "plaato", "plex", "plugwise", diff --git a/requirements_all.txt b/requirements_all.txt index b06589bac1b..4fcb1250572 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1830,6 +1830,9 @@ python-nmap==0.6.1 # homeassistant.components.ozw python-openzwave-mqtt[mqtt-client]==1.4.0 +# homeassistant.components.picnic +python-picnic-api==1.1.0 + # homeassistant.components.qbittorrent python-qbittorrent==0.4.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 33f110af521..4cfa722c5d5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -985,6 +985,9 @@ python-nest==4.1.0 # homeassistant.components.ozw python-openzwave-mqtt[mqtt-client]==1.4.0 +# homeassistant.components.picnic +python-picnic-api==1.1.0 + # homeassistant.components.smarttub python-smarttub==0.0.23 diff --git a/tests/components/picnic/__init__.py b/tests/components/picnic/__init__.py new file mode 100644 index 00000000000..fe6e65cbd2b --- /dev/null +++ b/tests/components/picnic/__init__.py @@ -0,0 +1 @@ +"""Tests for the Picnic integration.""" diff --git a/tests/components/picnic/test_config_flow.py b/tests/components/picnic/test_config_flow.py new file mode 100644 index 00000000000..7cdc04e4a39 --- /dev/null +++ b/tests/components/picnic/test_config_flow.py @@ -0,0 +1,124 @@ +"""Test the Picnic config flow.""" +from unittest.mock import patch + +from python_picnic_api.session import PicnicAuthError +import requests + +from homeassistant import config_entries, setup +from homeassistant.components.picnic.const import CONF_COUNTRY_CODE, DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN + + +async def test_form(hass): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] is None + + auth_token = "af3wh738j3fa28l9fa23lhiufahu7l" + auth_data = { + "user_id": "f29-2a6-o32n", + "address": { + "street": "Teststreet", + "house_number": 123, + "house_number_ext": "b", + }, + } + with patch( + "homeassistant.components.picnic.config_flow.PicnicAPI", + ) as mock_picnic, patch( + "homeassistant.components.picnic.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + mock_picnic().session.auth_token = auth_token + mock_picnic().get_user.return_value = auth_data + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + "country_code": "NL", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "Teststreet 123b" + assert result2["data"] == { + CONF_ACCESS_TOKEN: auth_token, + CONF_COUNTRY_CODE: "NL", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth(hass): + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.picnic.config_flow.PicnicHub.authenticate", + side_effect=PicnicAuthError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + "country_code": "NL", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.picnic.config_flow.PicnicHub.authenticate", + side_effect=requests.exceptions.ConnectionError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + "country_code": "NL", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_exception(hass): + """Test we handle random exceptions.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.picnic.config_flow.PicnicHub.authenticate", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + "country_code": "NL", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown"} diff --git a/tests/components/picnic/test_sensor.py b/tests/components/picnic/test_sensor.py new file mode 100644 index 00000000000..08a2e0282c0 --- /dev/null +++ b/tests/components/picnic/test_sensor.py @@ -0,0 +1,407 @@ +"""The tests for the Picnic sensor platform.""" +import copy +from datetime import timedelta +import unittest +from unittest.mock import patch + +import pytest +import requests + +from homeassistant import config_entries +from homeassistant.components.picnic import const +from homeassistant.components.picnic.const import CONF_COUNTRY_CODE, SENSOR_TYPES +from homeassistant.config_entries import CONN_CLASS_CLOUD_POLL +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CURRENCY_EURO, + DEVICE_CLASS_TIMESTAMP, + STATE_UNAVAILABLE, +) +from homeassistant.util import dt + +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + async_test_home_assistant, +) + +DEFAULT_USER_RESPONSE = { + "user_id": "295-6y3-1nf4", + "firstname": "User", + "lastname": "Name", + "address": { + "house_number": 123, + "house_number_ext": "a", + "postcode": "4321 AB", + "street": "Commonstreet", + "city": "Somewhere", + }, + "total_deliveries": 123, + "completed_deliveries": 112, +} +DEFAULT_CART_RESPONSE = { + "items": [], + "delivery_slots": [ + { + "slot_id": "611a3b074872b23576bef456a", + "window_start": "2021-03-03T14:45:00.000+01:00", + "window_end": "2021-03-03T15:45:00.000+01:00", + "cut_off_time": "2021-03-02T22:00:00.000+01:00", + "minimum_order_value": 3500, + }, + ], + "selected_slot": {"slot_id": "611a3b074872b23576bef456a", "state": "EXPLICIT"}, + "total_count": 10, + "total_price": 2535, +} +DEFAULT_DELIVERY_RESPONSE = { + "delivery_id": "z28fjso23e", + "creation_time": "2021-02-24T21:48:46.395+01:00", + "slot": { + "slot_id": "602473859a40dc24c6b65879", + "hub_id": "AMS", + "window_start": "2021-02-26T20:15:00.000+01:00", + "window_end": "2021-02-26T21:15:00.000+01:00", + "cut_off_time": "2021-02-25T22:00:00.000+01:00", + "minimum_order_value": 3500, + }, + "eta2": { + "start": "2021-02-26T20:54:00.000+01:00", + "end": "2021-02-26T21:14:00.000+01:00", + }, + "status": "COMPLETED", + "delivery_time": { + "start": "2021-02-26T20:54:05.221+01:00", + "end": "2021-02-26T20:58:31.802+01:00", + }, + "orders": [ + { + "creation_time": "2021-02-24T21:48:46.418+01:00", + "total_price": 3597, + }, + { + "creation_time": "2021-02-25T17:10:26.816+01:00", + "total_price": 536, + }, + ], +} + + +@pytest.mark.usefixtures("hass_storage") +class TestPicnicSensor(unittest.IsolatedAsyncioTestCase): + """Test the Picnic sensor.""" + + async def asyncSetUp(self): + """Set up things to be run when tests are started.""" + self.hass = await async_test_home_assistant(None) + self.entity_registry = ( + await self.hass.helpers.entity_registry.async_get_registry() + ) + + # Patch the api client + self.picnic_patcher = patch("homeassistant.components.picnic.PicnicAPI") + self.picnic_mock = self.picnic_patcher.start() + + # Add a config entry and setup the integration + config_data = { + CONF_ACCESS_TOKEN: "x-original-picnic-auth-token", + CONF_COUNTRY_CODE: "NL", + } + self.config_entry = MockConfigEntry( + domain=const.DOMAIN, + data=config_data, + connection_class=CONN_CLASS_CLOUD_POLL, + unique_id="295-6y3-1nf4", + ) + self.config_entry.add_to_hass(self.hass) + + async def asyncTearDown(self): + """Tear down the test setup, stop hass/patchers.""" + await self.hass.async_stop(force=True) + self.picnic_patcher.stop() + + @property + def _coordinator(self): + return self.hass.data[const.DOMAIN][self.config_entry.entry_id][ + const.CONF_COORDINATOR + ] + + def _assert_sensor(self, name, state=None, cls=None, unit=None, disabled=False): + sensor = self.hass.states.get(name) + if disabled: + assert sensor is None + return + + assert sensor.state == state + if cls: + assert sensor.attributes["device_class"] == cls + if unit: + assert sensor.attributes["unit_of_measurement"] == unit + + async def _setup_platform( + self, use_default_responses=False, enable_all_sensors=True + ): + """Set up the Picnic sensor platform.""" + if use_default_responses: + self.picnic_mock().get_user.return_value = copy.deepcopy( + DEFAULT_USER_RESPONSE + ) + self.picnic_mock().get_cart.return_value = copy.deepcopy( + DEFAULT_CART_RESPONSE + ) + self.picnic_mock().get_deliveries.return_value = [ + copy.deepcopy(DEFAULT_DELIVERY_RESPONSE) + ] + self.picnic_mock().get_delivery_position.return_value = {} + + await self.hass.config_entries.async_setup(self.config_entry.entry_id) + await self.hass.async_block_till_done() + + if enable_all_sensors: + await self._enable_all_sensors() + + async def _enable_all_sensors(self): + """Enable all sensors of the Picnic integration.""" + # Enable the sensors + for sensor_type in SENSOR_TYPES.keys(): + updated_entry = self.entity_registry.async_update_entity( + f"sensor.picnic_{sensor_type}", disabled_by=None + ) + assert updated_entry.disabled is False + await self.hass.async_block_till_done() + + # Trigger a reload of the data + async_fire_time_changed( + self.hass, + dt.utcnow() + + timedelta(seconds=config_entries.RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await self.hass.async_block_till_done() + + async def test_sensor_setup_platform_not_available(self): + """Test the set-up of the sensor platform if API is not available.""" + # Configure mock requests to yield exceptions + self.picnic_mock().get_user.side_effect = requests.exceptions.ConnectionError + self.picnic_mock().get_cart.side_effect = requests.exceptions.ConnectionError + self.picnic_mock().get_deliveries.side_effect = ( + requests.exceptions.ConnectionError + ) + self.picnic_mock().get_delivery_position.side_effect = ( + requests.exceptions.ConnectionError + ) + await self._setup_platform(enable_all_sensors=False) + + # Assert that sensors are not set up + assert ( + self.hass.states.get("sensor.picnic_selected_slot_max_order_time") is None + ) + assert self.hass.states.get("sensor.picnic_last_order_status") is None + assert self.hass.states.get("sensor.picnic_last_order_total_price") is None + + async def test_sensors_setup(self): + """Test the default sensor setup behaviour.""" + await self._setup_platform(use_default_responses=True) + + self._assert_sensor("sensor.picnic_cart_items_count", "10") + self._assert_sensor( + "sensor.picnic_cart_total_price", "25.35", unit=CURRENCY_EURO + ) + self._assert_sensor( + "sensor.picnic_selected_slot_start", + "2021-03-03T14:45:00.000+01:00", + cls=DEVICE_CLASS_TIMESTAMP, + ) + self._assert_sensor( + "sensor.picnic_selected_slot_end", + "2021-03-03T15:45:00.000+01:00", + cls=DEVICE_CLASS_TIMESTAMP, + ) + self._assert_sensor( + "sensor.picnic_selected_slot_max_order_time", + "2021-03-02T22:00:00.000+01:00", + cls=DEVICE_CLASS_TIMESTAMP, + ) + self._assert_sensor("sensor.picnic_selected_slot_min_order_value", "35.0") + self._assert_sensor( + "sensor.picnic_last_order_slot_start", + "2021-02-26T20:15:00.000+01:00", + cls=DEVICE_CLASS_TIMESTAMP, + ) + self._assert_sensor( + "sensor.picnic_last_order_slot_end", + "2021-02-26T21:15:00.000+01:00", + cls=DEVICE_CLASS_TIMESTAMP, + ) + self._assert_sensor("sensor.picnic_last_order_status", "COMPLETED") + self._assert_sensor( + "sensor.picnic_last_order_eta_start", + "2021-02-26T20:54:00.000+01:00", + cls=DEVICE_CLASS_TIMESTAMP, + ) + self._assert_sensor( + "sensor.picnic_last_order_eta_end", + "2021-02-26T21:14:00.000+01:00", + cls=DEVICE_CLASS_TIMESTAMP, + ) + self._assert_sensor( + "sensor.picnic_last_order_delivery_time", + "2021-02-26T20:54:05.221+01:00", + cls=DEVICE_CLASS_TIMESTAMP, + ) + self._assert_sensor( + "sensor.picnic_last_order_total_price", "41.33", unit=CURRENCY_EURO + ) + + async def test_sensors_setup_disabled_by_default(self): + """Test that some sensors are disabled by default.""" + await self._setup_platform(use_default_responses=True, enable_all_sensors=False) + + self._assert_sensor("sensor.picnic_cart_items_count", disabled=True) + self._assert_sensor("sensor.picnic_last_order_slot_start", disabled=True) + self._assert_sensor("sensor.picnic_last_order_slot_end", disabled=True) + self._assert_sensor("sensor.picnic_last_order_status", disabled=True) + self._assert_sensor("sensor.picnic_last_order_total_price", disabled=True) + + async def test_sensors_no_selected_time_slot(self): + """Test sensor states with no explicit selected time slot.""" + # Adjust cart response + cart_response = copy.deepcopy(DEFAULT_CART_RESPONSE) + cart_response["selected_slot"]["state"] = "IMPLICIT" + + # Set mock responses + self.picnic_mock().get_user.return_value = copy.deepcopy(DEFAULT_USER_RESPONSE) + self.picnic_mock().get_cart.return_value = cart_response + self.picnic_mock().get_deliveries.return_value = [ + copy.deepcopy(DEFAULT_DELIVERY_RESPONSE) + ] + self.picnic_mock().get_delivery_position.return_value = {} + await self._setup_platform() + + # Assert sensors are unknown + self._assert_sensor("sensor.picnic_selected_slot_start", STATE_UNAVAILABLE) + self._assert_sensor("sensor.picnic_selected_slot_end", STATE_UNAVAILABLE) + self._assert_sensor( + "sensor.picnic_selected_slot_max_order_time", STATE_UNAVAILABLE + ) + self._assert_sensor( + "sensor.picnic_selected_slot_min_order_value", STATE_UNAVAILABLE + ) + + async def test_sensors_last_order_in_future(self): + """Test sensor states when last order is not yet delivered.""" + # Adjust default delivery response + delivery_response = copy.deepcopy(DEFAULT_DELIVERY_RESPONSE) + del delivery_response["delivery_time"] + + # Set mock responses + self.picnic_mock().get_user.return_value = copy.deepcopy(DEFAULT_USER_RESPONSE) + self.picnic_mock().get_cart.return_value = copy.deepcopy(DEFAULT_CART_RESPONSE) + self.picnic_mock().get_deliveries.return_value = [delivery_response] + self.picnic_mock().get_delivery_position.return_value = {} + await self._setup_platform() + + # Assert delivery time is not available, but eta is + self._assert_sensor("sensor.picnic_last_order_delivery_time", STATE_UNAVAILABLE) + self._assert_sensor( + "sensor.picnic_last_order_eta_start", "2021-02-26T20:54:00.000+01:00" + ) + self._assert_sensor( + "sensor.picnic_last_order_eta_end", "2021-02-26T21:14:00.000+01:00" + ) + + async def test_sensors_use_detailed_eta_if_available(self): + """Test sensor states when last order is not yet delivered.""" + # Set-up platform with default mock responses + await self._setup_platform(use_default_responses=True) + + # Provide a delivery position response with different ETA and remove delivery time from response + delivery_response = copy.deepcopy(DEFAULT_DELIVERY_RESPONSE) + del delivery_response["delivery_time"] + self.picnic_mock().get_deliveries.return_value = [delivery_response] + self.picnic_mock().get_delivery_position.return_value = { + "eta_window": { + "start": "2021-03-05T11:19:20.452+01:00", + "end": "2021-03-05T11:39:20.452+01:00", + } + } + await self._coordinator.async_refresh() + + # Assert detailed ETA is used + self.picnic_mock().get_delivery_position.assert_called_with( + delivery_response["delivery_id"] + ) + self._assert_sensor( + "sensor.picnic_last_order_eta_start", "2021-03-05T11:19:20.452+01:00" + ) + self._assert_sensor( + "sensor.picnic_last_order_eta_end", "2021-03-05T11:39:20.452+01:00" + ) + + async def test_sensors_no_data(self): + """Test sensor states when the api only returns empty objects.""" + # Setup platform with default responses + await self._setup_platform(use_default_responses=True) + + # Change mock responses to empty data and refresh the coordinator + self.picnic_mock().get_user.return_value = {} + self.picnic_mock().get_cart.return_value = None + self.picnic_mock().get_deliveries.return_value = None + self.picnic_mock().get_delivery_position.side_effect = ValueError + await self._coordinator.async_refresh() + + # Assert all default-enabled sensors have STATE_UNAVAILABLE because the last update failed + assert self._coordinator.last_update_success is False + self._assert_sensor("sensor.picnic_cart_total_price", STATE_UNAVAILABLE) + self._assert_sensor("sensor.picnic_selected_slot_start", STATE_UNAVAILABLE) + self._assert_sensor("sensor.picnic_selected_slot_end", STATE_UNAVAILABLE) + self._assert_sensor( + "sensor.picnic_selected_slot_max_order_time", STATE_UNAVAILABLE + ) + self._assert_sensor( + "sensor.picnic_selected_slot_min_order_value", STATE_UNAVAILABLE + ) + self._assert_sensor("sensor.picnic_last_order_eta_start", STATE_UNAVAILABLE) + self._assert_sensor("sensor.picnic_last_order_eta_end", STATE_UNAVAILABLE) + self._assert_sensor("sensor.picnic_last_order_delivery_time", STATE_UNAVAILABLE) + + async def test_sensors_malformed_response(self): + """Test coordinator update fails when API yields ValueError.""" + # Setup platform with default responses + await self._setup_platform(use_default_responses=True) + + # Change mock responses to empty data and refresh the coordinator + self.picnic_mock().get_user.side_effect = ValueError + self.picnic_mock().get_cart.side_effect = ValueError + await self._coordinator.async_refresh() + + # Assert coordinator update failed + assert self._coordinator.last_update_success is False + + async def test_device_registry_entry(self): + """Test if device registry entry is populated correctly.""" + # Setup platform and default mock responses + await self._setup_platform(use_default_responses=True) + + device_registry = await self.hass.helpers.device_registry.async_get_registry() + picnic_service = device_registry.async_get_device( + identifiers={(const.DOMAIN, DEFAULT_USER_RESPONSE["user_id"])} + ) + assert picnic_service.model == DEFAULT_USER_RESPONSE["user_id"] + assert picnic_service.name == "Picnic: Commonstreet 123a" + assert picnic_service.entry_type == "service" + + async def test_auth_token_is_saved_on_update(self): + """Test that auth-token changes in the session object are reflected by the config entry.""" + # Setup platform and default mock responses + await self._setup_platform(use_default_responses=True) + + # Set a different auth token in the session mock + updated_auth_token = "x-updated-picnic-auth-token" + self.picnic_mock().session.auth_token = updated_auth_token + + # Verify the updated auth token is not set and fetch data using the coordinator + assert self.config_entry.data.get(CONF_ACCESS_TOKEN) != updated_auth_token + await self._coordinator.async_refresh() + + # Verify that the updated auth token is saved in the config entry + assert self.config_entry.data.get(CONF_ACCESS_TOKEN) == updated_auth_token