-
-
Notifications
You must be signed in to change notification settings - Fork 37.4k
Add CatGenie integration #169533
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
base: dev
Are you sure you want to change the base?
Add CatGenie integration #169533
Changes from 5 commits
14f768e
58f6c43
52b44fa
a86ae98
20e48f3
08c0f70
cf852f0
fa099e3
db5b859
b6adba8
cae4a7f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,78 @@ | ||||||||||||||||||||||||||||||||||
| """The CatGenie integration.""" | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| from __future__ import annotations | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| from contextlib import AsyncExitStack | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| from catgenie import CatGenieAuth, CatGenieClient, Credentials | ||||||||||||||||||||||||||||||||||
| from catgenie.exceptions import CatGenieAuthenticationError, CatGenieException | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| from homeassistant.const import CONF_TOKEN, Platform | ||||||||||||||||||||||||||||||||||
| from homeassistant.core import HomeAssistant | ||||||||||||||||||||||||||||||||||
| from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| from .coordinator import CatGenieConfigEntry, CatGenieCoordinator, CatGenieRuntimeData | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| PLATFORMS: list[Platform] = [Platform.SENSOR] | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| async def async_setup_entry(hass: HomeAssistant, entry: CatGenieConfigEntry) -> bool: | ||||||||||||||||||||||||||||||||||
| """Set up CatGenie from a config entry.""" | ||||||||||||||||||||||||||||||||||
| credentials = Credentials(refresh_token=entry.data[CONF_TOKEN]) | ||||||||||||||||||||||||||||||||||
| stack = AsyncExitStack() | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| auth = CatGenieAuth() | ||||||||||||||||||||||||||||||||||
| await stack.enter_async_context(auth) | ||||||||||||||||||||||||||||||||||
| auth.credentials = credentials | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| # Obtain a fresh access token using the stored refresh token | ||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||
| credentials = await auth.refresh() | ||||||||||||||||||||||||||||||||||
| except CatGenieAuthenticationError as err: | ||||||||||||||||||||||||||||||||||
| await stack.aclose() | ||||||||||||||||||||||||||||||||||
| raise ConfigEntryAuthFailed( | ||||||||||||||||||||||||||||||||||
| translation_domain="catgenie", | ||||||||||||||||||||||||||||||||||
| translation_key="authentication_failed", | ||||||||||||||||||||||||||||||||||
| ) from err | ||||||||||||||||||||||||||||||||||
| except (CatGenieException, ConnectionError) as err: | ||||||||||||||||||||||||||||||||||
| await stack.aclose() | ||||||||||||||||||||||||||||||||||
| raise ConfigEntryNotReady( | ||||||||||||||||||||||||||||||||||
| translation_domain="catgenie", | ||||||||||||||||||||||||||||||||||
| translation_key="communication_error", | ||||||||||||||||||||||||||||||||||
| translation_placeholders={"error": str(err)}, | ||||||||||||||||||||||||||||||||||
|
kclif9 marked this conversation as resolved.
|
||||||||||||||||||||||||||||||||||
| ) from err | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| # Persist rotated refresh token so subsequent startups use the latest token | ||||||||||||||||||||||||||||||||||
| if credentials.refresh_token != entry.data[CONF_TOKEN]: | ||||||||||||||||||||||||||||||||||
| hass.config_entries.async_update_entry( | ||||||||||||||||||||||||||||||||||
| entry, data={**entry.data, CONF_TOKEN: credentials.refresh_token} | ||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| client = CatGenieClient(credentials) | ||||||||||||||||||||||||||||||||||
| await stack.enter_async_context(client) | ||||||||||||||||||||||||||||||||||
|
kclif9 marked this conversation as resolved.
Outdated
|
||||||||||||||||||||||||||||||||||
| client.set_auth(auth) | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| coordinator = CatGenieCoordinator(hass, entry, client, auth) | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||
| await coordinator.async_config_entry_first_refresh() | ||||||||||||||||||||||||||||||||||
| except Exception: | ||||||||||||||||||||||||||||||||||
| await stack.aclose() | ||||||||||||||||||||||||||||||||||
| raise | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| entry.runtime_data = CatGenieRuntimeData( | ||||||||||||||||||||||||||||||||||
| stack=stack, | ||||||||||||||||||||||||||||||||||
| coordinator=coordinator, | ||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
|
Comment on lines
+60
to
+67
|
||||||||||||||||||||||||||||||||||
| entry.runtime_data = CatGenieRuntimeData( | |
| stack=stack, | |
| coordinator=coordinator, | |
| ) | |
| await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) | |
| try: | |
| await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) | |
| except Exception: | |
| await stack.aclose() | |
| raise | |
| entry.runtime_data = CatGenieRuntimeData( | |
| stack=stack, | |
| coordinator=coordinator, | |
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,171 @@ | ||
| """Config flow for the CatGenie integration.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from collections.abc import Mapping | ||
| from typing import Any | ||
|
|
||
| from catgenie import CatGenieAuth | ||
| from catgenie.exceptions import CatGenieAuthenticationError, CatGenieException | ||
| import voluptuous as vol | ||
|
|
||
| from homeassistant.config_entries import ConfigFlow, ConfigFlowResult | ||
| from homeassistant.const import CONF_CODE, CONF_COUNTRY_CODE, CONF_TOKEN | ||
|
|
||
| from .const import DOMAIN, LOGGER | ||
|
|
||
| CONF_PHONE = "phone" | ||
|
|
||
| STEP_PHONE_DATA_SCHEMA = vol.Schema( | ||
| { | ||
| vol.Required(CONF_COUNTRY_CODE): int, | ||
| vol.Required(CONF_PHONE): str, | ||
| } | ||
| ) | ||
|
|
||
| STEP_CODE_DATA_SCHEMA = vol.Schema( | ||
| { | ||
| vol.Required(CONF_CODE): str, | ||
| } | ||
| ) | ||
|
|
||
|
|
||
| class CatGenieConfigFlow(ConfigFlow, domain=DOMAIN): | ||
| """Handle a config flow for CatGenie.""" | ||
|
|
||
| VERSION = 1 | ||
|
|
||
|
kclif9 marked this conversation as resolved.
|
||
| _country_code: int | ||
| _phone: str | ||
|
|
||
| async def async_step_user( | ||
| self, user_input: dict[str, Any] | None = None | ||
| ) -> ConfigFlowResult: | ||
| """Handle the phone number step.""" | ||
| errors: dict[str, str] = {} | ||
|
|
||
| if user_input is not None: | ||
| self._country_code = user_input[CONF_COUNTRY_CODE] | ||
| self._phone = user_input[CONF_PHONE] | ||
|
|
||
| try: | ||
| async with CatGenieAuth() as auth: | ||
| await auth.request_login_code( | ||
| country_code=self._country_code, | ||
| phone=self._phone, | ||
| ) | ||
| except CatGenieAuthenticationError: | ||
| errors["base"] = "cannot_connect" | ||
| except CatGenieException: | ||
| LOGGER.exception("Unexpected exception requesting login code") | ||
| errors["base"] = "unknown" | ||
|
kclif9 marked this conversation as resolved.
|
||
| else: | ||
| return await self.async_step_code() | ||
|
|
||
| return self.async_show_form( | ||
| step_id="user", | ||
| data_schema=STEP_PHONE_DATA_SCHEMA, | ||
| errors=errors, | ||
| ) | ||
|
|
||
| async def async_step_code( | ||
| self, user_input: dict[str, Any] | None = None | ||
| ) -> ConfigFlowResult: | ||
| """Handle the SMS verification code step.""" | ||
| errors: dict[str, str] = {} | ||
|
|
||
| if user_input is not None: | ||
| try: | ||
| async with CatGenieAuth() as auth: | ||
| credentials = await auth.login( | ||
| country_code=self._country_code, | ||
| phone=self._phone, | ||
| code=user_input[CONF_CODE], | ||
| ) | ||
| except CatGenieAuthenticationError: | ||
| errors["base"] = "invalid_auth" | ||
| except CatGenieException: | ||
| LOGGER.exception("Unexpected exception during login") | ||
| errors["base"] = "unknown" | ||
| else: | ||
| await self.async_set_unique_id(credentials.user_id) | ||
| self._abort_if_unique_id_configured() | ||
|
|
||
| return self.async_create_entry( | ||
| title=f"CatGenie ({self._phone})", | ||
| data={CONF_TOKEN: credentials.refresh_token}, | ||
| ) | ||
|
|
||
| return self.async_show_form( | ||
| step_id=CONF_CODE, | ||
| data_schema=STEP_CODE_DATA_SCHEMA, | ||
| errors=errors, | ||
| ) | ||
|
|
||
| async def async_step_reauth( | ||
| self, entry_data: Mapping[str, Any] | ||
| ) -> ConfigFlowResult: | ||
| """Handle re-authentication when the token is rejected.""" | ||
| return await self.async_step_reauth_confirm() | ||
|
|
||
| async def async_step_reauth_confirm( | ||
| self, user_input: dict[str, Any] | None = None | ||
| ) -> ConfigFlowResult: | ||
|
kclif9 marked this conversation as resolved.
Outdated
|
||
| """Handle re-auth: collect phone number and request a new SMS code.""" | ||
| errors: dict[str, str] = {} | ||
|
|
||
| if user_input is not None: | ||
| self._country_code = user_input[CONF_COUNTRY_CODE] | ||
| self._phone = user_input[CONF_PHONE] | ||
|
|
||
| try: | ||
| async with CatGenieAuth() as auth: | ||
| await auth.request_login_code( | ||
| country_code=self._country_code, | ||
| phone=self._phone, | ||
| ) | ||
| except CatGenieAuthenticationError: | ||
| errors["base"] = "cannot_connect" | ||
| except CatGenieException: | ||
| LOGGER.exception("Unexpected exception requesting login code") | ||
| errors["base"] = "unknown" | ||
| else: | ||
| return await self.async_step_reauth_code() | ||
|
|
||
| return self.async_show_form( | ||
| step_id="reauth_confirm", | ||
| data_schema=STEP_PHONE_DATA_SCHEMA, | ||
| errors=errors, | ||
| ) | ||
|
|
||
| async def async_step_reauth_code( | ||
| self, user_input: dict[str, Any] | None = None | ||
| ) -> ConfigFlowResult: | ||
| """Handle re-auth: enter the new SMS code.""" | ||
| errors: dict[str, str] = {} | ||
|
|
||
| if user_input is not None: | ||
| try: | ||
| async with CatGenieAuth() as auth: | ||
| credentials = await auth.login( | ||
| country_code=self._country_code, | ||
| phone=self._phone, | ||
| code=user_input[CONF_CODE], | ||
| ) | ||
| except CatGenieAuthenticationError: | ||
| errors["base"] = "invalid_auth" | ||
| except CatGenieException: | ||
| LOGGER.exception("Unexpected exception during re-auth login") | ||
| errors["base"] = "unknown" | ||
| else: | ||
| reauth_entry = self._get_reauth_entry() | ||
| return self.async_update_reload_and_abort( | ||
| reauth_entry, | ||
| data={CONF_TOKEN: credentials.refresh_token}, | ||
| ) | ||
|
kclif9 marked this conversation as resolved.
Outdated
|
||
|
|
||
| return self.async_show_form( | ||
| step_id="reauth_code", | ||
| data_schema=STEP_CODE_DATA_SCHEMA, | ||
| errors=errors, | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| """Constants for the CatGenie integration.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import logging | ||
|
|
||
| DOMAIN = "catgenie" | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. how do they advertise it? cat_genie? catgenie? No preference, just want to throw it on the table
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. They advertise as "CatGenie" with the G uppercase and no space. |
||
| LOGGER = logging.getLogger(__package__) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| """Data update coordinator for the CatGenie integration.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from contextlib import AsyncExitStack | ||
| from dataclasses import dataclass | ||
| from datetime import timedelta | ||
|
|
||
| from catgenie import CatGenieAuth, CatGenieClient, Credentials, Device | ||
| from catgenie.exceptions import CatGenieAPIError, CatGenieAuthenticationError | ||
|
|
||
| from homeassistant.config_entries import ConfigEntry | ||
| from homeassistant.const import CONF_TOKEN | ||
| from homeassistant.core import HomeAssistant | ||
| from homeassistant.exceptions import ConfigEntryAuthFailed | ||
| from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed | ||
|
|
||
| from .const import DOMAIN, LOGGER | ||
|
|
||
| SCAN_INTERVAL = timedelta(seconds=60) | ||
|
|
||
|
|
||
| @dataclass | ||
| class CatGenieRuntimeData: | ||
| """Runtime data for CatGenie.""" | ||
|
|
||
| stack: AsyncExitStack | ||
| coordinator: CatGenieCoordinator | ||
|
|
||
|
|
||
| type CatGenieConfigEntry = ConfigEntry[CatGenieRuntimeData] | ||
|
|
||
|
|
||
| class CatGenieCoordinator(DataUpdateCoordinator[dict[str, Device]]): | ||
| """Coordinator that fetches all CatGenie devices in a single API call.""" | ||
|
|
||
| config_entry: CatGenieConfigEntry | ||
|
|
||
| def __init__( | ||
| self, | ||
| hass: HomeAssistant, | ||
| config_entry: CatGenieConfigEntry, | ||
| client: CatGenieClient, | ||
| auth: CatGenieAuth, | ||
| ) -> None: | ||
| """Initialize the coordinator.""" | ||
| super().__init__( | ||
| hass, | ||
| LOGGER, | ||
| config_entry=config_entry, | ||
| name=DOMAIN, | ||
| update_interval=SCAN_INTERVAL, | ||
| ) | ||
| self.client = client | ||
| self.auth = auth | ||
|
|
||
| def _update_entry_tokens(self, credentials: Credentials) -> None: | ||
| """Persist refreshed refresh token back to the config entry.""" | ||
| self.hass.config_entries.async_update_entry( | ||
| self.config_entry, | ||
| data={ | ||
| **self.config_entry.data, | ||
| CONF_TOKEN: credentials.refresh_token, | ||
| }, | ||
| ) | ||
|
|
||
| async def _async_update_data(self) -> dict[str, Device]: | ||
| """Fetch all devices from the CatGenie API.""" | ||
| try: | ||
| # Refresh the access token if needed and retry the request once | ||
| try: | ||
| devices = await self.client.get_devices() | ||
| except CatGenieAuthenticationError: | ||
| credentials = await self.auth.refresh() | ||
| self._update_entry_tokens(credentials) | ||
| devices = await self.client.get_devices() | ||
| except CatGenieAuthenticationError as err: | ||
| raise ConfigEntryAuthFailed( | ||
| translation_domain=DOMAIN, | ||
| translation_key="authentication_failed", | ||
| ) from err | ||
| except CatGenieAPIError as err: | ||
| raise UpdateFailed( | ||
| translation_domain=DOMAIN, | ||
| translation_key="communication_error", | ||
| translation_placeholders={"error": str(err)}, | ||
| ) from err | ||
|
kclif9 marked this conversation as resolved.
kclif9 marked this conversation as resolved.
|
||
|
|
||
| return {device.manufacturer_id: device for device in devices} | ||
Uh oh!
There was an error while loading. Please reload this page.