-
-
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 1 commit
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,83 @@ | ||||||||||||||||||||||||||||||||||
| """The CatGenie integration.""" | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| from __future__ import annotations | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| from contextlib import AsyncExitStack | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| from catgenie import CatGenieAuth, CatGenieClient, Credentials, Device | ||||||||||||||||||||||||||||||||||
| 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, | ||||||||||||||||||||||||||||||||||
| CatGenieDeviceCoordinator, | ||||||||||||||||||||||||||||||||||
| 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 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 | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| client = CatGenieClient(credentials) | ||||||||||||||||||||||||||||||||||
| await stack.enter_async_context(client) | ||||||||||||||||||||||||||||||||||
|
kclif9 marked this conversation as resolved.
Outdated
|
||||||||||||||||||||||||||||||||||
| client.set_auth(auth) | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| # Fetch all devices and create a coordinator for each | ||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||
| devices: list[Device] = await client.get_devices() | ||||||||||||||||||||||||||||||||||
| except Exception as err: | ||||||||||||||||||||||||||||||||||
| await stack.aclose() | ||||||||||||||||||||||||||||||||||
| raise ConfigEntryNotReady( | ||||||||||||||||||||||||||||||||||
| translation_domain="catgenie", | ||||||||||||||||||||||||||||||||||
| translation_key="communication_error", | ||||||||||||||||||||||||||||||||||
| translation_placeholders={"error": str(err)}, | ||||||||||||||||||||||||||||||||||
| ) from err | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| device_coordinators: dict[str, CatGenieDeviceCoordinator] = {} | ||||||||||||||||||||||||||||||||||
| for device in devices: | ||||||||||||||||||||||||||||||||||
| coordinator = CatGenieDeviceCoordinator(hass, entry, client, auth, device) | ||||||||||||||||||||||||||||||||||
| await coordinator.async_config_entry_first_refresh() | ||||||||||||||||||||||||||||||||||
| device_coordinators[device.manufacturer_id] = coordinator | ||||||||||||||||||||||||||||||||||
|
kclif9 marked this conversation as resolved.
Outdated
|
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| entry.runtime_data = CatGenieRuntimeData( | ||||||||||||||||||||||||||||||||||
| auth=auth, | ||||||||||||||||||||||||||||||||||
| client=client, | ||||||||||||||||||||||||||||||||||
| device_coordinators=device_coordinators, | ||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
|
kclif9 marked this conversation as resolved.
Outdated
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,95 @@ | ||
| """Data update coordinator for the CatGenie integration.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| 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.""" | ||
|
|
||
| auth: CatGenieAuth | ||
| client: CatGenieClient | ||
| device_coordinators: dict[str, CatGenieDeviceCoordinator] | ||
|
|
||
|
|
||
| type CatGenieConfigEntry = ConfigEntry[CatGenieRuntimeData] | ||
|
|
||
|
|
||
| class CatGenieDeviceCoordinator(DataUpdateCoordinator[Device]): | ||
| """Coordinator for a single CatGenie device.""" | ||
|
|
||
| config_entry: CatGenieConfigEntry | ||
|
|
||
| def __init__( | ||
| self, | ||
| hass: HomeAssistant, | ||
| config_entry: CatGenieConfigEntry, | ||
| client: CatGenieClient, | ||
| auth: CatGenieAuth, | ||
| device: Device, | ||
| ) -> None: | ||
| """Initialize the coordinator.""" | ||
| super().__init__( | ||
| hass, | ||
| LOGGER, | ||
| config_entry=config_entry, | ||
| name=f"{DOMAIN}_{device.manufacturer_id}", | ||
| update_interval=SCAN_INTERVAL, | ||
| ) | ||
| self.client = client | ||
| self.auth = auth | ||
| self.device_id = device.manufacturer_id | ||
|
|
||
| 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={CONF_TOKEN: credentials.refresh_token}, | ||
|
kclif9 marked this conversation as resolved.
Outdated
|
||
| ) | ||
|
|
||
| async def _async_update_data(self) -> Device: | ||
| """Fetch data for this device from the CatGenie API.""" | ||
| try: | ||
| devices = await self.client.get_devices() | ||
| except CatGenieAuthenticationError: | ||
| try: | ||
| credentials = await self.auth.refresh() | ||
| self._update_entry_tokens(credentials) | ||
| devices = await self.client.get_devices() | ||
| except CatGenieAuthenticationError as refresh_err: | ||
| raise ConfigEntryAuthFailed( | ||
| translation_domain=DOMAIN, | ||
| translation_key="authentication_failed", | ||
| ) from refresh_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.
|
||
|
|
||
| for device in devices: | ||
| if device.manufacturer_id == self.device_id: | ||
| return device | ||
|
|
||
| raise UpdateFailed( | ||
| translation_domain=DOMAIN, | ||
| translation_key="communication_error", | ||
| translation_placeholders={"error": f"Device {self.device_id} not found"}, | ||
| ) | ||
Uh oh!
There was an error while loading. Please reload this page.