Skip to content

Commit

Permalink
Add support for native oauth2 in Point (home-assistant#118243)
Browse files Browse the repository at this point in the history
* initial oauth2 implementation

* fix unload_entry

* read old yaml/entry config

* update tests

* fix: pylint on tests

* Apply suggestions from code review

Co-authored-by: Robert Resch <[email protected]>

* fix constants, formatting

* use runtime_data

* Apply suggestions from code review

Co-authored-by: Joost Lekkerkerker <[email protected]>

* fix missing import

* adopt to PointData dataclass

* fix typing

* add more strings (copied from weheat)

* move the PointData dataclass to avoid circular imports

* use configflow inspired by withings

* raise ConfigEntryAuthFailed

* it is called entry_lock

* fix webhook issue

* fix oauth_create_entry

* stop using async_forward_entry_setup

* Fixup

* fix strings

* fix issue that old config might be without unique_id

* parametrize tests

* Update homeassistant/components/point/config_flow.py

* Update tests/components/point/test_config_flow.py

* Fix

---------

Co-authored-by: Robert Resch <[email protected]>
Co-authored-by: Joost Lekkerkerker <[email protected]>
  • Loading branch information
3 people authored and JakeMartin-ICL committed Sep 21, 2024
1 parent 6629ebb commit 4eda40d
Show file tree
Hide file tree
Showing 15 changed files with 383 additions and 397 deletions.
167 changes: 101 additions & 66 deletions homeassistant/components/point/__init__.py
Original file line number Diff line number Diff line change
@@ -1,38 +1,46 @@
"""Support for Minut Point."""

import asyncio
from dataclasses import dataclass
from http import HTTPStatus
import logging

from aiohttp import web
from httpx import ConnectTimeout
from aiohttp import ClientError, ClientResponseError, web
from pypoint import PointSession
import voluptuous as vol

from homeassistant import config_entries
from homeassistant.components import webhook
from homeassistant.config_entries import ConfigEntry
from homeassistant.components.application_credentials import (
ClientCredential,
async_import_client_credential,
)
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import (
CONF_CLIENT_ID,
CONF_CLIENT_SECRET,
CONF_TOKEN,
CONF_WEBHOOK_ID,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import (
aiohttp_client,
config_entry_oauth2_flow,
config_validation as cv,
device_registry as dr,
)
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.dt import as_local, parse_datetime, utc_from_timestamp

from . import config_flow
from . import api
from .const import (
CONF_WEBHOOK_URL,
DOMAIN,
Expand All @@ -45,11 +53,10 @@

_LOGGER = logging.getLogger(__name__)

DATA_CONFIG_ENTRY_LOCK = "point_config_entry_lock"
CONFIG_ENTRY_IS_SETUP = "point_config_entry_is_setup"

PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]

type PointConfigEntry = ConfigEntry[PointData]

CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
Expand All @@ -70,57 +77,80 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:

conf = config[DOMAIN]

config_flow.register_flow_implementation(
hass, DOMAIN, conf[CONF_CLIENT_ID], conf[CONF_CLIENT_SECRET]
async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
breaks_in_ha_version="2025.4.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Point",
},
)

hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_IMPORT}
if not hass.config_entries.async_entries(DOMAIN):
await async_import_client_credential(
hass,
DOMAIN,
ClientCredential(
conf[CONF_CLIENT_ID],
conf[CONF_CLIENT_SECRET],
),
)

hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=conf
)
)
)

