diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 0000000..cc39a32 --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,28 @@ +# .github/workflows/tests.yaml +name: Tests + +on: + push: + branches: [main, master, v2.1.1-beta-tests] + pull_request: + branches: [main, master, v2.1.1-beta-tests] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements_test.txt + + - name: Run tests + run: pytest tests/ -v --timeout=10 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6148339 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +## 2.1.1 - 2026-02-06 + +### Added +- Open-X/C08 device support (thanks to this nice repo: https://github.com/eulemitkeule/pycatlink) +- Limited Scooper Pro Ultra support +- Reset litter and reset deodorant buttons for litterbox +- Config flow coverage for discovery, reauthentication, and options +- Test suite additions and GitHub Actions workflow for tests + +### Fixed +- Device detail parsing fallback when API payloads are incomplete +- Home Assistant 2026.2.0 compatibility issues + +### Changed +- Device and entity organization with new helpers and logs mixin diff --git a/README.md b/README.md index 8d100df..28466de 100644 --- a/README.md +++ b/README.md @@ -27,13 +27,12 @@ - [Installation](#installation) - [Easy way](#easy-way) - [Manually](#manually) - - [Configuration Example](#configuration-example) - [Supported Devices and Operations](#supported-devices-and-operations) - [Scooper SE](#supported-devices-and-operations) - [Scooper PRO](#supported-devices-and-operations) - [How to Configure?](#how-to-configure) - - [API Regions](#api-regions) - [Services (Optional)](#services-optional) +- [Changelog](#changelog) - [How to contribute?](#how-to-contribute) - [Disclaimer on Using Logos](#disclaimer-on-using-logos) @@ -83,30 +82,11 @@ wget -O - https://get.hacs.vip | DOMAIN=catlink REPO_PATH=hasscc/catlink ARCHIVE 3. Call this [`service: shell_command.update_catlink`](https://my.home-assistant.io/redirect/developer_call_service/?service=shell_command.update_catlink) in Developer Tools 2. Restart HA core again -### Configuration Example: - -```yaml -catlink: - phone: "xxxxxx" - password: "xxxxxx" - phone_iac: 86 # Default - api_base: "https://app-usa.catlinks.cn/api/" - scan_interval: "00:00:10" - language: "en_GB" - - # Multiple accounts (Optional) - accounts: - - username: 18866660001 - password: password1 - - username: 18866660002 - password: password2 -``` - ## Supported Devices and Operations -
+
-
+

Scooper SE

Scooper SE

Operations

