Skip to content
Draft
Show file tree
Hide file tree
Changes from 8 commits
Commits
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
1 change: 1 addition & 0 deletions .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ homeassistant.components.cambridge_audio.*
homeassistant.components.camera.*
homeassistant.components.canary.*
homeassistant.components.casper_glow.*
homeassistant.components.catgenie.*
homeassistant.components.cert_expiry.*
homeassistant.components.clickatell.*
homeassistant.components.clicksend.*
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

78 changes: 78 additions & 0 deletions homeassistant/components/catgenie/__init__.py
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:
Comment thread
kclif9 marked this conversation as resolved.
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)},
Comment thread
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)
Comment thread
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
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ensure AsyncExitStack is closed if forwarding platform setups fails (e.g., async_forward_entry_setups raises), to avoid leaking open client/auth contexts; wrap the forward call in try/except (or set runtime_data only after successful forwarding) and close the stack on failure.

Suggested change
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,
)

Copilot uses AI. Check for mistakes.
return True


async def async_unload_entry(hass: HomeAssistant, entry: CatGenieConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
await entry.runtime_data.stack.aclose()
return unload_ok
173 changes: 173 additions & 0 deletions homeassistant/components/catgenie/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
"""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

Comment thread
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"
Comment thread
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:
Comment thread
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:
await self.async_set_unique_id(credentials.user_id)
self._abort_if_unique_id_mismatch(reason="reauth_account_mismatch")
reauth_entry = self._get_reauth_entry()
return self.async_update_reload_and_abort(
reauth_entry,
data={CONF_TOKEN: credentials.refresh_token},
)
Comment thread
kclif9 marked this conversation as resolved.
Outdated

return self.async_show_form(
step_id="reauth_code",
data_schema=STEP_CODE_DATA_SCHEMA,
errors=errors,
)
8 changes: 8 additions & 0 deletions homeassistant/components/catgenie/const.py
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"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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__)
89 changes: 89 additions & 0 deletions homeassistant/components/catgenie/coordinator.py
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
Comment thread
kclif9 marked this conversation as resolved.
Comment thread
kclif9 marked this conversation as resolved.

return {device.manufacturer_id: device for device in devices}
Loading
Loading