return True


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Point from a config entry."""
async def async_setup_entry(hass: HomeAssistant, entry: PointConfigEntry) -> bool:
"""Set up Minut Point from a config entry."""

async def token_saver(token, **kwargs):
_LOGGER.debug("Saving updated token %s", token)
hass.config_entries.async_update_entry(
entry, data={**entry.data, CONF_TOKEN: token}
)
if "auth_implementation" not in entry.data:
raise ConfigEntryAuthFailed("Authentication failed. Please re-authenticate.")

session = PointSession(
async_get_clientsession(hass),
entry.data["refresh_args"][CONF_CLIENT_ID],
entry.data["refresh_args"][CONF_CLIENT_SECRET],
token=entry.data[CONF_TOKEN],
token_saver=token_saver,
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
)
)
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
auth = api.AsyncConfigEntryAuth(
aiohttp_client.async_get_clientsession(hass), session
)

try:
# the call to user() implicitly calls ensure_active_token() in authlib
await session.user()
except ConnectTimeout as err:
_LOGGER.debug("Connection Timeout")
await auth.async_get_access_token()
except ClientResponseError as err:
if err.status in {HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN}:
raise ConfigEntryAuthFailed from err
raise ConfigEntryNotReady from err
except ClientError as err:
raise ConfigEntryNotReady from err
except Exception: # noqa: BLE001
_LOGGER.error("Authentication Error")
return False

hass.data[DATA_CONFIG_ENTRY_LOCK] = asyncio.Lock()
hass.data[CONFIG_ENTRY_IS_SETUP] = set()
point_session = PointSession(auth)

await async_setup_webhook(hass, entry, session)
client = MinutPointClient(hass, entry, session)
hass.data.setdefault(DOMAIN, {}).update({entry.entry_id: client})
client = MinutPointClient(hass, entry, point_session)
hass.async_create_task(client.update())
entry.runtime_data = PointData(client)

await async_setup_webhook(hass, entry, point_session)
# Entries are added in the client.update() function.

return True


async def async_setup_webhook(hass: HomeAssistant, entry: ConfigEntry, session):
async def async_setup_webhook(
hass: HomeAssistant, entry: PointConfigEntry, session: PointSession
) -> None:
"""Set up a webhook to handle binary sensor events."""
if CONF_WEBHOOK_ID not in entry.data:
webhook_id = webhook.async_generate_id()
Expand All @@ -135,27 +165,26 @@ async def async_setup_webhook(hass: HomeAssistant, entry: ConfigEntry, session):
CONF_WEBHOOK_URL: webhook_url,
},
)

await session.update_webhook(
entry.data[CONF_WEBHOOK_URL],
webhook.async_generate_url(hass, entry.data[CONF_WEBHOOK_ID]),
entry.data[CONF_WEBHOOK_ID],
["*"],
)

webhook.async_register(
hass, DOMAIN, "Point", entry.data[CONF_WEBHOOK_ID], handle_webhook
)


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: PointConfigEntry) -> bool:
"""Unload a config entry."""
webhook.async_unregister(hass, entry.data[CONF_WEBHOOK_ID])
session = hass.data[DOMAIN].pop(entry.entry_id)
await session.remove_webhook()

unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if not hass.data[DOMAIN]:
hass.data.pop(DOMAIN)

if unload_ok := await hass.config_entries.async_unload_platforms(
entry, [*PLATFORMS, Platform.ALARM_CONTROL_PANEL]
):
session: PointSession = entry.runtime_data.client
if CONF_WEBHOOK_ID in entry.data:
webhook.async_unregister(hass, entry.data[CONF_WEBHOOK_ID])
await session.remove_webhook()
return unload_ok


Expand Down Expand Up @@ -205,25 +234,23 @@ async def _sync(self):

async def new_device(device_id, platform):
"""Load new device."""
config_entries_key = f"{platform}.{DOMAIN}"
async with self._hass.data[DATA_CONFIG_ENTRY_LOCK]:
if config_entries_key not in self._hass.data[CONFIG_ENTRY_IS_SETUP]:
await self._hass.config_entries.async_forward_entry_setups(
self._config_entry, [platform]
)
self._hass.data[CONFIG_ENTRY_IS_SETUP].add(config_entries_key)

async_dispatcher_send(
self._hass, POINT_DISCOVERY_NEW.format(platform, DOMAIN), device_id
)

self._is_available = True
for home_id in self._client.homes:
if home_id not in self._known_homes:
await self._hass.config_entries.async_forward_entry_setups(
self._config_entry, [Platform.ALARM_CONTROL_PANEL]
)
await new_device(home_id, "alarm_control_panel")
self._known_homes.add(home_id)
for device in self._client.devices:
if device.device_id not in self._known_devices:
await self._hass.config_entries.async_forward_entry_setups(
self._config_entry, PLATFORMS
)
for platform in PLATFORMS:
await new_device(device.device_id, platform)
self._known_devices.add(device.device_id)
Expand Down Expand Up @@ -262,7 +289,7 @@ class MinutPointEntity(Entity): # pylint: disable=hass-enforce-class-module # s

_attr_should_poll = False

def __init__(self, point_client, device_id, device_class):
def __init__(self, point_client, device_id, device_class) -> None:
"""Initialize the entity."""
self._async_unsub_dispatcher_connect = None
self._client = point_client
Expand All @@ -284,7 +311,7 @@ def __init__(self, point_client, device_id, device_class):
if device_class:
self._attr_name = f"{self._name} {device_class.capitalize()}"

def __str__(self):
def __str__(self) -> str:
"""Return string representation of device."""
return f"MinutPoint {self.name}"

Expand Down Expand Up @@ -337,3 +364,11 @@ def is_updated(self):
def last_update(self):
"""Return the last_update time for the device."""
return parse_datetime(self.device.last_update)


@dataclass
class PointData:
"""Point Data."""

client: MinutPointClient
entry_lock: asyncio.Lock = asyncio.Lock()
2 changes: 1 addition & 1 deletion homeassistant/components/point/alarm_control_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ async def async_setup_entry(

async def async_discover_home(home_id):
"""Discover and add a discovered home."""
client = hass.data[POINT_DOMAIN][config_entry.entry_id]
client = config_entry.runtime_data.client
async_add_entities([MinutPointAlarmControl(client, home_id)], True)

async_dispatcher_connect(
Expand Down
26 changes: 26 additions & 0 deletions homeassistant/components/point/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""API for Minut Point bound to Home Assistant OAuth."""

from aiohttp import ClientSession
import pypoint

from homeassistant.helpers import config_entry_oauth2_flow


class AsyncConfigEntryAuth(pypoint.AbstractAuth):
"""Provide Minut Point authentication tied to an OAuth2 based config entry."""

def __init__(
self,
websession: ClientSession,
oauth_session: config_entry_oauth2_flow.OAuth2Session,
) -> None:
"""Initialize Minut Point 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 self._oauth_session.token["access_token"]
14 changes: 14 additions & 0 deletions homeassistant/components/point/application_credentials.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""application_credentials platform the Minut Point integration."""

from homeassistant.components.application_credentials import AuthorizationServer
from homeassistant.core import HomeAssistant

from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN


async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
"""Return authorization server."""
return AuthorizationServer(
authorize_url=OAUTH2_AUTHORIZE,
token_url=OAUTH2_TOKEN,
)
2 changes: 1 addition & 1 deletion homeassistant/components/point/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ async def async_setup_entry(

async def async_discover_sensor(device_id):
"""Discover and add a discovered sensor."""
client = hass.data[POINT_DOMAIN][config_entry.entry_id]
client = config_entry.runtime_data.client
async_add_entities(
(
MinutPointBinarySensor(client, device_id, device_name)
Expand Down
Loading

0 comments on commit 4eda40d

Please sign in to comment.