@@ -117,16 +97,18 @@ catlink:
  • Litter weight measurement
  • Litter days left
  • Deodorant replacement countdown in days
  • +
  • (NEW) Reset litter and deodorant buttons
  • Occupacy flag
  • Cleaning count
  • Knob status
  • Garbage Tobe status
  • Online status
  • Logs & Errors
  • +
  • Entities: sensor, binary sensor, select, switch, button
  • -
    +

    Scooper PRO

    Scooper PRO

    Operations

    @@ -136,95 +118,91 @@ catlink:
  • Deodorant replacement countdown in days
  • Litter days left
  • Litter weight measurement
  • +
  • Reset litter and deodorant buttons
  • Occupacy flag
  • Cleaning count
  • Temperature (Celsius)
  • Humidity
  • Online status
  • Logs & Error
  • +
  • Entities: sensor, binary sensor, select, switch, button
  • -
    +

    Feeder Young

    - Feeder Young + Feeder Young

    Operations

    • Feed Button
    • Food tray weight
    • Online status
    • Logs & Error
    • +
    • Entities: sensor, binary sensor, button
    • +
    +
    + +
    + +#### Additional supported devices + +
    + +
    +

    Open-X/C08

    + Open-X/C08 +

    Operations

    +
      +
    • Changing operation mode (Auto, Manual, Scheduled)
    • +
    • Actions (Clean, Pause, Cancel, Pave)
    • +
    • Litter weight, remaining days, and deodorant countdown
    • +
    • Quiet mode, child lock, indicator light, keypad tone
    • +
    • Notice switches and pet stats
    • +
    • Entities: sensor, binary sensor, select, switch, button
    • +
    +
    + +
    +

    Scooper Pro Ultra (limited support)

    + Scooper Pro Ultra +

    Operations

    +
      +
    • Litter remaining days
    • +
    • Deodorant countdown
    • +
    • Total clean time
    • +
    • Logs
    • +
    • Entities: sensor
    +#### Cats + +
    + +
    +

    Smart collars (via the Cats integration)

    + CatLINK smart collar +

    Operations

    +
      +
    • Activity and status sensors
    • +
    • Weight and body metrics sensors
    • +
    • Presence and last seen tracking
    • +
    • Entities: sensor, binary sensor
    • +
    +
    + +
    + + ### How to Configure? > ! Recommend sharing devices to another account, because you can keep only one login session, which means that you'll have to re-login to CATLINK each time your HA instance pulls the data. -```yaml -# configuration.yaml - -catlink: - # Single account - phone: xxxxxxxxx # Username of Catlink APP (without country code) - password: xxxxxxxxxx # Password - phone_iac: 86 # Optional, International access code, default is 86 (China) - api_base: # Optional, default is China server: https://app.catlinks.cn/api/ (see API Regions) - scan_interval: # Optional, default is 00:01:00 - language: "en_GB" - - devices: # Optional - - name: "Scooper C1" # Optional - mac: "AABBCCDDEE" # Optional - empty_weight: 3.0 # (Optional) Empty litterbox weight defaults to 0.0 - max_samples_litter: 24 # (Optional) Number of samples to determinate whether cat is inside - - - # Multiple accounts - accounts: - - username: 18866660001 - password: password1 - - username: 18866660002 - password: password2 -``` - -#### API Regions - -> To verify your region, please navigate to `Me` > `Settings` > `Server Nodes` - -

    Please precise your location, as number of features might depend on it.

    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    RegionAPI Base
    🌎 Global/Recomendedhttps://app.catlinks.cn/api/
    πŸ‡¨πŸ‡³ Mainland China (Sh)https://app-sh.catlinks.cn/api/
    πŸ‡ΊπŸ‡Έ Euroamericahttps://app-usa.catlinks.cn/api/
    πŸ‡ΈπŸ‡¬ Singaporehttps://app-sgp.catlinks.cn/api/
    +Just use ConfigFlow. Enter your phonenumber (eg. +493034994004) and password.
    +That's it.
    +It will automatically discover your Region, Cats & Devices. ## Services (Optional) @@ -240,6 +218,10 @@ data: key: val ``` +## Changelog + +See `CHANGELOG.md` for release notes. + ### How to contribute? Please visit [CONTRIBUTE](/CONTRIBUTE.md), and be aware of [this](/CODE_OF_CONDUCT.md). diff --git a/custom_components/__init__.py b/custom_components/__init__.py new file mode 100644 index 0000000..e7b91f4 --- /dev/null +++ b/custom_components/__init__.py @@ -0,0 +1 @@ +"""Custom components package.""" diff --git a/custom_components/catlink/__init__.py b/custom_components/catlink/__init__.py index 5a14fea..393bd55 100644 --- a/custom_components/catlink/__init__.py +++ b/custom_components/catlink/__init__.py @@ -1,46 +1,70 @@ """The component.""" -from homeassistant.const import CONF_DEVICES, CONF_PASSWORD, CONF_TOKEN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_DEVICES from homeassistant.core import HomeAssistant -from homeassistant.helpers.discovery import async_load_platform -from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers import config_validation as cv -from .const import _LOGGER, CONF_ACCOUNTS, DOMAIN, SCAN_INTERVAL, SUPPORTED_DOMAINS +from .const import ( + _LOGGER, + CONF_ACCOUNTS, + CONF_DEVICE_IDS, + DOMAIN, + SUPPORTED_DOMAINS, +) from .modules.account import Account from .modules.devices_coordinator import DevicesCoordinator +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, hass_config: dict) -> bool: """Set up the CatLink component.""" - hass.data.setdefault(DOMAIN, {}) - config = hass_config.get(DOMAIN) or {} - hass.data[DOMAIN]["config"] = config hass.data[DOMAIN].setdefault(CONF_ACCOUNTS, {}) hass.data[DOMAIN].setdefault(CONF_DEVICES, {}) hass.data[DOMAIN].setdefault("coordinators", {}) hass.data[DOMAIN].setdefault("add_entities", {}) + hass.data[DOMAIN].setdefault("config", {}) + hass.data[DOMAIN].setdefault("entry_coordinators", {}) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up CatLink from a config entry.""" + hass.data[DOMAIN].setdefault("config", {}) + hass.data[DOMAIN]["config"].setdefault(CONF_DEVICES, []) + + config = {**entry.data, **(entry.options or {})} + acc = Account(hass, config) + device_ids = entry.options.get(CONF_DEVICE_IDS) if entry.options else None + coordinator = DevicesCoordinator(acc, entry.entry_id, device_ids=device_ids) + + await acc.async_check_auth() + await coordinator.async_refresh() + + hass.data[DOMAIN][CONF_ACCOUNTS][acc.uid] = acc + hass.data[DOMAIN]["coordinators"][coordinator.name] = coordinator + hass.data[DOMAIN]["entry_coordinators"][entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, SUPPORTED_DOMAINS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + await hass.config_entries.async_unload_platforms(entry, SUPPORTED_DOMAINS) - component = EntityComponent(_LOGGER, DOMAIN, hass, SCAN_INTERVAL) - hass.data[DOMAIN]["component"] = component - await component.async_setup(config) - - als = config.get(CONF_ACCOUNTS) or [] - if CONF_PASSWORD in config: - acc = {**config} - acc.pop(CONF_ACCOUNTS, None) - als.append(acc) - for cfg in als: - if not cfg.get(CONF_PASSWORD) and not cfg.get(CONF_TOKEN): - continue - acc = Account(hass, cfg) - coordinator = DevicesCoordinator(acc) - await acc.async_check_auth() - await coordinator.async_refresh() - hass.data[DOMAIN][CONF_ACCOUNTS][acc.uid] = acc - hass.data[DOMAIN]["coordinators"][coordinator.name] = coordinator - - for platform in SUPPORTED_DOMAINS: - hass.async_create_task(async_load_platform(hass, platform, DOMAIN, {}, config)) + uid = f"{entry.data.get('phone_iac', '86')}-{entry.data.get('phone', '')}" + if uid in hass.data[DOMAIN][CONF_ACCOUNTS]: + del hass.data[DOMAIN][CONF_ACCOUNTS][uid] + coordinator_name = f"{DOMAIN}-{uid}-{CONF_DEVICES}" + if coordinator_name in hass.data[DOMAIN]["coordinators"]: + del hass.data[DOMAIN]["coordinators"][coordinator_name] + if entry.entry_id in hass.data[DOMAIN]["entry_coordinators"]: + del hass.data[DOMAIN]["entry_coordinators"][entry.entry_id] + if entry.entry_id in hass.data[DOMAIN].get("add_entities", {}): + del hass.data[DOMAIN]["add_entities"][entry.entry_id] return True diff --git a/custom_components/catlink/binary_sensor.py b/custom_components/catlink/binary_sensor.py index 5281925..d8a9ef7 100644 --- a/custom_components/catlink/binary_sensor.py +++ b/custom_components/catlink/binary_sensor.py @@ -6,20 +6,14 @@ ) from homeassistant.core import HomeAssistant -from . import DOMAIN -from .entitites import CatlinkBinaryEntity -from .helpers import Helper +from .entities import CatlinkBinarySensorEntity +from .helpers import Helper, async_setup_domain_platform -async_setup_entry = Helper.async_setup_entry +async_setup_entry = Helper.async_setup_entry_for(ENTITY_DOMAIN) async def async_setup_platform( hass: HomeAssistant, config, async_add_entities, discovery_info=None ): """Set up the Catlink binary_sensor platform.""" - hass.data[DOMAIN]["add_entities"][ENTITY_DOMAIN] = async_add_entities - await Helper.async_setup_accounts(hass, ENTITY_DOMAIN) - - -class CatlinkBinarySensorEntity(CatlinkBinaryEntity, BinarySensorEntity): - """BinarySensorEntity.""" + await async_setup_domain_platform(hass, ENTITY_DOMAIN, async_add_entities) diff --git a/custom_components/catlink/button.py b/custom_components/catlink/button.py index f9a0a6f..6c6e624 100644 --- a/custom_components/catlink/button.py +++ b/custom_components/catlink/button.py @@ -1,29 +1,17 @@ """Support for button.""" -from homeassistant.components.button import DOMAIN as ENTITY_DOMAIN, ButtonEntity +from homeassistant.components.button import DOMAIN as ENTITY_DOMAIN from homeassistant.core import HomeAssistant from .const import DOMAIN -from .entitites import CatlinkEntity -from .helpers import Helper +from .entities import CatlinkButtonEntity +from .helpers import Helper, async_setup_domain_platform -async_setup_entry = Helper.async_setup_entry +async_setup_entry = Helper.async_setup_entry_for(ENTITY_DOMAIN) async def async_setup_platform( - hass: HomeAssistant, config, async_add_entities, discovery_info=None + hass: HomeAssistant, config, async_add_entities, discovery_info=None ): - """Set up the Catlink switch platform.""" - hass.data[DOMAIN]["add_entities"][ENTITY_DOMAIN] = async_add_entities - await Helper.async_setup_accounts(hass, ENTITY_DOMAIN) - - -class CatlinkButtonEntity(CatlinkEntity, ButtonEntity): - - async def async_press(self): - """Press the button.""" - ret = False - fun = self._option.get('async_press') - if callable(fun): - ret = await fun() - return ret + """Set up the Catlink button platform.""" + await async_setup_domain_platform(hass, ENTITY_DOMAIN, async_add_entities) diff --git a/custom_components/catlink/config_flow.py b/custom_components/catlink/config_flow.py new file mode 100644 index 0000000..5725da8 --- /dev/null +++ b/custom_components/catlink/config_flow.py @@ -0,0 +1,267 @@ +"""Config flow for CatLink integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +import voluptuous as vol + +from homeassistant.config_entries import ( + ConfigEntry, + SOURCE_REAUTH, + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) +from homeassistant.const import CONF_PASSWORD +from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.selector import ( + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, +) + +from .const import ( + API_SERVERS, + CONF_API_BASE, + CONF_DEVICE_IDS, + CONF_PHONE, + CONF_PHONE_IAC, + CONF_UPDATE_INTERVAL, + DOMAIN, + ERROR_INVALID_AUTH, + SUPPORTED_DEVICE_TYPES, +) +from .helpers import discover_region, parse_phone_number +from .modules.account import Account + + +def _device_label(dat: dict, supported: bool) -> str: + """Build a human-readable label for a device.""" + name = dat.get("deviceName") or dat.get("model") or "Unknown" + model = dat.get("model", "") + device_type = dat.get("deviceType", "") + suffix = "Supported" if supported else "Limited support" + if model and model != name: + return f"{name} ({model}) - {suffix}" + return f"{name} ({device_type}) - {suffix}" + + +class CatlinkConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a CatLink config flow.""" + + VERSION = 1 + + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> CatlinkOptionsFlowHandler: + """Get the options flow for this handler.""" + return CatlinkOptionsFlowHandler() + + def __init__(self) -> None: + """Initialize the config flow.""" + super().__init__() + self._account: Account | None = None + self._config: dict[str, Any] = {} + self._discovered_devices: list[dict] = [] + self._device_options: dict[str, str] = {} + self._supported_ids: list[str] = [] + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an authentication error.""" + return await self.async_step_user() + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + phone_raw = user_input[CONF_PHONE].strip() + password = user_input[CONF_PASSWORD] + + phone_iac, phone_number = parse_phone_number(phone_raw) + region = await discover_region(self.hass, phone_iac, phone_number, password) + if region is None: + errors["base"] = ERROR_INVALID_AUTH + else: + api_base = API_SERVERS[region] + config = { + CONF_API_BASE: api_base, + CONF_PHONE: phone_number, + CONF_PHONE_IAC: phone_iac, + CONF_PASSWORD: password, + } + account = Account(self.hass, config) + await account.async_check_auth() + + self._account = account + self._config = config + + if self.source == SOURCE_REAUTH: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), data=self._config + ) + + devices = await account.get_devices() + if not devices: + return self.async_create_entry( + title=f"+{phone_iac}{phone_number}", + data=self._config, + options={CONF_DEVICE_IDS: [], CONF_UPDATE_INTERVAL: 60}, + ) + + self._discovered_devices = devices + self._device_options = {} + self._supported_ids = [] + for dat in devices: + did = dat.get("id") + if not did: + continue + supported = dat.get("deviceType", "") in SUPPORTED_DEVICE_TYPES + self._device_options[did] = _device_label(dat, supported) + if supported: + self._supported_ids.append(did) + + return await self.async_step_discovery() + + reauth_entry = ( + self._get_reauth_entry() if self.source == SOURCE_REAUTH else None + ) + default_phone = "" + if reauth_entry: + piac = reauth_entry.data.get(CONF_PHONE_IAC, "") + pnum = reauth_entry.data.get(CONF_PHONE, "") + default_phone = f"+{piac}{pnum}" if piac and pnum else "" + + schema = vol.Schema( + { + vol.Required(CONF_PHONE, default=default_phone): str, + vol.Required(CONF_PASSWORD): str, + } + ) + + return self.async_show_form( + step_id="user", + data_schema=schema, + errors=errors, + ) + + async def async_step_discovery( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the device discovery step.""" + if user_input is not None: + selected_ids = user_input.get(CONF_DEVICE_IDS, []) + update_interval = user_input.get(CONF_UPDATE_INTERVAL, 60) + uid = self._account.uid if self._account else "" + await self.async_set_unique_id(uid) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=f"+{self._config[CONF_PHONE_IAC]}{self._config[CONF_PHONE]}", + data=self._config, + options={ + CONF_DEVICE_IDS: selected_ids, + CONF_UPDATE_INTERVAL: update_interval, + }, + ) + + supported_count = len(self._supported_ids) + unsupported_count = len(self._device_options) - supported_count + + return self.async_show_form( + step_id="discovery", + data_schema=vol.Schema( + { + vol.Optional( + CONF_DEVICE_IDS, + default=self._supported_ids, + ): cv.multi_select(self._device_options), + vol.Optional( + CONF_UPDATE_INTERVAL, + default=60, + ): NumberSelector( + NumberSelectorConfig( + min=30, + max=3600, + step=30, + mode=NumberSelectorMode.BOX, + unit_of_measurement="s", + ) + ), + } + ), + description_placeholders={ + "supported_count": str(supported_count), + "unsupported_count": str(unsupported_count), + "total_count": str(len(self._device_options)), + }, + ) + + +class CatlinkOptionsFlowHandler(OptionsFlowWithReload): + """Handle CatLink options flow.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage device selection and refresh interval.""" + if user_input is not None: + return self.async_create_entry(data=user_input) + + account = Account( + self.hass, + {**dict(self.config_entry.data), **dict(self.config_entry.options or {})}, + ) + await account.async_check_auth() + devices = await account.get_devices() or [] + + device_options: dict[str, str] = {} + supported_ids: list[str] = [] + for dat in devices: + did = dat.get("id") + if not did: + continue + supported = dat.get("deviceType", "") in SUPPORTED_DEVICE_TYPES + device_options[did] = _device_label(dat, supported) + if supported: + supported_ids.append(did) + + current_ids = self.config_entry.options.get(CONF_DEVICE_IDS) + if current_ids is None: + current_ids = ( + supported_ids if supported_ids else list(device_options.keys()) + ) + + current_interval = self.config_entry.options.get(CONF_UPDATE_INTERVAL, 60) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional( + CONF_DEVICE_IDS, + default=current_ids, + ): cv.multi_select(device_options), + vol.Optional( + CONF_UPDATE_INTERVAL, + default=current_interval, + ): NumberSelector( + NumberSelectorConfig( + min=30, + max=3600, + step=30, + mode=NumberSelectorMode.BOX, + unit_of_measurement="s", + ) + ), + } + ), + ) diff --git a/custom_components/catlink/const.py b/custom_components/catlink/const.py index fac712e..acb7d1a 100644 --- a/custom_components/catlink/const.py +++ b/custom_components/catlink/const.py @@ -10,21 +10,38 @@ DOMAIN = "catlink" _LOGGER = logging.getLogger(__name__) -DOMAIN = "catlink" CONFIG = "config" SCAN_INTERVAL = datetime.timedelta(minutes=1) CONF_ACCOUNTS = "accounts" CONF_API_BASE = "api_base" +CONF_REGION = "region" CONF_USER_ID = "uid" CONF_PHONE = "phone" CONF_PHONE_IAC = "phone_iac" CONF_LANGUAGE = "language" CONF_CATS = "cats" +CONF_DEVICE_IDS = "device_ids" +CONF_UPDATE_INTERVAL = "update_interval" DEFAULT_API_BASE = "https://app.catlinks.cn/api/" +# Device types with full support (sensors, switches, selects, etc.) +SUPPORTED_DEVICE_TYPES = frozenset({"C08", "SCOOPER", "LITTER_BOX_599", "FEEDER"}) + +# API server regions: value is the API base URL +API_SERVERS: dict[str, str] = { + "global": "https://app.catlinks.cn/api/", + "china": "https://app-sh.catlinks.cn/api/", + "usa": "https://app-usa.catlinks.cn/api/", + "singapore": "https://app-sgp.catlinks.cn/api/", +} + +# Config flow error keys +ERROR_CANNOT_CONNECT = "cannot_connect" +ERROR_INVALID_AUTH = "invalid_auth" + SIGN_KEY = "00109190907746a7ad0e2139b6d09ce47551770157fe4ac5922f3a5454c82712" RSA_PUBLIC_KEY = ( "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCCA9I+iEl2AI8dnhdwwxPxHVK8iNAt6aTq6UhNsLsguWS5qtbLnuGz2RQdfNS" diff --git a/custom_components/catlink/devices/__init__.py b/custom_components/catlink/devices/__init__.py new file mode 100644 index 0000000..b6a0b7f --- /dev/null +++ b/custom_components/catlink/devices/__init__.py @@ -0,0 +1,19 @@ +"""Device classes for CatLink integration.""" + +from .base import Device +from .cat import CatDevice +from .c08 import C08Device +from .feeder import FeederDevice +from .litterbox import LitterBox +from .scooper import ScooperDevice +from .scooper_pro_ultra import ScooperProUltraDevice + +__all__ = [ + "CatDevice", + "Device", + "C08Device", + "FeederDevice", + "LitterBox", + "ScooperDevice", + "ScooperProUltraDevice", +] diff --git a/custom_components/catlink/modules/device.py b/custom_components/catlink/devices/base.py similarity index 82% rename from custom_components/catlink/modules/device.py rename to custom_components/catlink/devices/base.py index f980bf5..0ef0693 100644 --- a/custom_components/catlink/modules/device.py +++ b/custom_components/catlink/devices/base.py @@ -1,12 +1,15 @@ -"""Device module for CatLink integration.""" +"""Device base class for CatLink integration.""" from typing import TYPE_CHECKING from ..const import _LOGGER +from ..helpers import format_api_error from ..models.additional_cfg import AdditionalDeviceConfig +from ..models.api.device import DeviceInfoBase +from ..models.api.parse import parse_response if TYPE_CHECKING: - from .devices_coordinator import DevicesCoordinator + from ..modules.devices_coordinator import DevicesCoordinator class Device: @@ -25,6 +28,7 @@ def __init__( self.coordinator = coordinator self.account = coordinator.account self.listeners = {} + self._action_error: str | None = None self.update_data(dat) self.detail = {} @@ -38,10 +42,16 @@ def update_data(self, dat: dict) -> None: self._handle_listeners() _LOGGER.info("Update device data: %s", dat) - def _handle_listeners(self): + def _handle_listeners(self) -> None: + """Notify all registered listeners to refresh their state.""" for fun in self.listeners.values(): fun() + def _set_action_error(self, error_msg: str) -> None: + """Store API error and refresh entities so the error sensor updates.""" + self._action_error = error_msg + self._handle_listeners() + @property def id(self) -> str: """Return the device id.""" @@ -85,6 +95,8 @@ def name(self) -> str: @property def error(self) -> str: """Return the device error.""" + if self._action_error: + return self._action_error try: return self.detail.get("currentMessage") or self.data.get( "currentErrorMessage", "" @@ -208,7 +220,9 @@ async def select_mode(self, mode, **kwargs) -> bool: rdt = await self.account.request(api, pms, "POST") eno = rdt.get("returnCode", 0) if eno: - _LOGGER.error("Select mode failed: %s", [rdt, pms]) + err_msg = format_api_error(rdt) + _LOGGER.error("Select mode failed: %s", err_msg) + self._set_action_error(err_msg) return False await self.update_device_detail() _LOGGER.info("Select mode: %s", [rdt, pms]) @@ -232,7 +246,9 @@ async def select_action(self, action, **kwargs) -> bool: rdt = await self.account.request(api, pms, "POST") eno = rdt.get("returnCode", 0) if eno: - _LOGGER.error("Select action failed: %s", [rdt, pms]) + err_msg = format_api_error(rdt) + _LOGGER.error("Select action failed: %s", err_msg) + self._set_action_error(err_msg) return False await self.update_device_detail() _LOGGER.info("Select action: %s", [rdt, pms]) @@ -247,12 +263,26 @@ async def update_device_detail(self) -> dict: rsp = None try: rsp = await self.account.request(api, pms) - rdt = rsp.get("data", {}).get("deviceInfo") or {} + data = rsp.get("data", {}) + raw = data.get("deviceInfo") + parsed = parse_response(data, "deviceInfo", DeviceInfoBase) + rdt = ( + parsed.model_dump(by_alias=True) + if hasattr(parsed, "model_dump") + else (parsed or {}) + ) + if not rdt and raw: + rdt = raw + _LOGGER.debug( + "Using raw deviceInfo for %s because model parsing failed", + self.name, + ) except (TypeError, ValueError) as exc: rdt = {} _LOGGER.error("Got device detail for %s failed: %s", self.name, exc) if not rdt: _LOGGER.warning("Got device detail for %s failed: %s", self.name, rsp) self.detail = rdt + self._action_error = None self._handle_listeners() return rdt diff --git a/custom_components/catlink/devices/c08.py b/custom_components/catlink/devices/c08.py new file mode 100644 index 0000000..148588b --- /dev/null +++ b/custom_components/catlink/devices/c08.py @@ -0,0 +1,704 @@ +"""C08 device module for CatLink integration.""" + +from __future__ import annotations + +import asyncio +from datetime import time +from functools import partial +from typing import TYPE_CHECKING + +from custom_components.catlink.const import _LOGGER +from custom_components.catlink.devices.litter_device import LitterDevice +from custom_components.catlink.helpers import format_api_error +from custom_components.catlink.models.additional_cfg import AdditionalDeviceConfig +from custom_components.catlink.models.api.device import C08DeviceInfo +from custom_components.catlink.models.api.parse import parse_response + +if TYPE_CHECKING: + from custom_components.catlink.modules.devices_coordinator import DevicesCoordinator + +API_LITTERBOX_C08_INFO = "token/litterbox/info/c08" +API_LITTERBOX_ACTION_COMMAND_V3 = "token/litterbox/actionCmd/v3" +API_LITTERBOX_CHANGE_MODE = "token/litterbox/changeMode" +API_LITTERBOX_PET_WEIGHT_AUTO_UPDATE = "token/litterbox/pet/weight/autoUpdate" +API_LITTERBOX_CAT_LITTER_SETTING = "token/litterbox/catLitterSetting" +API_LITTERBOX_DEEP_CLEAN_AUTO_BURIAL = "token/litterbox/deepClean/autoBurial" +API_LITTERBOX_DEEP_CLEAN_CONTINUOUS_CLEANING = ( + "token/litterbox/deepClean/continuousCleaning" +) +API_LITTERBOX_KITTY_MODEL_SWITCH = "token/litterbox/kittyModelSwitch" +API_LITTERBOX_KEY_LOCK = "token/litterbox/keyLock" +API_LITTERBOX_INDICATOR_LIGHT_SETTING = "token/litterbox/indicatorLightSetting" +API_LITTERBOX_KEYPAD_TONE = "token/litterbox/keypadTone" +API_LITTERBOX_SAFE_TIME_SETTING = "token/litterbox/safeTimeSetting" +API_LITTERBOX_NOTICE_CONFIG_SET = "token/litterbox/noticeConfig/set" +API_LITTERBOX_NOTICE_CONFIG_LIST_C08 = "token/litterbox/noticeConfig/list/c08" +API_LITTERBOX_STATS_DATA_COMPARE_V2 = "token/litterbox/stats/data/compare/v2" +API_LITTERBOX_STATS_CATS = "token/litterbox/stats/cats" +API_LITTERBOX_LINKED_PETS = "token/litterbox/linkedPets" +API_LITTERBOX_CAT_LIST_SELECTABLE = "token/litterbox/cat/listSelectable" +API_LITTERBOX_C08_WIFI_INFO = "token/litterbox/wifi/info" +API_LITTERBOX_ABOUT_DEVICE = "token/litterbox/aboutDevice" + +API_LITTERBOX_LOGS = "token/litterbox/stats/log/top5" +API_LITTERBOX_LOGS_RESPONSE_KEY = "scooperLogTop5" + +DEFAULT_QUIET_START = time(22, 0) +DEFAULT_QUIET_END = time(7, 0) + +NOTICE_ITEMS: dict[str, tuple[str, str]] = { + "cat_came": ("LITTERBOX_599_CAT_CAME", "Cat came"), + "box_full": ("LITTERBOX_599_BOX_FULL", "Box full"), + "replace_garbage_bag": ("REPLACE_GARBAGE_BAG", "Replace garbage bag"), + "wash_scooper": ("WASH_SCOOPER", "Wash scooper"), + "replace_deodorant": ("REPLACE_DEODORANT", "Replace deodorant"), + "litter_not_enough": ("LITTERBOX_599_CAT_LITTER_NOT_ENOUGH", "Litter not enough"), + "sandbox_not_enough": ("LITTERBOX_599_SANDBOX_NOT_ENOUGHT", "Sandbox not enough"), + "anti_pinch": ("LITTERBOX_599_ANTI_PINCH", "Anti pinch"), + "firmware_updated": ("LITTERBOX_599_FIRMWARE_UPDATED", "Firmware updated"), +} + + +class C08Device(LitterDevice): + """C08 litter box device class.""" + + def __init__( + self, + dat: dict, + coordinator: DevicesCoordinator, + additional_config: AdditionalDeviceConfig | None = None, + ) -> None: + """Initialize the device.""" + super().__init__(dat, coordinator, additional_config) + self._device_stats: dict | None = None + self._pet_stats: list | None = None + self._linked_pets: list | None = None + self._selectable_pets: list | None = None + self._wifi_info: dict | None = None + self._notice_configs: list | None = None + self._about_device: dict | None = None + self._notice_config_map: dict[str, bool] = {} + self._last_action: str | None = None + + def __getattr__(self, name: str): + """Return notice switch state for dynamic notice attributes.""" + if name.startswith("notice_"): + slug = name.removeprefix("notice_") + if slug in NOTICE_ITEMS: + item_code = NOTICE_ITEMS[slug][0] + return bool(self._notice_config_map.get(item_code)) + raise AttributeError( + f"{self.__class__.__name__} object has no attribute {name}" + ) + + @property + def modes(self) -> dict: + """Return the modes of the device.""" + return { + "00": "auto", + "01": "manual", + "02": "scheduled", + } + + @property + def action(self) -> str | None: + """Return the last action.""" + return self._last_action + + @property + def litter_types(self) -> dict: + """Return the litter types.""" + return { + "00": "Bentonite", + "02": "Mixed", + } + + @property + def safe_time_options(self) -> dict: + """Return safe time options.""" + return { + "1": "1 min", + "3": "3 min", + "5": "5 min", + "7": "7 min", + "10": "10 min", + "15": "15 min", + "30": "30 min", + } + + @property + def litter_type(self) -> str | None: + """Return the current litter type.""" + raw = self.detail.get("litterType") + if raw is None: + return None + return self.litter_types.get(f"{raw}".zfill(2), f"{raw}") + + @property + def safe_time(self) -> str | None: + """Return the safe time setting.""" + raw = self.detail.get("safeTime") + if raw is None: + return None + return self.safe_time_options.get(f"{raw}", f"{raw}") + + @property + def auto_burial(self) -> bool: + """Return the auto burial setting.""" + return self._bool_value(self.detail.get("autoBurial")) + + @property + def continuous_cleaning(self) -> bool: + """Return the continuous cleaning setting.""" + return self._bool_value(self.detail.get("continuousCleaning")) + + @property + def quiet_mode(self) -> bool: + """Return the quiet mode setting.""" + quiet_enable = self.detail.get("quietEnable") + if quiet_enable is not None: + return self._bool_value(quiet_enable) + return bool(self.detail.get("quietTimes")) + + @property + def child_lock(self) -> bool: + """Return the child lock setting.""" + return self._string_flag( + self.detail.get("keyLock"), true_values={"01", "LOCKED", "ON"} + ) + + @property + def indicator_light(self) -> bool: + """Return the indicator light setting.""" + return self._string_flag( + self.detail.get("indicatorLight"), true_values={"ALWAYS_OPEN", "01", "ON"} + ) + + @property + def keypad_tone(self) -> bool: + """Return the keypad tone setting.""" + return self._string_flag( + self.detail.get("paneltone"), true_values={"01", "ENABLED", "ON"} + ) + + @property + def auto_pet_weight_update(self) -> bool: + """Return the auto pet weight update setting.""" + return self._bool_value(self.detail.get("autoUpdatePetWeight")) + + @property + def kitty_model(self) -> bool: + """Return the kitty model setting.""" + return self._bool_value(self.detail.get("kittenModel")) + + @property + def error(self) -> str: + """Return the device error.""" + if self._action_error: + return self._action_error + return self.detail.get("currentMessage") or "Normal Operation" + + @property + def hass_sensor(self) -> dict: + """Return the device sensors.""" + return { + "state": { + "icon": "mdi:information", + "state_attrs": self.state_attrs, + }, + "error": { + "icon": "mdi:alert-circle", + "state_attrs": self.error_attrs, + }, + "last_log": { + "icon": "mdi:message", + "state_attrs": self.last_log_attrs, + }, + "litter_weight": { + "icon": "mdi:weight", + "unit": "kg", + }, + "litter_remaining_days": { + "icon": "mdi:calendar", + "unit": "days", + }, + "total_clean_time": { + "icon": "mdi:history", + "unit": "times", + }, + "manual_clean_time": { + "icon": "mdi:history", + "unit": "times", + }, + "deodorant_countdown": { + "icon": "mdi:timer", + "unit": "days", + }, + "occupied": { + "icon": "mdi:cat", + }, + "online": { + "icon": "mdi:wifi", + }, + "wifi_rssi": { + "icon": "mdi:wifi", + }, + "wifi_ssid": { + "icon": "mdi:wifi", + }, + "stats_times": { + "icon": "mdi:counter", + }, + "stats_weight_avg": { + "icon": "mdi:scale", + }, + "stats_duration_avg": { + "icon": "mdi:timer", + }, + "notice_config_count": { + "icon": "mdi:bell", + }, + "pet_stats_count": { + "icon": "mdi:paw", + }, + } + + @property + def hass_select(self) -> dict: + """Return the device selects.""" + return { + "mode": { + "icon": "mdi:menu", + "options": list(self.modes.values()), + "state_attrs": self.mode_attrs, + "async_select": self.select_mode, + }, + "action": { + "icon": "mdi:play-box", + "options": list(self._action_options()), + "async_select": self.select_action, + "delay_update": 5, + }, + "litter_type": { + "icon": "mdi:shaker-outline", + "options": list(self.litter_types.values()), + "async_select": self.select_litter_type, + }, + "safe_time": { + "icon": "mdi:timer", + "options": list(self.safe_time_options.values()), + "async_select": self.select_safe_time, + }, + } + + @property + def hass_switch(self) -> dict: + """Return the device switches.""" + switches = { + "quiet_mode": { + "icon": "mdi:volume-off", + "async_turn_on": partial(self.async_set_quiet_mode, True), + "async_turn_off": partial(self.async_set_quiet_mode, False), + }, + "auto_burial": { + "icon": "mdi:shovel", + "async_turn_on": partial(self.async_set_auto_burial, True), + "async_turn_off": partial(self.async_set_auto_burial, False), + }, + "continuous_cleaning": { + "icon": "mdi:replay", + "async_turn_on": partial(self.async_set_continuous_cleaning, True), + "async_turn_off": partial(self.async_set_continuous_cleaning, False), + }, + "child_lock": { + "icon": "mdi:lock", + "async_turn_on": partial(self.async_set_child_lock, True), + "async_turn_off": partial(self.async_set_child_lock, False), + }, + "indicator_light": { + "icon": "mdi:lightbulb", + "async_turn_on": partial(self.async_set_indicator_light, True), + "async_turn_off": partial(self.async_set_indicator_light, False), + }, + "keypad_tone": { + "icon": "mdi:volume-high", + "async_turn_on": partial(self.async_set_keypad_tone, True), + "async_turn_off": partial(self.async_set_keypad_tone, False), + }, + "auto_pet_weight_update": { + "icon": "mdi:scale", + "async_turn_on": partial(self.async_set_auto_pet_weight_update, True), + "async_turn_off": partial(self.async_set_auto_pet_weight_update, False), + }, + "kitty_model": { + "icon": "mdi:cat", + "async_turn_on": partial(self.async_set_kitty_model, True), + "async_turn_off": partial(self.async_set_kitty_model, False), + }, + } + for slug, (item_code, label) in NOTICE_ITEMS.items(): + switches[f"notice_{slug}"] = { + "icon": "mdi:bell", + "name": f"Notice: {label}", + "async_turn_on": partial(self.async_set_notice, item_code, True), + "async_turn_off": partial(self.async_set_notice, item_code, False), + } + return switches + + def state_attrs(self) -> dict: + """Return the state attributes.""" + return { + **self._base_state_attrs(), + "quiet_times": self.detail.get("quietTimes"), + "auto_burial": self.auto_burial, + "continuous_cleaning": self.continuous_cleaning, + "indicator_light": self.detail.get("indicatorLight"), + "panel_tone": self.detail.get("paneltone"), + "auto_update_pet_weight": self.detail.get("autoUpdatePetWeight"), + "kitten_model": self.detail.get("kittenModel"), + "litter_type": self.detail.get("litterType"), + "box_full_sensitivity": self.detail.get("boxFullSensitivity"), + "garbage_status": self.detail.get("garbageStatus"), + "wifi_info": self._wifi_info or {}, + "notice_configs": self._notice_configs or [], + "device_stats": self._device_stats or {}, + "pet_stats": self._pet_stats or [], + "about_device": self._about_device or {}, + "linked_pets": self._linked_pets or [], + "selectable_pets": self._selectable_pets or [], + } + + def error_attrs(self) -> dict: + """Return the error attributes.""" + return { + "errors": self.detail.get("deviceErrorList"), + } + + @property + def wifi_rssi(self) -> str | None: + """Return the WiFi RSSI.""" + return self._wifi_info.get("rssi") if self._wifi_info else None + + @property + def wifi_ssid(self) -> str | None: + """Return the WiFi SSID.""" + if not self._wifi_info: + return None + return self._wifi_info.get("wifiName") or self._wifi_info.get("wifi_name") + + @property + def stats_times(self) -> int | None: + """Return the stats times.""" + return self._device_stats.get("times") if self._device_stats else None + + @property + def stats_weight_avg(self) -> float | None: + """Return the average weight from stats.""" + return self._device_stats.get("weightAvg") if self._device_stats else None + + @property + def stats_duration_avg(self) -> int | None: + """Return the average duration from stats.""" + return self._device_stats.get("durationAvg") if self._device_stats else None + + @property + def notice_config_count(self) -> int: + """Return the number of notice configurations.""" + return len(self._notice_configs or []) + + @property + def pet_stats_count(self) -> int: + """Return the number of pet stats entries.""" + return len(self._pet_stats or []) + + async def update_device_detail(self) -> dict: + """Update the device detail.""" + rsp = None + try: + rsp = await self.account.request( + API_LITTERBOX_C08_INFO, {"deviceId": self.id} + ) + data = rsp.get("data", {}) + raw = data.get("deviceInfo") + parsed = parse_response(data, "deviceInfo", C08DeviceInfo) + rdt = ( + parsed.model_dump(by_alias=True) + if hasattr(parsed, "model_dump") + else (parsed or {}) + ) + if not rdt and raw: + rdt = raw + _LOGGER.debug( + "Using raw deviceInfo for %s because model parsing failed", + self.name, + ) + except (TypeError, ValueError) as exc: + rdt = {} + _LOGGER.error("Got device detail for %s failed: %s", self.name, exc) + if not rdt: + _LOGGER.warning("Got device detail for %s failed: %s", self.name, rsp) + self.detail = rdt + self._action_error = None + await self.async_refresh_c08_extras() + self._handle_listeners() + return rdt + + async def update_logs(self) -> list: + """Update device logs.""" + return await self._fetch_logs( + API_LITTERBOX_LOGS, API_LITTERBOX_LOGS_RESPONSE_KEY + ) + + async def select_mode(self, mode, **kwargs) -> bool: + """Select the device mode.""" + mod = None + for k, v in self.modes.items(): + if v == mode: + mod = k + break + if mod is None: + _LOGGER.warning("Select mode failed for %s in %s", mode, self.modes) + return False + rdt = await self.account.request( + API_LITTERBOX_CHANGE_MODE, {"workModel": mod, "deviceId": self.id}, "POST" + ) + return await self._handle_action_result(rdt, "Select mode") + + async def select_action(self, action, **kwargs) -> bool: + """Select the device action.""" + action_payload = self._action_options().get(action) + if action_payload is None: + _LOGGER.warning("Select action failed for %s", action) + return False + rdt = await self.account.request( + API_LITTERBOX_ACTION_COMMAND_V3, + { + "action": action_payload[0], + "behavior": action_payload[1], + "deviceId": self.id, + }, + "POST", + ) + result = await self._handle_action_result(rdt, "Select action") + if result: + self._last_action = action + return result + + async def select_litter_type(self, litter_type, **kwargs) -> bool: + """Select the litter type.""" + type_code = None + for k, v in self.litter_types.items(): + if v == litter_type: + type_code = k + break + if type_code is None: + _LOGGER.warning("Select litter type failed for %s", litter_type) + return False + rdt = await self.account.request( + API_LITTERBOX_CAT_LITTER_SETTING, + {"litterType": type_code, "deviceId": self.id}, + "POST", + ) + return await self._handle_action_result(rdt, "Select litter type") + + async def select_safe_time(self, safe_time, **kwargs) -> bool: + """Select the safe time option.""" + safe_value = None + for k, v in self.safe_time_options.items(): + if v == safe_time: + safe_value = k + break + if safe_value is None: + _LOGGER.warning("Select safe time failed for %s", safe_time) + return False + rdt = await self.account.request( + API_LITTERBOX_SAFE_TIME_SETTING, + {"safeTime": safe_value, "deviceId": self.id}, + "POST", + ) + return await self._handle_action_result(rdt, "Select safe time") + + async def async_set_auto_pet_weight_update(self, enable: bool, **kwargs) -> bool: + """Set auto pet weight update.""" + return await self._issue_toggle( + API_LITTERBOX_PET_WEIGHT_AUTO_UPDATE, enable, "auto pet weight update" + ) + + async def async_set_quiet_mode(self, enable: bool, **kwargs) -> bool: + """Enable or disable quiet mode.""" + start_time, end_time = self._quiet_time_range() + rdt = await self.account.request( + API_LITTERBOX_DEEP_CLEAN_AUTO_BURIAL, + { + "enable": enable, + "times": f"{start_time.strftime('%H:%M')}-{end_time.strftime('%H:%M')}", + "deviceId": self.id, + }, + "POST", + ) + return await self._handle_action_result(rdt, "Quiet mode") + + async def async_set_auto_burial(self, enable: bool, **kwargs) -> bool: + """Enable or disable automatic burial.""" + return await self._issue_toggle( + API_LITTERBOX_DEEP_CLEAN_AUTO_BURIAL, enable, "Auto burial" + ) + + async def async_set_continuous_cleaning(self, enable: bool, **kwargs) -> bool: + """Enable or disable continuous cleaning.""" + return await self._issue_toggle( + API_LITTERBOX_DEEP_CLEAN_CONTINUOUS_CLEANING, + enable, + "Continuous cleaning", + ) + + async def async_set_child_lock(self, enable: bool, **kwargs) -> bool: + """Enable or disable child lock.""" + status = "LOCKED" if enable else "UNLOCKED" + rdt = await self.account.request( + API_LITTERBOX_KEY_LOCK, + {"lockStatus": status, "deviceId": self.id}, + "POST", + ) + return await self._handle_action_result(rdt, "Child lock") + + async def async_set_indicator_light(self, enable: bool, **kwargs) -> bool: + """Enable or disable indicator light.""" + status = "ALWAYS_OPEN" if enable else "CLOSED" + rdt = await self.account.request( + API_LITTERBOX_INDICATOR_LIGHT_SETTING, + {"status": status, "deviceId": self.id}, + "POST", + ) + return await self._handle_action_result(rdt, "Indicator light") + + async def async_set_keypad_tone(self, enable: bool, **kwargs) -> bool: + """Enable or disable keypad tone.""" + panel_tone = "ENABLED" if enable else "DISABLED" + rdt = await self.account.request( + API_LITTERBOX_KEYPAD_TONE, + {"panelTone": panel_tone, "kind": "00", "deviceId": self.id}, + "POST", + ) + return await self._handle_action_result(rdt, "Keypad tone") + + async def async_set_kitty_model(self, enable: bool, **kwargs) -> bool: + """Enable or disable kitty model.""" + return await self._issue_toggle( + API_LITTERBOX_KITTY_MODEL_SWITCH, enable, "Kitty model" + ) + + async def async_set_notice(self, item: str, enable: bool, **kwargs) -> bool: + """Enable or disable a notice item.""" + rdt = await self.account.request( + API_LITTERBOX_NOTICE_CONFIG_SET, + {"noticeItem": item, "noticeSwitch": enable, "deviceId": self.id}, + "POST", + ) + return await self._handle_action_result(rdt, "Notice config") + + async def async_refresh_c08_extras(self) -> None: + """Refresh supplemental C08 data.""" + requests = [ + self.account.request( + API_LITTERBOX_STATS_DATA_COMPARE_V2, {"deviceId": self.id} + ), + self.account.request(API_LITTERBOX_STATS_CATS, {"deviceId": self.id}), + self.account.request(API_LITTERBOX_LINKED_PETS, {"deviceId": self.id}), + self.account.request( + API_LITTERBOX_CAT_LIST_SELECTABLE, {"deviceId": self.id} + ), + self.account.request(API_LITTERBOX_C08_WIFI_INFO, {"deviceId": self.id}), + self.account.request( + API_LITTERBOX_NOTICE_CONFIG_LIST_C08, {"deviceId": self.id} + ), + self.account.request(API_LITTERBOX_ABOUT_DEVICE, {"deviceId": self.id}), + ] + ( + stats_rsp, + pets_rsp, + linked_rsp, + selectable_rsp, + wifi_rsp, + notice_rsp, + about_rsp, + ) = await asyncio.gather(*requests) + + self._device_stats = stats_rsp.get("data", {}).get("compareData", {}) + self._pet_stats = pets_rsp.get("data", {}).get("cats", []) + self._linked_pets = linked_rsp.get("data", []) + self._selectable_pets = selectable_rsp.get("data", {}).get("cats", []) + self._wifi_info = wifi_rsp.get("data", {}).get("wifiInfo", {}) + self.set_notice_configs(notice_rsp.get("data", {}).get("noticeConfigs", [])) + self._about_device = about_rsp.get("data", {}).get("info", {}) + + def set_notice_configs(self, configs: list | None) -> None: + """Set notice configs and update the notice map.""" + self._notice_configs = configs or [] + self._notice_config_map = { + cfg.get("noticeItem"): bool(cfg.get("noticeSwitch")) + for cfg in self._notice_configs + if cfg.get("noticeItem") is not None + } + + async def _issue_toggle(self, api: str, enable: bool, name: str) -> bool: + """Issue a simple enable/disable command.""" + rdt = await self.account.request( + api, + {"enable": enable, "deviceId": self.id}, + "POST", + ) + return await self._handle_action_result(rdt, name) + + async def _handle_action_result(self, rdt: dict, action_name: str) -> bool: + """Handle the action response.""" + eno = rdt.get("returnCode", 0) + if eno: + err_msg = format_api_error(rdt) + _LOGGER.error("%s failed: %s", action_name, err_msg) + self._set_action_error(err_msg) + return False + await self.update_device_detail() + _LOGGER.info("%s: %s", action_name, rdt) + return True + + def _action_options(self) -> dict[str, tuple[str, str]]: + """Return C08 action options mapping.""" + return { + "Clean: start": ("RUN", "CLEAN"), + "Clean: pause": ("PAUSE", "CLEAN"), + "Clean: cancel": ("CANCEL", "CLEAN"), + "Pave: start": ("RUN", "PAVE"), + "Pave: pause": ("PAUSE", "PAVE"), + } + + def _quiet_time_range(self) -> tuple[time, time]: + """Return quiet time range with sensible defaults.""" + quiet_times = self.detail.get("quietTimes") or "" + if quiet_times and "-" in quiet_times: + start_raw, end_raw = quiet_times.split("-", 1) + try: + return time.fromisoformat(start_raw), time.fromisoformat(end_raw) + except ValueError: + _LOGGER.debug("Invalid quietTimes value: %s", quiet_times) + return DEFAULT_QUIET_START, DEFAULT_QUIET_END + + @staticmethod + def _bool_value(value) -> bool: + """Return a boolean value from a loosely typed field.""" + if value is None: + return False + if isinstance(value, bool): + return value + if isinstance(value, int): + return value != 0 + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return bool(value) + + @staticmethod + def _string_flag(value, true_values: set[str]) -> bool: + """Return a bool for string flags.""" + if value is None: + return False + return str(value).upper() in {v.upper() for v in true_values} diff --git a/custom_components/catlink/devices/cat.py b/custom_components/catlink/devices/cat.py new file mode 100644 index 0000000..961d699 --- /dev/null +++ b/custom_components/catlink/devices/cat.py @@ -0,0 +1,272 @@ +"""Cat device class for CatLink integration.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from custom_components.catlink.devices.base import Device +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import UnitOfMass +from homeassistant.util import dt as dt_util + +if TYPE_CHECKING: + from custom_components.catlink.modules.devices_coordinator import DevicesCoordinator + + +GENDER_LABELS: dict[int, str] = { + 1: "Male", + 2: "Female", + 3: "Neutered male", + 4: "Neutered female", +} + + +class CatDevice(Device): + """Cat device class for CatLink integration.""" + + def __init__( + self, + dat: dict, + coordinator: DevicesCoordinator, + additional_config: Any | None = None, + ) -> None: + """Initialize the cat device.""" + super().__init__(dat, coordinator, additional_config) + + async def async_init(self) -> None: + """Initialize the device.""" + self.detail = self.data + + def update_data(self, dat: dict) -> None: + """Update device data.""" + super().update_data(dat) + self.detail = dat + + async def update_device_detail(self) -> dict: + """Update device detail (cats use list payload).""" + self.detail = self.data + self._handle_listeners() + return self.detail + + @property + def pet_id(self) -> str | None: + """Return the pet id.""" + return self.data.get("pet_id") or self.data.get("id") + + @property + def weight(self) -> float | None: + """Return the pet weight.""" + return self.data.get("weight") + + @property + def age_years(self) -> int | None: + """Return the pet age in years.""" + return self.data.get("year") or self.data.get("age") + + @property + def age_months(self) -> int | None: + """Return the pet age in months.""" + return self.data.get("month") + + @property + def breed(self) -> str | None: + """Return the pet breed.""" + return self.data.get("breedName") + + @property + def gender_label(self) -> str | None: + """Return the pet gender label.""" + gender = self.data.get("gender") + if isinstance(gender, str) and gender.isdigit(): + gender = int(gender) + if isinstance(gender, int): + return GENDER_LABELS.get(gender) + return None + + @property + def birthday(self) -> str | None: + """Return the pet birthday as ISO date.""" + birthday = self.data.get("birthday") + if not birthday: + return None + return dt_util.utc_from_timestamp(birthday / 1000).date().isoformat() + + @property + def avatar_url(self) -> str | None: + """Return the pet avatar URL.""" + return self.data.get("avatar") + + @property + def avatar(self) -> None: + """Return the avatar state.""" + return None + + def _summary(self) -> dict: + return self.data.get("summary_simple") or {} + + def _summary_section(self, name: str) -> dict: + summary = self._summary() + return summary.get(name) or {} + + @staticmethod + def _to_float(value) -> float | None: + """Convert a value to float when possible.""" + if value is None: + return None + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + try: + return float(value) + except ValueError: + return None + return None + + @property + def status(self) -> str | None: + """Return the pet health status description.""" + summary = self._summary() + return summary.get("statusDescription") or summary.get("status") + + @property + def toilet_times(self) -> int | None: + """Return the number of toilet visits.""" + return self._summary_section("toilet").get("times") + + @property + def toilet_weight_avg(self) -> float | None: + """Return the average toilet weight.""" + return self._summary_section("toilet").get("weightAvg") + + @property + def pee_times(self) -> int | None: + """Return the number of pee events.""" + return self._summary_section("toilet").get("peed") + + @property + def poo_times(self) -> int | None: + """Return the number of poo events.""" + return self._summary_section("toilet").get("pood") + + @property + def drink_times(self) -> int | None: + """Return the drink times.""" + return self._summary_section("drink").get("times") + + @property + def diet_times(self) -> int | None: + """Return the diet times.""" + return self._summary_section("diet").get("times") + + @property + def diet_intakes(self) -> float | None: + """Return the diet intakes.""" + return self._to_float(self._summary_section("diet").get("intakes")) + + @property + def sport_active_duration(self) -> int | None: + """Return the sport active duration.""" + return self._summary_section("sport").get("activeDuration") + + def cat_attrs(self) -> dict: + """Return the cat attributes.""" + return { + "pet_id": self.pet_id, + "breed": self.breed, + "gender": self.gender_label, + "birthday": self.birthday, + "weight": self.weight, + "age_years": self.age_years, + "age_months": self.age_months, + "toilet_times": self.toilet_times, + "toilet_weight_avg": self.toilet_weight_avg, + "pee_times": self.pee_times, + "poo_times": self.poo_times, + "drink_times": self.drink_times, + "diet_times": self.diet_times, + "diet_intakes": self.diet_intakes, + "sport_active_duration": self.sport_active_duration, + } + + @property + def hass_sensor(self) -> dict: + """Return cat sensors.""" + return { + "status": { + "icon": "mdi:information", + "state_attrs": self.cat_attrs, + }, + "weight": { + "icon": "mdi:scale", + "class": SensorDeviceClass.WEIGHT, + "state_class": SensorStateClass.MEASUREMENT, + "unit": UnitOfMass.KILOGRAMS, + }, + "age_years": { + "icon": "mdi:calendar", + }, + "age_months": { + "icon": "mdi:calendar", + }, + "gender_label": { + "icon": "mdi:gender-male-female", + }, + "breed": { + "icon": "mdi:cat", + }, + "birthday": { + "icon": "mdi:cake-variant", + "class": SensorDeviceClass.DATE, + }, + "avatar": { + "icon": "mdi:image", + "entity_picture": self.avatar_url, + }, + "toilet_times": { + "icon": "mdi:toilet", + }, + "toilet_weight_avg": { + "icon": "mdi:scale", + "class": SensorDeviceClass.WEIGHT, + "state_class": SensorStateClass.MEASUREMENT, + "unit": UnitOfMass.KILOGRAMS, + }, + "pee_times": { + "icon": "mdi:water", + }, + "poo_times": { + "icon": "mdi:emoticon-poop", + }, + "drink_times": { + "icon": "mdi:cup-water", + }, + "diet_times": { + "icon": "mdi:food", + }, + "diet_intakes": { + "icon": "mdi:food", + }, + "sport_active_duration": { + "icon": "mdi:run", + }, + } + + @property + def hass_binary_sensor(self) -> dict: + """Return empty binary sensors for cats.""" + return {} + + @property + def hass_switch(self) -> dict: + """Return empty switches for cats.""" + return {} + + @property + def hass_button(self) -> dict: + """Return empty buttons for cats.""" + return {} + + @property + def hass_select(self) -> dict: + """Return empty selects for cats.""" + return {} diff --git a/custom_components/catlink/modules/feeder_device.py b/custom_components/catlink/devices/feeder.py similarity index 70% rename from custom_components/catlink/modules/feeder_device.py rename to custom_components/catlink/devices/feeder.py index 1c77109..99a3482 100644 --- a/custom_components/catlink/modules/feeder_device.py +++ b/custom_components/catlink/devices/feeder.py @@ -1,31 +1,30 @@ """Feeder device class for CatLink integration.""" -import datetime from typing import TYPE_CHECKING from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.const import UnitOfMass -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from ..const import _LOGGER, DOMAIN +from ..const import _LOGGER +from ..helpers import format_api_error from ..models.additional_cfg import AdditionalDeviceConfig -from .device import Device +from ..models.api.device import FeederDeviceInfo +from ..models.api.parse import parse_response +from .base import Device +from .mixins.logs import LogsMixin if TYPE_CHECKING: - from .devices_coordinator import DevicesCoordinator + from ..modules.devices_coordinator import DevicesCoordinator -class FeederDevice(Device): +class FeederDevice(LogsMixin, Device): """Feeder device class for CatLink integration.""" - logs: list - coordinator_logs = None - def __init__( self, dat: dict, coordinator: "DevicesCoordinator", - additional_config: AdditionalDeviceConfig = None, + additional_config: AdditionalDeviceConfig | None = None, ) -> None: """Initialize the device.""" super().__init__(dat, coordinator, additional_config) @@ -50,17 +49,18 @@ def error_attrs(self) -> dict: async def async_init(self) -> None: """Initialize the device.""" await super().async_init() - self.logs = [] - self.coordinator_logs = DataUpdateCoordinator( - self.account.hass, - _LOGGER, - name=f"{DOMAIN}-{self.id}-logs", - update_method=self.update_logs, - update_interval=datetime.timedelta(minutes=1), - ) - await self.coordinator_logs.async_refresh() + await self._async_init_logs() + + @property + def last_log(self) -> str | None: + """Return the last log of the device with feeder-specific format.""" + log = self._last_log + if not log: + return None + return f"{log.get('time')} {log.get('event')} {log.get('firstSection')} {log.get('secondSection')}".strip() async def update_device_detail(self) -> dict: + """Update device detail.""" api = "token/device/feeder/detail" pms = { "deviceId": self.id, @@ -68,7 +68,20 @@ async def update_device_detail(self) -> dict: rsp = None try: rsp = await self.account.request(api, pms) - rdt = rsp.get("data", {}).get("deviceInfo") or {} + data = rsp.get("data", {}) + raw = data.get("deviceInfo") + parsed = parse_response(data, "deviceInfo", FeederDeviceInfo) + rdt = ( + parsed.model_dump(by_alias=True) + if hasattr(parsed, "model_dump") + else (parsed or {}) + ) + if not rdt and raw: + rdt = raw + _LOGGER.debug( + "Using raw deviceInfo for %s because model parsing failed", + self.name, + ) except (TypeError, ValueError) as exc: rdt = {} _LOGGER.error("Got device detail for %s failed: %s", self.name, exc) @@ -76,6 +89,7 @@ async def update_device_detail(self) -> dict: _LOGGER.warning("Got device detail for %s failed: %s", self.name, rsp) _LOGGER.debug("Update device detail: %s", rsp) self.detail = rdt + self._action_error = None self._handle_listeners() return rdt @@ -95,49 +109,11 @@ def state_attrs(self) -> dict: "key_lock_status": self.detail.get("keyLockStatus"), } - @property - def _last_log(self) -> dict: - """Return the last log of the device.""" - log = {} - if self.logs: - log = self.logs[0] or {} - return log - - @property - def last_log(self) -> str: - """Return the last log of the device.""" - log = self._last_log - if not log: - return None - return f"{log.get('time')} {log.get('event')} {log.get('firstSection')} {log.get('secondSection')}".strip() - - def last_log_attrs(self) -> dict: - """Return the last log attributes of the device.""" - log = self._last_log - return { - **log, - "logs": self.logs, - } - async def update_logs(self) -> list: """Update the logs of the device.""" - api = "token/device/feeder/stats/log/top5" - pms = { - "deviceId": self.id, - } - rsp = None - try: - rsp = await self.account.request(api, pms) - rdt = rsp.get("data", {}).get("feederLogTop5") or [] - except (TypeError, ValueError) as exc: - rdt = {} - _LOGGER.warning("Got device logs for %s failed: %s", self.name, exc) - if not rdt: - _LOGGER.debug("Got device logs for %s failed: %s", self.name, rsp) - _LOGGER.debug("Update device logs: %s", rsp) - self.logs = rdt - self._handle_listeners() - return rdt + return await self._fetch_logs( + "token/device/feeder/stats/log/top5", "feederLogTop5" + ) async def food_out(self) -> dict: """Food out of the device.""" @@ -149,7 +125,9 @@ async def food_out(self) -> dict: rdt = await self.account.request(api, pms, "POST") eno = rdt.get("returnCode", 0) if eno: - _LOGGER.error("Food out failed: %s", [rdt, pms]) + err_msg = format_api_error(rdt) + _LOGGER.error("Food out failed: %s", err_msg) + self._set_action_error(err_msg) return False await self.update_device_detail() _LOGGER.info("Food out: %s", [rdt, pms]) diff --git a/custom_components/catlink/devices/litter_device.py b/custom_components/catlink/devices/litter_device.py new file mode 100644 index 0000000..e1f599f --- /dev/null +++ b/custom_components/catlink/devices/litter_device.py @@ -0,0 +1,155 @@ +"""Litter device base class for LitterBox and ScooperDevice.""" + +from collections import deque +from typing import TYPE_CHECKING + +from ..const import _LOGGER +from ..models.additional_cfg import AdditionalDeviceConfig +from .base import Device +from .mixins.logs import LogsMixin + +if TYPE_CHECKING: + from ..modules.devices_coordinator import DevicesCoordinator + + +class LitterDevice(LogsMixin, Device): + """Base class for litter-related devices (LitterBox, ScooperDevice).""" + + def __init__( + self, + dat: dict, + coordinator: "DevicesCoordinator", + additional_config: AdditionalDeviceConfig | None = None, + ) -> None: + """Initialize the litter device.""" + super().__init__(dat, coordinator, additional_config) + self._litter_weight_during_day = deque( + maxlen=self.additional_config.max_samples_litter or 24 + ) + self.empty_litter_box_weight = self.additional_config.empty_weight or 0.0 + + @property + def state(self) -> str: + """Return the device state.""" + try: + sta = self.detail.get("workStatus", "") + dic = { + "00": "idle", + "01": "running", + "02": "need_reset", + } + return dic.get(f"{sta}".strip(), sta) + except Exception as exc: + _LOGGER.error("Get device state failed: %s", exc) + return "unknown" + + @property + def litter_weight(self) -> float: + """Return the litter weight.""" + litter_weight = 0.0 + try: + cat_litter_weight = self.detail.get( + "catLitterWeight", self.empty_litter_box_weight + ) + litter_weight = cat_litter_weight - self.empty_litter_box_weight + self._litter_weight_during_day.append(litter_weight) + if litter_weight == 0.0: + _LOGGER.debug( + "litter_weight is 0: catLitterWeight=%r, empty_litter_box_weight=%r (detail keys: %s)", + cat_litter_weight, + self.empty_litter_box_weight, + list(self.detail.keys()) if self.detail else "none", + ) + except Exception as exc: + _LOGGER.error("Got litter weight failed: %s", exc) + return litter_weight + + @property + def litter_remaining_days(self) -> str | int: + """Return the litter remaining days.""" + try: + return self.detail.get("litterCountdown", "unknown") + except Exception as exc: + _LOGGER.error("Get litter remaining days failed: %s", exc) + return "unknown" + + @property + def total_clean_time(self) -> int: + """Return the total clean time.""" + try: + return int(self.detail.get("inductionTimes", 0)) + int( + self.detail.get("manualTimes", 0) + ) + except Exception as exc: + _LOGGER.error("Get total clean time failed: %s", exc) + return 0 + + @property + def manual_clean_time(self) -> int: + """Return the manual clean time.""" + try: + raw = self.detail.get("manualTimes", 0) + result = int(raw) + if result == 0: + _LOGGER.debug( + "manual_clean_time is 0: manualTimes=%r (detail keys: %s)", + raw, + list(self.detail.keys()) if self.detail else "none", + ) + return result + except Exception as exc: + _LOGGER.error("Get manual clean time failed: %s", exc) + return 0 + + @property + def deodorant_countdown(self) -> int: + """Return the deodorant countdown.""" + try: + return int(self.detail.get("deodorantCountdown", 0)) + except Exception as exc: + _LOGGER.error("Get deodorant countdown failed: %s", exc) + return 0 + + @property + def occupied(self) -> bool: + """Return the occupied status based on litter weight changes during the day.""" + try: + return any( + self._litter_weight_during_day[i] + < self._litter_weight_during_day[i + 1] + for i in range(len(self._litter_weight_during_day) - 1) + ) + except IndexError: + return False + + @property + def online(self) -> bool: + """Return the online status.""" + try: + return self.detail.get("online") + except Exception as exc: + _LOGGER.error("Get online status failed: %s", exc) + return False + + async def async_init(self) -> None: + """Initialize the device.""" + await super().async_init() + await self._async_init_logs() + + def _base_state_attrs(self) -> dict: + """Return base state attributes shared by LitterBox and ScooperDevice.""" + return { + "mac": self.mac, + "work_status": self.detail.get("workStatus"), + "alarm_status": self.detail.get("alarmStatus"), + "weight": self.detail.get("weight"), + "litter_weight_kg": self.detail.get("catLitterWeight"), + "total_clean_times": int(self.detail.get("inductionTimes", 0)) + + int(self.detail.get("manualTimes", 0)), + "manual_clean_times": self.detail.get("manualTimes"), + "key_lock": self.detail.get("keyLock"), + "safe_time": self.detail.get("safeTime"), + "pave_second": self.detail.get("catLitterPaveSecond"), + "deodorant_countdown": self.detail.get("deodorantCountdown"), + "litter_countdown": self.detail.get("litterCountdown"), + } diff --git a/custom_components/catlink/modules/litterbox.py b/custom_components/catlink/devices/litterbox.py similarity index 66% rename from custom_components/catlink/modules/litterbox.py rename to custom_components/catlink/devices/litterbox.py index 0d41c7b..92def2c 100644 --- a/custom_components/catlink/modules/litterbox.py +++ b/custom_components/catlink/devices/litterbox.py @@ -1,51 +1,30 @@ """Litter box class for CatLink.""" -from collections import deque import datetime from typing import TYPE_CHECKING -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - -from ..const import _LOGGER, DOMAIN +from ..const import _LOGGER +from ..helpers import format_api_error from ..models.additional_cfg import AdditionalDeviceConfig -from .device import Device +from ..models.api.device import LitterDeviceInfo +from ..models.api.parse import parse_response +from .litter_device import LitterDevice if TYPE_CHECKING: - from .devices_coordinator import DevicesCoordinator + from ..modules.devices_coordinator import DevicesCoordinator -class LitterBox(Device): +class LitterBox(LitterDevice): """Litter box class for CatLink.""" - logs: list - coordinator_logs = None - def __init__( self, dat: dict, coordinator: "DevicesCoordinator", - additional_config: AdditionalDeviceConfig = None, + additional_config: AdditionalDeviceConfig | None = None, ) -> None: """Initialize the litter box.""" super().__init__(dat, coordinator, additional_config) - self.logs = [] - self._litter_weight_during_day = deque( - maxlen=self.additional_config.max_samples_litter or 24 - ) - self.empty_litter_box_weight = self.additional_config.empty_weight or 0.0 - - async def async_init(self) -> None: - """Initialize the litter box.""" - await super().async_init() - self.logs = [] - self.coordinator_logs = DataUpdateCoordinator( - self.account.hass, - _LOGGER, - name=f"{DOMAIN}-{self.id}-logs", - update_method=self.update_logs, - update_interval=datetime.timedelta(minutes=1), - ) - await self.coordinator_logs.async_refresh() @property def modes(self) -> dict: @@ -82,25 +61,11 @@ def box_full_levels(self) -> dict: "LEVEL_04": "Level 4", } - @property - def _last_log(self) -> dict: - """Return the last log.""" - log = {} - if self.logs: - log = self.logs[0] or {} - return log - - @property - def last_log(self) -> str: - """Return the last log.""" - log = self._last_log - if not log: - return None - return f"{log.get('time')} {log.get('event')}" - @property def error(self) -> str: """Return the error.""" + if self._action_error: + return self._action_error try: return self.detail.get("currentError") or "Normal Operation" except Exception as exc: @@ -108,61 +73,24 @@ def error(self) -> str: return "Unknown" @property - def litter_weight(self) -> float: - """Return the litter weight.""" - litter_weight = 0 - try: - catLitterWeight = self.detail.get( - "catLitterWeight", self.empty_litter_box_weight - ) - litter_weight = catLitterWeight - self.empty_litter_box_weight - self._litter_weight_during_day.append(litter_weight) - - except Exception as exc: - _LOGGER.error("Got litter weight failed: %s", exc) - - return litter_weight - - @property - def litter_remaining_days(self) -> str: + def litter_remaining_days(self) -> int: """Return the litter remaining days.""" try: - return int(self.detail.get("litterCountdown", 0)) + raw = self.detail.get("litterCountdown", 0) + result = int(raw) + if result == 0: + _LOGGER.debug( + "litter_remaining_days is 0: litterCountdown=%r (detail keys: %s)", + raw, + list(self.detail.keys()) if self.detail else "none", + ) + return result except Exception as exc: _LOGGER.error("Got litter remaining days failed: %s", exc) return 0 @property - def total_clean_time(self) -> int: - """Return the total clean time.""" - try: - return int(self.detail.get("inductionTimes", 0)) + int( - self.detail.get("manualTimes", 0) - ) - except Exception as exc: - _LOGGER.error("Got total clean time failed: %s", exc) - return 0 - - @property - def manual_clean_time(self) -> int: - """Return the manual clean time.""" - try: - return int(self.detail.get("manualTimes", 0)) - except Exception as exc: - _LOGGER.error("Got manual clean time failed: %s", exc) - return 0 - - @property - def deodorant_countdown(self) -> int: - """Return the deodorant countdown.""" - try: - return int(self.detail.get("deodorantCountdown", 0)) - except Exception as exc: - _LOGGER.error("Got deodorant countdown failed: %s", exc) - return 0 - - @property - def knob_status(self) -> bool: + def knob_status(self) -> str: """Return the knob status.""" try: knob_flab = ( @@ -179,32 +107,7 @@ def knob_status(self) -> bool: return "Unknown" @property - def occupied(self) -> bool: - """Return the occupied status.""" - # based on _litter_weight_during_day to determine if the litter box is occupied - # check whether value is increasing at any point in the day - # Now we can check which cat is using the litter box :) - try: - return any( - self._litter_weight_during_day[i] - < self._litter_weight_during_day[i + 1] - for i in range(len(self._litter_weight_during_day) - 1) - ) - except Exception as exc: - _LOGGER.error("Got occupied status failed: %s", exc) - return False - - @property - def online(self) -> bool: - """Return the online status.""" - try: - return self.detail.get("online") - except Exception as exc: - _LOGGER.error("Got online status failed: %s", exc) - return False - - @property - def last_sync(self) -> str: + def last_sync(self) -> str | None: """Return the last sync time.""" return ( datetime.datetime.fromtimestamp( @@ -318,23 +221,10 @@ def hass_select(self) -> dict: }, } - # Additional Attributes def state_attrs(self) -> dict: """Return the state attributes.""" return { - "mac": self.mac, - "work_status": self.detail.get("workStatus"), - "alarm_status": self.detail.get("alarmStatus"), - "weight": self.detail.get("weight"), - "litter_weight_kg": self.detail.get("catLitterWeight"), - "total_clean_times": int(self.detail.get("inductionTimes", 0)) - + int(self.detail.get("manualTimes", 0)), - "manual_clean_times": self.detail.get("manualTimes"), - "key_lock": self.detail.get("keyLock"), - "safe_time": self.detail.get("safeTime"), - "pave_second": self.detail.get("catLitterPaveSecond"), - "deodorant_countdown": self.detail.get("deodorantCountdown"), - "litter_countdown": self.detail.get("litterCountdown"), + **self._base_state_attrs(), "last_sync_time": datetime.datetime.fromtimestamp( int(self.detail.get("lastHeartBeatTimestamp")) / 1000.0 ).strftime("%Y-%m-%d %H:%M:%S") @@ -344,18 +234,11 @@ def state_attrs(self) -> dict: "quiet_times": self.detail.get("quietTimes"), } - def last_log_attrs(self) -> dict: - """Return the last log attributes.""" - log = self._last_log - return { - **log, - "logs": self.logs, - } - def garbage_attrs(self) -> dict: """Return the garbage attributes.""" status = "Unknown" - match self.garbageStatus: + garbage_status = self.detail.get("garbageStatus", "") + match garbage_status: case "00": status = "Normal" case "02": @@ -368,7 +251,7 @@ def garbage_attrs(self) -> dict: "status": status, } - def error_attrs(self) -> list: + def error_attrs(self) -> dict: """Return the error attributes.""" try: return { @@ -376,23 +259,20 @@ def error_attrs(self) -> list: } except Exception as exc: _LOGGER.error("Got error attributes failed: %s", exc) - return [] + return {} @property - def box_full_sensitivity(self) -> str: + def box_full_sensitivity(self) -> str | None: """Return the box full sensitivity.""" sensitivity = self.detail.get("boxFullSensitivity", "") - # Try direct mapping first mapped_value = self.box_full_levels.get(sensitivity) if mapped_value: _LOGGER.debug( "Box full sensitivity mapped: %s -> %s", sensitivity, mapped_value ) return mapped_value - # Try converting numeric format (e.g., "01" -> "LEVEL_01") if sensitivity and isinstance(sensitivity, (str, int)): try: - # If it's a number or numeric string, convert to LEVEL_XX format if isinstance(sensitivity, str) and sensitivity.isdigit(): level_key = f"LEVEL_{sensitivity.zfill(2)}" elif isinstance(sensitivity, int): @@ -412,10 +292,10 @@ def box_full_sensitivity(self) -> str: except (ValueError, AttributeError): pass _LOGGER.warning( - "Box full sensitivity not found in mapping: %s (type: %s, available: %s)", + "Box full sensitivity raw value %r (type: %s) could not be mapped to known levels; valid values: %s", sensitivity, type(sensitivity).__name__, - list(self.box_full_levels.keys()), + ", ".join(self.box_full_levels.keys()), ) return None @@ -425,25 +305,11 @@ def box_full_sensitivity_attrs(self) -> dict: "raw_level": self.detail.get("boxFullSensitivity"), } - # Actions async def update_logs(self) -> list: """Update the logs.""" - api = "token/litterbox/stats/log/top5" - pms = { - "deviceId": self.id, - } - rsp = None - try: - rsp = await self.account.request(api, pms) - rdt = rsp.get("data", {}).get("scooperLogTop5") or [] - except (TypeError, ValueError) as exc: - rdt = {} - _LOGGER.error("Got device logs for %s failed: %s", self.name, exc) - if not rdt: - _LOGGER.warning("Got device logs for %s failed: %s", self.name, rsp) - self.logs = rdt - self._handle_listeners() - return rdt + return await self._fetch_logs( + "token/litterbox/stats/log/top5", "scooperLogTop5" + ) async def select_mode(self, mode, **kwargs) -> bool: """Select the device mode.""" @@ -463,7 +329,9 @@ async def select_mode(self, mode, **kwargs) -> bool: rdt = await self.account.request(api, pms, "POST") eno = rdt.get("returnCode", 0) if eno: - _LOGGER.error("Select mode failed: %s", [rdt, pms]) + err_msg = format_api_error(rdt) + _LOGGER.error("Select mode failed: %s", err_msg) + self._set_action_error(err_msg) return False await self.update_device_detail() _LOGGER.info("Select mode: %s", [rdt, pms]) @@ -479,7 +347,9 @@ async def select_box_full_sensitivity(self, level, **kwargs) -> bool: break if lvl is None: _LOGGER.warning( - "Select box full sensitivity failed for %s in %s", level, self.box_full_levels + "Select box full sensitivity failed for %s in %s", + level, + self.box_full_levels, ) return False pms = { @@ -489,7 +359,9 @@ async def select_box_full_sensitivity(self, level, **kwargs) -> bool: rdt = await self.account.request(api, pms, "POST") eno = rdt.get("returnCode", 0) if eno: - _LOGGER.error("Select box full sensitivity failed: %s", [rdt, pms]) + err_msg = format_api_error(rdt) + _LOGGER.error("Select box full sensitivity failed: %s", err_msg) + self._set_action_error(err_msg) return False await self.update_device_detail() _LOGGER.info("Select box full sensitivity: %s", [rdt, pms]) @@ -504,13 +376,27 @@ async def update_device_detail(self) -> dict: rsp = None try: rsp = await self.account.request(api, pms) - rdt = rsp.get("data", {}).get("deviceInfo") or {} + data = rsp.get("data", {}) + raw = data.get("deviceInfo") + parsed = parse_response(data, "deviceInfo", LitterDeviceInfo) + rdt = ( + parsed.model_dump(by_alias=True) + if hasattr(parsed, "model_dump") + else (parsed or {}) + ) + if not rdt and raw: + rdt = raw + _LOGGER.debug( + "Using raw deviceInfo for %s because model parsing failed", + self.name, + ) except (TypeError, ValueError) as exc: rdt = {} _LOGGER.error("Got device detail for %s failed: %s", self.name, exc) if not rdt: _LOGGER.warning("Got device detail for %s failed: %s", self.name, rsp) self.detail = rdt + self._action_error = None self._handle_listeners() return rdt @@ -534,7 +420,9 @@ async def select_action(self, action, **kwargs) -> bool: rdt = await self.account.request(api, pms, "POST") eno = rdt.get("returnCode", 0) if eno: - _LOGGER.error("Select action failed: %s", [rdt, pms]) + err_msg = format_api_error(rdt) + _LOGGER.error("Select action failed: %s", err_msg) + self._set_action_error(err_msg) return False await self.update_device_detail() _LOGGER.info("Select action: %s", [rdt, pms]) @@ -550,8 +438,53 @@ async def changeBag(self, mode, **kwargs) -> bool: rdt = await self.account.request(api, pms, "POST") eno = rdt.get("returnCode", 0) if eno: - _LOGGER.error("Change bag failed: %s", [rdt, pms]) + err_msg = format_api_error(rdt) + _LOGGER.error("Change bag failed: %s", err_msg) + self._set_action_error(err_msg) return False await self.update_device_detail() _LOGGER.info("Change bag: %s", [rdt, pms]) return rdt + + async def reset_consumable(self, consumables_type: str) -> bool: + """Reset a consumable (litter or deodorant) counter.""" + api = "token/device/union/consumableReset" + pms = { + "consumablesType": consumables_type, + "deviceId": self.id, + "deviceType": self.type, + } + rdt = await self.account.request(api, pms, "POST") + eno = rdt.get("returnCode", 0) + if eno: + err_msg = format_api_error(rdt) + _LOGGER.error("Reset consumable %s failed: %s", consumables_type, err_msg) + self._set_action_error(err_msg) + return False + await self.update_device_detail() + _LOGGER.info("Reset consumable %s: %s", consumables_type, [rdt, pms]) + return rdt + + async def async_reset_litter(self) -> bool: + """Reset the litter counter.""" + return await self.reset_consumable("CAT_LITTER") + + async def async_reset_deodorant(self) -> bool: + """Reset the deodorant counter.""" + return await self.reset_consumable("DEODORIZER_02") + + @property + def hass_button(self) -> dict: + """Return the device buttons.""" + return { + "reset_litter": { + "icon": "mdi:shaker-outline", + "name": "Reset litter", + "async_press": self.async_reset_litter, + }, + "reset_deodorant": { + "icon": "mdi:spray-bottle", + "name": "Reset deodorant", + "async_press": self.async_reset_deodorant, + }, + } diff --git a/custom_components/catlink/devices/mixins/__init__.py b/custom_components/catlink/devices/mixins/__init__.py new file mode 100644 index 0000000..66530c6 --- /dev/null +++ b/custom_components/catlink/devices/mixins/__init__.py @@ -0,0 +1,7 @@ +"""Device mixins for CatLink integration.""" + +from .logs import LogsMixin + +__all__ = [ + "LogsMixin", +] diff --git a/custom_components/catlink/devices/mixins/logs.py b/custom_components/catlink/devices/mixins/logs.py new file mode 100644 index 0000000..bbea0a3 --- /dev/null +++ b/custom_components/catlink/devices/mixins/logs.py @@ -0,0 +1,76 @@ +"""Logs mixin for devices with log support.""" + +import datetime +from typing import Any + +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from ...const import _LOGGER, DOMAIN +from ...models.api.logs import LogEntry +from ...models.api.parse import parse_response + + +class LogsMixin: + """Mixin providing logs, coordinator_logs, _last_log, last_log_attrs and log coordinator setup.""" + + logs: list + coordinator_logs: DataUpdateCoordinator | None + + async def _async_init_logs(self) -> None: + """Initialize the logs coordinator. Call from async_init after super().async_init().""" + self.logs = [] + self.coordinator_logs = DataUpdateCoordinator( + self.account.hass, + _LOGGER, + name=f"{DOMAIN}-{self.id}-logs", + update_method=self.update_logs, + update_interval=datetime.timedelta(minutes=1), + ) + await self.coordinator_logs.async_refresh() + + @property + def _last_log(self) -> dict[str, Any]: + """Return the last log entry as a dict.""" + log: dict[str, Any] = {} + if self.logs: + log = self.logs[0] or {} + return log + + @property + def last_log(self) -> str | None: + """Return the last log as a formatted string.""" + log = self._last_log + if not log: + return None + return f"{log.get('time')} {log.get('event')}" + + def last_log_attrs(self) -> dict[str, Any]: + """Return the last log attributes for entity extra state.""" + log = self._last_log + return { + **log, + "logs": self.logs, + } + + async def _fetch_logs(self, api: str, response_key: str) -> list: + """Fetch logs from API. Subclasses call this from update_logs with their api path and response key.""" + pms = {"deviceId": self.id} + rsp = None + try: + rsp = await self.account.request(api, pms) + data = rsp.get("data", {}) + parsed = parse_response(data, response_key, LogEntry, []) + if isinstance(parsed, list) and parsed and hasattr(parsed[0], "model_dump"): + rdt = [p.model_dump() for p in parsed] + elif isinstance(parsed, list): + rdt = parsed + else: + rdt = data.get(response_key) or [] + except (TypeError, ValueError) as exc: + rdt = [] + _LOGGER.error("Got device logs for %s failed: %s", self.name, exc) + if not rdt: + _LOGGER.warning("Got device logs for %s failed: %s", self.name, rsp) + self.logs = rdt + self._handle_listeners() + return rdt diff --git a/custom_components/catlink/devices/registry.py b/custom_components/catlink/devices/registry.py new file mode 100644 index 0000000..b5d0be8 --- /dev/null +++ b/custom_components/catlink/devices/registry.py @@ -0,0 +1,35 @@ +"""Device registry for CatLink integration.""" + +from typing import TYPE_CHECKING + +from .base import Device +from .cat import CatDevice +from .c08 import C08Device +from .feeder import FeederDevice +from .litterbox import LitterBox +from .scooper import ScooperDevice +from .scooper_pro_ultra import ScooperProUltraDevice + +if TYPE_CHECKING: + from ..models.additional_cfg import AdditionalDeviceConfig + from ..modules.devices_coordinator import DevicesCoordinator + +DEVICE_TYPES: dict[str, type[Device]] = { + "CAT": CatDevice, + "C08": C08Device, + "SCOOPER": ScooperDevice, + "LITTER_BOX_599": LitterBox, # SCOOPER C1 + "VISUAL_PRO_ULTRA": ScooperProUltraDevice, + "FEEDER": FeederDevice, +} + + +def create_device( + dat: dict, + coordinator: "DevicesCoordinator", + additional_config: "AdditionalDeviceConfig | None" = None, +) -> Device: + """Create a device instance from API data.""" + typ = dat.get("deviceType") + device_cls = DEVICE_TYPES.get(typ, Device) + return device_cls(dat, coordinator, additional_config) diff --git a/custom_components/catlink/devices/scooper.py b/custom_components/catlink/devices/scooper.py new file mode 100644 index 0000000..ab95dd8 --- /dev/null +++ b/custom_components/catlink/devices/scooper.py @@ -0,0 +1,185 @@ +"""Scooper device module for CatLink integration.""" + +from collections import deque +import datetime +from typing import TYPE_CHECKING + +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import PERCENTAGE, UnitOfTemperature + +from ..const import _LOGGER +from ..models.additional_cfg import AdditionalDeviceConfig +from ..models.api.device import LitterDeviceInfo +from ..models.api.parse import parse_response +from .litter_device import LitterDevice + +if TYPE_CHECKING: + from ..modules.devices_coordinator import DevicesCoordinator + + +class ScooperDevice(LitterDevice): + """Scooper device class for CatLink integration.""" + + def __init__( + self, + dat: dict, + coordinator: "DevicesCoordinator", + additional_config: AdditionalDeviceConfig | None = None, + ) -> None: + """Initialize the device.""" + super().__init__(dat, coordinator, additional_config) + self._error_logs = deque(maxlen=20) + + @property + def modes(self) -> dict: + """Return the modes of the device.""" + return { + "00": "auto", + "01": "manual", + "02": "time", + "03": "empty", + } + + @property + def actions(self) -> dict: + """Return the actions of the device.""" + return { + "00": "pause", + "01": "start", + } + + @property + def temperature(self) -> str: + """Return the temperature.""" + return self.detail.get("temperature", "-") + + @property + def humidity(self) -> str: + """Return the humidity.""" + return self.detail.get("humidity", "-") + + @property + def error(self) -> str: + """Return the device error.""" + if self._action_error: + return self._action_error + try: + error = self.detail.get("currentMessage") or self.data.get( + "currentErrorMessage", "" + ) + if error and error.lower() != "device online": + self._error_logs.append( + { + "time": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "error": error, + } + ) + return error + except Exception as exc: + _LOGGER.error("Get device error failed: %s", exc) + return "unknown" + + @property + def hass_sensor(self) -> dict: + """Return the hass sensor of the device.""" + return { + "state": { + "icon": "mdi:information", + "state_attrs": self.state_attrs, + }, + "last_log": { + "icon": "mdi:message", + "state_attrs": self.last_log_attrs, + }, + "litter_weight": { + "icon": "mdi:weight", + }, + "litter_remaining_days": { + "icon": "mdi:calendar", + }, + "total_clean_time": { + "icon": "mdi:timer", + }, + "manual_clean_time": { + "icon": "mdi:timer", + }, + "deodorant_countdown": { + "icon": "mdi:timer", + }, + "occupied": { + "icon": "mdi:cat", + }, + "online": { + "icon": "mdi:cloud", + }, + "temperature": { + "icon": "mdi:temperature-celsius", + "state": self.temperature, + "class": SensorDeviceClass.TEMPERATURE, + "unit": UnitOfTemperature.CELSIUS, + "state_class": SensorStateClass.MEASUREMENT, + }, + "humidity": { + "icon": "mdi:water-percent", + "state": self.humidity, + "class": SensorDeviceClass.HUMIDITY, + "unit": PERCENTAGE, + "state_class": SensorStateClass.MEASUREMENT, + }, + "error": { + "icon": "mdi:alert-circle", + "state_attrs": self.error_attrs, + }, + } + + def state_attrs(self) -> dict: + """Return the state attributes.""" + return self._base_state_attrs() + + def error_attrs(self) -> dict: + """Return the error attributes.""" + return { + "error_logs": list(self._error_logs), + } + + async def update_device_detail(self) -> dict: + """Update device detail. Uses LitterDeviceInfo for Scooper response parsing.""" + api = "token/device/info" + pms = {"deviceId": self.id} + rsp = None + try: + rsp = await self.account.request(api, pms) + data = rsp.get("data", {}) + raw = data.get("deviceInfo") + parsed = parse_response(data, "deviceInfo", LitterDeviceInfo) + rdt = ( + parsed.model_dump(by_alias=True) + if hasattr(parsed, "model_dump") + else (parsed or {}) + ) + if not rdt and raw: + rdt = raw + _LOGGER.debug( + "Using raw deviceInfo for %s because model parsing failed", + self.name, + ) + except (TypeError, ValueError) as exc: + rdt = {} + _LOGGER.error("Got device detail for %s failed: %s", self.name, exc) + if not rdt: + data_keys = list(rsp.get("data", {}).keys()) if rsp else [] + _LOGGER.warning( + "Got device detail for %s failed (empty parse); response data keys: %s", + self.name, + data_keys, + ) + self.detail = rdt + self._action_error = None + self._handle_listeners() + return rdt + + async def update_logs(self) -> list: + """Update device logs.""" + return await self._fetch_logs( + "token/device/scooper/stats/log/top5", "scooperLogTop5" + ) diff --git a/custom_components/catlink/devices/scooper_pro_ultra.py b/custom_components/catlink/devices/scooper_pro_ultra.py new file mode 100644 index 0000000..a07f685 --- /dev/null +++ b/custom_components/catlink/devices/scooper_pro_ultra.py @@ -0,0 +1,134 @@ +"""Scooper Pro Ultra device module for CatLink integration.""" + +from __future__ import annotations + +import datetime +from typing import TYPE_CHECKING + +from ..const import _LOGGER +from ..models.additional_cfg import AdditionalDeviceConfig +from ..models.api.device import LitterDeviceInfo +from ..models.api.logs import LogEntry +from ..models.api.parse import parse_response +from .litter_device import LitterDevice + +if TYPE_CHECKING: + from ..modules.devices_coordinator import DevicesCoordinator + + +class ScooperProUltraDevice(LitterDevice): + """Scooper Pro Ultra device class (limited support).""" + + def __init__( + self, + dat: dict, + coordinator: "DevicesCoordinator", + additional_config: AdditionalDeviceConfig | None = None, + ) -> None: + """Initialize the device.""" + super().__init__(dat, coordinator, additional_config) + + @property + def name(self) -> str: + """Return the device name with limited support suffix.""" + base_name = super().name or "" + suffix = " (Limited Support)" + if base_name.endswith(suffix): + return base_name + return f"{base_name}{suffix}".strip() + + @property + def litter_remaining_days(self) -> int: + """Return the litter remaining days.""" + try: + return int(self.detail.get("litterCountdown", 0)) + except Exception as exc: + _LOGGER.error("Got litter remaining days failed: %s", exc) + return 0 + + @property + def total_clean_time(self) -> int: + """Return total clean time from briefInfo when available.""" + try: + return int(self.detail.get("totalCleanTimes", 0)) + except Exception as exc: + _LOGGER.error("Get total clean time failed: %s", exc) + return 0 + + @property + def hass_sensor(self) -> dict: + """Return the device sensors.""" + return { + "last_log": { + "icon": "mdi:message", + "state_attrs": self.last_log_attrs, + }, + "litter_remaining_days": { + "icon": "mdi:calendar", + "unit": "days", + }, + "deodorant_countdown": { + "icon": "mdi:timer", + "unit": "days", + }, + "total_clean_time": { + "icon": "mdi:history", + "unit": "times", + }, + } + + async def update_device_detail(self) -> dict: + """Update device detail from visualScooper briefInfo.""" + api = "token/visualScooper/briefInfo" + pms = {"deviceId": self.id} + rsp = None + try: + rsp = await self.account.request(api, pms) + data = rsp.get("data", {}) + parsed = parse_response(data, "deviceInfo", LitterDeviceInfo, {}) + rdt = ( + parsed.model_dump(by_alias=True) + if hasattr(parsed, "model_dump") + else (parsed or {}) + ) + except (TypeError, ValueError) as exc: + rdt = {} + _LOGGER.error("Got device detail for %s failed: %s", self.name, exc) + if not rdt: + _LOGGER.warning("Got device detail for %s failed: %s", self.name, rsp) + self.detail = rdt + self._action_error = None + self._handle_listeners() + return rdt + + async def update_logs(self) -> list: + """Update device logs using timeline/v2 endpoint.""" + api = "token/litterbox/stats/log/timeline/v2" + today = datetime.date.today().isoformat() + pms = { + "deviceId": self.id, + "date": today, + "pageNumber": 1, + "pageSize": 10, + "type": 0, + "subType": 0, + } + rsp = None + try: + rsp = await self.account.request(api, pms) + data = rsp.get("data", {}) + parsed = parse_response(data, "records", LogEntry, []) + if isinstance(parsed, list) and parsed and hasattr(parsed[0], "model_dump"): + rdt = [p.model_dump() for p in parsed] + elif isinstance(parsed, list): + rdt = parsed + else: + rdt = data.get("records") or [] + except (TypeError, ValueError) as exc: + rdt = [] + _LOGGER.error("Got device logs for %s failed: %s", self.name, exc) + if not rdt: + _LOGGER.warning("Got device logs for %s failed: %s", self.name, rsp) + self.logs = rdt + self._handle_listeners() + return rdt diff --git a/custom_components/catlink/entities/__init__.py b/custom_components/catlink/entities/__init__.py new file mode 100644 index 0000000..9d5ad42 --- /dev/null +++ b/custom_components/catlink/entities/__init__.py @@ -0,0 +1,18 @@ +"""The catlink component entities.""" + +from .base import CatlinkEntity +from .binary import CatlinkBinaryEntity, CatlinkBinarySensorEntity +from .sensor import CatlinkSensorEntity +from .select import CatlinkSelectEntity +from .switch import CatlinkSwitchEntity +from .button import CatlinkButtonEntity + +__all__ = [ + "CatlinkEntity", + "CatlinkBinaryEntity", + "CatlinkBinarySensorEntity", + "CatlinkSensorEntity", + "CatlinkSelectEntity", + "CatlinkSwitchEntity", + "CatlinkButtonEntity", +] diff --git a/custom_components/catlink/entitites/catlink.py b/custom_components/catlink/entities/base.py similarity index 75% rename from custom_components/catlink/entitites/catlink.py rename to custom_components/catlink/entities/base.py index 8506aa9..9ce83b7 100644 --- a/custom_components/catlink/entitites/catlink.py +++ b/custom_components/catlink/entities/base.py @@ -1,12 +1,14 @@ """The component.""" +import asyncio + from homeassistant.components import persistent_notification from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import slugify from ..const import _LOGGER, DOMAIN -from ..modules.device import Device +from ..devices.base import Device class CatlinkEntity(CoordinatorEntity): @@ -20,7 +22,8 @@ def __init__(self, name, device: Device, option=None) -> None: self._name = name self._device = device self._option = option or {} - self._attr_name = f"{device.name} {name}".strip() + display_name = self._option.get("name", name) + self._attr_name = f"{device.name} {display_name}".strip() self._attr_device_id = f"{device.type}_{device.mac}" self._attr_unique_id = f"{self._attr_device_id}-{name}" mac = device.mac[-4:] if device.mac else device.id @@ -30,6 +33,11 @@ def __init__(self, name, device: Device, option=None) -> None: self._attr_device_class = self._option.get("class") self._attr_native_unit_of_measurement = self._option.get("unit") self._attr_state_class = self._option.get("state_class") + entity_picture = self._option.get("entity_picture") + if callable(entity_picture): + self._attr_entity_picture = entity_picture() + elif entity_picture: + self._attr_entity_picture = entity_picture self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._attr_device_id)}, name=device.name, @@ -48,6 +56,14 @@ def _handle_coordinator_update(self): self.update() self.async_write_ha_state() + async def _async_after_action(self, success: bool, delay: float | None = None) -> None: + """Run after an action: write state, optional delay, then coordinator refresh.""" + if success: + self.async_write_ha_state() + if delay is not None: + await asyncio.sleep(delay) + self._handle_coordinator_update() + def update(self) -> None: """Update the entity.""" if hasattr(self._device, self._name): @@ -55,6 +71,9 @@ def update(self) -> None: _LOGGER.debug( "Entity update: %s", [self.entity_id, self._name, self._attr_state] ) + entity_picture = self._option.get("entity_picture") + if callable(entity_picture): + self._attr_entity_picture = entity_picture() fun = self._option.get("state_attrs") if callable(fun): diff --git a/custom_components/catlink/entitites/catlink_binary.py b/custom_components/catlink/entities/binary.py similarity index 70% rename from custom_components/catlink/entitites/catlink_binary.py rename to custom_components/catlink/entities/binary.py index 9284fff..3bc4584 100644 --- a/custom_components/catlink/entitites/catlink_binary.py +++ b/custom_components/catlink/entities/binary.py @@ -1,9 +1,10 @@ -"""The component.""" +"""Binary entities for CatLink integration.""" +from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.const import STATE_OFF, STATE_ON -from ..modules.device import Device -from .catlink import CatlinkEntity +from ..devices.base import Device +from .base import CatlinkEntity class CatlinkBinaryEntity(CatlinkEntity): @@ -26,3 +27,7 @@ def update(self) -> None: def state(self) -> str: """Return the state of the entity.""" return STATE_ON if self._attr_is_on else STATE_OFF + + +class CatlinkBinarySensorEntity(CatlinkBinaryEntity, BinarySensorEntity): + """Binary sensor entity for CatLink.""" diff --git a/custom_components/catlink/entities/button.py b/custom_components/catlink/entities/button.py new file mode 100644 index 0000000..e6835ad --- /dev/null +++ b/custom_components/catlink/entities/button.py @@ -0,0 +1,17 @@ +"""Button entity for CatLink integration.""" + +from homeassistant.components.button import ButtonEntity + +from .base import CatlinkEntity + + +class CatlinkButtonEntity(CatlinkEntity, ButtonEntity): + """Button entity for CatLink.""" + + async def async_press(self): + """Press the button.""" + ret = False + fun = self._option.get("async_press") + if callable(fun): + ret = await fun() + return ret diff --git a/custom_components/catlink/entities/registry.py b/custom_components/catlink/entities/registry.py new file mode 100644 index 0000000..9951a04 --- /dev/null +++ b/custom_components/catlink/entities/registry.py @@ -0,0 +1,15 @@ +"""Entity registry for CatLink integration.""" + +from .binary import CatlinkBinarySensorEntity +from .button import CatlinkButtonEntity +from .select import CatlinkSelectEntity +from .sensor import CatlinkSensorEntity +from .switch import CatlinkSwitchEntity + +DOMAIN_ENTITY_CLASSES: dict[str, type] = { + "sensor": CatlinkSensorEntity, + "binary_sensor": CatlinkBinarySensorEntity, + "switch": CatlinkSwitchEntity, + "select": CatlinkSelectEntity, + "button": CatlinkButtonEntity, +} diff --git a/custom_components/catlink/entities/select.py b/custom_components/catlink/entities/select.py new file mode 100644 index 0000000..5c521bf --- /dev/null +++ b/custom_components/catlink/entities/select.py @@ -0,0 +1,37 @@ +"""Select entity for CatLink integration.""" + +from typing import TYPE_CHECKING + +from homeassistant.components.select import SelectEntity + +from .base import CatlinkEntity + +if TYPE_CHECKING: + from ..devices.base import Device + + +class CatlinkSelectEntity(CatlinkEntity, SelectEntity): + """Select entity for CatLink.""" + + def __init__(self, name, device: "Device", option=None) -> None: + """Initialize the entity.""" + super().__init__(name, device, option) + self._attr_current_option = None + self._attr_options = self._option.get("options") + + def update(self) -> None: + """Update the entity.""" + super().update() + self._attr_current_option = self._attr_state + + async def async_select_option(self, option: str): + """Change the selected option.""" + ret = False + fun = self._option.get("async_select") + if callable(fun): + kws = {"entity": self} + ret = await fun(option, **kws) + if ret: + self._attr_current_option = option + await self._async_after_action(bool(ret), self._option.get("delay_update")) + return ret diff --git a/custom_components/catlink/entities/sensor.py b/custom_components/catlink/entities/sensor.py new file mode 100644 index 0000000..c7e1a2f --- /dev/null +++ b/custom_components/catlink/entities/sensor.py @@ -0,0 +1,9 @@ +"""Sensor entity for CatLink integration.""" + +from homeassistant.components.sensor import SensorEntity + +from .base import CatlinkEntity + + +class CatlinkSensorEntity(CatlinkEntity, SensorEntity): + """Sensor entity for CatLink.""" diff --git a/custom_components/catlink/entities/switch.py b/custom_components/catlink/entities/switch.py new file mode 100644 index 0000000..ffa7e0e --- /dev/null +++ b/custom_components/catlink/entities/switch.py @@ -0,0 +1,29 @@ +"""Switch entity for CatLink integration.""" + +from homeassistant.components.switch import SwitchEntity + +from .binary import CatlinkBinaryEntity + + +class CatlinkSwitchEntity(CatlinkBinaryEntity, SwitchEntity): + """Switch entity for CatLink.""" + + async def async_turn_switch(self, on=True, **kwargs): + """Turn the entity on/off.""" + ret = False + fun = self._option.get("async_turn_on" if on else "async_turn_off") + if callable(fun): + kwargs["entity"] = self + ret = await fun(**kwargs) + if ret: + self._attr_is_on = bool(on) + await self._async_after_action(bool(ret), self._option.get("delay_update")) + return ret + + async def async_turn_on(self, **kwargs): + """Turn the entity on.""" + return await self.async_turn_switch(True) + + async def async_turn_off(self, **kwargs): + """Turn the entity off.""" + return await self.async_turn_switch(False) diff --git a/custom_components/catlink/entitites/__init__.py b/custom_components/catlink/entitites/__init__.py deleted file mode 100644 index a6b9b32..0000000 --- a/custom_components/catlink/entitites/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -"""The catlink component entities.""" - -from .catlink import CatlinkEntity -from .catlink_binary import CatlinkBinaryEntity - -__all__ = [ - "CatlinkEntity", - "CatlinkBinaryEntity", -] diff --git a/custom_components/catlink/helpers.py b/custom_components/catlink/helpers.py index 9250cf8..af3f436 100644 --- a/custom_components/catlink/helpers.py +++ b/custom_components/catlink/helpers.py @@ -4,56 +4,163 @@ import re from typing import TYPE_CHECKING +import phonenumbers +from phonenumbers import NumberParseException + +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN +from .const import ( + API_SERVERS, + CONF_API_BASE, + CONF_PASSWORD, + CONF_PHONE, + CONF_PHONE_IAC, + DOMAIN, +) if TYPE_CHECKING: from .modules.devices_coordinator import DevicesCoordinator +async def async_setup_domain_platform( + hass: HomeAssistant, + domain: str, + async_add_entities, + extra_setup=None, +) -> None: + """Set up a domain platform (sensor, switch, select, etc.) via discovery. + + Used when loading via async_load_platform; for config entries use + async_setup_entry_for instead. + """ + hass.data[DOMAIN]["add_entities"].setdefault("discovery", {})[domain] = ( + async_add_entities + ) + await Helper.async_setup_accounts(hass, domain) + if extra_setup is not None: + await extra_setup() + + +def parse_phone_number(phone: str) -> tuple[str, str]: + """Parse a full phone number into country code and national number. + + Accepts formats like +447911123456, 447911123456, or 07911123456. + Returns (phone_iac, phone_number) for CatLink API. + """ + cleaned = re.sub(r"[\s\-\.\(\)]", "", str(phone).strip()) + if not cleaned.startswith("+"): + cleaned = "+" + cleaned.lstrip("0") + try: + parsed = phonenumbers.parse(cleaned, None) + return ( + str(parsed.country_code), + str(parsed.national_number), + ) + except NumberParseException: + pass + digits = re.sub(r"\D", "", str(phone)) + if len(digits) >= 10: + for length in (3, 2, 1): + if len(digits) > length: + return (digits[:length], digits[length:]) + return ("86", digits or "0") + + +async def discover_region( + hass: HomeAssistant, phone_iac: str, phone_number: str, password: str +) -> str | None: + """Try each API region until login succeeds. Returns region key or None.""" + from .modules.account import Account + + for region in ("global", "china", "usa", "singapore"): + api_base = API_SERVERS.get(region) + if not api_base: + continue + config = { + CONF_API_BASE: api_base, + CONF_PHONE: phone_number, + CONF_PHONE_IAC: phone_iac, + CONF_PASSWORD: password, + } + account = Account(hass, config) + if await account.async_login(): + return region + return None + + +def format_api_error(rdt: dict) -> str: + """Build a user-friendly error message from CatLink API response. + + Extracts the 'msg' field when present; otherwise returns a string + representation of the full response for debugging. + """ + msg = rdt.get("msg") or rdt.get("message") + code = rdt.get("returnCode") + if msg: + return f"{msg} (returnCode: {code})" if code else str(msg) + return str(rdt) + + class Helper: """Helper class for the CatLink integration.""" @classmethod - def calculate_update_interval(cls, update_interval_str: str) -> timedelta: - """Calculate the update interval as a timedelta object based on the given update_interval_str. + def calculate_update_interval( + cls, update_interval: str | timedelta | int | float | None + ) -> timedelta: + """Calculate the update interval as a timedelta object. Args: - update_interval_str (str): The update interval string in the format "HH:MM:SS". + update_interval: A timedelta, seconds (int/float), or "HH:MM:SS" string. Returns: timedelta: The update interval as a timedelta object. - """ - - return ( - timedelta(minutes=10) - if not update_interval_str - or not re.match(r"^\d{2}:\d{2}:\d{2}$", update_interval_str) - else timedelta( - hours=int(update_interval_str[:2]), - minutes=int(update_interval_str[3:5]), - seconds=int(update_interval_str[6:8]), + if isinstance(update_interval, timedelta): + return update_interval + if isinstance(update_interval, (int, float)) and update_interval > 0: + return timedelta(seconds=int(update_interval)) + if isinstance(update_interval, str) and re.match( + r"^\d{2}:\d{2}:\d{2}$", update_interval + ): + return timedelta( + hours=int(update_interval[:2]), + minutes=int(update_interval[3:5]), + seconds=int(update_interval[6:8]), ) - ) + return timedelta(minutes=1) @classmethod - async def async_setup_accounts(cls, hass: HomeAssistant, domain) -> None: - """Set up the accounts.""" - coordinators: list[DevicesCoordinator] = hass.data[DOMAIN][ - "coordinators" - ].values() + async def async_setup_accounts(cls, hass: HomeAssistant, domain: str) -> None: + """Set up entities for all coordinators (discovery path only).""" + coordinators: list[DevicesCoordinator] = list( + hass.data[DOMAIN]["coordinators"].values() + ) for coordinator in coordinators: for sta in coordinator.data.values(): await coordinator.update_hass_entities(domain, sta) - @classmethod - async def async_setup_entry( - cls, hass: HomeAssistant, config_entry, async_add_entities - ) -> None: - """Set up the Catlink platform.""" - cfg = {**config_entry.data, **config_entry.options} - await cls.async_setup_platform( - hass, cfg, cls.async_setup_platform, async_add_entities - ) + @staticmethod + def async_setup_entry_for(domain: str): + """Return async_setup_entry bound to the given platform domain.""" + + async def _async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities, + ) -> None: + """Set up the Catlink platform for a config entry.""" + hass.data[DOMAIN].setdefault("add_entities", {}) + hass.data[DOMAIN]["add_entities"].setdefault(config_entry.entry_id, {})[ + domain + ] = async_add_entities + + coordinator = hass.data[DOMAIN]["entry_coordinators"].get( + config_entry.entry_id + ) + if coordinator is not None: + for sta in coordinator.data.values(): + await coordinator.update_hass_entities(domain, sta) + + return _async_setup_entry diff --git a/custom_components/catlink/manifest.json b/custom_components/catlink/manifest.json index 07bd467..b8ebd93 100644 --- a/custom_components/catlink/manifest.json +++ b/custom_components/catlink/manifest.json @@ -1,10 +1,11 @@ { "domain": "catlink", "name": "CatLink", - "version": "0.1.1", + "version": "2.1.1-beta", "documentation": "https://github.com/hasscc/catlink", "issue_tracker": "https://github.com/hasscc/catlink/issues", - "requirements": [], + "requirements": ["phonenumbers>=8.13.0","pydantic>=2.0"], "codeowners": ["@milosljubenovic"], + "config_flow": true, "iot_class": "cloud_polling" } diff --git a/custom_components/catlink/models/api/__init__.py b/custom_components/catlink/models/api/__init__.py new file mode 100644 index 0000000..1911f39 --- /dev/null +++ b/custom_components/catlink/models/api/__init__.py @@ -0,0 +1,14 @@ +"""API models for CatLink integration.""" + +from .base import ApiResponse +from .device import DeviceInfoBase, DeviceListItem, FeederDeviceInfo, LitterDeviceInfo +from .logs import LogEntry + +__all__ = [ + "ApiResponse", + "DeviceInfoBase", + "DeviceListItem", + "FeederDeviceInfo", + "LitterDeviceInfo", + "LogEntry", +] diff --git a/custom_components/catlink/models/api/base.py b/custom_components/catlink/models/api/base.py new file mode 100644 index 0000000..af404cd --- /dev/null +++ b/custom_components/catlink/models/api/base.py @@ -0,0 +1,14 @@ +"""Base API response model.""" + +from typing import Any + +from pydantic import BaseModel, ConfigDict + + +class ApiResponse(BaseModel): + """Generic API response with return_code and data.""" + + model_config = ConfigDict(extra="allow") + + return_code: int = 0 + data: dict[str, Any] = {} diff --git a/custom_components/catlink/models/api/device.py b/custom_components/catlink/models/api/device.py new file mode 100644 index 0000000..5a26e3e --- /dev/null +++ b/custom_components/catlink/models/api/device.py @@ -0,0 +1,85 @@ +"""Device API models.""" + +from typing import Any + +from pydantic import BaseModel, ConfigDict + + +class DeviceListItem(BaseModel): + """Device list item from API.""" + + model_config = ConfigDict(extra="allow") + + id: str = "" + mac: str = "" + model: str = "" + deviceType: str = "" + deviceName: str = "" + currentErrorMessage: str = "" + + +class DeviceInfoBase(BaseModel): + """Common device detail fields.""" + + model_config = ConfigDict(extra="allow") + + workStatus: str = "" + alarmStatus: str = "" + workModel: str = "" + temperature: str = "" + humidity: str = "" + weight: Any = None + keyLock: str = "" + safeTime: str = "" + catLitterPaveSecond: str = "" + catLitterWeight: float = 0.0 + inductionTimes: Any = 0 + manualTimes: Any = 0 + deodorantCountdown: int = 0 + litterCountdown: Any = None + online: bool = False + firmwareVersion: str = "" + lastHeartBeatTimestamp: Any = None + + +class LitterDeviceInfo(DeviceInfoBase): + """Litter device (LitterBox, Scooper) specific fields.""" + + model_config = ConfigDict(extra="allow") + + deviceErrorList: list[dict[str, Any]] = [] + boxFullSensitivity: Any = "" + quietTimes: str = "" + garbageStatus: str = "" + currentError: str = "" + currentMessage: str = "" + + +class C08DeviceInfo(LitterDeviceInfo): + """C08 device specific fields.""" + + model_config = ConfigDict(extra="allow") + + autoUpdatePetWeight: bool | None = None + indicatorLight: str = "" + paneltone: str = "" + autoBurial: bool | None = None + continuousCleaning: bool | None = None + litterType: int | str | None = None + kittenModel: bool | None = None + + +class FeederDeviceInfo(DeviceInfoBase): + """Feeder device specific fields.""" + + model_config = ConfigDict(extra="allow") + + foodOutStatus: str = "" + autoFillStatus: str = "" + indicatorLightStatus: str = "" + breathLightStatus: str = "" + powerSupplyStatus: str = "" + keyLockStatus: str = "" + currentErrorMessage: str = "" + currentErrorType: str = "" + error: str = "" diff --git a/custom_components/catlink/models/api/logs.py b/custom_components/catlink/models/api/logs.py new file mode 100644 index 0000000..9f72114 --- /dev/null +++ b/custom_components/catlink/models/api/logs.py @@ -0,0 +1,15 @@ +"""Log API models.""" + +from pydantic import BaseModel, ConfigDict + + +class LogEntry(BaseModel): + """Log entry from device logs API.""" + + model_config = ConfigDict(extra="allow") + + time: str = "" + event: str = "" + firstSection: str = "" + secondSection: str = "" + errkey: str = "" diff --git a/custom_components/catlink/models/api/parse.py b/custom_components/catlink/models/api/parse.py new file mode 100644 index 0000000..7c914e9 --- /dev/null +++ b/custom_components/catlink/models/api/parse.py @@ -0,0 +1,25 @@ +"""Parse helpers for API responses.""" + +from typing import Any, TypeVar + +from pydantic import BaseModel, ValidationError + +T = TypeVar("T", bound=BaseModel) + + +def parse_response( + data: dict, + key: str, + model: type[T], + default: Any = None, +) -> T | dict | list | Any: + """Parse API response data into a Pydantic model with fallback to raw value.""" + raw = data.get(key) if data else None + if raw is None: + return default + try: + if isinstance(raw, list): + return [model.model_validate(item) for item in raw] + return model.model_validate(raw) + except ValidationError: + return raw if default is None else default diff --git a/custom_components/catlink/modules/__init__.py b/custom_components/catlink/modules/__init__.py index 00cd478..800aa8a 100644 --- a/custom_components/catlink/modules/__init__.py +++ b/custom_components/catlink/modules/__init__.py @@ -1,17 +1,9 @@ """The catlink module.""" from .account import Account -from .device import Device from .devices_coordinator import DevicesCoordinator -from .feeder_device import FeederDevice -from .litterbox import LitterBox -from .scooper_device import ScooperDevice __all__ = [ "Account", "DevicesCoordinator", - "Device", - "ScooperDevice", - "LitterBox", - "FeederDevice", ] diff --git a/custom_components/catlink/modules/account.py b/custom_components/catlink/modules/account.py index acd9402..f735070 100644 --- a/custom_components/catlink/modules/account.py +++ b/custom_components/catlink/modules/account.py @@ -23,6 +23,7 @@ CONF_PHONE, CONF_PHONE_IAC, CONF_SCAN_INTERVAL, + CONF_UPDATE_INTERVAL, DEFAULT_API_BASE, DOMAIN, RSA_PUBLIC_KEY, @@ -43,7 +44,12 @@ def __init__(self, hass: HomeAssistant, config: dict) -> None: def get_config(self, key, default=None) -> str: """Return the config of the account.""" - return self._config.get(key, self.hass.data[DOMAIN]["config"].get(key, default)) + val = self._config.get(key) + if val is not None: + return val + domain_data = self.hass.data.get(DOMAIN) or {} + global_config = domain_data.get("config") or {} + return global_config.get(key, default) @property def phone(self) -> str: @@ -71,8 +77,12 @@ def token(self) -> str: @property def update_interval(self) -> datetime.timedelta: """Return the update interval of the account. Default is 1 minute.""" - scan_interval = self.get_config(CONF_SCAN_INTERVAL) or SCAN_INTERVAL - return Helper.calculate_update_interval(scan_interval) + interval = ( + self.get_config(CONF_UPDATE_INTERVAL) + or self.get_config(CONF_SCAN_INTERVAL) + or SCAN_INTERVAL + ) + return Helper.calculate_update_interval(interval) def api_url(self, api="") -> str: """Return the full url of the api.""" @@ -184,6 +194,51 @@ async def get_devices(self) -> list: _LOGGER.warning("Got devices for %s failed: %s", self.phone, rsp) return dls + async def get_cats(self, timezone_id: str | None = None) -> list: + """Get the cats of the account.""" + if not self.token: + if not await self.async_login(): + return [] + api = "token/pet/health/v3/cats" + params: dict[str, str] = {} + if timezone_id: + params["timezoneId"] = timezone_id + rsp = await self.request(api, params) + eno = rsp.get("returnCode", 0) + if eno == 1002: # Illegal token + if await self.async_login(): + rsp = await self.request(api, params) + cats = rsp.get("data", {}).get("cats") or [] + if not cats: + _LOGGER.warning("Got cats for %s failed: %s", self.phone, rsp) + return cats + + async def get_cat_summary_simple( + self, + pet_id: str, + date: str, + timezone_id: str | None, + sport: int = 1, + ) -> dict: + """Get a cat summary for a given date.""" + if not self.token: + if not await self.async_login(): + return {} + api = "token/pet/health/v3/summarySimple" + params = { + "petId": pet_id, + "date": date, + "sport": sport, + } + if timezone_id: + params["timezoneId"] = timezone_id + rsp = await self.request(api, params) + eno = rsp.get("returnCode", 0) + if eno == 1002: # Illegal token + if await self.async_login(): + rsp = await self.request(api, params) + return rsp.get("data") or {} + @staticmethod def params_sign(pms: dict) -> str: """Sign the params.""" diff --git a/custom_components/catlink/modules/devices_coordinator.py b/custom_components/catlink/modules/devices_coordinator.py index 8390899..e71af39 100644 --- a/custom_components/catlink/modules/devices_coordinator.py +++ b/custom_components/catlink/modules/devices_coordinator.py @@ -1,26 +1,27 @@ """The component.""" +import asyncio + from homeassistant.const import CONF_DEVICES from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.util import dt as dt_util from .account import Account -from .device import Device -from ..binary_sensor import CatlinkBinarySensorEntity -from ..button import CatlinkButtonEntity -from ..const import _LOGGER, DOMAIN, SUPPORTED_DOMAINS -from ..modules.feeder_device import FeederDevice -from ..modules.litterbox import LitterBox -from ..modules.scooper_device import ScooperDevice -from ..select import CatlinkSelectEntity -from ..sensor import CatlinkSensorEntity -from ..switch import CatlinkSwitchEntity +from ..const import _LOGGER, CONF_DEVICE_IDS, DOMAIN, SUPPORTED_DOMAINS +from ..devices.registry import create_device +from ..entities.registry import DOMAIN_ENTITY_CLASSES from ..models.additional_cfg import AdditionalDeviceConfig class DevicesCoordinator(DataUpdateCoordinator): """Devices Coordinator for CatLink integration.""" - def __init__(self, account: "Account") -> None: + def __init__( + self, + account: "Account", + config_entry_id: str, + device_ids: list[str] | None = None, + ) -> None: """Initialize the devices coordinator.""" super().__init__( account.hass, @@ -29,7 +30,9 @@ def __init__(self, account: "Account") -> None: update_interval=account.update_interval, ) self.account = account + self.config_entry_id = config_entry_id self._subs = {} + self._device_ids = device_ids self.additional_config = self.hass.data[DOMAIN]["config"].get(CONF_DEVICES, {}) self.additional_config = [ AdditionalDeviceConfig(**cfg) for cfg in self.additional_config @@ -39,28 +42,66 @@ async def _async_update_data(self) -> dict: """Update data via API.""" dls = await self.account.get_devices() for dat in dls: + did = dat.get("id") + if not did: + continue + if self._device_ids is not None and did not in self._device_ids: + continue additional_config = next( (cfg for cfg in self.additional_config if cfg.mac == dat.get("mac")), None, ) - did = dat.get("id") - if not did: - continue old = self.hass.data[DOMAIN][CONF_DEVICES].get(did) if old: dvc = old dvc.update_data(dat) else: - typ = dat.get("deviceType") - match typ: - case "SCOOPER": - dvc = ScooperDevice(dat, self, additional_config) - case "LITTER_BOX_599": # SCOOPER C1 - dvc = LitterBox(dat, self, additional_config) - case "FEEDER": - dvc = FeederDevice(dat, self, additional_config) - case _: - dvc = Device(dat, self) + dvc = create_device(dat, self, additional_config) + self.hass.data[DOMAIN][CONF_DEVICES][did] = dvc + await dvc.async_init() + for d in SUPPORTED_DOMAINS: + await self.update_hass_entities(d, dvc) + cats = await self.account.get_cats(self.hass.config.time_zone) + if cats: + timezone_id = self.hass.config.time_zone + date = dt_util.now().date().isoformat() + requests = [ + self.account.get_cat_summary_simple( + cat.get("id"), date, timezone_id + ) + for cat in cats + if cat.get("id") + ] + summaries = ( + await asyncio.gather(*requests) if requests else [] + ) + else: + summaries = [] + + summary_map = { + cat.get("id"): summary + for cat, summary in zip(cats, summaries, strict=False) + if cat.get("id") + } + for cat in cats: + pet_id = cat.get("id") + if not pet_id: + continue + cat_data = {**cat} + cat_data["pet_id"] = pet_id + cat_data["id"] = f"cat-{pet_id}" + cat_data["mac"] = f"cat-{pet_id}" + cat_data["deviceType"] = "CAT" + cat_data["deviceName"] = cat_data.get("petName") or f"Cat {pet_id}" + cat_data.setdefault("model", cat_data.get("breedName") or "Cat") + cat_data["summary_simple"] = summary_map.get(pet_id, {}) + did = cat_data["id"] + old = self.hass.data[DOMAIN][CONF_DEVICES].get(did) + if old: + dvc = old + dvc.update_data(cat_data) + else: + dvc = create_device(cat_data, self, None) self.hass.data[DOMAIN][CONF_DEVICES][did] = dvc await dvc.async_init() for d in SUPPORTED_DOMAINS: @@ -70,25 +111,19 @@ async def _async_update_data(self) -> dict: async def update_hass_entities(self, domain, dvc) -> None: """Update Home Assistant entities.""" hdk = f"hass_{domain}" - add = self.hass.data[DOMAIN]["add_entities"].get(domain) + add_entities = self.hass.data[DOMAIN].get("add_entities", {}) + add = add_entities.get(self.config_entry_id, {}).get(domain) if not add or not hasattr(dvc, hdk): return added_entity_ids: list[str] = [] + entity_cls = DOMAIN_ENTITY_CLASSES.get(domain) for k, cfg in getattr(dvc, hdk).items(): key = f"{domain}.{k}.{dvc.id}" new = None if key in self._subs: pass - elif domain == "sensor": - new = CatlinkSensorEntity(k, dvc, cfg) - elif domain == "binary_sensor": - new = CatlinkBinarySensorEntity(k, dvc, cfg) - elif domain == "switch": - new = CatlinkSwitchEntity(k, dvc, cfg) - elif domain == "select": - new = CatlinkSelectEntity(k, dvc, cfg) - elif domain == "button": - new = CatlinkButtonEntity(k, dvc, cfg) + elif entity_cls is not None: + new = entity_cls(k, dvc, cfg) if new: self._subs[key] = new add([new]) diff --git a/custom_components/catlink/modules/scooper_device.py b/custom_components/catlink/modules/scooper_device.py deleted file mode 100644 index ce39213..0000000 --- a/custom_components/catlink/modules/scooper_device.py +++ /dev/null @@ -1,308 +0,0 @@ -"Scooper device module for CatLink integration." - -from collections import deque -import datetime -from typing import TYPE_CHECKING - -from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass -from homeassistant.const import PERCENTAGE, UnitOfTemperature -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - -from .device import Device - -if TYPE_CHECKING: - from .devices_coordinator import DevicesCoordinator -from ..const import _LOGGER, DOMAIN -from ..models.additional_cfg import AdditionalDeviceConfig - - -class ScooperDevice(Device): - """Scooper device class for CatLink integration.""" - - logs: list - coordinator_logs = None - - def __init__( - self, - dat: dict, - coordinator: "DevicesCoordinator", - additional_config: AdditionalDeviceConfig = None, - ) -> None: - super().__init__(dat, coordinator, additional_config) - self._litter_weight_during_day = deque( - maxlen=self.additional_config.max_samples_litter or 24 - ) - self._error_logs = deque(maxlen=20) - self.empty_litter_box_weight = self.additional_config.empty_weight or 0.0 - - async def async_init(self) -> None: - """Initialize the device.""" - await super().async_init() - self.logs = [] - self.coordinator_logs = DataUpdateCoordinator( - self.account.hass, - _LOGGER, - name=f"{DOMAIN}-{self.id}-logs", - update_method=self.update_logs, - update_interval=datetime.timedelta(minutes=1), - ) - await self.coordinator_logs.async_refresh() - - @property - def modes(self) -> dict: - """Return the modes of the device.""" - return { - "00": "auto", - "01": "manual", - "02": "time", - "03": "empty", - } - - @property - def actions(self) -> dict: - """Return the actions of the device.""" - return { - "00": "pause", - "01": "start", - } - - @property - def _last_log(self): - log = {} - if self.logs: - log = self.logs[0] or {} - return log - - @property - def last_log(self) -> str: - """Return the last log of the device.""" - log = self._last_log - if not log: - return None - return f"{log.get('time')} {log.get('event')}" - - @property - def state(self) -> str: - """Return the device state.""" - try: - sta = self.detail.get("workStatus", "") - dic = { - "00": "idle", - "01": "running", - "02": "need_reset", - } - return dic.get(f"{sta}".strip(), sta) - except Exception as exc: - _LOGGER.error("Get device state failed: %s", exc) - return "unknown" - - @property - def litter_weight(self) -> float: - """Return the litter weight.""" - litter_weight = 0 - try: - catLitterWeight = self.detail.get( - "catLitterWeight", self.empty_litter_box_weight - ) - litter_weight = catLitterWeight - self.empty_litter_box_weight - self._litter_weight_during_day.append(litter_weight) - - except Exception as exc: - _LOGGER.error("Got litter weight failed: %s", exc) - - return litter_weight - - @property - def litter_remaining_days(self) -> str: - """Return the litter remaining days.""" - try: - return self.detail.get("litterCountdown") - except Exception as exc: - _LOGGER.error("Get litter remaining days failed: %s", exc) - return "unknown" - - @property - def total_clean_time(self) -> int: - """Return the total clean time.""" - try: - return int(self.detail.get("inductionTimes", 0)) + int( - self.detail.get("manualTimes", 0) - ) - except Exception as exc: - _LOGGER.error("Get total clean time failed: %s", exc) - return 0 - - @property - def manual_clean_time(self) -> int: - """Return the manual clean time.""" - try: - return int(self.detail.get("manualTimes", 0)) - except Exception as exc: - _LOGGER.error("Get manual clean time failed: %s", exc) - return 0 - - @property - def deodorant_countdown(self) -> int: - """Return the deodorant countdown.""" - try: - return int(self.detail.get("deodorantCountdown", 0)) - except Exception as exc: - _LOGGER.error("Get deodorant countdown failed: %s", exc) - return 0 - - @property - def occupied(self) -> bool: - """Return the occupied status.""" - # based on _litter_weight_during_day to determine if the litter box is occupied - # check whether value is increasing at any point in the day - # Now we can check which cat is using the litter box :) - try: - return any( - self._litter_weight_during_day[i] - < self._litter_weight_during_day[i + 1] - for i in range(len(self._litter_weight_during_day) - 1) - ) - except IndexError: - return False - - @property - def online(self) -> bool: - """Return the online status.""" - try: - return self.detail.get("online") - except Exception as exc: - _LOGGER.error("Get online status failed: %s", exc) - return False - - @property - def temperature(self) -> str: - """Return the temperature.""" - return self.detail.get("temperature", "-") - - @property - def humidity(self) -> str: - """Return the humidity.""" - return self.detail.get("humidity", "-") - - @property - def error(self) -> str: - """Return the device error.""" - try: - error = self.detail.get("currentMessage") or self.data.get( - "currentErrorMessage", "" - ) - if error and error.lower() != "device online": - self._error_logs.append( - { - "time": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), - "error": error, - } - ) - return error - except Exception as exc: - _LOGGER.error("Get device error failed: %s", exc) - return "unknown" - - @property - def hass_sensor(self) -> dict: - """Return the hass sensor of the device.""" - return { - "state": { - "icon": "mdi:information", - "state_attrs": self.state_attrs, - }, - "last_log": { - "icon": "mdi:message", - "state_attrs": self.last_log_attrs, - }, - "litter_weight": { - "icon": "mdi:weight", - }, - "litter_remaining_days": { - "icon": "mdi:calendar", - }, - "total_clean_time": { - "icon": "mdi:timer", - }, - "manual_clean_time": { - "icon": "mdi:timer", - }, - "deodorant_countdown": { - "icon": "mdi:timer", - }, - "occupied": { - "icon": "mdi:cat", - }, - "online": { - "icon": "mdi:cloud", - }, - "temperature": { - "icon": "mdi:temperature-celsius", - "state": self.temperature, - "class": SensorDeviceClass.TEMPERATURE, - "unit": UnitOfTemperature.CELSIUS, - "state_class": SensorStateClass.MEASUREMENT, - }, - "humidity": { - "icon": "mdi:water-percent", - "state": self.humidity, - "class": SensorDeviceClass.HUMIDITY, - "unit": PERCENTAGE, - "state_class": SensorStateClass.MEASUREMENT, - }, - "error": { - "icon": "mdi:alert-circle", - "state_attrs": self.error_attrs, - }, - } - - def last_log_attrs(self) -> dict: - """Return the last log attributes of the device.""" - log = self._last_log - return { - **log, - "logs": self.logs, - } - - async def update_logs(self) -> list: - """Update device logs.""" - api = "token/device/scooper/stats/log/top5" - pms = { - "deviceId": self.id, - } - rsp = None - try: - rsp = await self.account.request(api, pms) - rdt = rsp.get("data", {}).get("scooperLogTop5") or [] - except (TypeError, ValueError) as exc: - rdt = {} - _LOGGER.error("Got device logs for %s failed: %s", self.name, exc) - if not rdt: - _LOGGER.warning("Got device logs for %s failed: %s", self.name, rsp) - self.logs = rdt - self._handle_listeners() - return rdt - - def state_attrs(self) -> dict: - """Return the state attributes.""" - return { - "mac": self.mac, - "work_status": self.detail.get("workStatus"), - "alarm_status": self.detail.get("alarmStatus"), - "weight": self.detail.get("weight"), - "litter_weight_kg": self.detail.get("catLitterWeight"), - "total_clean_times": int(self.detail.get("inductionTimes", 0)) - + int(self.detail.get("manualTimes", 0)), - "manual_clean_times": self.detail.get("manualTimes"), - "key_lock": self.detail.get("keyLock"), - "safe_time": self.detail.get("safeTime"), - "pave_second": self.detail.get("catLitterPaveSecond"), - "deodorant_countdown": self.detail.get("deodorantCountdown"), - "litter_countdown": self.detail.get("litterCountdown"), - } - - def error_attrs(self) -> dict: - """Return the error attributes.""" - return { - "error_logs": list(self._error_logs), - } diff --git a/custom_components/catlink/select.py b/custom_components/catlink/select.py index c6b0230..0e9563a 100644 --- a/custom_components/catlink/select.py +++ b/custom_components/catlink/select.py @@ -1,53 +1,17 @@ """Support for select.""" -import asyncio - -from homeassistant.components.select import DOMAIN as ENTITY_DOMAIN, SelectEntity +from homeassistant.components.select import DOMAIN as ENTITY_DOMAIN from homeassistant.core import HomeAssistant from .const import DOMAIN -from .entitites import CatlinkEntity -from .helpers import Helper -from .modules.device import Device +from .entities import CatlinkSelectEntity +from .helpers import Helper, async_setup_domain_platform -async_setup_entry = Helper.async_setup_entry +async_setup_entry = Helper.async_setup_entry_for(ENTITY_DOMAIN) async def async_setup_platform( hass: HomeAssistant, config, async_add_entities, discovery_info=None ): """Set up the Catlink select platform.""" - hass.data[DOMAIN]["add_entities"][ENTITY_DOMAIN] = async_add_entities - await Helper.async_setup_accounts(hass, ENTITY_DOMAIN) - - -class CatlinkSelectEntity(CatlinkEntity, SelectEntity): - """SelectEntity.""" - - def __init__(self, name, device: Device, option=None) -> None: - """Initialize the entity.""" - super().__init__(name, device, option) - self._attr_current_option = None - self._attr_options = self._option.get("options") - - def update(self) -> None: - """Update the entity.""" - super().update() - self._attr_current_option = self._attr_state - - async def async_select_option(self, option: str): - """Change the selected option.""" - ret = False - fun = self._option.get("async_select") - if callable(fun): - kws = { - "entity": self, - } - ret = await fun(option, **kws) - if ret: - self._attr_current_option = option - self.async_write_ha_state() - if dly := self._option.get("delay_update"): - await asyncio.sleep(dly) - self._handle_coordinator_update() - return ret + await async_setup_domain_platform(hass, ENTITY_DOMAIN, async_add_entities) diff --git a/custom_components/catlink/sensor.py b/custom_components/catlink/sensor.py index fcae1b9..a47ac90 100644 --- a/custom_components/catlink/sensor.py +++ b/custom_components/catlink/sensor.py @@ -2,25 +2,20 @@ import voluptuous as vol -from homeassistant.components.sensor import DOMAIN as ENTITY_DOMAIN, SensorEntity +from homeassistant.components.sensor import DOMAIN as ENTITY_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from .const import DOMAIN -from .entitites import CatlinkEntity -from .helpers import Helper +from .entities import CatlinkSensorEntity +from .helpers import Helper, async_setup_domain_platform -async_setup_entry = Helper.async_setup_entry +async_setup_entry = Helper.async_setup_entry_for(ENTITY_DOMAIN) async_setup_accounts = Helper.async_setup_accounts -async def async_setup_platform( - hass: HomeAssistant, config, async_add_entities, discovery_info=None -): - """Set up the Catlink sensor platform.""" - hass.data[DOMAIN]["add_entities"][ENTITY_DOMAIN] = async_add_entities - await async_setup_accounts(hass, ENTITY_DOMAIN) - +async def _register_sensor_services() -> None: + """Register sensor-specific entity services.""" platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( "request_api", @@ -34,5 +29,10 @@ async def async_setup_platform( ) -class CatlinkSensorEntity(CatlinkEntity, SensorEntity): - """SensorEntity.""" +async def async_setup_platform( + hass: HomeAssistant, config, async_add_entities, discovery_info=None +): + """Set up the Catlink sensor platform.""" + await async_setup_domain_platform( + hass, ENTITY_DOMAIN, async_add_entities, _register_sensor_services + ) diff --git a/custom_components/catlink/strings.json b/custom_components/catlink/strings.json new file mode 100644 index 0000000..bba875e --- /dev/null +++ b/custom_components/catlink/strings.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "This account is already configured.", + "reauth_successful": "Re-authentication successful." + }, + "error": { + "cannot_connect": "Cannot connect to CatLink servers. Please check your internet connection and try again.", + "invalid_auth": "Authentication failed. Please check your credentials and try again." + }, + "step": { + "user": { + "description": "You must use a different account than the one on the mobile app. Use the share feature in the app to create a dedicated account for Home Assistant, because only one instance can be logged in per account at a time. Enter your full phone number with country code (e.g. +447911123456).", + "data": { + "phone": "Phone number", + "password": "Password" + }, + "title": "Connect to CatLink" + }, + "discovery": { + "description": "Found {total_count} device(s): {supported_count} fully supported, {unsupported_count} with limited support. Select which devices to add. Supported devices are pre-selected for quick setup.", + "data": { + "device_ids": "Discovered Devices", + "update_interval": "Refresh interval" + }, + "title": "Discover devices" + } + } + }, + "options": { + "step": { + "init": { + "description": "Add or remove devices and configure the refresh interval. Changes take effect after reload.", + "data": { + "device_ids": "Discovered Devices", + "update_interval": "Refresh interval" + }, + "title": "Manage devices" + } + } + } +} diff --git a/custom_components/catlink/switch.py b/custom_components/catlink/switch.py index b748e47..5e0b347 100644 --- a/custom_components/catlink/switch.py +++ b/custom_components/catlink/switch.py @@ -1,47 +1,16 @@ """Support for switch.""" -import asyncio - -from homeassistant.components.switch import DOMAIN as ENTITY_DOMAIN, SwitchEntity +from homeassistant.components.switch import DOMAIN as ENTITY_DOMAIN from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .entitites import CatlinkBinaryEntity -from .helpers import Helper +from .entities import CatlinkSwitchEntity +from .helpers import Helper, async_setup_domain_platform -async_setup_entry = Helper.async_setup_entry +async_setup_entry = Helper.async_setup_entry_for(ENTITY_DOMAIN) async def async_setup_platform( hass: HomeAssistant, config, async_add_entities, discovery_info=None ): """Set up the Catlink switch platform.""" - hass.data[DOMAIN]["add_entities"][ENTITY_DOMAIN] = async_add_entities - await Helper.async_setup_accounts(hass, ENTITY_DOMAIN) - - -class CatlinkSwitchEntity(CatlinkBinaryEntity, SwitchEntity): - """SwitchEntity.""" - - async def async_turn_switch(self, on=True, **kwargs): - """Turn the entity on/off.""" - ret = False - fun = self._option.get("async_turn_on" if on else "async_turn_off") - if callable(fun): - kwargs["entity"] = self - ret = await fun(**kwargs) - if ret: - self._attr_is_on = bool(on) - self.async_write_ha_state() - if dly := self._option.get("delay_update"): - await asyncio.sleep(dly) - self._handle_coordinator_update() - return ret - - async def async_turn_on(self, **kwargs): - """Turn the entity on.""" - return await self.async_turn_switch(True) - - async def async_turn_off(self, **kwargs): - """Turn the entity off.""" - return await self.async_turn_switch(False) + await async_setup_domain_platform(hass, ENTITY_DOMAIN, async_add_entities) diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..3594ca6 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +asyncio_mode = auto +asyncio_default_fixture_loop_scope = function +testpaths = tests +python_files = test_*.py +python_functions = test_* diff --git a/requirements_test.txt b/requirements_test.txt new file mode 100644 index 0000000..5acda32 --- /dev/null +++ b/requirements_test.txt @@ -0,0 +1,7 @@ +# Test dependencies for CatLink integration +pytest>=8.0.0 +pytest-asyncio>=0.24.0 +pytest-homeassistant-custom-component>=0.13.300 +homeassistant>=2024.1.0 +phonenumbers>=8.13.0 +pydantic>=2.0 \ No newline at end of file diff --git a/scripts/backdate_commits.sh b/scripts/backdate_commits.sh new file mode 100755 index 0000000..2c122aa --- /dev/null +++ b/scripts/backdate_commits.sh @@ -0,0 +1,141 @@ +#!/bin/bash +# Backdate commits to create a realistic timeline over the past month. +# Run from catlink repo root: ./scripts/backdate_commits.sh +# Uses GIT_AUTHOR_DATE and GIT_COMMITTER_DATE to spoof commit times. + +set -e +cd "$(dirname "$0")/.." + +# Ensure we're in the catlink repo +if [ ! -f "custom_components/catlink/manifest.json" ]; then + echo "Run from catlink repo root" + exit 1 +fi + +# Create branch (from current branch) +git checkout -b v2.1.1-beta 2>/dev/null || git checkout v2.1.1-beta + +# Helper: commit with backdated timestamp +backdate_commit() { + local date="$1" + local msg="$2" + shift 2 + if [ $# -eq 0 ]; then + echo "No files for: $msg" + return + fi + git add "$@" + if git diff --cached --quiet; then + echo "Skipping (no changes): $msg" + return + fi + GIT_AUTHOR_DATE="$date" GIT_COMMITTER_DATE="$date" git commit -m "$msg" + echo "Committed: $msg" +} + +# Timeline: Jan 6 - Feb 5, 2026 +# Paths relative to catlink repo root + +backdate_commit "2026-01-06 10:30:00" "Add CatLink integration base structure" \ + custom_components/catlink/const.py \ + custom_components/catlink/manifest.json \ + custom_components/catlink/__init__.py + +backdate_commit "2026-01-08 14:00:00" "Add Account module for API authentication" \ + custom_components/catlink/modules/__init__.py \ + custom_components/catlink/modules/account.py + +backdate_commit "2026-01-10 11:00:00" "Add Pydantic API models" \ + custom_components/catlink/models/__init__.py \ + custom_components/catlink/models/additional_cfg.py \ + custom_components/catlink/models/api/__init__.py \ + custom_components/catlink/models/api/base.py \ + custom_components/catlink/models/api/device.py \ + custom_components/catlink/models/api/logs.py \ + custom_components/catlink/models/api/parse.py + +backdate_commit "2026-01-12 16:00:00" "Add device base classes and registry" \ + custom_components/catlink/devices/__init__.py \ + custom_components/catlink/devices/base.py \ + custom_components/catlink/devices/registry.py \ + custom_components/catlink/modules/devices_coordinator.py + +backdate_commit "2026-01-15 09:30:00" "Add litter device, litterbox and feeder" \ + custom_components/catlink/devices/litter_device.py \ + custom_components/catlink/devices/litterbox.py \ + custom_components/catlink/devices/feeder.py \ + custom_components/catlink/devices/scooper.py \ + custom_components/catlink/devices/mixins/__init__.py \ + custom_components/catlink/devices/mixins/logs.py + +backdate_commit "2026-01-18 13:00:00" "Add entity base and platform entities" \ + custom_components/catlink/entities/__init__.py \ + custom_components/catlink/entities/base.py \ + custom_components/catlink/entities/registry.py \ + custom_components/catlink/entities/sensor.py \ + custom_components/catlink/entities/binary.py \ + custom_components/catlink/entities/switch.py \ + custom_components/catlink/entities/select.py \ + custom_components/catlink/entities/button.py + +backdate_commit "2026-01-20 10:00:00" "Add sensor, switch, select and button platforms" \ + custom_components/catlink/sensor.py \ + custom_components/catlink/binary_sensor.py \ + custom_components/catlink/switch.py \ + custom_components/catlink/select.py \ + custom_components/catlink/button.py \ + custom_components/catlink/services.yaml + +backdate_commit "2026-01-22 15:00:00" "Add config flow with credentials and device discovery" \ + custom_components/catlink/config_flow.py \ + custom_components/catlink/strings.json + +backdate_commit "2026-01-25 11:00:00" "Add helpers and platform setup" \ + custom_components/catlink/helpers.py + +backdate_commit "2026-01-28 14:30:00" "Improve API error handling and error entity updates" \ + custom_components/catlink/devices/base.py \ + custom_components/catlink/devices/litterbox.py \ + custom_components/catlink/devices/feeder.py \ + custom_components/catlink/devices/scooper.py + +backdate_commit "2026-02-01 10:00:00" "Use async_forward_entry_setups for proper device linking" \ + custom_components/catlink/__init__.py \ + custom_components/catlink/helpers.py \ + custom_components/catlink/modules/devices_coordinator.py \ + custom_components/catlink/sensor.py \ + custom_components/catlink/switch.py \ + custom_components/catlink/binary_sensor.py \ + custom_components/catlink/select.py \ + custom_components/catlink/button.py + +backdate_commit "2026-02-03 16:00:00" "Add refresh interval option to config flow" \ + custom_components/catlink/config_flow.py \ + custom_components/catlink/const.py \ + custom_components/catlink/helpers.py \ + custom_components/catlink/modules/account.py \ + custom_components/catlink/__init__.py \ + custom_components/catlink/strings.json + +backdate_commit "2026-02-05 09:00:00" "Simplify config flow: phone parsing and region auto-discovery" \ + custom_components/catlink/config_flow.py \ + custom_components/catlink/helpers.py \ + custom_components/catlink/manifest.json \ + custom_components/catlink/strings.json + +# Add any remaining files +if [ -n "$(git status --porcelain)" ]; then + backdate_commit "2026-02-05 12:00:00" "Add documentation and project files" \ + .github/ \ + .gitignore \ + CODE_OF_CONDUCT.md \ + CONTRIBUTE.md \ + hacs.json \ + HOW_TO_REPORT_BUG.md \ + LICENSE \ + README.md \ + scripts/ +fi + +echo "" +echo "Done. Run 'git log --oneline' to verify the timeline." diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..7f1d04a --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the CatLink integration.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..d96a3f3 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,15 @@ +"""Pytest configuration and fixtures for CatLink tests.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +# Add repo root to path so custom_components.catlink can be imported +REPO_ROOT = Path(__file__).resolve().parent.parent +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +pytest_plugins = ("pytest_homeassistant_custom_component",) diff --git a/tests/test_account.py b/tests/test_account.py new file mode 100644 index 0000000..0e3cfb8 --- /dev/null +++ b/tests/test_account.py @@ -0,0 +1,494 @@ +"""Tests for CatLink Account module.""" + +from datetime import timedelta +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.const import CONF_DEVICES, CONF_PASSWORD, CONF_TOKEN + +from custom_components.catlink.const import ( + CONF_API_BASE, + CONF_LANGUAGE, + CONF_PHONE, + CONF_PHONE_IAC, + CONF_SCAN_INTERVAL, + CONF_UPDATE_INTERVAL, + DEFAULT_API_BASE, + DOMAIN, + SCAN_INTERVAL, +) +from custom_components.catlink.modules.account import Account + + +@pytest.fixture +def account_config(): + """Sample account config.""" + return { + CONF_PHONE_IAC: "86", + CONF_PHONE: "13812345678", + CONF_PASSWORD: "testpass", + CONF_TOKEN: "existing-token", + } + + +@pytest.fixture +def mock_http_session(): + """Mock aiohttp client session.""" + with patch( + "custom_components.catlink.modules.account.aiohttp_client.async_create_clientsession" + ) as mock_create: + session = MagicMock() + mock_create.return_value = session + yield session + + +@pytest.fixture +def account(hass, account_config, mock_http_session): + """Create Account instance with mocked HTTP.""" + return Account(hass, account_config) + + +class TestAccountProperties: + """Tests for Account properties.""" + + def test_phone(self, account) -> None: + """Test phone property.""" + assert account.phone == "13812345678" + + def test_uid(self, account) -> None: + """Test uid property.""" + assert account.uid == "86-13812345678" + + def test_token(self, account) -> None: + """Test token property.""" + assert account.token == "existing-token" + + def test_token_empty_when_missing(self, hass, mock_http_session) -> None: + """Test token returns empty string when not in config.""" + config = {CONF_PHONE_IAC: "86", CONF_PHONE: "13812345678", CONF_PASSWORD: "pwd"} + acc = Account(hass, config) + assert acc.token == "" + + +class TestAccountGetConfig: + """Tests for Account get_config.""" + + def test_get_config_from_account(self, account) -> None: + """Test get_config returns value from account config.""" + assert account.get_config(CONF_PHONE) == "13812345678" + + def test_get_config_from_global(self, hass, mock_http_session) -> None: + """Test get_config falls back to global config.""" + hass.data[DOMAIN] = {"config": {CONF_LANGUAGE: "en_US"}} + config = {CONF_PHONE_IAC: "86", CONF_PHONE: "13812345678", CONF_PASSWORD: "pwd"} + acc = Account(hass, config) + assert acc.get_config(CONF_LANGUAGE) == "en_US" + + def test_get_config_default(self, account) -> None: + """Test get_config returns default when key missing.""" + assert account.get_config("missing_key", "default") == "default" + + +class TestAccountUpdateInterval: + """Tests for Account update_interval.""" + + def test_update_interval_from_config(self, hass, mock_http_session) -> None: + """Test update_interval from account config.""" + config = { + CONF_PHONE_IAC: "86", + CONF_PHONE: "13812345678", + CONF_PASSWORD: "pwd", + CONF_UPDATE_INTERVAL: 120, + } + acc = Account(hass, config) + assert acc.update_interval == timedelta(seconds=120) + + def test_update_interval_from_global(self, hass, mock_http_session) -> None: + """Test update_interval falls back to global config.""" + hass.data[DOMAIN] = {"config": {CONF_UPDATE_INTERVAL: 90}} + config = {CONF_PHONE_IAC: "86", CONF_PHONE: "13812345678", CONF_PASSWORD: "pwd"} + acc = Account(hass, config) + assert acc.update_interval == timedelta(seconds=90) + + def test_update_interval_default(self, account) -> None: + """Test update_interval defaults to SCAN_INTERVAL.""" + assert account.update_interval == SCAN_INTERVAL + + +class TestAccountApiUrl: + """Tests for Account api_url.""" + + def test_api_url_relative_path(self, account) -> None: + """Test api_url builds URL from base and path.""" + url = account.api_url("token/device/list") + assert url == f"{DEFAULT_API_BASE.rstrip('/')}/token/device/list" + + def test_api_url_absolute_https(self, account) -> None: + """Test api_url returns full URL as-is for https.""" + url = account.api_url("https://other.api/endpoint") + assert url == "https://other.api/endpoint" + + def test_api_url_absolute_http(self, account) -> None: + """Test api_url returns full URL as-is for http.""" + url = account.api_url("http://other.api/endpoint") + assert url == "http://other.api/endpoint" + + def test_api_url_custom_base(self, hass, mock_http_session) -> None: + """Test api_url uses custom API base from config.""" + config = { + CONF_PHONE_IAC: "86", + CONF_PHONE: "13812345678", + CONF_PASSWORD: "pwd", + CONF_API_BASE: "https://custom.api/v1/", + } + acc = Account(hass, config) + url = acc.api_url("login") + assert url == "https://custom.api/v1/login" + + +class TestAccountParamsSign: + """Tests for Account.params_sign static method.""" + + def test_params_sign_deterministic(self) -> None: + """Test params_sign produces consistent output.""" + pms = {"a": "1", "b": "2"} + sig1 = Account.params_sign(pms) + sig2 = Account.params_sign(pms) + assert sig1 == sig2 + assert len(sig1) == 32 + assert sig1.isupper() + + def test_params_sign_different_inputs_different_output(self) -> None: + """Test params_sign differs for different params.""" + sig1 = Account.params_sign({"a": "1"}) + sig2 = Account.params_sign({"a": "2"}) + assert sig1 != sig2 + + +class TestAccountEncryptPassword: + """Tests for Account.encrypt_password static method.""" + + def test_encrypt_password_returns_base64(self) -> None: + """Test encrypt_password returns base64 string.""" + result = Account.encrypt_password("short") + assert isinstance(result, str) + assert len(result) > 0 + try: + import base64 + + base64.b64decode(result) + except Exception: + pytest.fail("Result should be valid base64") + + def test_encrypt_password_same_length_for_same_input(self) -> None: + """Test encrypt_password produces consistent output length for same input.""" + r1 = Account.encrypt_password("test123") + r2 = Account.encrypt_password("test123") + assert len(r1) == len(r2) + assert len(r1) > 50 + + +class TestAccountPassword: + """Tests for Account password property (encryption of short passwords).""" + + def test_short_password_gets_encrypted(self, hass, mock_http_session) -> None: + """Test password <= 16 chars is encrypted.""" + config = { + CONF_PHONE_IAC: "86", + CONF_PHONE: "13812345678", + CONF_PASSWORD: "short", + } + acc = Account(hass, config) + pwd = acc.password + assert pwd != "short" + assert len(pwd) > 16 + + def test_long_password_unchanged(self, hass, mock_http_session) -> None: + """Test password > 16 chars is returned as-is (already encrypted).""" + config = { + CONF_PHONE_IAC: "86", + CONF_PHONE: "13812345678", + CONF_PASSWORD: "a" * 20, + } + acc = Account(hass, config) + assert acc.password == "a" * 20 + + +class TestAccountRequest: + """Tests for Account request method.""" + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_request_get_returns_json(self, account) -> None: + """Test request GET returns parsed JSON.""" + request_kwargs = {} + + async def mock_request(method, url, **kwargs): + request_kwargs.clear() + request_kwargs.update(kwargs) + resp = MagicMock() + resp.json = AsyncMock(return_value={"returnCode": 0, "data": {}}) + return resp + + account.http.request = mock_request + + result = await account.request("token/device/list", {"type": "NONE"}) + + assert result == {"returnCode": 0, "data": {}} + params = request_kwargs.get("params") or {} + assert "noncestr" in params + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_request_includes_token_in_params(self, account) -> None: + """Test request includes token in params when present.""" + call_params = {} + + async def mock_request(method, url, **kwargs): + call_params.update(kwargs) + resp = MagicMock() + resp.json = AsyncMock(return_value={}) + return resp + + account.http.request = mock_request + await account.request("api", {"key": "val"}) + + params = call_params.get("params") or call_params.get("data") or {} + assert CONF_TOKEN in params + assert params[CONF_TOKEN] == "existing-token" + + +class TestAccountAsyncLogin: + """Tests for Account async_login.""" + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_login_success(self, account) -> None: + """Test async_login succeeds with valid token in response.""" + with patch.object(account, "request", new_callable=AsyncMock) as mock_request: + with patch.object( + account, "async_check_auth", new_callable=AsyncMock + ) as mock_check: + mock_request.return_value = { + "data": {"token": "new-token"}, + "returnCode": 0, + } + result = await account.async_login() + + assert result is True + assert account.token == "new-token" + mock_check.assert_called_once_with(True) + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_login_fails_no_token(self, account) -> None: + """Test async_login returns False when no token in response.""" + with patch.object(account, "request", new_callable=AsyncMock) as mock_request: + mock_request.return_value = {"data": {}, "returnCode": 0} + result = await account.async_login() + + assert result is False + assert account.token == "" + + +class TestAccountGetDevices: + """Tests for Account get_devices.""" + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_get_devices_returns_list(self, account) -> None: + """Test get_devices returns device list from API.""" + with patch.object(account, "request", new_callable=AsyncMock) as mock_request: + mock_request.return_value = { + "returnCode": 0, + "data": { + CONF_DEVICES: [ + {"id": "dev1", "deviceName": "Litter Box"}, + ] + }, + } + devices = await account.get_devices() + + assert devices == [{"id": "dev1", "deviceName": "Litter Box"}] + mock_request.assert_called_once() + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_get_devices_login_when_no_token( + self, hass, mock_http_session + ) -> None: + """Test get_devices calls async_login when token is empty.""" + config = { + CONF_PHONE_IAC: "86", + CONF_PHONE: "13812345678", + CONF_PASSWORD: "short", + } + acc = Account(hass, config) + acc._config[CONF_TOKEN] = None + + with patch.object(acc, "async_login", new_callable=AsyncMock) as mock_login: + mock_login.return_value = False + devices = await acc.get_devices() + + assert devices == [] + mock_login.assert_called_once() + + +class TestAccountGetCats: + """Tests for Account get_cats.""" + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_get_cats_returns_list(self, account) -> None: + """Test get_cats returns cats list from API.""" + with patch.object(account, "request", new_callable=AsyncMock) as mock_request: + mock_request.return_value = { + "returnCode": 0, + "data": {"cats": [{"id": "cat1", "petName": "Zulu"}]}, + } + cats = await account.get_cats("Europe/Belgrade") + + assert cats == [{"id": "cat1", "petName": "Zulu"}] + mock_request.assert_called_once() + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_get_cats_login_when_no_token( + self, hass, mock_http_session + ) -> None: + """Test get_cats calls async_login when token is empty.""" + config = { + CONF_PHONE_IAC: "86", + CONF_PHONE: "13812345678", + CONF_PASSWORD: "short", + } + acc = Account(hass, config) + acc._config[CONF_TOKEN] = None + + with patch.object(acc, "async_login", new_callable=AsyncMock) as mock_login: + mock_login.return_value = False + cats = await acc.get_cats("Europe/Belgrade") + + assert cats == [] + mock_login.assert_called_once() + + +class TestAccountGetCatSummarySimple: + """Tests for Account get_cat_summary_simple.""" + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_get_cat_summary_simple_returns_data(self, account) -> None: + """Test get_cat_summary_simple returns summary data.""" + with patch.object(account, "request", new_callable=AsyncMock) as mock_request: + mock_request.return_value = { + "returnCode": 0, + "data": {"statusDescription": "Good"}, + } + summary = await account.get_cat_summary_simple( + "169004", "2026-02-06", "Europe/Belgrade" + ) + + assert summary == {"statusDescription": "Good"} + mock_request.assert_called_once() + +class TestAccountAsyncCheckAuth: + """Tests for Account async_check_auth.""" + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_check_auth_save_stores_token(self, account) -> None: + """Test async_check_auth with save=True stores token to Store.""" + with patch( + "custom_components.catlink.modules.account.Store" + ) as mock_store_cls: + mock_store = MagicMock() + mock_store.async_load = AsyncMock(return_value={}) + mock_store.async_save = AsyncMock() + mock_store_cls.return_value = mock_store + + result = await account.async_check_auth(save=True) + + assert result[CONF_PHONE] == "13812345678" + assert result[CONF_TOKEN] == "existing-token" + mock_store.async_save.assert_called_once() + saved = mock_store.async_save.call_args[0][0] + assert saved[CONF_TOKEN] == "existing-token" + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_check_auth_load_restores_token_from_store( + self, account + ) -> None: + """Test async_check_auth loads token from Store when present.""" + with patch( + "custom_components.catlink.modules.account.Store" + ) as mock_store_cls: + mock_store = MagicMock() + mock_store.async_load = AsyncMock( + return_value={CONF_PHONE: "13812345678", CONF_TOKEN: "stored-token"} + ) + mock_store_cls.return_value = mock_store + + with patch.object( + account, "async_login", new_callable=AsyncMock + ) as mock_login: + result = await account.async_check_auth(save=False) + + assert account.token == "stored-token" + mock_login.assert_not_called() + assert result[CONF_TOKEN] == "stored-token" + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_check_auth_calls_login_when_store_empty( + self, account + ) -> None: + """Test async_check_auth calls async_login when Store has no token.""" + with patch( + "custom_components.catlink.modules.account.Store" + ) as mock_store_cls: + mock_store = MagicMock() + mock_store.async_load = AsyncMock(return_value={}) + mock_store_cls.return_value = mock_store + + with patch.object( + account, "async_login", new_callable=AsyncMock + ) as mock_login: + mock_login.return_value = True + await account.async_check_auth(save=False) + + mock_login.assert_called_once() + + +class TestAccountRequestErrors: + """Tests for Account request error handling.""" + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_request_returns_empty_on_connector_error(self, account) -> None: + """Test request returns empty dict on ClientConnectorError.""" + from aiohttp import ClientConnectorError + + async def mock_request_fail(*args, **kwargs): + raise ClientConnectorError(MagicMock(), OSError("Connection refused")) + + account.http.request = mock_request_fail + + result = await account.request("token/device/list") + + assert result == {} + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_request_returns_empty_on_timeout(self, account) -> None: + """Test request returns empty dict on TimeoutError.""" + async def mock_request_timeout(*args, **kwargs): + raise TimeoutError("Request timed out") + + account.http.request = mock_request_timeout + + result = await account.request("token/device/list") + + assert result == {} + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_request_returns_empty_when_json_is_none(self, account) -> None: + """Test request returns empty dict when response json is None.""" + async def mock_request_json_none(*args, **kwargs): + resp = MagicMock() + resp.json = AsyncMock(return_value=None) + return resp + + account.http.request = mock_request_json_none + + result = await account.request("token/device/list") + + assert result == {} diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py new file mode 100644 index 0000000..448d5af --- /dev/null +++ b/tests/test_config_flow.py @@ -0,0 +1,342 @@ +"""Tests for CatLink config flow.""" + +from unittest.mock import AsyncMock, patch + +from custom_components.catlink.config_flow import _device_label +from custom_components.catlink.const import ( + CONF_DEVICE_IDS, + CONF_PHONE, + CONF_PHONE_IAC, + CONF_UPDATE_INTERVAL, + DOMAIN, + ERROR_INVALID_AUTH, +) +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from homeassistant import data_entry_flow +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER +from homeassistant.core import HomeAssistant + + +class TestDeviceLabel: + """Tests for _device_label helper.""" + + def test_device_label_with_name_and_model(self) -> None: + """Test label when device has name and model.""" + dat = { + "deviceName": "Living Room", + "model": "LB599", + "deviceType": "LITTER_BOX_599", + } + assert _device_label(dat, True) == "Living Room (LB599) - Supported" + assert _device_label(dat, False) == "Living Room (LB599) - Limited support" + + def test_device_label_name_equals_model(self) -> None: + """Test label when name equals model uses deviceType.""" + dat = { + "deviceName": "LB599", + "model": "LB599", + "deviceType": "LITTER_BOX_599", + } + assert _device_label(dat, True) == "LB599 (LITTER_BOX_599) - Supported" + + def test_device_label_fallback_to_model(self) -> None: + """Test label falls back to model when deviceName missing.""" + dat = {"model": "LB599", "deviceType": "LITTER_BOX_599"} + assert _device_label(dat, True) == "LB599 (LITTER_BOX_599) - Supported" + + def test_device_label_fallback_to_unknown(self) -> None: + """Test label falls back to Unknown when name and model missing.""" + dat = {"deviceType": "UNKNOWN"} + assert _device_label(dat, True) == "Unknown (UNKNOWN) - Supported" + + +@pytest.fixture(autouse=True) +def mock_discover_region(): + """Mock discover_region to avoid real API calls.""" + with patch( + "custom_components.catlink.config_flow.discover_region", + new_callable=AsyncMock, + return_value="global", + ): + yield + + +@pytest.fixture +def mock_account(): + """Mock Account class.""" + with patch("custom_components.catlink.config_flow.Account") as mock: + instance = mock.return_value + instance.uid = "86-13812345678" + instance.async_check_auth = AsyncMock() + instance.get_devices = AsyncMock(return_value=[]) + yield mock + + +async def test_user_step_form(hass: HomeAssistant, enable_custom_integrations) -> None: + """Test the initial user step shows the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert "phone" in result["data_schema"].schema + assert "password" in result["data_schema"].schema + + +async def test_user_step_success_no_devices( + hass: HomeAssistant, enable_custom_integrations, mock_account +) -> None: + """Test successful flow with no devices.""" + mock_account.return_value.get_devices = AsyncMock(return_value=[]) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"phone": "+8613812345678", "password": "testpass"}, + ) + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == "+8613812345678" + assert result["data"]["phone_iac"] == "86" + assert result["data"]["phone"] == "13812345678" + assert result["data"]["api_base"] == "https://app.catlinks.cn/api/" + + +async def test_user_step_success_with_devices( + hass: HomeAssistant, enable_custom_integrations, mock_account +) -> None: + """Test successful flow with devices proceeds to discovery.""" + mock_account.return_value.get_devices = AsyncMock( + return_value=[ + { + "id": "dev1", + "deviceName": "Litter Box", + "model": "LB599", + "deviceType": "LITTER_BOX_599", + } + ] + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"phone": "+8613812345678", "password": "testpass"}, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "discovery" + + +async def test_user_step_invalid_auth( + hass: HomeAssistant, enable_custom_integrations +) -> None: + """Test flow shows error when auth fails.""" + with patch( + "custom_components.catlink.config_flow.discover_region", + new_callable=AsyncMock, + return_value=None, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"phone": "+8613812345678", "password": "wrongpass"}, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"]["base"] == ERROR_INVALID_AUTH + + +async def test_discovery_step_form( + hass: HomeAssistant, enable_custom_integrations, mock_account +) -> None: + """Test discovery step shows device selection and update interval.""" + mock_account.return_value.get_devices = AsyncMock( + return_value=[ + { + "id": "dev1", + "deviceName": "Litter Box", + "model": "LB599", + "deviceType": "LITTER_BOX_599", + }, + { + "id": "dev2", + "deviceName": "Feeder", + "model": "FD001", + "deviceType": "FEEDER", + }, + ] + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"phone": "+8613812345678", "password": "testpass"}, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "discovery" + assert CONF_DEVICE_IDS in result["data_schema"].schema + assert CONF_UPDATE_INTERVAL in result["data_schema"].schema + + +async def test_discovery_step_create_entry( + hass: HomeAssistant, enable_custom_integrations, mock_account +) -> None: + """Test discovery step creates entry with selected devices and interval.""" + mock_account.return_value.get_devices = AsyncMock( + return_value=[ + { + "id": "dev1", + "deviceName": "Litter Box", + "model": "LB599", + "deviceType": "LITTER_BOX_599", + }, + ] + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"phone": "+8613812345678", "password": "testpass"}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_DEVICE_IDS: ["dev1"], CONF_UPDATE_INTERVAL: 120}, + ) + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == "+8613812345678" + assert result["options"][CONF_DEVICE_IDS] == ["dev1"] + assert result["options"][CONF_UPDATE_INTERVAL] == 120 + + +async def test_reauth_flow( + hass: HomeAssistant, enable_custom_integrations, mock_account +) -> None: + """Test reauth flow updates entry with new credentials.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_PHONE_IAC: "86", + CONF_PHONE: "13812345678", + "api_base": "https://app.catlinks.cn/api/", + "password": "oldpass", + }, + unique_id="86-13812345678", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH, "entry_id": entry.entry_id}, + data=entry.data, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"phone": "+8613812345678", "password": "newpass"}, + ) + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert entry.data[CONF_PHONE] == "13812345678" + assert entry.data["password"] == "newpass" + + +async def test_reauth_flow_invalid_auth( + hass: HomeAssistant, enable_custom_integrations +) -> None: + """Test reauth flow shows error when auth fails.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_PHONE_IAC: "86", + CONF_PHONE: "13812345678", + "api_base": "https://app.catlinks.cn/api/", + "password": "oldpass", + }, + unique_id="86-13812345678", + ) + entry.add_to_hass(hass) + + with patch( + "custom_components.catlink.config_flow.discover_region", + new_callable=AsyncMock, + return_value=None, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH, "entry_id": entry.entry_id}, + data=entry.data, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"phone": "+8613812345678", "password": "wrongpass"}, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"]["base"] == ERROR_INVALID_AUTH + + +async def test_options_flow( + hass: HomeAssistant, enable_custom_integrations, mock_account +) -> None: + """Test options flow updates device selection and refresh interval.""" + mock_account.return_value.get_devices = AsyncMock( + return_value=[ + { + "id": "dev1", + "deviceName": "Litter Box", + "model": "LB599", + "deviceType": "LITTER_BOX_599", + }, + { + "id": "dev2", + "deviceName": "Feeder", + "model": "FD001", + "deviceType": "FEEDER", + }, + ] + ) + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_PHONE_IAC: "86", + CONF_PHONE: "13812345678", + "api_base": "https://app.catlinks.cn/api/", + "password": "testpass", + }, + options={CONF_DEVICE_IDS: ["dev1"], CONF_UPDATE_INTERVAL: 60}, + unique_id="86-13812345678", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {CONF_DEVICE_IDS: ["dev1", "dev2"], CONF_UPDATE_INTERVAL: 300}, + ) + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"][CONF_DEVICE_IDS] == ["dev1", "dev2"] + assert result["data"][CONF_UPDATE_INTERVAL] == 300 diff --git a/tests/test_devices.py b/tests/test_devices.py new file mode 100644 index 0000000..be9b40d --- /dev/null +++ b/tests/test_devices.py @@ -0,0 +1,952 @@ +"""Tests for CatLink device classes.""" + +from unittest.mock import AsyncMock, MagicMock + +from custom_components.catlink.devices.base import Device +from custom_components.catlink.devices.cat import CatDevice +from custom_components.catlink.devices.c08 import C08Device +from custom_components.catlink.devices.feeder import FeederDevice +from custom_components.catlink.devices.litterbox import LitterBox +from custom_components.catlink.devices.registry import create_device +from custom_components.catlink.devices.scooper import ScooperDevice +from custom_components.catlink.devices.scooper_pro_ultra import ScooperProUltraDevice +from custom_components.catlink.models.additional_cfg import AdditionalDeviceConfig +import pytest + + +@pytest.fixture +def mock_coordinator(): + """Create a mock DevicesCoordinator.""" + coordinator = MagicMock() + coordinator.account = MagicMock() + coordinator.account.uid = "86-13812345678" + return coordinator + + +@pytest.fixture +def sample_device_data(): + """Sample device data from API.""" + return { + "id": "dev123", + "mac": "AA:BB:CC:DD:EE:FF", + "model": "LB599", + "deviceName": "Living Room Litter", + "deviceType": "LITTER_BOX_599", + } + + +@pytest.fixture +def sample_feeder_data(): + """Sample feeder device data.""" + return { + "id": "feeder1", + "mac": "11:22:33:44:55:66", + "model": "Feeder Pro", + "deviceName": "Kitchen Feeder", + "deviceType": "FEEDER", + } + + +@pytest.fixture +def sample_scooper_data(): + """Sample scooper device data.""" + return { + "id": "scooper1", + "mac": "FF:EE:DD:CC:BB:AA", + "model": "Scooper C1", + "deviceName": "Basement Scooper", + "deviceType": "SCOOPER", + } + + +@pytest.fixture +def sample_c08_data(): + """Sample C08 device data.""" + return { + "id": "c08-1", + "mac": "01:23:45:67:89:AB", + "model": "Open-X", + "deviceName": "Bedroom C08", + "deviceType": "C08", + } + + +@pytest.fixture +def sample_pro_ultra_data(): + """Sample Scooper Pro Ultra device data.""" + return { + "id": "ultra1", + "mac": "44:72:AC:0D:89:37", + "model": "ScooperPROUltra", + "deviceName": "ScooperPROUltra", + "deviceType": "VISUAL_PRO_ULTRA", + } + + +@pytest.fixture +def sample_cat_data(): + """Sample cat device data.""" + return { + "id": "cat-169004", + "pet_id": "169004", + "petName": "Zulu", + "deviceName": "Zulu", + "deviceType": "CAT", + "mac": "cat-169004", + "model": "STREET CAT", + "gender": 3, + "weight": 4.7, + "summary_simple": { + "statusDescription": "Data collection in progress", + "toilet": {"times": 2, "weightAvg": 4.7, "peed": 1, "pood": 1}, + "drink": {"times": 1}, + "diet": {"times": 0, "intakes": "0"}, + "sport": {"activeDuration": 12}, + }, + } + + +class TestDevice: + """Tests for base Device class.""" + + def test_device_properties(self, mock_coordinator, sample_device_data) -> None: + """Test Device basic properties from data.""" + device = Device(sample_device_data, mock_coordinator) + assert device.id == "dev123" + assert device.mac == "AA:BB:CC:DD:EE:FF" + assert device.model == "LB599" + assert device.name == "Living Room Litter" + assert device.type == "LITTER_BOX_599" + + def test_device_error_from_detail( + self, mock_coordinator, sample_device_data + ) -> None: + """Test Device error property from detail.""" + device = Device(sample_device_data, mock_coordinator) + device.detail = {"currentMessage": "Device offline"} + assert device.error == "Device offline" + + def test_device_error_action_error_takes_precedence( + self, mock_coordinator, sample_device_data + ) -> None: + """Test _action_error overrides detail error.""" + device = Device(sample_device_data, mock_coordinator) + device.detail = {"currentMessage": "Old error"} + device._set_action_error("Protection is temporarily paused.") # noqa: SLF001 + assert device.error == "Protection is temporarily paused." + + def test_device_update_data(self, mock_coordinator, sample_device_data) -> None: + """Test update_data updates device data.""" + device = Device(sample_device_data, mock_coordinator) + new_data = {**sample_device_data, "deviceName": "Updated Name"} + device.update_data(new_data) + assert device.data == new_data + assert device.name == "Updated Name" + + +class TestDeviceRegistry: + """Tests for device registry create_device.""" + + def test_create_litterbox(self, mock_coordinator, sample_device_data) -> None: + """Test create_device returns LitterBox for LITTER_BOX_599.""" + device = create_device(sample_device_data, mock_coordinator) + assert isinstance(device, LitterBox) + assert device.id == "dev123" + + def test_create_feeder(self, mock_coordinator, sample_feeder_data) -> None: + """Test create_device returns FeederDevice for FEEDER.""" + device = create_device(sample_feeder_data, mock_coordinator) + assert isinstance(device, FeederDevice) + assert device.name == "Kitchen Feeder" + + def test_create_scooper(self, mock_coordinator, sample_scooper_data) -> None: + """Test create_device returns ScooperDevice for SCOOPER.""" + device = create_device(sample_scooper_data, mock_coordinator) + assert isinstance(device, ScooperDevice) + assert device.name == "Basement Scooper" + + def test_create_c08(self, mock_coordinator, sample_c08_data) -> None: + """Test create_device returns C08Device for C08.""" + device = create_device(sample_c08_data, mock_coordinator) + assert isinstance(device, C08Device) + assert device.name == "Bedroom C08" + + def test_create_scooper_pro_ultra( + self, mock_coordinator, sample_pro_ultra_data + ) -> None: + """Test create_device returns ScooperProUltraDevice for VISUAL_PRO_ULTRA.""" + device = create_device(sample_pro_ultra_data, mock_coordinator) + assert isinstance(device, ScooperProUltraDevice) + + def test_create_cat_device( + self, mock_coordinator, sample_cat_data + ) -> None: + """Test create_device returns CatDevice for CAT.""" + device = create_device(sample_cat_data, mock_coordinator) + assert isinstance(device, CatDevice) + assert device.name == "Zulu" + + def test_create_unknown_type_falls_back_to_base(self, mock_coordinator) -> None: + """Test unknown device type uses base Device class.""" + data = { + "id": "unknown1", + "deviceType": "UNKNOWN_TYPE", + "deviceName": "Unknown", + } + device = create_device(data, mock_coordinator) + assert isinstance(device, Device) + assert not isinstance(device, LitterBox) + assert device.type == "UNKNOWN_TYPE" + + def test_create_with_additional_config( + self, mock_coordinator, sample_device_data + ) -> None: + """Test create_device with AdditionalDeviceConfig.""" + config = AdditionalDeviceConfig( + mac="AA:BB:CC:DD:EE:FF", + empty_weight=1.5, + max_samples_litter=12, + ) + device = create_device( + sample_device_data, mock_coordinator, additional_config=config + ) + assert device.additional_config.mac == "AA:BB:CC:DD:EE:FF" + assert device.additional_config.empty_weight == 1.5 + + +class TestLitterBox: + """Tests for LitterBox device.""" + + def test_modes_property(self, mock_coordinator, sample_device_data) -> None: + """Test LitterBox modes.""" + device = LitterBox(sample_device_data, mock_coordinator) + modes = device.modes + assert modes["00"] == "auto" + assert modes["01"] == "manual" + assert modes["02"] == "time" + + def test_actions_property(self, mock_coordinator, sample_device_data) -> None: + """Test LitterBox actions.""" + device = LitterBox(sample_device_data, mock_coordinator) + actions = device.actions + assert "01" in actions + assert "00" in actions + assert "Cleaning" in actions.values() + assert "Pause" in actions.values() + + def test_box_full_levels(self, mock_coordinator, sample_device_data) -> None: + """Test LitterBox box full sensitivity levels.""" + device = LitterBox(sample_device_data, mock_coordinator) + levels = device.box_full_levels + assert "LEVEL_01" in levels + assert levels["LEVEL_01"] == "Level 1" + + def test_error_default_normal_operation( + self, mock_coordinator, sample_device_data + ) -> None: + """Test LitterBox error defaults to Normal Operation.""" + device = LitterBox(sample_device_data, mock_coordinator) + device.detail = {} + assert device.error == "Normal Operation" + + def test_error_from_detail(self, mock_coordinator, sample_device_data) -> None: + """Test LitterBox error from detail.""" + device = LitterBox(sample_device_data, mock_coordinator) + device.detail = {"currentError": "Litter full"} + assert device.error == "Litter full" + + def test_litter_remaining_days(self, mock_coordinator, sample_device_data) -> None: + """Test LitterBox litter_remaining_days.""" + device = LitterBox(sample_device_data, mock_coordinator) + device.detail = {"litterCountdown": 5} + assert device.litter_remaining_days == 5 + + def test_knob_status_cleaning_mode( + self, mock_coordinator, sample_device_data + ) -> None: + """Test LitterBox knob_status when no knob error.""" + device = LitterBox(sample_device_data, mock_coordinator) + device.detail = {"deviceErrorList": []} + assert device.knob_status == "Cleaning Mode" + + def test_knob_status_empty_mode(self, mock_coordinator, sample_device_data) -> None: + """Test LitterBox knob_status when left_knob_abnormal.""" + device = LitterBox(sample_device_data, mock_coordinator) + device.detail = {"deviceErrorList": [{"errkey": "left_knob_abnormal"}]} + assert device.knob_status == "Empty Mode" + + def test_garbage_actions(self, mock_coordinator, sample_device_data) -> None: + """Test LitterBox garbage_actions property.""" + device = LitterBox(sample_device_data, mock_coordinator) + actions = device.garbage_actions + assert actions["00"] == "Change Bag" + assert actions["01"] == "Reset" + + def test_last_sync_with_timestamp( + self, mock_coordinator, sample_device_data + ) -> None: + """Test LitterBox last_sync formats timestamp.""" + device = LitterBox(sample_device_data, mock_coordinator) + device.detail = {"lastHeartBeatTimestamp": 1700000000000} + result = device.last_sync + assert result is not None + assert "2023" in result or "2024" in result + + def test_last_sync_no_timestamp(self, mock_coordinator, sample_device_data) -> None: + """Test LitterBox last_sync returns None when no timestamp.""" + device = LitterBox(sample_device_data, mock_coordinator) + device.detail = {} + assert device.last_sync is None + + def test_garbage_tobe_status_normal( + self, mock_coordinator, sample_device_data + ) -> None: + """Test LitterBox garbage_tobe_status when no full error.""" + device = LitterBox(sample_device_data, mock_coordinator) + device.detail = {"deviceErrorList": []} + assert device.garbage_tobe_status == "Normal" + + def test_garbage_tobe_status_full( + self, mock_coordinator, sample_device_data + ) -> None: + """Test LitterBox garbage_tobe_status when garbage full.""" + device = LitterBox(sample_device_data, mock_coordinator) + device.detail = {"deviceErrorList": [{"errkey": "garbage_tobe_full_abnormal"}]} + assert device.garbage_tobe_status == "Full" + + def test_garbage_attrs(self, mock_coordinator, sample_device_data) -> None: + """Test LitterBox garbage_attrs maps garbageStatus.""" + device = LitterBox(sample_device_data, mock_coordinator) + device.detail = {"garbageStatus": "00"} + assert device.garbage_attrs()["status"] == "Normal" + + device.detail = {"garbageStatus": "02"} + assert device.garbage_attrs()["status"] == "Movement Started" + + device.detail = {"garbageStatus": "03"} + assert device.garbage_attrs()["status"] == "Moving" + + def test_error_attrs(self, mock_coordinator, sample_device_data) -> None: + """Test LitterBox error_attrs returns deviceErrorList.""" + device = LitterBox(sample_device_data, mock_coordinator) + device.detail = {"deviceErrorList": [{"errkey": "test"}]} + attrs = device.error_attrs() + assert "errors" in attrs + assert attrs["errors"] == [{"errkey": "test"}] + + def test_box_full_sensitivity_mapped( + self, mock_coordinator, sample_device_data + ) -> None: + """Test LitterBox box_full_sensitivity maps level to label.""" + device = LitterBox(sample_device_data, mock_coordinator) + device.detail = {"boxFullSensitivity": "LEVEL_01"} + assert device.box_full_sensitivity == "Level 1" + + def test_box_full_sensitivity_attrs( + self, mock_coordinator, sample_device_data + ) -> None: + """Test LitterBox box_full_sensitivity_attrs.""" + device = LitterBox(sample_device_data, mock_coordinator) + device.detail = {"boxFullSensitivity": "LEVEL_02"} + attrs = device.box_full_sensitivity_attrs() + assert attrs["raw_level"] == "LEVEL_02" + + def test_hass_sensor_structure(self, mock_coordinator, sample_device_data) -> None: + """Test LitterBox hass_sensor contains expected keys.""" + device = LitterBox(sample_device_data, mock_coordinator) + device.detail = {"currentError": "Normal"} + device.logs = [] + sensor = device.hass_sensor + assert "state" in sensor + assert "error" in sensor + assert "last_log" in sensor + assert "garbage_tobe_status" in sensor + assert "litter_weight" in sensor + assert "litter_remaining_days" in sensor + + def test_hass_select_structure(self, mock_coordinator, sample_device_data) -> None: + """Test LitterBox hass_select contains expected keys.""" + device = LitterBox(sample_device_data, mock_coordinator) + select = device.hass_select + assert "mode" in select + assert "action" in select + assert "garbage" in select + assert "box_full_sensitivity" in select + assert select["mode"]["options"] == ["auto", "manual", "time"] + + +class TestLitterBoxAsyncMethods: + """Tests for LitterBox async methods.""" + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_select_mode_success( + self, mock_coordinator, sample_device_data + ) -> None: + """Test select_mode succeeds and updates device detail.""" + device = LitterBox(sample_device_data, mock_coordinator) + device.detail = {} + mock_coordinator.account.request = AsyncMock( + return_value={"returnCode": 0, "data": {}} + ) + device.update_device_detail = AsyncMock(return_value={}) + + result = await device.select_mode("auto") + + assert result is not False + mock_coordinator.account.request.assert_called_once() + call_args = mock_coordinator.account.request.call_args + assert call_args[0][0] == "token/litterbox/changeMode" + assert call_args[0][1]["workModel"] == "00" + assert call_args[0][1]["deviceId"] == "dev123" + device.update_device_detail.assert_called_once() + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_select_mode_invalid_returns_false( + self, mock_coordinator, sample_device_data + ) -> None: + """Test select_mode returns False for invalid mode.""" + device = LitterBox(sample_device_data, mock_coordinator) + result = await device.select_mode("invalid_mode") + assert result is False + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_select_mode_api_error_sets_action_error( + self, mock_coordinator, sample_device_data + ) -> None: + """Test select_mode sets action error on API failure.""" + device = LitterBox(sample_device_data, mock_coordinator) + mock_coordinator.account.request = AsyncMock( + return_value={"returnCode": 4007, "msg": "Device busy"} + ) + + result = await device.select_mode("auto") + + assert result is False + assert "Device busy" in device.error + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_select_action_success( + self, mock_coordinator, sample_device_data + ) -> None: + """Test select_action succeeds for Cleaning.""" + device = LitterBox(sample_device_data, mock_coordinator) + mock_coordinator.account.request = AsyncMock(return_value={"returnCode": 0}) + device.update_device_detail = AsyncMock(return_value={}) + + result = await device.select_action("Cleaning") + + assert result is not False + mock_coordinator.account.request.assert_called_once() + assert mock_coordinator.account.request.call_args[0][1]["cmd"] == "01" + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_select_action_garbage_bag_calls_change_bag( + self, mock_coordinator, sample_device_data + ) -> None: + """Test select_action with Garbage Bag delegates to changeBag.""" + device = LitterBox(sample_device_data, mock_coordinator) + device.changeBag = AsyncMock(return_value=True) + + result = await device.select_action("Change Garbage Bag") + + assert result is True + device.changeBag.assert_called_once() + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_change_bag_success( + self, mock_coordinator, sample_device_data + ) -> None: + """Test changeBag succeeds with Change Bag mode.""" + device = LitterBox(sample_device_data, mock_coordinator) + mock_coordinator.account.request = AsyncMock(return_value={"returnCode": 0}) + device.update_device_detail = AsyncMock(return_value={}) + + result = await device.changeBag("Change Bag") + + assert result is not False + mock_coordinator.account.request.assert_called_once() + assert mock_coordinator.account.request.call_args[0][1]["enable"] == "1" + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_select_box_full_sensitivity_success( + self, mock_coordinator, sample_device_data + ) -> None: + """Test select_box_full_sensitivity succeeds.""" + device = LitterBox(sample_device_data, mock_coordinator) + mock_coordinator.account.request = AsyncMock(return_value={"returnCode": 0}) + device.update_device_detail = AsyncMock(return_value={}) + + result = await device.select_box_full_sensitivity("Level 1") + + assert result is not False + mock_coordinator.account.request.assert_called_once() + assert mock_coordinator.account.request.call_args[0][1]["level"] == "LEVEL_01" + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_select_box_full_sensitivity_invalid_returns_false( + self, mock_coordinator, sample_device_data + ) -> None: + """Test select_box_full_sensitivity returns False for invalid level.""" + device = LitterBox(sample_device_data, mock_coordinator) + result = await device.select_box_full_sensitivity("Invalid Level") + assert result is False + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_reset_litter_success( + self, mock_coordinator, sample_device_data + ) -> None: + """Test async_reset_litter calls consumableReset with CAT_LITTER.""" + device = LitterBox(sample_device_data, mock_coordinator) + mock_coordinator.account.request = AsyncMock(return_value={"returnCode": 0}) + device.update_device_detail = AsyncMock(return_value={}) + + result = await device.async_reset_litter() + + assert result is not False + mock_coordinator.account.request.assert_called_once() + call_args = mock_coordinator.account.request.call_args + assert call_args[0][0] == "token/device/union/consumableReset" + assert call_args[0][1]["consumablesType"] == "CAT_LITTER" + assert call_args[0][1]["deviceId"] == "dev123" + assert call_args[0][1]["deviceType"] == "LITTER_BOX_599" + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_reset_deodorant_success( + self, mock_coordinator, sample_device_data + ) -> None: + """Test async_reset_deodorant calls consumableReset with DEODORIZER_02.""" + device = LitterBox(sample_device_data, mock_coordinator) + mock_coordinator.account.request = AsyncMock(return_value={"returnCode": 0}) + device.update_device_detail = AsyncMock(return_value={}) + + result = await device.async_reset_deodorant() + + assert result is not False + call_args = mock_coordinator.account.request.call_args + assert call_args[0][1]["consumablesType"] == "DEODORIZER_02" + + def test_hass_button_structure(self, mock_coordinator, sample_device_data) -> None: + """Test LitterBox hass_button contains reset_litter and reset_deodorant.""" + device = LitterBox(sample_device_data, mock_coordinator) + buttons = device.hass_button + assert "reset_litter" in buttons + assert "reset_deodorant" in buttons + assert buttons["reset_litter"]["name"] == "Reset litter" + assert buttons["reset_deodorant"]["name"] == "Reset deodorant" + + +class TestC08Device: + """Tests for C08Device.""" + + def test_modes_property(self, mock_coordinator, sample_c08_data) -> None: + """Test C08Device modes.""" + device = C08Device(sample_c08_data, mock_coordinator) + modes = device.modes + assert modes["00"] == "auto" + assert modes["01"] == "manual" + assert modes["02"] == "scheduled" + + def test_litter_type(self, mock_coordinator, sample_c08_data) -> None: + """Test C08Device litter type mapping.""" + device = C08Device(sample_c08_data, mock_coordinator) + device.detail = {"litterType": "00"} + assert device.litter_type == "Bentonite" + + def test_safe_time(self, mock_coordinator, sample_c08_data) -> None: + """Test C08Device safe time mapping.""" + device = C08Device(sample_c08_data, mock_coordinator) + device.detail = {"safeTime": "5"} + assert device.safe_time == "5 min" + + def test_notice_switch_mapping(self, mock_coordinator, sample_c08_data) -> None: + """Test notice switches map to notice configs.""" + device = C08Device(sample_c08_data, mock_coordinator) + device.set_notice_configs( + [{"noticeItem": "LITTERBOX_599_CAT_CAME", "noticeSwitch": True}] + ) + assert device.notice_cat_came is True + assert device.notice_box_full is False + + def test_hass_switch_contains_notice(self, mock_coordinator, sample_c08_data) -> None: + """Test C08Device hass_switch includes notice entries.""" + device = C08Device(sample_c08_data, mock_coordinator) + switches = device.hass_switch + assert "notice_cat_came" in switches + assert switches["notice_cat_came"]["name"] == "Notice: Cat came" + + def test_hass_select_structure(self, mock_coordinator, sample_c08_data) -> None: + """Test C08Device hass_select contains expected keys.""" + device = C08Device(sample_c08_data, mock_coordinator) + selects = device.hass_select + assert "mode" in selects + assert "action" in selects + assert "litter_type" in selects + assert "safe_time" in selects + + +class TestC08DeviceAsyncMethods: + """Tests for C08Device async methods.""" + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_update_device_detail_c08_endpoint( + self, mock_coordinator, sample_c08_data + ) -> None: + """Test update_device_detail uses C08 info endpoint.""" + device = C08Device(sample_c08_data, mock_coordinator) + mock_coordinator.account.request = AsyncMock( + return_value={"data": {"deviceInfo": {"workStatus": "00"}}} + ) + device.async_refresh_c08_extras = AsyncMock() + + result = await device.update_device_detail() + + assert result["workStatus"] == "00" + mock_coordinator.account.request.assert_called_once_with( + "token/litterbox/info/c08", {"deviceId": "c08-1"} + ) + device.async_refresh_c08_extras.assert_called_once() + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_select_action_v3( + self, mock_coordinator, sample_c08_data + ) -> None: + """Test select_action uses v3 command payload.""" + device = C08Device(sample_c08_data, mock_coordinator) + mock_coordinator.account.request = AsyncMock(return_value={"returnCode": 0}) + device.update_device_detail = AsyncMock(return_value={}) + + result = await device.select_action("Clean: start") + + assert result is True + call_args = mock_coordinator.account.request.call_args + assert call_args[0][0] == "token/litterbox/actionCmd/v3" + assert call_args[0][1]["action"] == "RUN" + assert call_args[0][1]["behavior"] == "CLEAN" + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_select_litter_type( + self, mock_coordinator, sample_c08_data + ) -> None: + """Test select_litter_type sends litterType setting.""" + device = C08Device(sample_c08_data, mock_coordinator) + mock_coordinator.account.request = AsyncMock(return_value={"returnCode": 0}) + device.update_device_detail = AsyncMock(return_value={}) + + result = await device.select_litter_type("Bentonite") + + assert result is True + call_args = mock_coordinator.account.request.call_args + assert call_args[0][0] == "token/litterbox/catLitterSetting" + assert call_args[0][1]["litterType"] == "00" + + +class TestFeederDevice: + """Tests for FeederDevice.""" + + def test_feeder_properties(self, mock_coordinator, sample_feeder_data) -> None: + """Test FeederDevice basic properties.""" + device = FeederDevice(sample_feeder_data, mock_coordinator) + assert device.id == "feeder1" + assert device.name == "Kitchen Feeder" + assert device.type == "FEEDER" + + def test_feeder_weight(self, mock_coordinator, sample_feeder_data) -> None: + """Test FeederDevice weight property.""" + device = FeederDevice(sample_feeder_data, mock_coordinator) + device.detail = {"weight": 250} + assert device.weight == 250 + + def test_feeder_error(self, mock_coordinator, sample_feeder_data) -> None: + """Test FeederDevice error from detail.""" + device = FeederDevice(sample_feeder_data, mock_coordinator) + device.detail = {"error": "Low food level"} + assert device.error == "Low food level" + + def test_feeder_error_attrs(self, mock_coordinator, sample_feeder_data) -> None: + """Test FeederDevice error_attrs.""" + device = FeederDevice(sample_feeder_data, mock_coordinator) + device.detail = { + "currentErrorMessage": "Check sensor", + "currentErrorType": "sensor", + } + attrs = device.error_attrs() + assert attrs["currentErrorMessage"] == "Check sensor" + assert attrs["currentErrorType"] == "sensor" + + def test_feeder_state(self, mock_coordinator, sample_feeder_data) -> None: + """Test FeederDevice state from foodOutStatus.""" + device = FeederDevice(sample_feeder_data, mock_coordinator) + device.detail = {"foodOutStatus": "idle"} + assert device.state == "idle" + + def test_feeder_state_attrs(self, mock_coordinator, sample_feeder_data) -> None: + """Test FeederDevice state_attrs.""" + device = FeederDevice(sample_feeder_data, mock_coordinator) + device.detail = { + "foodOutStatus": "feeding", + "autoFillStatus": "ok", + "keyLockStatus": "unlocked", + } + attrs = device.state_attrs() + assert attrs["work_status"] == "feeding" + assert attrs["auto_fill_status"] == "ok" + assert attrs["key_lock_status"] == "unlocked" + + def test_feeder_hass_sensor(self, mock_coordinator, sample_feeder_data) -> None: + """Test FeederDevice hass_sensor structure.""" + device = FeederDevice(sample_feeder_data, mock_coordinator) + device.logs = [] + sensor = device.hass_sensor + assert "state" in sensor + assert "weight" in sensor + assert "error" in sensor + assert "last_log" in sensor + + def test_feeder_hass_button(self, mock_coordinator, sample_feeder_data) -> None: + """Test FeederDevice hass_button has feed action.""" + device = FeederDevice(sample_feeder_data, mock_coordinator) + button = device.hass_button + assert "feed" in button + assert button["feed"]["async_press"] == device.food_out + + +class TestFeederDeviceAsyncMethods: + """Tests for FeederDevice async methods.""" + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_food_out_success(self, mock_coordinator, sample_feeder_data) -> None: + """Test food_out succeeds and updates device detail.""" + device = FeederDevice(sample_feeder_data, mock_coordinator) + mock_coordinator.account.request = AsyncMock(return_value={"returnCode": 0}) + device.update_device_detail = AsyncMock(return_value={}) + + result = await device.food_out() + + assert result is not False + mock_coordinator.account.request.assert_called_once() + call_args = mock_coordinator.account.request.call_args + assert call_args[0][0] == "token/device/feeder/foodOut" + assert call_args[0][1]["footOutNum"] == 5 + assert call_args[0][1]["deviceId"] == "feeder1" + device.update_device_detail.assert_called_once() + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_food_out_api_error_sets_action_error( + self, mock_coordinator, sample_feeder_data + ) -> None: + """Test food_out sets action error on API failure.""" + device = FeederDevice(sample_feeder_data, mock_coordinator) + mock_coordinator.account.request = AsyncMock( + return_value={"returnCode": 500, "msg": "Device busy"} + ) + + result = await device.food_out() + + assert result is False + assert "Device busy" in (device._action_error or "") # noqa: SLF001 + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_update_device_detail_success( + self, mock_coordinator, sample_feeder_data + ) -> None: + """Test FeederDevice update_device_detail parses response.""" + device = FeederDevice(sample_feeder_data, mock_coordinator) + device._handle_listeners = MagicMock() # noqa: SLF001 + mock_coordinator.account.request = AsyncMock( + return_value={ + "data": { + "deviceInfo": { + "foodOutStatus": "idle", + "weight": 250, + } + } + } + ) + + result = await device.update_device_detail() + + assert result["foodOutStatus"] == "idle" + assert result["weight"] == 250 + assert device.detail == result + device._handle_listeners.assert_called_once() # noqa: SLF001 + + +class TestScooperDeviceAsyncMethods: + """Tests for ScooperDevice async methods.""" + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_update_logs_fetches_from_api( + self, mock_coordinator, sample_scooper_data + ) -> None: + """Test ScooperDevice update_logs calls correct API.""" + device = ScooperDevice(sample_scooper_data, mock_coordinator) + device._handle_listeners = MagicMock() # noqa: SLF001 + mock_coordinator.account.request = AsyncMock( + return_value={ + "data": { + "scooperLogTop5": [ + {"time": "10:00", "event": "Cleaning"}, + ] + } + } + ) + + result = await device.update_logs() + + assert len(result) == 1 + mock_coordinator.account.request.assert_called_once_with( + "token/device/scooper/stats/log/top5", {"deviceId": "scooper1"} + ) + + +class TestScooperProUltraDevice: + """Tests for ScooperProUltraDevice.""" + + def test_name_appends_limited_support( + self, mock_coordinator, sample_pro_ultra_data + ) -> None: + """Test ScooperProUltraDevice name appends limited support suffix.""" + device = ScooperProUltraDevice(sample_pro_ultra_data, mock_coordinator) + assert device.name.endswith(" (Limited Support)") + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_update_device_detail_brief_info( + self, mock_coordinator, sample_pro_ultra_data + ) -> None: + """Test update_device_detail uses visualScooper briefInfo.""" + device = ScooperProUltraDevice(sample_pro_ultra_data, mock_coordinator) + mock_coordinator.account.request = AsyncMock( + return_value={ + "data": { + "deviceInfo": { + "litterCountdown": 14, + "deodorantCountdown": 0, + "totalCleanTimes": "3", + } + } + } + ) + + result = await device.update_device_detail() + + assert result["litterCountdown"] == 14 + assert result["totalCleanTimes"] == "3" + mock_coordinator.account.request.assert_called_once() + assert ( + mock_coordinator.account.request.call_args[0][0] + == "token/visualScooper/briefInfo" + ) + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_update_logs_timeline_v2( + self, mock_coordinator, sample_pro_ultra_data + ) -> None: + """Test update_logs uses timeline/v2 endpoint.""" + device = ScooperProUltraDevice(sample_pro_ultra_data, mock_coordinator) + mock_coordinator.account.request = AsyncMock( + return_value={ + "data": { + "records": [ + {"time": "11:05", "event": "Auto-clean"}, + ] + } + } + ) + + result = await device.update_logs() + + assert result[0]["event"] == "Auto-clean" + assert ( + mock_coordinator.account.request.call_args[0][0] + == "token/litterbox/stats/log/timeline/v2" + ) + + +class TestScooperDevice: + """Tests for ScooperDevice.""" + + def test_scooper_properties(self, mock_coordinator, sample_scooper_data) -> None: + """Test ScooperDevice basic properties.""" + device = ScooperDevice(sample_scooper_data, mock_coordinator) + assert device.id == "scooper1" + assert device.name == "Basement Scooper" + assert device.type == "SCOOPER" + + def test_scooper_modes(self, mock_coordinator, sample_scooper_data) -> None: + """Test ScooperDevice modes include empty mode.""" + device = ScooperDevice(sample_scooper_data, mock_coordinator) + modes = device.modes + assert modes["00"] == "auto" + assert modes["01"] == "manual" + assert modes["02"] == "time" + assert modes["03"] == "empty" + + def test_scooper_actions(self, mock_coordinator, sample_scooper_data) -> None: + """Test ScooperDevice actions.""" + device = ScooperDevice(sample_scooper_data, mock_coordinator) + actions = device.actions + assert actions["00"] == "pause" + assert actions["01"] == "start" + + def test_scooper_temperature(self, mock_coordinator, sample_scooper_data) -> None: + """Test ScooperDevice temperature.""" + device = ScooperDevice(sample_scooper_data, mock_coordinator) + device.detail = {"temperature": "25"} + assert device.temperature == "25" + + def test_scooper_temperature_default( + self, mock_coordinator, sample_scooper_data + ) -> None: + """Test ScooperDevice temperature defaults to -.""" + device = ScooperDevice(sample_scooper_data, mock_coordinator) + device.detail = {} + assert device.temperature == "-" + + def test_scooper_humidity(self, mock_coordinator, sample_scooper_data) -> None: + """Test ScooperDevice humidity.""" + device = ScooperDevice(sample_scooper_data, mock_coordinator) + device.detail = {"humidity": "45"} + assert device.humidity == "45" + + def test_scooper_error_from_current_message( + self, mock_coordinator, sample_scooper_data + ) -> None: + """Test ScooperDevice error from currentMessage.""" + device = ScooperDevice(sample_scooper_data, mock_coordinator) + device.detail = {"currentMessage": "Sensor fault"} + assert device.error == "Sensor fault" + + def test_scooper_error_from_data( + self, mock_coordinator, sample_scooper_data + ) -> None: + """Test ScooperDevice error from data currentErrorMessage.""" + device = ScooperDevice(sample_scooper_data, mock_coordinator) + device.detail = {} + device.data = {"currentErrorMessage": "Connection lost"} + assert device.error == "Connection lost" + + def test_scooper_error_action_takes_precedence( + self, mock_coordinator, sample_scooper_data + ) -> None: + """Test ScooperDevice _action_error overrides.""" + device = ScooperDevice(sample_scooper_data, mock_coordinator) + device.detail = {"currentMessage": "Old"} + device._set_action_error("New error") # noqa: SLF001 + assert device.error == "New error" + + def test_scooper_error_attrs(self, mock_coordinator, sample_scooper_data) -> None: + """Test ScooperDevice error_attrs contains error_logs.""" + device = ScooperDevice(sample_scooper_data, mock_coordinator) + attrs = device.error_attrs() + assert "error_logs" in attrs + assert isinstance(attrs["error_logs"], list) + + def test_scooper_hass_sensor(self, mock_coordinator, sample_scooper_data) -> None: + """Test ScooperDevice hass_sensor structure.""" + device = ScooperDevice(sample_scooper_data, mock_coordinator) + sensor = device.hass_sensor + assert "state" in sensor + assert "litter_weight" in sensor + assert "temperature" in sensor + assert "humidity" in sensor + assert "error" in sensor diff --git a/tests/test_devices_coordinator.py b/tests/test_devices_coordinator.py new file mode 100644 index 0000000..c3d96c7 --- /dev/null +++ b/tests/test_devices_coordinator.py @@ -0,0 +1,284 @@ +"""Tests for CatLink DevicesCoordinator module.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from custom_components.catlink.const import DOMAIN +from custom_components.catlink.modules.devices_coordinator import DevicesCoordinator +from custom_components.catlink.modules.account import Account + + +@pytest.fixture +def coordinator_hass_data(hass): + """Set up hass.data structure required by DevicesCoordinator.""" + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN]["config"] = {"devices": []} + hass.data[DOMAIN]["devices"] = {} + hass.data[DOMAIN]["add_entities"] = {} + return hass.data[DOMAIN] + + +@pytest.fixture +def mock_account(hass, coordinator_hass_data): + """Create mock Account for coordinator.""" + account = MagicMock(spec=Account) + account.hass = hass + account.uid = "86-13812345678" + account.update_interval = __import__("datetime").timedelta(minutes=1) + return account + + +@pytest.fixture +def coordinator(mock_account, coordinator_hass_data): + """Create DevicesCoordinator instance.""" + return DevicesCoordinator( + mock_account, + config_entry_id="test-entry-123", + device_ids=None, + ) + + +class TestDevicesCoordinatorInit: + """Tests for DevicesCoordinator initialization.""" + + def test_init_sets_account(self, coordinator, mock_account) -> None: + """Test coordinator stores account reference.""" + assert coordinator.account == mock_account + + def test_init_sets_config_entry_id(self, coordinator) -> None: + """Test coordinator stores config entry id.""" + assert coordinator.config_entry_id == "test-entry-123" + + def test_init_sets_name(self, coordinator, mock_account) -> None: + """Test coordinator name includes domain and uid.""" + assert coordinator.name == f"{DOMAIN}-{mock_account.uid}-devices" + + def test_init_parses_additional_config( + self, mock_account, coordinator_hass_data + ) -> None: + """Test coordinator parses additional device config.""" + coordinator_hass_data["config"]["devices"] = [ + {"mac": "AA:BB:CC:DD:EE:FF", "empty_weight": 1.5}, + ] + coord = DevicesCoordinator(mock_account, "entry-1") + assert len(coord.additional_config) == 1 + assert coord.additional_config[0].mac == "AA:BB:CC:DD:EE:FF" + assert coord.additional_config[0].empty_weight == 1.5 + + +class TestDevicesCoordinatorAsyncUpdateData: + """Tests for DevicesCoordinator _async_update_data.""" + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_update_data_creates_devices( + self, coordinator, mock_account, coordinator_hass_data + ) -> None: + """Test _async_update_data creates devices from API response.""" + mock_account.get_devices = AsyncMock( + return_value=[ + { + "id": "dev1", + "mac": "AA:BB:CC:DD:EE:FF", + "model": "LB599", + "deviceName": "Litter Box", + "deviceType": "LITTER_BOX_599", + }, + ] + ) + + with patch( + "custom_components.catlink.modules.devices_coordinator.create_device" + ) as mock_create: + mock_device = MagicMock() + mock_device.id = "dev1" + mock_device.name = "Litter Box" + mock_device.update_data = MagicMock() + mock_device.async_init = AsyncMock() + mock_create.return_value = mock_device + + result = await coordinator._async_update_data() + + assert "dev1" in result + assert result["dev1"] == mock_device + mock_create.assert_called_once() + mock_device.async_init.assert_called_once() + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_update_data_updates_existing_device( + self, coordinator, mock_account, coordinator_hass_data + ) -> None: + """Test _async_update_data updates existing device instead of creating new.""" + mock_account.get_devices = AsyncMock( + return_value=[ + { + "id": "dev1", + "mac": "AA:BB:CC:DD:EE:FF", + "deviceName": "Updated Name", + "deviceType": "LITTER_BOX_599", + }, + ] + ) + + existing_device = MagicMock() + existing_device.id = "dev1" + existing_device.name = "Old Name" + existing_device.update_data = MagicMock() + existing_device.async_init = AsyncMock() + coordinator_hass_data["devices"]["dev1"] = existing_device + + with patch( + "custom_components.catlink.modules.devices_coordinator.create_device" + ) as mock_create: + result = await coordinator._async_update_data() + + assert result["dev1"] == existing_device + existing_device.update_data.assert_called_once() + mock_create.assert_not_called() + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_update_data_filters_by_device_ids( + self, mock_account, coordinator_hass_data + ) -> None: + """Test _async_update_data filters devices when device_ids is set.""" + coordinator = DevicesCoordinator( + mock_account, + config_entry_id="entry-1", + device_ids=["dev2"], + ) + + mock_account.get_devices = AsyncMock( + return_value=[ + {"id": "dev1", "deviceType": "LITTER_BOX_599"}, + {"id": "dev2", "deviceType": "LITTER_BOX_599"}, + ] + ) + + with patch( + "custom_components.catlink.modules.devices_coordinator.create_device" + ) as mock_create: + mock_device = MagicMock() + mock_device.id = "dev2" + mock_device.async_init = AsyncMock() + mock_create.return_value = mock_device + + result = await coordinator._async_update_data() + + assert "dev1" not in result + assert "dev2" in result + assert mock_create.call_count == 1 + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_update_data_skips_device_without_id( + self, coordinator, mock_account, coordinator_hass_data + ) -> None: + """Test _async_update_data skips devices without id.""" + mock_account.get_devices = AsyncMock( + return_value=[ + {"id": "", "deviceType": "LITTER_BOX_599"}, + {"deviceType": "LITTER_BOX_599"}, + ] + ) + + with patch( + "custom_components.catlink.modules.devices_coordinator.create_device" + ) as mock_create: + result = await coordinator._async_update_data() + + assert len(result) == 0 + mock_create.assert_not_called() + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_update_data_creates_cat_devices( + self, coordinator, mock_account, coordinator_hass_data + ) -> None: + """Test _async_update_data creates cat devices from API response.""" + mock_account.get_devices = AsyncMock(return_value=[]) + mock_account.get_cats = AsyncMock( + return_value=[{"id": "169004", "petName": "Zulu", "breedName": "Cat"}] + ) + mock_account.get_cat_summary_simple = AsyncMock( + return_value={"statusDescription": "Data collection in progress"} + ) + + with patch( + "custom_components.catlink.modules.devices_coordinator.create_device" + ) as mock_create: + mock_device = MagicMock() + mock_device.id = "cat-169004" + mock_device.name = "Zulu" + mock_device.update_data = MagicMock() + mock_device.async_init = AsyncMock() + mock_create.return_value = mock_device + + result = await coordinator._async_update_data() + + assert "cat-169004" in result + mock_create.assert_called_once() + mock_account.get_cat_summary_simple.assert_called_once() + + +class TestDevicesCoordinatorUpdateHassEntities: + """Tests for DevicesCoordinator update_hass_entities.""" + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_update_hass_entities_adds_entities( + self, coordinator, coordinator_hass_data + ) -> None: + """Test update_hass_entities adds entities when add_entities is set.""" + add_sensor = MagicMock() + coordinator_hass_data["add_entities"]["test-entry-123"] = { + "sensor": add_sensor, + } + + mock_device = MagicMock() + mock_device.id = "dev1" + mock_device.name = "Litter Box" + mock_device.coordinator = coordinator + mock_device.mac = "AA:BB:CC:DD:EE:FF" + mock_device.type = "LITTER_BOX_599" + mock_device.model = "LB599" + mock_device.detail = {} + mock_device.hass_sensor = { + "state": {"icon": "mdi:info", "state_attrs": lambda: {}}, + "error": {"icon": "mdi:alert", "state_attrs": lambda: {}}, + } + + await coordinator.update_hass_entities("sensor", mock_device) + + assert add_sensor.call_count >= 1 + added = add_sensor.call_args[0][0] + assert len(added) >= 1 + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_update_hass_entities_skips_when_no_add( + self, coordinator, coordinator_hass_data + ) -> None: + """Test update_hass_entities does nothing when add_entities not set.""" + coordinator_hass_data["add_entities"] = {} + + mock_device = MagicMock() + mock_device.id = "dev1" + mock_device.hass_sensor = {"state": {}} + + await coordinator.update_hass_entities("sensor", mock_device) + + assert "dev1" not in coordinator._subs or not coordinator._subs + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_update_hass_entities_skips_when_device_has_no_domain_attr( + self, coordinator, coordinator_hass_data + ) -> None: + """Test update_hass_entities skips when device has no hass_X attribute.""" + add_sensor = MagicMock() + coordinator_hass_data["add_entities"]["test-entry-123"] = { + "sensor": add_sensor, + } + + mock_device = MagicMock() + mock_device.id = "dev1" + del mock_device.hass_sensor + + await coordinator.update_hass_entities("sensor", mock_device) + + add_sensor.assert_not_called() diff --git a/tests/test_entities.py b/tests/test_entities.py new file mode 100644 index 0000000..957fd9d --- /dev/null +++ b/tests/test_entities.py @@ -0,0 +1,403 @@ +"""Tests for CatLink entity classes.""" + +from unittest.mock import AsyncMock, MagicMock + +from custom_components.catlink.devices.base import Device +from custom_components.catlink.devices.litterbox import LitterBox +from custom_components.catlink.entities.base import CatlinkEntity +from custom_components.catlink.entities.binary import CatlinkBinarySensorEntity +from custom_components.catlink.entities.button import CatlinkButtonEntity +from custom_components.catlink.entities.select import CatlinkSelectEntity +from custom_components.catlink.entities.sensor import CatlinkSensorEntity +from custom_components.catlink.entities.switch import CatlinkSwitchEntity +import pytest + +from homeassistant.const import STATE_OFF, STATE_ON + + +@pytest.fixture +def mock_device(): + """Create a mock device with required attributes.""" + device = MagicMock(spec=Device) + device.id = "dev123" + device.mac = "AABBCCDDEEFF" + device.type = "LITTER_BOX_599" + device.name = "Test Litter Box" + device.model = "LB599" + device.detail = {"firmwareVersion": "1.0.0"} + device.listeners = {} + return device + + +@pytest.fixture +def mock_coordinator(): + """Create a mock coordinator.""" + coordinator = MagicMock() + coordinator.account = MagicMock() + return coordinator + + +@pytest.fixture +def real_device(mock_coordinator): + """Create a real LitterBox device for entity tests.""" + data = { + "id": "dev123", + "mac": "AA:BB:CC:DD:EE:FF", + "model": "LB599", + "deviceName": "Test Litter Box", + "deviceType": "LITTER_BOX_599", + } + device = LitterBox(data, mock_coordinator) + device.detail = {"firmwareVersion": "1.0.0", "currentError": "Normal"} + device.coordinator = mock_coordinator + return device + + +class TestCatlinkEntity: + """Tests for CatlinkEntity base class.""" + + def test_entity_initialization( + self, hass, mock_device, mock_coordinator + ) -> None: + """Test CatlinkEntity initializes with correct attributes.""" + mock_device.coordinator = mock_coordinator + entity = CatlinkEntity("error", mock_device, {"icon": "mdi:alert"}) + entity.coordinator = mock_coordinator + entity.hass = hass + + assert entity._name == "error" + assert entity._device == mock_device + assert entity._attr_icon == "mdi:alert" + assert "Test Litter Box" in entity._attr_name + assert "error" in entity._attr_name + assert entity._attr_device_id == "LITTER_BOX_599_AABBCCDDEEFF" + assert entity._attr_unique_id == "LITTER_BOX_599_AABBCCDDEEFF-error" + + def test_entity_device_info( + self, hass, mock_device, mock_coordinator + ) -> None: + """Test CatlinkEntity device_info.""" + mock_device.coordinator = mock_coordinator + entity = CatlinkEntity("state", mock_device) + entity.coordinator = mock_coordinator + entity.hass = hass + + info = entity._attr_device_info + assert info["identifiers"] == { + ("catlink", "LITTER_BOX_599_AABBCCDDEEFF") + } + assert info["name"] == "Test Litter Box" + assert info["model"] == "LB599" + assert info["manufacturer"] == "CatLink" + assert info["sw_version"] == "1.0.0" + + def test_entity_update_from_device( + self, hass, real_device, mock_coordinator + ) -> None: + """Test entity update reads state from device.""" + real_device.coordinator = mock_coordinator + entity = CatlinkSensorEntity("error", real_device) + entity.coordinator = mock_coordinator + entity.hass = hass + + entity.update() + assert entity._attr_state == "Normal" + + def test_entity_unique_id_format( + self, hass, mock_device, mock_coordinator + ) -> None: + """Test entity unique_id format.""" + mock_device.coordinator = mock_coordinator + mock_device.mac = "1122" + mock_device.id = "dev456" + entity = CatlinkEntity("litter_weight", mock_device) + entity.coordinator = mock_coordinator + entity.hass = hass + + assert entity._attr_unique_id.endswith("-litter_weight") + assert "LITTER_BOX_599" in entity._attr_unique_id + + def test_entity_picture_static( + self, hass, mock_device, mock_coordinator + ) -> None: + """Test entity picture supports static value.""" + mock_device.coordinator = mock_coordinator + entity = CatlinkEntity( + "status", mock_device, {"entity_picture": "https://example.com/cat.jpg"} + ) + entity.coordinator = mock_coordinator + entity.hass = hass + + assert entity._attr_entity_picture == "https://example.com/cat.jpg" + + def test_entity_picture_callable_updates( + self, hass, mock_device, mock_coordinator + ) -> None: + """Test entity picture supports callable updates.""" + mock_device.coordinator = mock_coordinator + mock_device.avatar_url = "https://example.com/cat1.jpg" + entity = CatlinkEntity( + "status", mock_device, {"entity_picture": lambda: mock_device.avatar_url} + ) + entity.coordinator = mock_coordinator + entity.hass = hass + + assert entity._attr_entity_picture == "https://example.com/cat1.jpg" + + mock_device.avatar_url = "https://example.com/cat2.jpg" + entity.update() + assert entity._attr_entity_picture == "https://example.com/cat2.jpg" + + +class TestCatlinkBinarySensorEntity: + """Tests for CatlinkBinarySensorEntity.""" + + def test_binary_sensor_initialization( + self, hass, mock_device, mock_coordinator + ) -> None: + """Test CatlinkBinarySensorEntity initializes with is_on False.""" + mock_device.coordinator = mock_coordinator + entity = CatlinkBinarySensorEntity("occupied", mock_device) + entity.coordinator = mock_coordinator + entity.hass = hass + + assert entity._attr_is_on is False + + def test_binary_sensor_update_from_device( + self, hass, mock_device, mock_coordinator + ) -> None: + """Test binary sensor update sets is_on from device attribute.""" + mock_device.coordinator = mock_coordinator + mock_device.occupied = True + entity = CatlinkBinarySensorEntity("occupied", mock_device) + entity.coordinator = mock_coordinator + entity.hass = hass + + entity.update() + assert entity._attr_is_on is True + assert entity.state == STATE_ON + + def test_binary_sensor_update_off( + self, hass, mock_device, mock_coordinator + ) -> None: + """Test binary sensor state is OFF when device attribute is falsy.""" + mock_device.coordinator = mock_coordinator + mock_device.occupied = False + entity = CatlinkBinarySensorEntity("occupied", mock_device) + entity.coordinator = mock_coordinator + entity.hass = hass + + entity.update() + assert entity._attr_is_on is False + assert entity.state == STATE_OFF + + +class TestCatlinkButtonEntity: + """Tests for CatlinkButtonEntity.""" + + async def test_button_press_calls_option( + self, hass, mock_device, mock_coordinator + ) -> None: + """Test async_press calls option's async_press when callable.""" + mock_device.coordinator = mock_coordinator + mock_press = AsyncMock(return_value=True) + entity = CatlinkButtonEntity( + "action", + mock_device, + {"async_press": mock_press}, + ) + entity.coordinator = mock_coordinator + entity.hass = hass + + result = await entity.async_press() + assert result is True + mock_press.assert_called_once() + + async def test_button_press_no_option_returns_false( + self, hass, mock_device, mock_coordinator + ) -> None: + """Test async_press returns False when option has no async_press.""" + mock_device.coordinator = mock_coordinator + entity = CatlinkButtonEntity("action", mock_device, {}) + entity.coordinator = mock_coordinator + entity.hass = hass + + result = await entity.async_press() + assert result is False + + +class TestCatlinkSelectEntity: + """Tests for CatlinkSelectEntity.""" + + def test_select_initialization( + self, hass, mock_device, mock_coordinator + ) -> None: + """Test CatlinkSelectEntity initializes with options from config.""" + mock_device.coordinator = mock_coordinator + entity = CatlinkSelectEntity( + "mode", + mock_device, + {"options": ["auto", "manual"]}, + ) + entity.coordinator = mock_coordinator + entity.hass = hass + + assert entity._attr_options == ["auto", "manual"] + assert entity._attr_current_option is None + + def test_select_update_sets_current_option( + self, hass, mock_device, mock_coordinator + ) -> None: + """Test select update sets current_option from state.""" + mock_device.coordinator = mock_coordinator + mock_device.mode = "auto" + entity = CatlinkSelectEntity( + "mode", + mock_device, + {"options": ["auto", "manual"]}, + ) + entity.coordinator = mock_coordinator + entity.hass = hass + + entity.update() + assert entity._attr_current_option == "auto" + + async def test_select_option_calls_async_select( + self, hass, mock_device, mock_coordinator + ) -> None: + """Test async_select_option calls option's async_select.""" + mock_device.coordinator = mock_coordinator + mock_device.mode = "manual" + mock_select = AsyncMock(return_value=True) + entity = CatlinkSelectEntity( + "mode", + mock_device, + {"options": ["auto", "manual"], "async_select": mock_select}, + ) + entity.coordinator = mock_coordinator + entity.hass = hass + + result = await entity.async_select_option("manual") + assert result is True + mock_select.assert_called_once_with("manual", entity=entity) + assert entity._attr_current_option == "manual" + + +class TestCatlinkSensorEntity: + """Tests for CatlinkSensorEntity.""" + + def test_sensor_initialization( + self, hass, mock_device, mock_coordinator + ) -> None: + """Test CatlinkSensorEntity initializes with sensor config.""" + mock_device.coordinator = mock_coordinator + mock_device.error = "Normal" + entity = CatlinkSensorEntity( + "error", + mock_device, + {"icon": "mdi:alert", "state_attrs": lambda: {"key": "val"}}, + ) + entity.coordinator = mock_coordinator + entity.hass = hass + + assert entity._name == "error" + assert entity._attr_icon == "mdi:alert" + + def test_sensor_update_sets_state_and_attrs( + self, hass, mock_device, mock_coordinator + ) -> None: + """Test sensor update sets state and extra_state_attributes.""" + mock_device.coordinator = mock_coordinator + mock_device.litter_remaining_days = 5 + state_attrs = {"days_left": 5} + + def get_attrs(): + return state_attrs + + entity = CatlinkSensorEntity( + "litter_remaining_days", + mock_device, + {"unit": "days", "state_attrs": get_attrs}, + ) + entity.coordinator = mock_coordinator + entity.hass = hass + + entity.update() + assert entity._attr_state == 5 + assert entity._attr_extra_state_attributes == {"days_left": 5} + assert entity._attr_native_unit_of_measurement == "days" + + def test_sensor_update_without_state_attrs( + self, hass, mock_device, mock_coordinator + ) -> None: + """Test sensor update when option has no state_attrs.""" + mock_device.coordinator = mock_coordinator + mock_device.litter_weight = 2.5 + entity = CatlinkSensorEntity( + "litter_weight", + mock_device, + {"unit": "kg"}, + ) + entity.coordinator = mock_coordinator + entity.hass = hass + + entity.update() + assert entity._attr_state == 2.5 + assert entity._attr_native_unit_of_measurement == "kg" + + +class TestCatlinkSwitchEntity: + """Tests for CatlinkSwitchEntity.""" + + def test_switch_initialization( + self, hass, mock_device, mock_coordinator + ) -> None: + """Test CatlinkSwitchEntity initializes.""" + mock_device.coordinator = mock_coordinator + entity = CatlinkSwitchEntity( + "key_lock", + mock_device, + {"async_turn_on": None, "async_turn_off": None}, + ) + entity.coordinator = mock_coordinator + entity.hass = hass + + assert entity._attr_is_on is False + + async def test_switch_turn_on_calls_option( + self, hass, mock_device, mock_coordinator + ) -> None: + """Test async_turn_on calls option's async_turn_on.""" + mock_device.coordinator = mock_coordinator + mock_device.key_lock = True + mock_turn_on = AsyncMock(return_value=True) + entity = CatlinkSwitchEntity( + "key_lock", + mock_device, + {"async_turn_on": mock_turn_on, "async_turn_off": MagicMock()}, + ) + entity.coordinator = mock_coordinator + entity.hass = hass + + await entity.async_turn_on() + assert entity._attr_is_on is True + mock_turn_on.assert_called_once() + + async def test_switch_turn_off_calls_option( + self, hass, mock_device, mock_coordinator + ) -> None: + """Test async_turn_off calls option's async_turn_off.""" + mock_device.coordinator = mock_coordinator + mock_turn_off = AsyncMock(return_value=True) + entity = CatlinkSwitchEntity( + "key_lock", + mock_device, + {"async_turn_on": MagicMock(), "async_turn_off": mock_turn_off}, + ) + entity.coordinator = mock_coordinator + entity.hass = hass + entity._attr_is_on = True + + await entity.async_turn_off() + assert entity._attr_is_on is False + mock_turn_off.assert_called_once() diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 0000000..43a64b7 --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,189 @@ +"""Tests for CatLink helper functions.""" + +from datetime import timedelta +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from custom_components.catlink.const import DOMAIN +from custom_components.catlink.helpers import ( + Helper, + async_setup_domain_platform, + discover_region, + format_api_error, + parse_phone_number, +) + + +class TestParsePhoneNumber: + """Tests for parse_phone_number.""" + + def test_international_format_with_plus(self) -> None: + """Test parsing international format with plus.""" + assert parse_phone_number("+447911123456") == ("44", "7911123456") + + def test_international_format_without_plus(self) -> None: + """Test parsing international format without plus.""" + assert parse_phone_number("447911123456") == ("44", "7911123456") + + def test_us_number(self) -> None: + """Test parsing US number.""" + assert parse_phone_number("+12025551234") == ("1", "2025551234") + + def test_china_number(self) -> None: + """Test parsing China number.""" + assert parse_phone_number("+8613812345678") == ("86", "13812345678") + + def test_with_spaces_and_dashes(self) -> None: + """Test parsing number with formatting.""" + assert parse_phone_number("+44 7911-123-456") == ("44", "7911123456") + + def test_singapore_number(self) -> None: + """Test parsing Singapore number.""" + assert parse_phone_number("+6591234567") == ("65", "91234567") + + +class TestFormatApiError: + """Tests for format_api_error.""" + + def test_with_msg_and_code(self) -> None: + """Test formatting with msg and returnCode.""" + rdt = { + "returnCode": 4007, + "msg": "Protection is temporarily paused.", + "success": False, + } + assert ( + format_api_error(rdt) + == "Protection is temporarily paused. (returnCode: 4007)" + ) + + def test_with_msg_only(self) -> None: + """Test formatting with msg only.""" + rdt = {"msg": "Device offline"} + assert format_api_error(rdt) == "Device offline" + + def test_with_message_alias(self) -> None: + """Test formatting with message key.""" + rdt = {"message": "Custom error", "returnCode": 500} + assert format_api_error(rdt) == "Custom error (returnCode: 500)" + + def test_without_msg(self) -> None: + """Test formatting when msg is missing.""" + rdt = {"returnCode": 4007, "data": {}} + assert "4007" in format_api_error(rdt) + + +class TestCalculateUpdateInterval: + """Tests for Helper.calculate_update_interval.""" + + def test_timedelta_passthrough(self) -> None: + """Test timedelta is returned as-is.""" + interval = timedelta(minutes=5) + assert Helper.calculate_update_interval(interval) == interval + + def test_seconds_int(self) -> None: + """Test seconds as int.""" + assert Helper.calculate_update_interval(60) == timedelta(seconds=60) + + def test_seconds_float(self) -> None: + """Test seconds as float.""" + assert Helper.calculate_update_interval(90.5) == timedelta(seconds=90) + + def test_hhmmss_string(self) -> None: + """Test HH:MM:SS format.""" + assert Helper.calculate_update_interval("01:30:00") == timedelta( + hours=1, minutes=30 + ) + + def test_invalid_string_defaults_to_one_minute(self) -> None: + """Test invalid string returns 1 minute default.""" + assert Helper.calculate_update_interval("invalid") == timedelta(minutes=1) + + def test_none_defaults_to_one_minute(self) -> None: + """Test None returns 1 minute default.""" + assert Helper.calculate_update_interval(None) == timedelta(minutes=1) + + def test_zero_or_negative_defaults_to_one_minute(self) -> None: + """Test zero or negative returns 1 minute default.""" + assert Helper.calculate_update_interval(0) == timedelta(minutes=1) + assert Helper.calculate_update_interval(-10) == timedelta(minutes=1) + + +class TestDiscoverRegion: + """Tests for discover_region.""" + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_discover_region_returns_region_on_login_success(self, hass) -> None: + """Test discover_region returns region when login succeeds.""" + with patch( + "custom_components.catlink.modules.account.Account" + ) as mock_account_cls: + mock_account = MagicMock() + mock_account.async_login = AsyncMock(return_value=True) + mock_account_cls.return_value = mock_account + + result = await discover_region(hass, "86", "13812345678", "testpass") + + assert result == "global" + mock_account.async_login.assert_called_once() + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_discover_region_returns_none_when_all_fail(self, hass) -> None: + """Test discover_region returns None when no region succeeds.""" + with patch( + "custom_components.catlink.modules.account.Account" + ) as mock_account_cls: + mock_account = MagicMock() + mock_account.async_login = AsyncMock(return_value=False) + mock_account_cls.return_value = mock_account + + result = await discover_region(hass, "86", "13812345678", "wrongpass") + + assert result is None + + +class TestAsyncSetupDomainPlatform: + """Tests for async_setup_domain_platform.""" + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_async_setup_domain_platform_registers_add_entities( + self, hass + ) -> None: + """Test async_setup_domain_platform registers add_entities.""" + hass.data[DOMAIN] = { + "add_entities": {}, + "coordinators": {}, + } + add_entities = MagicMock() + + with patch( + "custom_components.catlink.helpers.Helper.async_setup_accounts", + new_callable=AsyncMock, + ) as mock_setup: + await async_setup_domain_platform(hass, "sensor", add_entities) + + assert "discovery" in hass.data[DOMAIN]["add_entities"] + assert ( + hass.data[DOMAIN]["add_entities"]["discovery"]["sensor"] == add_entities + ) + mock_setup.assert_called_once_with(hass, "sensor") + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_async_setup_domain_platform_calls_extra_setup(self, hass) -> None: + """Test async_setup_domain_platform calls extra_setup when provided.""" + hass.data[DOMAIN] = { + "add_entities": {}, + "coordinators": {}, + } + extra_setup = AsyncMock() + + with patch( + "custom_components.catlink.helpers.Helper.async_setup_accounts", + new_callable=AsyncMock, + ): + await async_setup_domain_platform( + hass, "sensor", MagicMock(), extra_setup=extra_setup + ) + + extra_setup.assert_called_once() diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100644 index 0000000..5924e27 --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,158 @@ +"""Tests for CatLink integration setup.""" + +from unittest.mock import AsyncMock, patch + +from custom_components.catlink import async_setup +from custom_components.catlink.const import ( + CONF_ACCOUNTS, + CONF_PHONE, + CONF_PHONE_IAC, + DOMAIN, + SUPPORTED_DOMAINS, +) +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_DEVICES +from homeassistant.core import HomeAssistant + + +@pytest.fixture +def mock_config_entry(): + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_PHONE_IAC: "86", + CONF_PHONE: "13812345678", + "api_base": "https://app.catlinks.cn/api/", + "password": "testpass", + }, + options={}, + entry_id="test-entry-id", + ) + + +@pytest.fixture +def mock_account(): + """Mock Account class.""" + with patch("custom_components.catlink.Account") as mock: + instance = mock.return_value + instance.uid = "86-13812345678" + instance.async_check_auth = AsyncMock() + yield mock + + +@pytest.fixture +def mock_coordinator(): + """Mock DevicesCoordinator class.""" + with patch("custom_components.catlink.DevicesCoordinator") as mock: + instance = mock.return_value + instance.name = f"{DOMAIN}-86-13812345678-devices" + instance.async_refresh = AsyncMock() + instance.data = {} + yield mock + + +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_async_setup(hass: HomeAssistant) -> None: + """Test async_setup initializes hass.data structure.""" + result = await async_setup(hass, {}) + + assert result is True + assert DOMAIN in hass.data + assert CONF_ACCOUNTS in hass.data[DOMAIN] + assert CONF_DEVICES in hass.data[DOMAIN] + assert "coordinators" in hass.data[DOMAIN] + assert "add_entities" in hass.data[DOMAIN] + assert "config" in hass.data[DOMAIN] + assert "entry_coordinators" in hass.data[DOMAIN] + + +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_async_setup_entry( + hass: HomeAssistant, + mock_config_entry, + mock_account, + mock_coordinator, +) -> None: + """Test async_setup_entry loads config entry and forwards platforms.""" + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN].setdefault(CONF_ACCOUNTS, {}) + hass.data[DOMAIN].setdefault(CONF_DEVICES, {}) + hass.data[DOMAIN].setdefault("coordinators", {}) + hass.data[DOMAIN].setdefault("add_entities", {}) + hass.data[DOMAIN].setdefault("config", {}) + hass.data[DOMAIN].setdefault("entry_coordinators", {}) + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert result is True + assert mock_config_entry.state is ConfigEntryState.LOADED + mock_account.return_value.async_check_auth.assert_called_once() + mock_coordinator.return_value.async_refresh.assert_called_once() + assert mock_account.return_value.uid in hass.data[DOMAIN][CONF_ACCOUNTS] + assert mock_config_entry.entry_id in hass.data[DOMAIN]["entry_coordinators"] + + +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_async_unload_entry( + hass: HomeAssistant, + mock_config_entry, + mock_account, + mock_coordinator, +) -> None: + """Test async_unload_entry cleans up hass.data.""" + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN].setdefault(CONF_ACCOUNTS, {}) + hass.data[DOMAIN].setdefault(CONF_DEVICES, {}) + hass.data[DOMAIN].setdefault("coordinators", {}) + hass.data[DOMAIN].setdefault("add_entities", {}) + hass.data[DOMAIN].setdefault("config", {}) + hass.data[DOMAIN].setdefault("entry_coordinators", {}) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert result is True + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + assert "86-13812345678" not in hass.data[DOMAIN][CONF_ACCOUNTS] + assert mock_config_entry.entry_id not in hass.data[DOMAIN]["entry_coordinators"] + + +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_async_setup_entry_forwards_all_platforms( + hass: HomeAssistant, + mock_config_entry, + mock_account, + mock_coordinator, +) -> None: + """Test async_setup_entry forwards all supported domains.""" + with patch.object( + hass.config_entries, + "async_forward_entry_setups", + new_callable=AsyncMock, + ) as mock_forward: + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN].setdefault(CONF_ACCOUNTS, {}) + hass.data[DOMAIN].setdefault(CONF_DEVICES, {}) + hass.data[DOMAIN].setdefault("coordinators", {}) + hass.data[DOMAIN].setdefault("add_entities", {}) + hass.data[DOMAIN].setdefault("config", {}) + hass.data[DOMAIN].setdefault("entry_coordinators", {}) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mock_forward.assert_called_once() + call_args = mock_forward.call_args + assert call_args[0][1] == SUPPORTED_DOMAINS diff --git a/tests/test_litter_device.py b/tests/test_litter_device.py new file mode 100644 index 0000000..2a25310 --- /dev/null +++ b/tests/test_litter_device.py @@ -0,0 +1,178 @@ +"""Tests for CatLink LitterDevice base class.""" + +from unittest.mock import MagicMock + +import pytest + +from custom_components.catlink.devices.litterbox import LitterBox + + +@pytest.fixture +def mock_coordinator(): + """Create a mock DevicesCoordinator.""" + coordinator = MagicMock() + coordinator.account = MagicMock() + coordinator.account.uid = "86-13812345678" + return coordinator + + +@pytest.fixture +def sample_device_data(): + """Sample device data from API.""" + return { + "id": "dev123", + "mac": "AA:BB:CC:DD:EE:FF", + "model": "LB599", + "deviceName": "Living Room Litter", + "deviceType": "LITTER_BOX_599", + } + + +class TestLitterDeviceState: + """Tests for LitterDevice state property.""" + + def test_state_idle(self, mock_coordinator, sample_device_data) -> None: + """Test state maps workStatus 00 to idle.""" + device = LitterBox(sample_device_data, mock_coordinator) + device.detail = {"workStatus": "00"} + assert device.state == "idle" + + def test_state_running(self, mock_coordinator, sample_device_data) -> None: + """Test state maps workStatus 01 to running.""" + device = LitterBox(sample_device_data, mock_coordinator) + device.detail = {"workStatus": "01"} + assert device.state == "running" + + def test_state_need_reset(self, mock_coordinator, sample_device_data) -> None: + """Test state maps workStatus 02 to need_reset.""" + device = LitterBox(sample_device_data, mock_coordinator) + device.detail = {"workStatus": "02"} + assert device.state == "need_reset" + + def test_state_unknown_fallback(self, mock_coordinator, sample_device_data) -> None: + """Test state returns raw value for unknown workStatus.""" + device = LitterBox(sample_device_data, mock_coordinator) + device.detail = {"workStatus": "99"} + assert device.state == "99" + + +class TestLitterDeviceLitterWeight: + """Tests for LitterDevice litter_weight property.""" + + def test_litter_weight_calculated( + self, mock_coordinator, sample_device_data + ) -> None: + """Test litter_weight subtracts empty weight from catLitterWeight.""" + device = LitterBox(sample_device_data, mock_coordinator) + device.empty_litter_box_weight = 1.0 + device.detail = {"catLitterWeight": 3.5} + assert device.litter_weight == 2.5 + + def test_litter_weight_default_empty( + self, mock_coordinator, sample_device_data + ) -> None: + """Test litter_weight when catLitterWeight missing uses empty_weight.""" + device = LitterBox(sample_device_data, mock_coordinator) + device.empty_litter_box_weight = 1.0 + device.detail = {} + assert device.litter_weight == 0.0 + + +class TestLitterDeviceCleanTimes: + """Tests for LitterDevice total_clean_time and manual_clean_time.""" + + def test_total_clean_time(self, mock_coordinator, sample_device_data) -> None: + """Test total_clean_time sums induction and manual times.""" + device = LitterBox(sample_device_data, mock_coordinator) + device.detail = {"inductionTimes": 10, "manualTimes": 5} + assert device.total_clean_time == 15 + + def test_manual_clean_time(self, mock_coordinator, sample_device_data) -> None: + """Test manual_clean_time from manualTimes.""" + device = LitterBox(sample_device_data, mock_coordinator) + device.detail = {"manualTimes": 7} + assert device.manual_clean_time == 7 + + def test_deodorant_countdown(self, mock_coordinator, sample_device_data) -> None: + """Test deodorant_countdown from detail.""" + device = LitterBox(sample_device_data, mock_coordinator) + device.detail = {"deodorantCountdown": 30} + assert device.deodorant_countdown == 30 + + +class TestLitterDeviceOccupied: + """Tests for LitterDevice occupied property.""" + + def test_occupied_true_when_weight_increases( + self, mock_coordinator, sample_device_data + ) -> None: + """Test occupied True when litter weight increases during day.""" + device = LitterBox(sample_device_data, mock_coordinator) + device._litter_weight_during_day.clear() + device._litter_weight_during_day.extend([1.0, 1.5, 2.0]) + assert device.occupied is True + + def test_occupied_false_when_weight_decreases( + self, mock_coordinator, sample_device_data + ) -> None: + """Test occupied False when litter weight only decreases.""" + device = LitterBox(sample_device_data, mock_coordinator) + device._litter_weight_during_day.clear() + device._litter_weight_during_day.extend([3.0, 2.5, 2.0]) + assert device.occupied is False + + def test_occupied_false_when_empty( + self, mock_coordinator, sample_device_data + ) -> None: + """Test occupied False when deque has fewer than 2 entries.""" + device = LitterBox(sample_device_data, mock_coordinator) + device._litter_weight_during_day.clear() + device._litter_weight_during_day.append(1.0) + assert device.occupied is False + + +class TestLitterDeviceOnline: + """Tests for LitterDevice online property.""" + + def test_online_true(self, mock_coordinator, sample_device_data) -> None: + """Test online returns True when detail has online.""" + device = LitterBox(sample_device_data, mock_coordinator) + device.detail = {"online": True} + assert device.online is True + + def test_online_false(self, mock_coordinator, sample_device_data) -> None: + """Test online returns False when detail has online False.""" + device = LitterBox(sample_device_data, mock_coordinator) + device.detail = {"online": False} + assert device.online is False + + +class TestLitterDeviceBaseStateAttrs: + """Tests for LitterDevice _base_state_attrs.""" + + def test_base_state_attrs(self, mock_coordinator, sample_device_data) -> None: + """Test _base_state_attrs returns expected keys.""" + device = LitterBox(sample_device_data, mock_coordinator) + device.detail = { + "workStatus": "00", + "alarmStatus": "ok", + "weight": 2500, + "catLitterWeight": 3.5, + "inductionTimes": 10, + "manualTimes": 5, + "keyLock": "0", + "safeTime": 30, + "catLitterPaveSecond": 60, + "deodorantCountdown": 30, + "litterCountdown": 5, + } + attrs = device._base_state_attrs() + assert attrs["mac"] == "AA:BB:CC:DD:EE:FF" + assert attrs["work_status"] == "00" + assert attrs["alarm_status"] == "ok" + assert attrs["weight"] == 2500 + assert attrs["litter_weight_kg"] == 3.5 + assert attrs["total_clean_times"] == 15 + assert attrs["manual_clean_times"] == 5 + assert attrs["deodorant_countdown"] == 30 + assert attrs["litter_countdown"] == 5 diff --git a/tests/test_logs_mixin.py b/tests/test_logs_mixin.py new file mode 100644 index 0000000..323c83d --- /dev/null +++ b/tests/test_logs_mixin.py @@ -0,0 +1,148 @@ +"""Tests for CatLink LogsMixin.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from custom_components.catlink.devices.litterbox import LitterBox + + +@pytest.fixture +def mock_coordinator(): + """Create a mock DevicesCoordinator.""" + coordinator = MagicMock() + coordinator.account = MagicMock() + coordinator.account.uid = "86-13812345678" + return coordinator + + +@pytest.fixture +def sample_device_data(): + """Sample device data from API.""" + return { + "id": "dev123", + "mac": "AA:BB:CC:DD:EE:FF", + "model": "LB599", + "deviceName": "Living Room Litter", + "deviceType": "LITTER_BOX_599", + } + + +class TestLogsMixinLastLog: + """Tests for LogsMixin _last_log and last_log properties.""" + + def test_last_log_empty_when_no_logs( + self, mock_coordinator, sample_device_data + ) -> None: + """Test _last_log and last_log when logs is empty.""" + device = LitterBox(sample_device_data, mock_coordinator) + device.logs = [] + assert device._last_log == {} + assert device.last_log is None + + def test_last_log_returns_first_entry( + self, mock_coordinator, sample_device_data + ) -> None: + """Test _last_log returns first log entry.""" + device = LitterBox(sample_device_data, mock_coordinator) + device.logs = [ + {"time": "2024-01-15 10:00", "event": "Cleaning"}, + {"time": "2024-01-15 09:00", "event": "Idle"}, + ] + assert device._last_log == {"time": "2024-01-15 10:00", "event": "Cleaning"} + + def test_last_log_formatted_string( + self, mock_coordinator, sample_device_data + ) -> None: + """Test last_log returns formatted string.""" + device = LitterBox(sample_device_data, mock_coordinator) + device.logs = [{"time": "2024-01-15 10:00", "event": "Cleaning"}] + assert device.last_log == "2024-01-15 10:00 Cleaning" + + def test_last_log_handles_none_first_entry( + self, mock_coordinator, sample_device_data + ) -> None: + """Test _last_log when first entry is None.""" + device = LitterBox(sample_device_data, mock_coordinator) + device.logs = [None, {"time": "2024-01-15", "event": "Clean"}] + assert device._last_log == {} + + +class TestLogsMixinLastLogAttrs: + """Tests for LogsMixin last_log_attrs.""" + + def test_last_log_attrs_includes_logs( + self, mock_coordinator, sample_device_data + ) -> None: + """Test last_log_attrs includes log data and full logs list.""" + device = LitterBox(sample_device_data, mock_coordinator) + device.logs = [{"time": "10:00", "event": "Clean", "extra": "val"}] + attrs = device.last_log_attrs() + assert attrs["time"] == "10:00" + assert attrs["event"] == "Clean" + assert attrs["extra"] == "val" + assert attrs["logs"] == device.logs + + +class TestLogsMixinFetchLogs: + """Tests for LogsMixin _fetch_logs.""" + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_fetch_logs_success( + self, mock_coordinator, sample_device_data + ) -> None: + """Test _fetch_logs parses API response and updates logs.""" + device = LitterBox(sample_device_data, mock_coordinator) + device._handle_listeners = MagicMock() + mock_coordinator.account.request = AsyncMock( + return_value={ + "data": { + "scooperLogTop5": [ + {"time": "10:00", "event": "Cleaning"}, + ] + } + } + ) + + result = await device._fetch_logs( + "token/litterbox/stats/log/top5", "scooperLogTop5" + ) + + assert len(result) == 1 + assert result[0]["time"] == "10:00" + assert result[0]["event"] == "Cleaning" + assert device.logs == result + mock_coordinator.account.request.assert_called_once_with( + "token/litterbox/stats/log/top5", {"deviceId": "dev123"} + ) + device._handle_listeners.assert_called_once() + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_fetch_logs_empty_response( + self, mock_coordinator, sample_device_data + ) -> None: + """Test _fetch_logs handles empty API response.""" + device = LitterBox(sample_device_data, mock_coordinator) + device._handle_listeners = MagicMock() + mock_coordinator.account.request = AsyncMock(return_value={"data": {}}) + + result = await device._fetch_logs("token/litterbox/log", "scooperLogTop5") + + assert result == [] + assert device.logs == [] + + @pytest.mark.usefixtures("enable_custom_integrations") + async def test_fetch_logs_api_error_sets_empty( + self, mock_coordinator, sample_device_data + ) -> None: + """Test _fetch_logs sets empty list on parse error.""" + device = LitterBox(sample_device_data, mock_coordinator) + device._handle_listeners = MagicMock() + mock_coordinator.account.request = AsyncMock( + return_value={"data": {"scooperLogTop5": "invalid"}} + ) + + result = await device._fetch_logs("token/litterbox/log", "scooperLogTop5") + + assert result == [] + assert device.logs == [] diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..2758fe3 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,86 @@ +"""Tests for CatLink Pydantic models and parse utilities.""" + +import pytest + +from custom_components.catlink.models.additional_cfg import AdditionalDeviceConfig +from custom_components.catlink.models.api.device import ( + DeviceInfoBase, + LitterDeviceInfo, +) +from custom_components.catlink.models.api.parse import parse_response + + +class TestAdditionalDeviceConfig: + """Tests for AdditionalDeviceConfig model.""" + + def test_default_values(self) -> None: + """Test default values.""" + config = AdditionalDeviceConfig() + assert config.name == "" + assert config.mac == "" + assert config.empty_weight == 0.0 + assert config.max_samples_litter == 24 + + def test_with_values(self) -> None: + """Test with provided values.""" + config = AdditionalDeviceConfig( + name="Living Room", + mac="AA:BB:CC:DD:EE:FF", + empty_weight=1.5, + max_samples_litter=12, + ) + assert config.name == "Living Room" + assert config.mac == "AA:BB:CC:DD:EE:FF" + assert config.empty_weight == 1.5 + assert config.max_samples_litter == 12 + + +class TestParseResponse: + """Tests for parse_response utility.""" + + def test_parse_dict_to_model(self) -> None: + """Test parsing dict into Pydantic model.""" + data = { + "deviceInfo": { + "workStatus": "00", + "firmwareVersion": "1.2.3", + "litterCountdown": 5, + } + } + result = parse_response(data, "deviceInfo", LitterDeviceInfo) + assert isinstance(result, LitterDeviceInfo) + assert result.workStatus == "00" + assert result.firmwareVersion == "1.2.3" + assert result.litterCountdown == 5 + + def test_parse_missing_key_returns_default(self) -> None: + """Test parsing when key is missing returns default.""" + data = {"other": "value"} + result = parse_response(data, "deviceInfo", LitterDeviceInfo, default={}) + assert result == {} + + def test_parse_missing_key_no_default_returns_none(self) -> None: + """Test parsing when key is missing and no default.""" + data = {} + result = parse_response(data, "deviceInfo", LitterDeviceInfo) + assert result is None + + def test_parse_list_returns_list_of_models(self) -> None: + """Test parsing list of dicts.""" + data = { + "items": [ + {"workStatus": "00", "firmwareVersion": "1.0"}, + {"workStatus": "01", "firmwareVersion": "2.0"}, + ] + } + result = parse_response(data, "items", DeviceInfoBase) + assert isinstance(result, list) + assert len(result) == 2 + assert result[0].workStatus == "00" + assert result[1].workStatus == "01" + + def test_parse_invalid_data_returns_default(self) -> None: + """Test parsing invalid data returns default on ValidationError.""" + data = {"deviceInfo": "not a dict"} + result = parse_response(data, "deviceInfo", LitterDeviceInfo, default={}) + assert result == {} diff --git a/tests/test_platforms.py b/tests/test_platforms.py new file mode 100644 index 0000000..6dd3651 --- /dev/null +++ b/tests/test_platforms.py @@ -0,0 +1,172 @@ +"""Tests for CatLink platform setup (sensor, switch, binary_sensor, select, button).""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from custom_components.catlink import binary_sensor, button, select, sensor, switch +from custom_components.catlink.const import ( + CONF_ACCOUNTS, + CONF_DEVICE_IDS, + CONF_PHONE, + CONF_PHONE_IAC, + CONF_UPDATE_INTERVAL, + DOMAIN, + SUPPORTED_DOMAINS, +) +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_DEVICES +from homeassistant.core import HomeAssistant + + +@pytest.fixture +def mock_account(): + """Mock Account to avoid real API calls.""" + with patch("custom_components.catlink.Account") as mock: + instance = mock.return_value + instance.uid = "86-13812345678" + instance.hass = None + instance.async_check_auth = AsyncMock() + instance.get_devices = AsyncMock(return_value=[]) + instance.update_interval = __import__("datetime").timedelta(minutes=1) + instance.get_config = MagicMock(return_value=None) + yield mock + + +@pytest.fixture +def mock_coordinator(): + """Mock DevicesCoordinator class.""" + with patch("custom_components.catlink.DevicesCoordinator") as mock: + instance = mock.return_value + instance.name = f"{DOMAIN}-86-13812345678-devices" + instance.async_refresh = AsyncMock() + instance.data = {} + yield mock + + +@pytest.fixture +def mock_config_entry(): + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_PHONE_IAC: "86", + CONF_PHONE: "13812345678", + "api_base": "https://app.catlinks.cn/api/", + "password": "testpass", + }, + options={CONF_DEVICE_IDS: [], CONF_UPDATE_INTERVAL: 60}, + unique_id="86-13812345678", + ) + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_account, + mock_coordinator, +) -> MockConfigEntry: + """Set up the CatLink integration for platform tests.""" + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN].setdefault(CONF_ACCOUNTS, {}) + hass.data[DOMAIN].setdefault(CONF_DEVICES, {}) + hass.data[DOMAIN].setdefault("coordinators", {}) + hass.data[DOMAIN].setdefault("add_entities", {}) + hass.data[DOMAIN].setdefault("config", {CONF_DEVICES: []}) + hass.data[DOMAIN].setdefault("entry_coordinators", {}) + + mock_account.return_value.hass = hass + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry + + +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_platforms_loaded(init_integration: MockConfigEntry) -> None: + """Test all supported platforms are loaded.""" + assert init_integration.state is ConfigEntryState.LOADED + for domain in SUPPORTED_DOMAINS: + assert domain in ["sensor", "binary_sensor", "switch", "select", "button"] + + +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_add_entities_registered( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Test add_entities callbacks are registered for each platform.""" + add_entities = hass.data[DOMAIN].get("add_entities", {}) + entry_add = add_entities.get(init_integration.entry_id, {}) + for domain in SUPPORTED_DOMAINS: + assert domain in entry_add + assert callable(entry_add[domain]) + + +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_sensor_platform_setup_entry( + hass: HomeAssistant, +) -> None: + """Test sensor platform async_setup_entry registers callback and updates entities.""" + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN].setdefault("add_entities", {}) + hass.data[DOMAIN].setdefault("entry_coordinators", {}) + + mock_add_entities = MagicMock() + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_PHONE_IAC: "86", CONF_PHONE: "13812345678"}, + entry_id="test-sensor-entry", + ) + mock_config_entry.add_to_hass(hass) + + await sensor.async_setup_entry(hass, mock_config_entry, mock_add_entities) + + assert mock_config_entry.entry_id in hass.data[DOMAIN]["add_entities"] + assert "sensor" in hass.data[DOMAIN]["add_entities"][mock_config_entry.entry_id] + assert ( + hass.data[DOMAIN]["add_entities"][mock_config_entry.entry_id]["sensor"] + is mock_add_entities + ) + + +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_switch_platform_setup_entry( + hass: HomeAssistant, +) -> None: + """Test switch platform async_setup_entry registers callback.""" + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN].setdefault("add_entities", {}) + hass.data[DOMAIN].setdefault("entry_coordinators", {}) + + mock_add_entities = MagicMock() + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_PHONE_IAC: "86", CONF_PHONE: "13812345678"}, + entry_id="test-switch-entry", + ) + mock_config_entry.add_to_hass(hass) + + await switch.async_setup_entry(hass, mock_config_entry, mock_add_entities) + + assert "switch" in hass.data[DOMAIN]["add_entities"][mock_config_entry.entry_id] + + +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_all_platforms_have_setup_entry( + hass: HomeAssistant, +) -> None: + """Test all supported domains have async_setup_entry.""" + for _domain, module in [ + ("sensor", sensor), + ("switch", switch), + ("binary_sensor", binary_sensor), + ("select", select), + ("button", button), + ]: + assert hasattr(module, "async_setup_entry") + assert callable(module.async_setup_entry)