mirror of
https://github.com/home-assistant/core.git
synced 2025-04-24 01:08:12 +00:00
Add Monzo integration (#101731)
* Initial monzo implementation * Tests and fixes * Extracted api to pypi package * Add app confirmation step * Corrected data path for accounts * Removed useless check * Improved tests * Exclude partially tested files from coverage check * Use has_entity_name naming * Bumped monzopy to 1.0.10 * Remove commented out code * Remove reauth from initial PR * Remove useless code * Correct comment * Remove reauth tests * Remove device triggers from intial PR * Set attr outside constructor * Remove f-strings where no longer needed in entity.py * Rename field to make clearer it's a Callable * Correct native_unit_of_measurement * Remove pot transfer service from intial PR * Remove reauth string * Remove empty fields in manifest.json * Freeze SensorEntityDescription and remove Mixin Also use list comprehensions for producing sensor lists * Use consts in application_credentials.py * Revert "Remove useless code" Apparently this wasn't useless This reverts commit c6b7109e47202f866c766ea4c16ce3eb0588795b. * Ruff and pylint style fixes * Bumped monzopy to 1.1.0 Adds support for joint/business/etc account pots * Update test snapshot * Rename AsyncConfigEntryAuth * Use dataclasses instead of dictionaries * Move OAuth constants to application_credentials.py * Remove remaining constants and dependencies for services from this PR * Remove empty manifest entry * Fix comment * Set device entry_type to service * ACC_SENSORS -> ACCOUNT_SENSORS * Make value_fn of sensors return StateType * Rename OAuthMonzoAPI again * Fix tests * Patch API instead of integration for unavailable test * Move pot constant to sensor.py * Improve type safety in async_get_monzo_api_data() * Update async_oauth_create_entry() docstring --------- Co-authored-by: Erik Montnemery <erik@montnemery.com>
This commit is contained in:
parent
5bef2d5d25
commit
6e024d54f1
@ -810,6 +810,8 @@ omit =
|
||||
homeassistant/components/moehlenhoff_alpha2/binary_sensor.py
|
||||
homeassistant/components/moehlenhoff_alpha2/climate.py
|
||||
homeassistant/components/moehlenhoff_alpha2/sensor.py
|
||||
homeassistant/components/monzo/__init__.py
|
||||
homeassistant/components/monzo/api.py
|
||||
homeassistant/components/motion_blinds/__init__.py
|
||||
homeassistant/components/motion_blinds/coordinator.py
|
||||
homeassistant/components/motion_blinds/cover.py
|
||||
|
@ -300,6 +300,7 @@ homeassistant.components.minecraft_server.*
|
||||
homeassistant.components.mjpeg.*
|
||||
homeassistant.components.modbus.*
|
||||
homeassistant.components.modem_callerid.*
|
||||
homeassistant.components.monzo.*
|
||||
homeassistant.components.moon.*
|
||||
homeassistant.components.mopeka.*
|
||||
homeassistant.components.motionmount.*
|
||||
|
@ -867,6 +867,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/moehlenhoff_alpha2/ @j-a-n
|
||||
/homeassistant/components/monoprice/ @etsinko @OnFreund
|
||||
/tests/components/monoprice/ @etsinko @OnFreund
|
||||
/homeassistant/components/monzo/ @jakemartin-icl
|
||||
/tests/components/monzo/ @jakemartin-icl
|
||||
/homeassistant/components/moon/ @fabaff @frenck
|
||||
/tests/components/moon/ @fabaff @frenck
|
||||
/homeassistant/components/mopeka/ @bdraco
|
||||
|
68
homeassistant/components/monzo/__init__.py
Normal file
68
homeassistant/components/monzo/__init__.py
Normal file
@ -0,0 +1,68 @@
|
||||
"""The Monzo integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .api import AuthenticatedMonzoAPI
|
||||
from .const import DOMAIN
|
||||
from .data import MonzoData, MonzoSensorData
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Monzo from a config entry."""
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
)
|
||||
)
|
||||
|
||||
async def async_get_monzo_api_data() -> MonzoSensorData:
|
||||
monzo_data: MonzoData = hass.data[DOMAIN][entry.entry_id]
|
||||
accounts = await external_api.user_account.accounts()
|
||||
pots = await external_api.user_account.pots()
|
||||
monzo_data.accounts = accounts
|
||||
monzo_data.pots = pots
|
||||
return MonzoSensorData(accounts=accounts, pots=pots)
|
||||
|
||||
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
|
||||
|
||||
external_api = AuthenticatedMonzoAPI(
|
||||
aiohttp_client.async_get_clientsession(hass), session
|
||||
)
|
||||
|
||||
coordinator = DataUpdateCoordinator(
|
||||
hass,
|
||||
logging.getLogger(__name__),
|
||||
name=DOMAIN,
|
||||
update_method=async_get_monzo_api_data,
|
||||
update_interval=timedelta(minutes=1),
|
||||
)
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hass.data[DOMAIN][entry.entry_id] = MonzoData(external_api, coordinator)
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
data = hass.data[DOMAIN]
|
||||
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
if unload_ok and entry.entry_id in data:
|
||||
data.pop(entry.entry_id)
|
||||
|
||||
return unload_ok
|
26
homeassistant/components/monzo/api.py
Normal file
26
homeassistant/components/monzo/api.py
Normal file
@ -0,0 +1,26 @@
|
||||
"""API for Monzo bound to Home Assistant OAuth."""
|
||||
|
||||
from aiohttp import ClientSession
|
||||
from monzopy import AbstractMonzoApi
|
||||
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
|
||||
|
||||
class AuthenticatedMonzoAPI(AbstractMonzoApi):
|
||||
"""A Monzo API instance with authentication tied to an OAuth2 based config entry."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
websession: ClientSession,
|
||||
oauth_session: config_entry_oauth2_flow.OAuth2Session,
|
||||
) -> None:
|
||||
"""Initialize Monzo auth."""
|
||||
super().__init__(websession)
|
||||
self._oauth_session = oauth_session
|
||||
|
||||
async def async_get_access_token(self) -> str:
|
||||
"""Return a valid access token."""
|
||||
if not self._oauth_session.valid_token:
|
||||
await self._oauth_session.async_ensure_token_valid()
|
||||
|
||||
return str(self._oauth_session.token["access_token"])
|
15
homeassistant/components/monzo/application_credentials.py
Normal file
15
homeassistant/components/monzo/application_credentials.py
Normal file
@ -0,0 +1,15 @@
|
||||
"""application_credentials platform the Monzo integration."""
|
||||
|
||||
from homeassistant.components.application_credentials import AuthorizationServer
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
OAUTH2_AUTHORIZE = "https://auth.monzo.com"
|
||||
OAUTH2_TOKEN = "https://api.monzo.com/oauth2/token"
|
||||
|
||||
|
||||
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
|
||||
"""Return authorization server."""
|
||||
return AuthorizationServer(
|
||||
authorize_url=OAUTH2_AUTHORIZE,
|
||||
token_url=OAUTH2_TOKEN,
|
||||
)
|
52
homeassistant/components/monzo/config_flow.py
Normal file
52
homeassistant/components/monzo/config_flow.py
Normal file
@ -0,0 +1,52 @@
|
||||
"""Config flow for Monzo."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlowResult
|
||||
from homeassistant.const import CONF_TOKEN
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class MonzoFlowHandler(
|
||||
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
|
||||
):
|
||||
"""Handle a config flow."""
|
||||
|
||||
DOMAIN = DOMAIN
|
||||
|
||||
oauth_data: dict[str, Any]
|
||||
|
||||
@property
|
||||
def logger(self) -> logging.Logger:
|
||||
"""Return logger."""
|
||||
return logging.getLogger(__name__)
|
||||
|
||||
async def async_step_await_approval_confirmation(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Wait for the user to confirm in-app approval."""
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title=DOMAIN, data={**self.oauth_data})
|
||||
|
||||
data_schema = vol.Schema({vol.Required("confirm"): bool})
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="await_approval_confirmation", data_schema=data_schema
|
||||
)
|
||||
|
||||
async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
|
||||
"""Create an entry for the flow."""
|
||||
user_id = str(data[CONF_TOKEN]["user_id"])
|
||||
await self.async_set_unique_id(user_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
self.oauth_data = data
|
||||
|
||||
return await self.async_step_await_approval_confirmation()
|
3
homeassistant/components/monzo/const.py
Normal file
3
homeassistant/components/monzo/const.py
Normal file
@ -0,0 +1,3 @@
|
||||
"""Constants for the Monzo integration."""
|
||||
|
||||
DOMAIN = "monzo"
|
24
homeassistant/components/monzo/data.py
Normal file
24
homeassistant/components/monzo/data.py
Normal file
@ -0,0 +1,24 @@
|
||||
"""Dataclass for Monzo data."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .api import AuthenticatedMonzoAPI
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class MonzoSensorData:
|
||||
"""A dataclass for holding sensor data returned by the DataUpdateCoordinator."""
|
||||
|
||||
accounts: list[dict[str, Any]] = field(default_factory=list)
|
||||
pots: list[dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MonzoData(MonzoSensorData):
|
||||
"""A dataclass for holding data stored in hass.data."""
|
||||
|
||||
external_api: AuthenticatedMonzoAPI
|
||||
coordinator: DataUpdateCoordinator[MonzoSensorData]
|
47
homeassistant/components/monzo/entity.py
Normal file
47
homeassistant/components/monzo/entity.py
Normal file
@ -0,0 +1,47 @@
|
||||
"""Base entity for Monzo."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
)
|
||||
|
||||
from .const import DOMAIN
|
||||
from .data import MonzoSensorData
|
||||
|
||||
|
||||
class MonzoBaseEntity(CoordinatorEntity[DataUpdateCoordinator[MonzoSensorData]]):
|
||||
"""Common base for Monzo entities."""
|
||||
|
||||
_attr_attribution = "Data provided by Monzo"
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: DataUpdateCoordinator[MonzoSensorData],
|
||||
index: int,
|
||||
device_model: str,
|
||||
data_accessor: Callable[[MonzoSensorData], list[dict[str, Any]]],
|
||||
) -> None:
|
||||
"""Initialize sensor."""
|
||||
super().__init__(coordinator)
|
||||
self.index = index
|
||||
self._data_accessor = data_accessor
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
identifiers={(DOMAIN, str(self.data["id"]))},
|
||||
manufacturer="Monzo",
|
||||
model=device_model,
|
||||
name=self.data["name"],
|
||||
)
|
||||
|
||||
@property
|
||||
def data(self) -> dict[str, Any]:
|
||||
"""Shortcut to access coordinator data for the entity."""
|
||||
return self._data_accessor(self.coordinator.data)[self.index]
|
10
homeassistant/components/monzo/manifest.json
Normal file
10
homeassistant/components/monzo/manifest.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"domain": "monzo",
|
||||
"name": "Monzo",
|
||||
"codeowners": ["@jakemartin-icl"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["application_credentials"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/monzo",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["monzopy==1.1.0"]
|
||||
}
|
123
homeassistant/components/monzo/sensor.py
Normal file
123
homeassistant/components/monzo/sensor.py
Normal file
@ -0,0 +1,123 @@
|
||||
"""Platform for sensor integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .const import DOMAIN
|
||||
from .data import MonzoSensorData
|
||||
from .entity import MonzoBaseEntity
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class MonzoSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes Monzo sensor entity."""
|
||||
|
||||
value_fn: Callable[[dict[str, Any]], StateType]
|
||||
|
||||
|
||||
ACCOUNT_SENSORS = (
|
||||
MonzoSensorEntityDescription(
|
||||
key="balance",
|
||||
translation_key="balance",
|
||||
value_fn=lambda data: data["balance"]["balance"] / 100,
|
||||
device_class=SensorDeviceClass.MONETARY,
|
||||
native_unit_of_measurement="GBP",
|
||||
suggested_display_precision=2,
|
||||
),
|
||||
MonzoSensorEntityDescription(
|
||||
key="total_balance",
|
||||
translation_key="total_balance",
|
||||
value_fn=lambda data: data["balance"]["total_balance"] / 100,
|
||||
device_class=SensorDeviceClass.MONETARY,
|
||||
native_unit_of_measurement="GBP",
|
||||
suggested_display_precision=2,
|
||||
),
|
||||
)
|
||||
|
||||
POT_SENSORS = (
|
||||
MonzoSensorEntityDescription(
|
||||
key="pot_balance",
|
||||
translation_key="pot_balance",
|
||||
value_fn=lambda data: data["balance"] / 100,
|
||||
device_class=SensorDeviceClass.MONETARY,
|
||||
native_unit_of_measurement="GBP",
|
||||
suggested_display_precision=2,
|
||||
),
|
||||
)
|
||||
|
||||
MODEL_POT = "Pot"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Defer sensor setup to the shared sensor module."""
|
||||
coordinator = hass.data[DOMAIN][config_entry.entry_id].coordinator
|
||||
|
||||
accounts = [
|
||||
MonzoSensor(
|
||||
coordinator,
|
||||
entity_description,
|
||||
index,
|
||||
account["name"],
|
||||
lambda x: x.accounts,
|
||||
)
|
||||
for entity_description in ACCOUNT_SENSORS
|
||||
for index, account in enumerate(
|
||||
hass.data[DOMAIN][config_entry.entry_id].accounts
|
||||
)
|
||||
]
|
||||
|
||||
pots = [
|
||||
MonzoSensor(coordinator, entity_description, index, MODEL_POT, lambda x: x.pots)
|
||||
for entity_description in POT_SENSORS
|
||||
for index, _pot in enumerate(hass.data[DOMAIN][config_entry.entry_id].pots)
|
||||
]
|
||||
|
||||
async_add_entities(accounts + pots)
|
||||
|
||||
|
||||
class MonzoSensor(MonzoBaseEntity, SensorEntity):
|
||||
"""Represents a Monzo sensor."""
|
||||
|
||||
entity_description: MonzoSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: DataUpdateCoordinator[MonzoSensorData],
|
||||
entity_description: MonzoSensorEntityDescription,
|
||||
index: int,
|
||||
device_model: str,
|
||||
data_accessor: Callable[[MonzoSensorData], list[dict[str, Any]]],
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator, index, device_model, data_accessor)
|
||||
self.entity_description = entity_description
|
||||
self._attr_unique_id = f"{self.data['id']}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the state."""
|
||||
|
||||
try:
|
||||
state = self.entity_description.value_fn(self.data)
|
||||
except (KeyError, ValueError):
|
||||
return None
|
||||
|
||||
return state
|
41
homeassistant/components/monzo/strings.json
Normal file
41
homeassistant/components/monzo/strings.json
Normal file
@ -0,0 +1,41 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"pick_implementation": {
|
||||
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
|
||||
},
|
||||
"await_approval_confirmation": {
|
||||
"title": "Confirm in Monzo app",
|
||||
"description": "Before proceeding, open your Monzo app and approve the request from Home Assistant.",
|
||||
"data": {
|
||||
"confirm": "I've approved"
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
|
||||
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
|
||||
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
|
||||
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
|
||||
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]"
|
||||
},
|
||||
"create_entry": {
|
||||
"default": "[%key:common::config_flow::create_entry::authenticated%]"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"balance": {
|
||||
"name": "Balance"
|
||||
},
|
||||
"total_balance": {
|
||||
"name": "Total Balance"
|
||||
},
|
||||
"pot_balance": {
|
||||
"name": "Balance"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -17,6 +17,7 @@ APPLICATION_CREDENTIALS = [
|
||||
"lametric",
|
||||
"lyric",
|
||||
"microbees",
|
||||
"monzo",
|
||||
"myuplink",
|
||||
"neato",
|
||||
"nest",
|
||||
|
@ -331,6 +331,7 @@ FLOWS = {
|
||||
"modern_forms",
|
||||
"moehlenhoff_alpha2",
|
||||
"monoprice",
|
||||
"monzo",
|
||||
"moon",
|
||||
"mopeka",
|
||||
"motion_blinds",
|
||||
|
@ -3739,6 +3739,12 @@
|
||||
"config_flow": true,
|
||||
"iot_class": "local_polling"
|
||||
},
|
||||
"monzo": {
|
||||
"name": "Monzo",
|
||||
"integration_type": "hub",
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling"
|
||||
},
|
||||
"moon": {
|
||||
"integration_type": "service",
|
||||
"config_flow": true,
|
||||
|
10
mypy.ini
10
mypy.ini
@ -2762,6 +2762,16 @@ disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.monzo.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
disallow_subclassing_any = true
|
||||
disallow_untyped_calls = true
|
||||
disallow_untyped_decorators = true
|
||||
disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.moon.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
|
@ -1325,6 +1325,9 @@ moat-ble==0.1.1
|
||||
# homeassistant.components.moehlenhoff_alpha2
|
||||
moehlenhoff-alpha2==1.3.0
|
||||
|
||||
# homeassistant.components.monzo
|
||||
monzopy==1.1.0
|
||||
|
||||
# homeassistant.components.mopeka
|
||||
mopeka-iot-ble==0.7.0
|
||||
|
||||
|
@ -1067,6 +1067,9 @@ moat-ble==0.1.1
|
||||
# homeassistant.components.moehlenhoff_alpha2
|
||||
moehlenhoff-alpha2==1.3.0
|
||||
|
||||
# homeassistant.components.monzo
|
||||
monzopy==1.1.0
|
||||
|
||||
# homeassistant.components.mopeka
|
||||
mopeka-iot-ble==0.7.0
|
||||
|
||||
|
12
tests/components/monzo/__init__.py
Normal file
12
tests/components/monzo/__init__.py
Normal file
@ -0,0 +1,12 @@
|
||||
"""Tests for the Monzo integration."""
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
|
||||
"""Fixture for setting up the component."""
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
125
tests/components/monzo/conftest.py
Normal file
125
tests/components/monzo/conftest.py
Normal file
@ -0,0 +1,125 @@
|
||||
"""Fixtures for tests."""
|
||||
|
||||
import time
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from monzopy.monzopy import UserAccount
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.application_credentials import (
|
||||
ClientCredential,
|
||||
async_import_client_credential,
|
||||
)
|
||||
from homeassistant.components.monzo.api import AuthenticatedMonzoAPI
|
||||
from homeassistant.components.monzo.const import DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
CLIENT_ID = "1234"
|
||||
CLIENT_SECRET = "5678"
|
||||
TEST_ACCOUNTS = [
|
||||
{
|
||||
"id": "acc_curr",
|
||||
"name": "Current Account",
|
||||
"type": "uk_retail",
|
||||
"balance": {"balance": 123, "total_balance": 321},
|
||||
},
|
||||
{
|
||||
"id": "acc_flex",
|
||||
"name": "Flex",
|
||||
"type": "uk_monzo_flex",
|
||||
"balance": {"balance": 123, "total_balance": 321},
|
||||
},
|
||||
]
|
||||
TEST_POTS = [
|
||||
{
|
||||
"id": "pot_savings",
|
||||
"name": "Savings",
|
||||
"style": "savings",
|
||||
"balance": 134578,
|
||||
"currency": "GBP",
|
||||
"type": "instant_access",
|
||||
}
|
||||
]
|
||||
TITLE = "jake"
|
||||
USER_ID = 12345
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def setup_credentials(hass: HomeAssistant) -> None:
|
||||
"""Fixture to setup credentials."""
|
||||
assert await async_setup_component(hass, "application_credentials", {})
|
||||
await async_import_client_credential(
|
||||
hass,
|
||||
DOMAIN,
|
||||
ClientCredential(CLIENT_ID, CLIENT_SECRET, DOMAIN),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="expires_at")
|
||||
def mock_expires_at() -> int:
|
||||
"""Fixture to set the oauth token expiration time."""
|
||||
return time.time() + 3600
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def polling_config_entry(expires_at: int) -> MockConfigEntry:
|
||||
"""Create Monzo entry in Home Assistant."""
|
||||
return MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title=TITLE,
|
||||
unique_id=str(USER_ID),
|
||||
data={
|
||||
"auth_implementation": DOMAIN,
|
||||
"token": {
|
||||
"status": 0,
|
||||
"userid": str(USER_ID),
|
||||
"access_token": "mock-access-token",
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"expires_in": 60,
|
||||
"expires_at": time.time() + 1000,
|
||||
},
|
||||
"profile": TITLE,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="basic_monzo")
|
||||
def mock_basic_monzo():
|
||||
"""Mock monzo with one pot."""
|
||||
|
||||
mock = AsyncMock(spec=AuthenticatedMonzoAPI)
|
||||
mock_user_account = AsyncMock(spec=UserAccount)
|
||||
|
||||
mock_user_account.accounts.return_value = []
|
||||
|
||||
mock_user_account.pots.return_value = TEST_POTS
|
||||
|
||||
mock.user_account = mock_user_account
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.monzo.AuthenticatedMonzoAPI",
|
||||
return_value=mock,
|
||||
):
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture(name="monzo")
|
||||
def mock_monzo():
|
||||
"""Mock monzo."""
|
||||
|
||||
mock = AsyncMock(spec=AuthenticatedMonzoAPI)
|
||||
mock_user_account = AsyncMock(spec=UserAccount)
|
||||
|
||||
mock_user_account.accounts.return_value = TEST_ACCOUNTS
|
||||
mock_user_account.pots.return_value = TEST_POTS
|
||||
|
||||
mock.user_account = mock_user_account
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.monzo.AuthenticatedMonzoAPI",
|
||||
return_value=mock,
|
||||
):
|
||||
yield mock
|
65
tests/components/monzo/snapshots/test_sensor.ambr
Normal file
65
tests/components/monzo/snapshots/test_sensor.ambr
Normal file
@ -0,0 +1,65 @@
|
||||
# serializer version: 1
|
||||
# name: test_all_entities
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'attribution': 'Data provided by Monzo',
|
||||
'device_class': 'monetary',
|
||||
'friendly_name': 'Current Account Balance',
|
||||
'unit_of_measurement': 'GBP',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.current_account_balance',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '1.23',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities.1
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'attribution': 'Data provided by Monzo',
|
||||
'device_class': 'monetary',
|
||||
'friendly_name': 'Current Account Total Balance',
|
||||
'unit_of_measurement': 'GBP',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.current_account_total_balance',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '3.21',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities.2
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'attribution': 'Data provided by Monzo',
|
||||
'device_class': 'monetary',
|
||||
'friendly_name': 'Flex Balance',
|
||||
'unit_of_measurement': 'GBP',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.flex_balance',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '1.23',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities.3
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'attribution': 'Data provided by Monzo',
|
||||
'device_class': 'monetary',
|
||||
'friendly_name': 'Flex Total Balance',
|
||||
'unit_of_measurement': 'GBP',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.flex_total_balance',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '3.21',
|
||||
})
|
||||
# ---
|
138
tests/components/monzo/test_config_flow.py
Normal file
138
tests/components/monzo/test_config_flow.py
Normal file
@ -0,0 +1,138 @@
|
||||
"""Tests for config flow."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from homeassistant.components.monzo.application_credentials import (
|
||||
OAUTH2_AUTHORIZE,
|
||||
OAUTH2_TOKEN,
|
||||
)
|
||||
from homeassistant.components.monzo.const import DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_USER
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
|
||||
from . import setup_integration
|
||||
from .conftest import CLIENT_ID, USER_ID
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||
from tests.typing import ClientSessionGenerator
|
||||
|
||||
|
||||
async def test_full_flow(
|
||||
hass: HomeAssistant,
|
||||
hass_client_no_auth: ClientSessionGenerator,
|
||||
current_request_with_host: None,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
) -> None:
|
||||
"""Check full flow."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
state = config_entry_oauth2_flow._encode_jwt(
|
||||
hass,
|
||||
{
|
||||
"flow_id": result["flow_id"],
|
||||
"redirect_uri": "https://example.com/auth/external/callback",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] == FlowResultType.EXTERNAL_STEP
|
||||
assert result["url"] == (
|
||||
f"{OAUTH2_AUTHORIZE}/?"
|
||||
f"response_type=code&client_id={CLIENT_ID}&"
|
||||
"redirect_uri=https://example.com/auth/external/callback&"
|
||||
f"state={state}"
|
||||
)
|
||||
|
||||
client = await hass_client_no_auth()
|
||||
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
|
||||
assert resp.status == 200
|
||||
assert resp.headers["content-type"] == "text/html; charset=utf-8"
|
||||
|
||||
aioclient_mock.clear_requests()
|
||||
aioclient_mock.post(
|
||||
OAUTH2_TOKEN,
|
||||
json={
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"access_token": "mock-access-token",
|
||||
"type": "Bearer",
|
||||
"expires_in": 60,
|
||||
"user_id": 600,
|
||||
},
|
||||
)
|
||||
with patch(
|
||||
"homeassistant.components.monzo.async_setup_entry", return_value=True
|
||||
) as mock_setup:
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||
|
||||
assert len(mock_setup.mock_calls) == 0
|
||||
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "await_approval_confirmation"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={"confirm": True}
|
||||
)
|
||||
|
||||
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
|
||||
assert len(mock_setup.mock_calls) == 1
|
||||
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == DOMAIN
|
||||
assert "result" in result
|
||||
assert result["result"].unique_id == "600"
|
||||
assert "token" in result["result"].data
|
||||
assert result["result"].data["token"]["access_token"] == "mock-access-token"
|
||||
assert result["result"].data["token"]["refresh_token"] == "mock-refresh-token"
|
||||
|
||||
|
||||
async def test_config_non_unique_profile(
|
||||
hass: HomeAssistant,
|
||||
hass_client_no_auth: ClientSessionGenerator,
|
||||
current_request_with_host: None,
|
||||
monzo: AsyncMock,
|
||||
polling_config_entry: MockConfigEntry,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
) -> None:
|
||||
"""Test setup a non-unique profile."""
|
||||
await setup_integration(hass, polling_config_entry)
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
state = config_entry_oauth2_flow._encode_jwt(
|
||||
hass,
|
||||
{
|
||||
"flow_id": result["flow_id"],
|
||||
"redirect_uri": "https://example.com/auth/external/callback",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] == FlowResultType.EXTERNAL_STEP
|
||||
assert result["url"] == (
|
||||
f"{OAUTH2_AUTHORIZE}/?"
|
||||
f"response_type=code&client_id={CLIENT_ID}&"
|
||||
"redirect_uri=https://example.com/auth/external/callback&"
|
||||
f"state={state}"
|
||||
)
|
||||
|
||||
client = await hass_client_no_auth()
|
||||
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
|
||||
assert resp.status == 200
|
||||
assert resp.headers["content-type"] == "text/html; charset=utf-8"
|
||||
|
||||
aioclient_mock.clear_requests()
|
||||
aioclient_mock.post(
|
||||
OAUTH2_TOKEN,
|
||||
json={
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"access_token": "mock-access-token",
|
||||
"type": "Bearer",
|
||||
"expires_in": 60,
|
||||
"user_id": str(USER_ID),
|
||||
},
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||
assert result["type"] == FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
141
tests/components/monzo/test_sensor.py
Normal file
141
tests/components/monzo/test_sensor.py
Normal file
@ -0,0 +1,141 @@
|
||||
"""Tests for the Monzo component."""
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
from syrupy import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.monzo.const import DOMAIN
|
||||
from homeassistant.components.monzo.sensor import (
|
||||
ACCOUNT_SENSORS,
|
||||
POT_SENSORS,
|
||||
MonzoSensorEntityDescription,
|
||||
)
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.const import STATE_UNAVAILABLE
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_registry import EntityRegistry
|
||||
|
||||
from . import setup_integration
|
||||
from .conftest import TEST_ACCOUNTS, TEST_POTS
|
||||
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||
from tests.typing import ClientSessionGenerator
|
||||
|
||||
EXPECTED_VALUE_GETTERS = {
|
||||
"balance": lambda x: x["balance"]["balance"] / 100,
|
||||
"total_balance": lambda x: x["balance"]["total_balance"] / 100,
|
||||
"pot_balance": lambda x: x["balance"] / 100,
|
||||
}
|
||||
|
||||
|
||||
async def async_get_entity_id(
|
||||
hass: HomeAssistant,
|
||||
acc_id: str,
|
||||
description: MonzoSensorEntityDescription,
|
||||
) -> str | None:
|
||||
"""Get an entity id for a user's attribute."""
|
||||
entity_registry = er.async_get(hass)
|
||||
unique_id = f"{acc_id}_{description.key}"
|
||||
|
||||
return entity_registry.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, unique_id)
|
||||
|
||||
|
||||
def async_assert_state_equals(
|
||||
entity_id: str,
|
||||
state_obj: State,
|
||||
expected: Any,
|
||||
description: MonzoSensorEntityDescription,
|
||||
) -> None:
|
||||
"""Assert at given state matches what is expected."""
|
||||
assert state_obj, f"Expected entity {entity_id} to exist but it did not"
|
||||
|
||||
assert state_obj.state == str(expected), (
|
||||
f"Expected {expected} but was {state_obj.state} "
|
||||
f"for measure {description.name}, {entity_id}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
async def test_sensor_default_enabled_entities(
|
||||
hass: HomeAssistant,
|
||||
monzo: AsyncMock,
|
||||
polling_config_entry: MockConfigEntry,
|
||||
hass_client_no_auth: ClientSessionGenerator,
|
||||
) -> None:
|
||||
"""Test entities enabled by default."""
|
||||
await setup_integration(hass, polling_config_entry)
|
||||
entity_registry: EntityRegistry = er.async_get(hass)
|
||||
|
||||
for acc in TEST_ACCOUNTS:
|
||||
for sensor_description in ACCOUNT_SENSORS:
|
||||
entity_id = await async_get_entity_id(hass, acc["id"], sensor_description)
|
||||
assert entity_id
|
||||
assert entity_registry.async_is_registered(entity_id)
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.state == str(
|
||||
EXPECTED_VALUE_GETTERS[sensor_description.key](acc)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
async def test_unavailable_entity(
|
||||
hass: HomeAssistant,
|
||||
basic_monzo: AsyncMock,
|
||||
polling_config_entry: MockConfigEntry,
|
||||
hass_client_no_auth: ClientSessionGenerator,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test entities enabled by default."""
|
||||
await setup_integration(hass, polling_config_entry)
|
||||
basic_monzo.user_account.pots.return_value = [{"id": "pot_savings"}]
|
||||
freezer.tick(timedelta(minutes=100))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
entity_id = await async_get_entity_id(hass, TEST_POTS[0]["id"], POT_SENSORS[0])
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.state == "unknown"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
async def test_all_entities(
|
||||
hass: HomeAssistant,
|
||||
snapshot: SnapshotAssertion,
|
||||
monzo: AsyncMock,
|
||||
polling_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test all entities."""
|
||||
await setup_integration(hass, polling_config_entry)
|
||||
|
||||
for acc in TEST_ACCOUNTS:
|
||||
for sensor in ACCOUNT_SENSORS:
|
||||
entity_id = await async_get_entity_id(hass, acc["id"], sensor)
|
||||
assert hass.states.get(entity_id) == snapshot
|
||||
|
||||
|
||||
async def test_update_failed(
|
||||
hass: HomeAssistant,
|
||||
snapshot: SnapshotAssertion,
|
||||
monzo: AsyncMock,
|
||||
polling_config_entry: MockConfigEntry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test all entities."""
|
||||
await setup_integration(hass, polling_config_entry)
|
||||
|
||||
monzo.user_account.accounts.side_effect = Exception
|
||||
freezer.tick(timedelta(minutes=10))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entity_id = await async_get_entity_id(
|
||||
hass, TEST_ACCOUNTS[0]["id"], ACCOUNT_SENSORS[0]
|
||||
)
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert state.state == STATE_UNAVAILABLE
|
Loading…
x
Reference in New Issue
Block a user