+
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
-
+
-

+
Operations
- Feed Button
- Food tray weight
- Online status
- Logs & Error
+ - Entities: sensor, binary sensor, button
+
+
+
+
+
+#### Additional supported devices
+
+
+
+
+
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)
+

+
Operations
+
+ - Litter remaining days
+ - Deodorant countdown
+ - Total clean time
+ - Logs
+ - Entities: sensor
+#### Cats
+
+
+
+
+
Smart collars (via the Cats integration)
+

+
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.
-
-
+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)