Skip to content

Commit

Permalink
Add Monzo integration (#101731)
Browse files Browse the repository at this point in the history
* 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 c6b7109.

* 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>
  • Loading branch information
JakeMartin-ICL and emontnemery committed May 7, 2024
1 parent 5bef2d5 commit 6e024d5
Show file tree
Hide file tree
Showing 24 changed files with 919 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 68 additions & 0 deletions homeassistant/components/monzo/__init__.py
Original file line number Diff line number Diff line change
@@ -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 changes: 26 additions & 0 deletions homeassistant/components/monzo/api.py
Original file line number Diff line number Diff line change
@@ -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 changes: 15 additions & 0 deletions homeassistant/components/monzo/application_credentials.py
Original file line number Diff line number Diff line change
@@ -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 changes: 52 additions & 0 deletions homeassistant/components/monzo/config_flow.py
Original file line number Diff line number Diff line change
@@ -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 changes: 3 additions & 0 deletions homeassistant/components/monzo/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Constants for the Monzo integration."""

DOMAIN = "monzo"
24 changes: 24 additions & 0 deletions homeassistant/components/monzo/data.py
Original file line number Diff line number Diff line change
@@ -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 changes: 47 additions & 0 deletions homeassistant/components/monzo/entity.py
Original file line number Diff line number Diff line change
@@ -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 changes: 10 additions & 0 deletions homeassistant/components/monzo/manifest.json
Original file line number Diff line number Diff line change
@@ -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"]
}

0 comments on commit 6e024d5

Please sign in to comment.