Add Picnic integration (#47507)
Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io> Co-authored-by: @tkdrob <tkdrob4390@yahoo.com>pull/49521/head
parent
cb4558c088
commit
303ab36c54
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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."""
|
|
@ -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,
|
||||
},
|
||||
}
|
|
@ -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)
|
|
@ -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"]
|
||||
}
|
|
@ -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()
|
|
@ -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%]"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -179,6 +179,7 @@ FLOWS = [
|
|||
"panasonic_viera",
|
||||
"philips_js",
|
||||
"pi_hole",
|
||||
"picnic",
|
||||
"plaato",
|
||||
"plex",
|
||||
"plugwise",
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
"""Tests for the Picnic integration."""
|
|
@ -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"}
|
|
@ -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
|
Loading…
Reference in New Issue