Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Monzo integration #101731

Merged
merged 45 commits into from
May 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
1fd5214
Initial monzo implementation
JakeMartin-ICL Oct 7, 2023
37c8f6a
Tests and fixes
JakeMartin-ICL Oct 8, 2023
a826c0b
Extracted api to pypi package
JakeMartin-ICL Oct 9, 2023
4277120
Add app confirmation step
JakeMartin-ICL Oct 9, 2023
dc9c6e0
Corrected data path for accounts
JakeMartin-ICL Oct 9, 2023
956f814
Removed useless check
JakeMartin-ICL Oct 11, 2023
334f60f
Improved tests
JakeMartin-ICL Oct 11, 2023
e2438d6
Exclude partially tested files from coverage check
JakeMartin-ICL Oct 11, 2023
cbd8c81
Use has_entity_name naming
JakeMartin-ICL Oct 16, 2023
6c2d534
Bumped monzopy to 1.0.10
JakeMartin-ICL Nov 16, 2023
8fa07ec
Remove commented out code
JakeMartin-ICL Apr 19, 2024
35132a1
Remove reauth from initial PR
JakeMartin-ICL Apr 19, 2024
8e89a09
Remove useless code
JakeMartin-ICL Apr 19, 2024
2a5db53
Correct comment
JakeMartin-ICL Apr 19, 2024
d90eb1c
Remove reauth tests
JakeMartin-ICL Apr 19, 2024
f9fcc79
Remove device triggers from intial PR
JakeMartin-ICL Apr 19, 2024
dd174e3
Set attr outside constructor
JakeMartin-ICL Apr 19, 2024
44c46e7
Remove f-strings where no longer needed in entity.py
JakeMartin-ICL Apr 19, 2024
a97fee5
Rename field to make clearer it's a Callable
JakeMartin-ICL Apr 19, 2024
ab09b4b
Correct native_unit_of_measurement
JakeMartin-ICL Apr 19, 2024
1036c25
Remove pot transfer service from intial PR
JakeMartin-ICL Apr 19, 2024
79f8b83
Remove reauth string
JakeMartin-ICL Apr 19, 2024
ca591ae
Remove empty fields in manifest.json
JakeMartin-ICL Apr 19, 2024
6a8e229
Freeze SensorEntityDescription and remove Mixin
JakeMartin-ICL Apr 19, 2024
57e2419
Use consts in application_credentials.py
JakeMartin-ICL Apr 19, 2024
d288af2
Revert "Remove useless code"
JakeMartin-ICL Apr 19, 2024
f6e8688
Ruff and pylint style fixes
JakeMartin-ICL Apr 22, 2024
61f28ea
Bumped monzopy to 1.1.0
JakeMartin-ICL Apr 22, 2024
5c94b2b
Update test snapshot
JakeMartin-ICL Apr 22, 2024
912aefa
Rename AsyncConfigEntryAuth
JakeMartin-ICL Apr 23, 2024
0528341
Use dataclasses instead of dictionaries
JakeMartin-ICL Apr 23, 2024
9a58a33
Move OAuth constants to application_credentials.py
JakeMartin-ICL Apr 23, 2024
196bfa9
Remove remaining constants and dependencies for services from this PR
JakeMartin-ICL Apr 23, 2024
9a73adf
Remove empty manifest entry
JakeMartin-ICL Apr 23, 2024
14c1065
Fix comment
JakeMartin-ICL Apr 23, 2024
4b0aff3
Set device entry_type to service
JakeMartin-ICL Apr 23, 2024
d8cb839
ACC_SENSORS -> ACCOUNT_SENSORS
JakeMartin-ICL Apr 23, 2024
7568fe1
Make value_fn of sensors return StateType
JakeMartin-ICL Apr 23, 2024
45c70b6
Rename OAuthMonzoAPI again
JakeMartin-ICL Apr 23, 2024
ffc4db1
Fix tests
JakeMartin-ICL Apr 23, 2024
b609b6f
Patch API instead of integration for unavailable test
JakeMartin-ICL Apr 24, 2024
c8ec701
Move pot constant to sensor.py
JakeMartin-ICL Apr 26, 2024
9e90d17
Improve type safety in async_get_monzo_api_data()
JakeMartin-ICL Apr 26, 2024
f3ca65c
Update async_oauth_create_entry() docstring
JakeMartin-ICL Apr 26, 2024
7c90a94
Merge branch 'dev' into monzo-integration
emontnemery May 7, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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(
JakeMartin-ICL marked this conversation as resolved.
Show resolved Hide resolved
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"
JakeMartin-ICL marked this conversation as resolved.
Show resolved Hide resolved


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
JakeMartin-ICL marked this conversation as resolved.
Show resolved Hide resolved

oauth_data: dict[str, Any]
emontnemery marked this conversation as resolved.
Show resolved Hide resolved

@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(
JakeMartin-ICL marked this conversation as resolved.
Show resolved Hide resolved
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"]
}
Loading