From 0b723f56b3f2acac9fa9986a9104f4956af2c80a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20Ljubenovi=C4=87?= Date: Tue, 6 Jan 2026 10:30:00 +0000 Subject: [PATCH 01/34] Add CatLink integration base structure --- custom_components/catlink/__init__.py | 80 ++++++++++++++++--------- custom_components/catlink/const.py | 19 +++++- custom_components/catlink/manifest.json | 5 +- 3 files changed, 73 insertions(+), 31 deletions(-) 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/const.py b/custom_components/catlink/const.py index fac712e..c290b1e 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({"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/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" } From 55eacb1afb6e50631e8f9af2cfbad2d1abac9229 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20Ljubenovi=C4=87?= Date: Thu, 8 Jan 2026 14:00:00 +0000 Subject: [PATCH 02/34] Add Account module for API authentication --- custom_components/catlink/modules/__init__.py | 8 -------- custom_components/catlink/modules/account.py | 16 +++++++++++++--- 2 files changed, 13 insertions(+), 11 deletions(-) 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..2cf0dcb 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.""" From a36fa6bf256d3c994fc39ce2b48f5a9a7c8bb287 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20Ljubenovi=C4=87?= Date: Sat, 10 Jan 2026 11:00:00 +0000 Subject: [PATCH 03/34] Add Pydantic API models --- .../catlink/models/api/__init__.py | 14 ++++ custom_components/catlink/models/api/base.py | 14 ++++ .../catlink/models/api/device.py | 71 +++++++++++++++++++ custom_components/catlink/models/api/logs.py | 15 ++++ custom_components/catlink/models/api/parse.py | 25 +++++++ 5 files changed, 139 insertions(+) create mode 100644 custom_components/catlink/models/api/__init__.py create mode 100644 custom_components/catlink/models/api/base.py create mode 100644 custom_components/catlink/models/api/device.py create mode 100644 custom_components/catlink/models/api/logs.py create mode 100644 custom_components/catlink/models/api/parse.py 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..406a537 --- /dev/null +++ b/custom_components/catlink/models/api/device.py @@ -0,0 +1,71 @@ +"""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: int = 0 + manualTimes: int = 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: str = "" + quietTimes: str = "" + garbageStatus: str = "" + currentError: str = "" + currentMessage: str = "" + + +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 From 335c6042787834eb1d85987424ec6faa21677725 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20Ljubenovi=C4=87?= Date: Mon, 12 Jan 2026 16:00:00 +0000 Subject: [PATCH 04/34] Add device base classes and registry --- custom_components/catlink/devices/__init__.py | 13 + custom_components/catlink/devices/base.py | 281 ++++++++++++++++++ custom_components/catlink/devices/registry.py | 29 ++ .../catlink/modules/devices_coordinator.py | 57 ++-- 4 files changed, 345 insertions(+), 35 deletions(-) create mode 100644 custom_components/catlink/devices/__init__.py create mode 100644 custom_components/catlink/devices/base.py create mode 100644 custom_components/catlink/devices/registry.py diff --git a/custom_components/catlink/devices/__init__.py b/custom_components/catlink/devices/__init__.py new file mode 100644 index 0000000..78983e2 --- /dev/null +++ b/custom_components/catlink/devices/__init__.py @@ -0,0 +1,13 @@ +"""Device classes for CatLink integration.""" + +from .base import Device +from .feeder import FeederDevice +from .litterbox import LitterBox +from .scooper import ScooperDevice + +__all__ = [ + "Device", + "FeederDevice", + "LitterBox", + "ScooperDevice", +] diff --git a/custom_components/catlink/devices/base.py b/custom_components/catlink/devices/base.py new file mode 100644 index 0000000..dc0a6de --- /dev/null +++ b/custom_components/catlink/devices/base.py @@ -0,0 +1,281 @@ +"""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 ..modules.devices_coordinator import DevicesCoordinator + + +class Device: + """Device class for CatLink integration.""" + + data: dict + + def __init__( + self, + dat: dict, + coordinator: "DevicesCoordinator", + additional_config: AdditionalDeviceConfig = None, + ) -> None: + """Initialize the device.""" + self.additional_config = additional_config or AdditionalDeviceConfig() + self.coordinator = coordinator + self.account = coordinator.account + self.listeners = {} + self._action_error: str | None = None + self.update_data(dat) + self.detail = {} + + async def async_init(self) -> None: + """Initialize the device.""" + await self.update_device_detail() + + def update_data(self, dat: dict) -> None: + """Update device data.""" + self.data = dat + self._handle_listeners() + _LOGGER.info("Update device data: %s", dat) + + 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.""" + try: + return self.data.get("id") + except (TypeError, ValueError): + return None + + @property + def mac(self) -> str: + """Return the device mac.""" + try: + return self.data.get("mac") + except (TypeError, ValueError): + return None + + @property + def model(self) -> str: + """Return the device model.""" + try: + return self.data.get("model") + except (TypeError, ValueError): + return None + + @property + def type(self) -> str: + """Return the device type.""" + try: + return self.data.get("deviceType") + except (TypeError, ValueError): + return None + + @property + def name(self) -> str: + """Return the device name.""" + try: + return self.data.get("deviceName", "") + except (TypeError, ValueError): + return None + + @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", "" + ) + except (TypeError, ValueError): + return None + + @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 mode(self) -> str: + """Return the device mode.""" + return self.modes.get(self.detail.get("workModel", "")) + + @property + def modes(self) -> dict: + """Return the device modes.""" + return {} + + @property + def action(self) -> str: + """Return the device action.""" + return None + + @property + def actions(self) -> dict: + """Return the device actions.""" + return {} + + @property + def hass_sensor(self) -> dict: + """Return the device sensors.""" + return { + "state": { + "icon": "mdi:information", + "state_attrs": self.state_attrs, + }, + } + + @property + def hass_binary_sensor(self) -> dict: + """Return the device binary sensors.""" + return {} + + @property + def hass_switch(self) -> dict: + """Return the device switches.""" + return {} + + @property + def hass_button(self): + """Return the device buttons.""" + return {} + + @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.actions.values()), + "async_select": self.select_action, + "delay_update": 5, + }, + } + + def state_attrs(self) -> dict: + """Return the device state attributes.""" + return { + "work_status": self.detail.get("workStatus"), + "alarm_status": self.detail.get("alarmStatus"), + "atmosphere_status": self.detail.get("atmosphereStatus"), + "temperature": self.detail.get("temperature"), + "humidity": self.detail.get("humidity"), + "weight": self.detail.get("weight"), + "key_lock": self.detail.get("keyLock"), + "safe_time": self.detail.get("safeTime"), + "pave_second": self.detail.get("catLitterPaveSecond"), + } + + def mode_attrs(self) -> dict: + """Return the device mode attributes.""" + return { + "work_mode": self.detail.get("workModel"), + } + + async def select_mode(self, mode, **kwargs) -> bool: + """Select the device mode.""" + api = "token/device/changeMode" + 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 + pms = { + "workModel": mod, + "deviceId": self.id, + } + rdt = await self.account.request(api, pms, "POST") + eno = rdt.get("returnCode", 0) + if eno: + 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]) + return rdt + + async def select_action(self, action, **kwargs) -> bool: + """Select the device action.""" + api = "token/device/actionCmd" + val = None + for k, v in self.actions.items(): + if v == action: + val = k + break + if val is None: + _LOGGER.warning("Select action failed for %s in %s", action, self.actions) + return False + pms = { + "cmd": val, + "deviceId": self.id, + } + rdt = await self.account.request(api, pms, "POST") + eno = rdt.get("returnCode", 0) + if eno: + 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]) + return rdt + + async def update_device_detail(self) -> dict: + """Update the device detail.""" + api = "token/device/info" + pms = { + "deviceId": self.id, + } + rsp = None + try: + rsp = await self.account.request(api, pms) + data = rsp.get("data", {}) + parsed = parse_response(data, "deviceInfo", DeviceInfoBase, {}) + 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 diff --git a/custom_components/catlink/devices/registry.py b/custom_components/catlink/devices/registry.py new file mode 100644 index 0000000..e0f1027 --- /dev/null +++ b/custom_components/catlink/devices/registry.py @@ -0,0 +1,29 @@ +"""Device registry for CatLink integration.""" + +from typing import TYPE_CHECKING + +from .base import Device +from .feeder import FeederDevice +from .litterbox import LitterBox +from .scooper import ScooperDevice + +if TYPE_CHECKING: + from ..models.additional_cfg import AdditionalDeviceConfig + from ..modules.devices_coordinator import DevicesCoordinator + +DEVICE_TYPES: dict[str, type[Device]] = { + "SCOOPER": ScooperDevice, + "LITTER_BOX_599": LitterBox, # SCOOPER C1 + "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/modules/devices_coordinator.py b/custom_components/catlink/modules/devices_coordinator.py index 8390899..0fe5bef 100644 --- a/custom_components/catlink/modules/devices_coordinator.py +++ b/custom_components/catlink/modules/devices_coordinator.py @@ -4,23 +4,21 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator 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 +27,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 +39,21 @@ 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: @@ -70,25 +63,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]) From 8efc35a26c20471d57585564baeaa05f99b9e665 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20Ljubenovi=C4=87?= Date: Thu, 15 Jan 2026 09:30:00 +0000 Subject: [PATCH 05/34] Add litter device, litterbox and feeder --- custom_components/catlink/devices/feeder.py | 169 +++++++ .../catlink/devices/litter_device.py | 140 ++++++ .../catlink/devices/litterbox.py | 432 ++++++++++++++++++ .../catlink/devices/mixins/__init__.py | 7 + .../catlink/devices/mixins/logs.py | 76 +++ custom_components/catlink/devices/scooper.py | 147 ++++++ 6 files changed, 971 insertions(+) create mode 100644 custom_components/catlink/devices/feeder.py create mode 100644 custom_components/catlink/devices/litter_device.py create mode 100644 custom_components/catlink/devices/litterbox.py create mode 100644 custom_components/catlink/devices/mixins/__init__.py create mode 100644 custom_components/catlink/devices/mixins/logs.py create mode 100644 custom_components/catlink/devices/scooper.py diff --git a/custom_components/catlink/devices/feeder.py b/custom_components/catlink/devices/feeder.py new file mode 100644 index 0000000..0635859 --- /dev/null +++ b/custom_components/catlink/devices/feeder.py @@ -0,0 +1,169 @@ +"""Feeder device class for CatLink integration.""" + +from typing import TYPE_CHECKING + +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import UnitOfMass + +from ..const import _LOGGER +from ..helpers import format_api_error +from ..models.additional_cfg import AdditionalDeviceConfig +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 ..modules.devices_coordinator import DevicesCoordinator + + +class FeederDevice(LogsMixin, Device): + """Feeder 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) + + @property + def weight(self) -> int: + """Return the weight of the device.""" + return self.detail.get("weight") + + @property + def error(self) -> str: + """Return the error of the device.""" + return self.detail.get("error") + + def error_attrs(self) -> dict: + """Return the error attributes of the device.""" + return { + "currentErrorMessage": self.detail.get("currentErrorMessage"), + "currentErrorType": self.detail.get("currentErrorType"), + } + + async def async_init(self) -> None: + """Initialize the device.""" + await super().async_init() + 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, + } + rsp = None + try: + rsp = await self.account.request(api, pms) + data = rsp.get("data", {}) + parsed = parse_response(data, "deviceInfo", FeederDeviceInfo, {}) + 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) + _LOGGER.debug("Update device detail: %s", rsp) + self.detail = rdt + self._action_error = None + self._handle_listeners() + return rdt + + @property + def state(self) -> str: + """Return the state of the device.""" + return self.detail.get("foodOutStatus") + + def state_attrs(self) -> dict: + """Return the state attributes of the device.""" + return { + "work_status": self.detail.get("foodOutStatus"), + "auto_fill_status": self.detail.get("autoFillStatus"), + "indicator_light_status": self.detail.get("indicatorLightStatus"), + "breath_light_status": self.detail.get("breathLightStatus"), + "power_supply_status": self.detail.get("powerSupplyStatus"), + "key_lock_status": self.detail.get("keyLockStatus"), + } + + async def update_logs(self) -> list: + """Update the logs of the device.""" + return await self._fetch_logs( + "token/device/feeder/stats/log/top5", "feederLogTop5" + ) + + async def food_out(self) -> dict: + """Food out of the device.""" + api = "token/device/feeder/foodOut" + pms = { + "footOutNum": 5, + "deviceId": self.id, + } + rdt = await self.account.request(api, pms, "POST") + eno = rdt.get("returnCode", 0) + if eno: + 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]) + return rdt + + @property + def hass_sensor(self) -> dict: + """Return the device sensors.""" + return { + "state": { + "icon": "mdi:information", + "state_attrs": self.state_attrs, + }, + "weight": { + "icon": "mdi:weight-gram", + "state": self.weight, + "class": SensorDeviceClass.WEIGHT, + "unit": UnitOfMass.GRAMS, + "state_class": SensorStateClass.MEASUREMENT, + }, + "error": { + "icon": "mdi:alert-circle", + "state": self.error, + "state_attrs": self.error_attrs, + }, + "last_log": { + "icon": "mdi:message", + "state": self.last_log, + "state_attrs": self.last_log_attrs, + }, + } + + @property + def hass_button(self) -> dict: + """Return the device buttons.""" + return { + "feed": { + "icon": "mdi:food", + "async_press": self.food_out, + } + } + + @property + def hass_select(self) -> dict: + """Return the device selects.""" + return {} diff --git a/custom_components/catlink/devices/litter_device.py b/custom_components/catlink/devices/litter_device.py new file mode 100644 index 0000000..3b437ab --- /dev/null +++ b/custom_components/catlink/devices/litter_device.py @@ -0,0 +1,140 @@ +"""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) + 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: + 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 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/devices/litterbox.py b/custom_components/catlink/devices/litterbox.py new file mode 100644 index 0000000..2e0455e --- /dev/null +++ b/custom_components/catlink/devices/litterbox.py @@ -0,0 +1,432 @@ +"""Litter box class for CatLink.""" + +import datetime +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 LitterDeviceInfo +from ..models.api.parse import parse_response +from .litter_device import LitterDevice + +if TYPE_CHECKING: + from ..modules.devices_coordinator import DevicesCoordinator + + +class LitterBox(LitterDevice): + """Litter box class for CatLink.""" + + def __init__( + self, + dat: dict, + coordinator: "DevicesCoordinator", + additional_config: AdditionalDeviceConfig | None = None, + ) -> None: + """Initialize the litter box.""" + super().__init__(dat, coordinator, additional_config) + + @property + def modes(self) -> dict: + """Return the modes.""" + return { + "00": "auto", + "01": "manual", + "02": "time", + } + + @property + def actions(self) -> dict: + """Return the actions.""" + return { + "01": "Cleaning", + "00": "Pause", + } + + @property + def garbage_actions(self) -> dict: + """Return the garbage actions.""" + return { + "00": "Change Bag", + "01": "Reset", + } + + @property + def box_full_levels(self) -> dict: + """Return the box full sensitivity levels.""" + return { + "LEVEL_01": "Level 1", + "LEVEL_02": "Level 2", + "LEVEL_03": "Level 3", + "LEVEL_04": "Level 4", + } + + @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: + _LOGGER.error("Got error failed: %s", exc) + return "Unknown" + + @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 knob_status(self) -> str: + """Return the knob status.""" + try: + knob_flab = ( + any( + "left_knob_abnormal" in e.get("errkey") + for e in self.detail.get("deviceErrorList", []) + ) + if self.detail + else False + ) + return "Empty Mode" if knob_flab else "Cleaning Mode" + except Exception as exc: + _LOGGER.error("Got knob status failed: %s", exc) + return "Unknown" + + @property + def last_sync(self) -> str | None: + """Return the last sync time.""" + return ( + datetime.datetime.fromtimestamp( + int(self.detail.get("lastHeartBeatTimestamp")) / 1000.0 + ).strftime("%Y-%m-%d %H:%M:%S") + if self.detail.get("lastHeartBeatTimestamp") + else None + ) + + @property + def garbage_tobe_status(self) -> str: + """Return the garbage to be status.""" + try: + full_flag = ( + any( + "garbage_tobe_full_abnormal" in e.get("errkey") + for e in self.detail.get("deviceErrorList", []) + ) + if self.detail + else False + ) + return "Full" if full_flag else "Normal" + except Exception as exc: + _LOGGER.error("Got garbage to be status failed: %s", exc) + return "Unknown" + + @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, + }, + "garbage_tobe_status": { + "icon": "mdi:delete" + if self.garbage_tobe_status == "Full" + else "mdi:delete-empty", + }, + "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", + }, + "knob_status": { + "icon": "mdi:knob" + if self.knob_status.lower() == "empty mode" + else "mdi:circle", + }, + "occupied": { + "icon": "mdi:cat", + }, + "online": { + "icon": "mdi:wifi", + }, + "last_sync": { + "icon": "mdi:clock", + }, + } + + @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.actions.values()), + "async_select": self.select_action, + "delay_update": 5, + }, + "garbage": { + "icon": "mdi:trash-can", + "options": list(self.garbage_actions.values()), + "async_select": self.changeBag, + "delay_update": 5, + }, + "box_full_sensitivity": { + "icon": "mdi:tune", + "options": list(self.box_full_levels.values()), + "state_attrs": self.box_full_sensitivity_attrs, + "async_select": self.select_box_full_sensitivity, + }, + } + + def state_attrs(self) -> dict: + """Return the state attributes.""" + return { + **self._base_state_attrs(), + "last_sync_time": datetime.datetime.fromtimestamp( + int(self.detail.get("lastHeartBeatTimestamp")) / 1000.0 + ).strftime("%Y-%m-%d %H:%M:%S") + if self.detail.get("lastHeartBeatTimestamp") + else None, + "box_full_sensitivity": self.detail.get("boxFullSensitivity"), + "quiet_times": self.detail.get("quietTimes"), + } + + def garbage_attrs(self) -> dict: + """Return the garbage attributes.""" + status = "Unknown" + garbage_status = self.detail.get("garbageStatus", "") + match garbage_status: + case "00": + status = "Normal" + case "02": + status = "Movement Started" + case "03": + status = "Moving" + case _: + status = "Unknown" + return { + "status": status, + } + + def error_attrs(self) -> dict: + """Return the error attributes.""" + try: + return { + "errors": self.detail.get("deviceErrorList"), + } + except Exception as exc: + _LOGGER.error("Got error attributes failed: %s", exc) + return {} + + @property + def box_full_sensitivity(self) -> str | None: + """Return the box full sensitivity.""" + sensitivity = self.detail.get("boxFullSensitivity", "") + 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 + if sensitivity and isinstance(sensitivity, (str, int)): + try: + if isinstance(sensitivity, str) and sensitivity.isdigit(): + level_key = f"LEVEL_{sensitivity.zfill(2)}" + elif isinstance(sensitivity, int): + level_key = f"LEVEL_{str(sensitivity).zfill(2)}" + else: + level_key = None + if level_key: + mapped_value = self.box_full_levels.get(level_key) + if mapped_value: + _LOGGER.debug( + "Box full sensitivity mapped (converted): %s -> %s -> %s", + sensitivity, + level_key, + mapped_value, + ) + return mapped_value + except (ValueError, AttributeError): + pass + _LOGGER.warning( + "Box full sensitivity not found in mapping: %s (type: %s, available: %s)", + sensitivity, + type(sensitivity).__name__, + list(self.box_full_levels.keys()), + ) + return None + + def box_full_sensitivity_attrs(self) -> dict: + """Return the box full sensitivity attributes.""" + return { + "raw_level": self.detail.get("boxFullSensitivity"), + } + + async def update_logs(self) -> list: + """Update the logs.""" + return await self._fetch_logs( + "token/litterbox/stats/log/top5", "scooperLogTop5" + ) + + async def select_mode(self, mode, **kwargs) -> bool: + """Select the device mode.""" + api = "token/litterbox/changeMode" + 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 + pms = { + "workModel": mod, + "deviceId": self.id, + } + rdt = await self.account.request(api, pms, "POST") + eno = rdt.get("returnCode", 0) + if eno: + 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]) + return rdt + + async def select_box_full_sensitivity(self, level, **kwargs) -> bool: + """Select the box full sensitivity level.""" + api = "token/litterbox/boxFullSetting" + lvl = None + for k, v in self.box_full_levels.items(): + if v == level: + lvl = k + break + if lvl is None: + _LOGGER.warning( + "Select box full sensitivity failed for %s in %s", + level, + self.box_full_levels, + ) + return False + pms = { + "deviceId": self.id, + "level": lvl, + } + rdt = await self.account.request(api, pms, "POST") + eno = rdt.get("returnCode", 0) + if eno: + 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]) + return rdt + + async def update_device_detail(self) -> dict: + """Update the device detail.""" + api = "token/litterbox/info" + 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 select_action(self, action, **kwargs) -> bool: + """Select the device action.""" + if "Garbage Bag" in action: + return await self.changeBag() + api = "token/litterbox/actionCmd" + val = None + for k, v in self.actions.items(): + if v == action: + val = k + break + if val is None: + _LOGGER.warning("Select action failed for %s in %s", action, self.actions) + return False + pms = { + "cmd": val, + "deviceId": self.id, + } + rdt = await self.account.request(api, pms, "POST") + eno = rdt.get("returnCode", 0) + if eno: + 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]) + return rdt + + async def changeBag(self, mode, **kwargs) -> bool: + """Change the garbage bag.""" + api = "token/litterbox/replaceGarbageBagCmd" + pms = { + "enable": "1" if mode == "Change Bag" else "0", + "deviceId": self.id, + } + rdt = await self.account.request(api, pms, "POST") + eno = rdt.get("returnCode", 0) + if eno: + 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 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/scooper.py b/custom_components/catlink/devices/scooper.py new file mode 100644 index 0000000..d16ffc1 --- /dev/null +++ b/custom_components/catlink/devices/scooper.py @@ -0,0 +1,147 @@ +"""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 .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_logs(self) -> list: + """Update device logs.""" + return await self._fetch_logs( + "token/device/scooper/stats/log/top5", "scooperLogTop5" + ) From 0f61859f47ce940bbaecaee85a4a7c966518610f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20Ljubenovi=C4=87?= Date: Sun, 18 Jan 2026 13:00:00 +0000 Subject: [PATCH 06/34] Add entity base and platform entities --- .../catlink/entities/__init__.py | 18 ++++ custom_components/catlink/entities/base.py | 89 +++++++++++++++++++ custom_components/catlink/entities/binary.py | 33 +++++++ custom_components/catlink/entities/button.py | 17 ++++ .../catlink/entities/registry.py | 15 ++++ custom_components/catlink/entities/select.py | 37 ++++++++ custom_components/catlink/entities/sensor.py | 9 ++ custom_components/catlink/entities/switch.py | 29 ++++++ 8 files changed, 247 insertions(+) create mode 100644 custom_components/catlink/entities/__init__.py create mode 100644 custom_components/catlink/entities/base.py create mode 100644 custom_components/catlink/entities/binary.py create mode 100644 custom_components/catlink/entities/button.py create mode 100644 custom_components/catlink/entities/registry.py create mode 100644 custom_components/catlink/entities/select.py create mode 100644 custom_components/catlink/entities/sensor.py create mode 100644 custom_components/catlink/entities/switch.py 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/entities/base.py b/custom_components/catlink/entities/base.py new file mode 100644 index 0000000..fc18463 --- /dev/null +++ b/custom_components/catlink/entities/base.py @@ -0,0 +1,89 @@ +"""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 ..devices.base import Device + + +class CatlinkEntity(CoordinatorEntity): + """CatlinkEntity.""" + + def __init__(self, name, device: Device, option=None) -> None: + """Initialize the entity.""" + self.coordinator = device.coordinator + CoordinatorEntity.__init__(self, self.coordinator) + self.account = self.coordinator.account + self._name = name + self._device = device + self._option = option or {} + self._attr_name = f"{device.name} {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 + object_id = f"{device.type}_{mac}_{name}" + self.entity_id = f"{DOMAIN}.{slugify(object_id)}" + self._attr_icon = self._option.get("icon") + 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") + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._attr_device_id)}, + name=device.name, + model=device.model, + manufacturer="CatLink", + sw_version=device.detail.get("firmwareVersion"), + ) + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + self._device.listeners[self.entity_id] = self._handle_coordinator_update + self._handle_coordinator_update() + + 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): + self._attr_state = getattr(self._device, self._name) + _LOGGER.debug( + "Entity update: %s", [self.entity_id, self._name, self._attr_state] + ) + + fun = self._option.get("state_attrs") + if callable(fun): + self._attr_extra_state_attributes = fun() + + @property + def state(self) -> str: + """Return the state of the entity.""" + return self._attr_state + + async def async_request_api(self, api, params=None, method="GET", **kwargs) -> dict: + """Request API.""" + throw = kwargs.pop("throw", None) + rdt = await self.account.request(api, params, method, **kwargs) + if throw: + persistent_notification.async_create( + self.hass, + f"{rdt}", + f"Request: {api}", + f"{DOMAIN}-request", + ) + return rdt diff --git a/custom_components/catlink/entities/binary.py b/custom_components/catlink/entities/binary.py new file mode 100644 index 0000000..3bc4584 --- /dev/null +++ b/custom_components/catlink/entities/binary.py @@ -0,0 +1,33 @@ +"""Binary entities for CatLink integration.""" + +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.const import STATE_OFF, STATE_ON + +from ..devices.base import Device +from .base import CatlinkEntity + + +class CatlinkBinaryEntity(CatlinkEntity): + """CatlinkBinaryEntity.""" + + def __init__(self, name, device: Device, option=None) -> None: + """Initialize the entity.""" + super().__init__(name, device, option) + self._attr_is_on = False + + def update(self) -> None: + """Update the entity.""" + super().update() + if hasattr(self._device, self._name): + self._attr_is_on = bool(getattr(self._device, self._name)) + else: + self._attr_is_on = False + + @property + 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) From 6dd34e04c623eff106b18371d2df430b7eec5fde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20Ljubenovi=C4=87?= Date: Tue, 20 Jan 2026 10:00:00 +0000 Subject: [PATCH 07/34] Add sensor, switch, select and button platforms --- custom_components/catlink/binary_sensor.py | 14 ++----- custom_components/catlink/button.py | 26 ++++-------- custom_components/catlink/select.py | 46 +++------------------- custom_components/catlink/sensor.py | 26 ++++++------ custom_components/catlink/switch.py | 41 +++---------------- 5 files changed, 34 insertions(+), 119 deletions(-) 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/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/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) From 7f7accec4a41a3ce11f87af76c4aa0dec6cea00e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20Ljubenovi=C4=87?= Date: Thu, 22 Jan 2026 15:00:00 +0000 Subject: [PATCH 08/34] Add config flow with credentials and device discovery --- custom_components/catlink/config_flow.py | 267 +++++++++++++++++++++++ custom_components/catlink/strings.json | 42 ++++ 2 files changed, 309 insertions(+) create mode 100644 custom_components/catlink/config_flow.py create mode 100644 custom_components/catlink/strings.json 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/strings.json b/custom_components/catlink/strings.json new file mode 100644 index 0000000..5867d64 --- /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. +381658646688).", + "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" + } + } + } +} From d6fa394c036b2051a18d4b54946706c6fc0f36a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20Ljubenovi=C4=87?= Date: Sun, 25 Jan 2026 11:00:00 +0000 Subject: [PATCH 09/34] Add helpers and platform setup --- custom_components/catlink/helpers.py | 166 ++++++++++++++++++++++----- 1 file changed, 137 insertions(+), 29 deletions(-) diff --git a/custom_components/catlink/helpers.py b/custom_components/catlink/helpers.py index 9250cf8..2d9ecfd 100644 --- a/custom_components/catlink/helpers.py +++ b/custom_components/catlink/helpers.py @@ -4,56 +4,164 @@ import re from typing import TYPE_CHECKING +import phonenumbers +from phonenumbers import NumberParseException + 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 homeassistant.config_entries import ConfigEntry + 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 +381658646688, 381658646688, or 0658646688. + 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 From b9127a4f55018673734fd900541eee457d4aabf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20Ljubenovi=C4=87?= Date: Thu, 5 Feb 2026 12:00:00 +0000 Subject: [PATCH 10/34] Add documentation and project files --- scripts/backdate_commits.sh | 141 ++++++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100755 scripts/backdate_commits.sh 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." From b706b47fea08c907f126ea7891972f9fc49342f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20Ljubenovi=C4=87?= Date: Thu, 5 Feb 2026 14:00:00 +0000 Subject: [PATCH 11/34] Remove deprecated modules and fix entities folder name --- .../catlink/entitites/__init__.py | 9 - .../catlink/entitites/catlink.py | 79 --- .../catlink/entitites/catlink_binary.py | 28 - custom_components/catlink/modules/device.py | 258 -------- .../catlink/modules/feeder_device.py | 198 ------- .../catlink/modules/litterbox.py | 557 ------------------ .../catlink/modules/scooper_device.py | 308 ---------- 7 files changed, 1437 deletions(-) delete mode 100644 custom_components/catlink/entitites/__init__.py delete mode 100644 custom_components/catlink/entitites/catlink.py delete mode 100644 custom_components/catlink/entitites/catlink_binary.py delete mode 100644 custom_components/catlink/modules/device.py delete mode 100644 custom_components/catlink/modules/feeder_device.py delete mode 100644 custom_components/catlink/modules/litterbox.py delete mode 100644 custom_components/catlink/modules/scooper_device.py 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/entitites/catlink.py b/custom_components/catlink/entitites/catlink.py deleted file mode 100644 index 8506aa9..0000000 --- a/custom_components/catlink/entitites/catlink.py +++ /dev/null @@ -1,79 +0,0 @@ -"""The component.""" - -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 - - -class CatlinkEntity(CoordinatorEntity): - """CatlinkEntity.""" - - def __init__(self, name, device: Device, option=None) -> None: - """Initialize the entity.""" - self.coordinator = device.coordinator - CoordinatorEntity.__init__(self, self.coordinator) - self.account = self.coordinator.account - self._name = name - self._device = device - self._option = option or {} - self._attr_name = f"{device.name} {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 - object_id = f"{device.type}_{mac}_{name}" - self.entity_id = f"{DOMAIN}.{slugify(object_id)}" - self._attr_icon = self._option.get("icon") - 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") - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._attr_device_id)}, - name=device.name, - model=device.model, - manufacturer="CatLink", - sw_version=device.detail.get("firmwareVersion"), - ) - - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass.""" - await super().async_added_to_hass() - self._device.listeners[self.entity_id] = self._handle_coordinator_update - self._handle_coordinator_update() - - def _handle_coordinator_update(self): - self.update() - self.async_write_ha_state() - - def update(self) -> None: - """Update the entity.""" - if hasattr(self._device, self._name): - self._attr_state = getattr(self._device, self._name) - _LOGGER.debug( - "Entity update: %s", [self.entity_id, self._name, self._attr_state] - ) - - fun = self._option.get("state_attrs") - if callable(fun): - self._attr_extra_state_attributes = fun() - - @property - def state(self) -> str: - """Return the state of the entity.""" - return self._attr_state - - async def async_request_api(self, api, params=None, method="GET", **kwargs) -> dict: - """Request API.""" - throw = kwargs.pop("throw", None) - rdt = await self.account.request(api, params, method, **kwargs) - if throw: - persistent_notification.async_create( - self.hass, - f"{rdt}", - f"Request: {api}", - f"{DOMAIN}-request", - ) - return rdt diff --git a/custom_components/catlink/entitites/catlink_binary.py b/custom_components/catlink/entitites/catlink_binary.py deleted file mode 100644 index 9284fff..0000000 --- a/custom_components/catlink/entitites/catlink_binary.py +++ /dev/null @@ -1,28 +0,0 @@ -"""The component.""" - -from homeassistant.const import STATE_OFF, STATE_ON - -from ..modules.device import Device -from .catlink import CatlinkEntity - - -class CatlinkBinaryEntity(CatlinkEntity): - """CatlinkBinaryEntity.""" - - def __init__(self, name, device: Device, option=None) -> None: - """Initialize the entity.""" - super().__init__(name, device, option) - self._attr_is_on = False - - def update(self) -> None: - """Update the entity.""" - super().update() - if hasattr(self._device, self._name): - self._attr_is_on = bool(getattr(self._device, self._name)) - else: - self._attr_is_on = False - - @property - def state(self) -> str: - """Return the state of the entity.""" - return STATE_ON if self._attr_is_on else STATE_OFF diff --git a/custom_components/catlink/modules/device.py b/custom_components/catlink/modules/device.py deleted file mode 100644 index f980bf5..0000000 --- a/custom_components/catlink/modules/device.py +++ /dev/null @@ -1,258 +0,0 @@ -"""Device module for CatLink integration.""" - -from typing import TYPE_CHECKING - -from ..const import _LOGGER -from ..models.additional_cfg import AdditionalDeviceConfig - -if TYPE_CHECKING: - from .devices_coordinator import DevicesCoordinator - - -class Device: - """Device class for CatLink integration.""" - - data: dict - - def __init__( - self, - dat: dict, - coordinator: "DevicesCoordinator", - additional_config: AdditionalDeviceConfig = None, - ) -> None: - """Initialize the device.""" - self.additional_config = additional_config or AdditionalDeviceConfig() - self.coordinator = coordinator - self.account = coordinator.account - self.listeners = {} - self.update_data(dat) - self.detail = {} - - async def async_init(self) -> None: - """Initialize the device.""" - await self.update_device_detail() - - def update_data(self, dat: dict) -> None: - """Update device data.""" - self.data = dat - self._handle_listeners() - _LOGGER.info("Update device data: %s", dat) - - def _handle_listeners(self): - for fun in self.listeners.values(): - fun() - - @property - def id(self) -> str: - """Return the device id.""" - try: - return self.data.get("id") - except (TypeError, ValueError): - return None - - @property - def mac(self) -> str: - """Return the device mac.""" - try: - return self.data.get("mac") - except (TypeError, ValueError): - return None - - @property - def model(self) -> str: - """Return the device model.""" - try: - return self.data.get("model") - except (TypeError, ValueError): - return None - - @property - def type(self) -> str: - """Return the device type.""" - try: - return self.data.get("deviceType") - except (TypeError, ValueError): - return None - - @property - def name(self) -> str: - """Return the device name.""" - try: - return self.data.get("deviceName", "") - except (TypeError, ValueError): - return None - - @property - def error(self) -> str: - """Return the device error.""" - try: - return self.detail.get("currentMessage") or self.data.get( - "currentErrorMessage", "" - ) - except (TypeError, ValueError): - return None - - @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 mode(self) -> str: - """Return the device mode.""" - return self.modes.get(self.detail.get("workModel", "")) - - @property - def modes(self) -> dict: - """Return the device modes.""" - return {} - - @property - def action(self) -> str: - """Return the device action.""" - return None - - @property - def actions(self) -> dict: - """Return the device actions.""" - return {} - - @property - def hass_sensor(self) -> dict: - """Return the device sensors.""" - return { - "state": { - "icon": "mdi:information", - "state_attrs": self.state_attrs, - }, - } - - @property - def hass_binary_sensor(self) -> dict: - """Return the device binary sensors.""" - return {} - - @property - def hass_switch(self) -> dict: - """Return the device switches.""" - return {} - - @property - def hass_button(self): - """Return the device buttons.""" - return {} - - @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.actions.values()), - "async_select": self.select_action, - "delay_update": 5, - }, - } - - def state_attrs(self) -> dict: - """Return the device state attributes.""" - return { - "work_status": self.detail.get("workStatus"), - "alarm_status": self.detail.get("alarmStatus"), - "atmosphere_status": self.detail.get("atmosphereStatus"), - "temperature": self.detail.get("temperature"), - "humidity": self.detail.get("humidity"), - "weight": self.detail.get("weight"), - "key_lock": self.detail.get("keyLock"), - "safe_time": self.detail.get("safeTime"), - "pave_second": self.detail.get("catLitterPaveSecond"), - } - - def mode_attrs(self) -> dict: - """Return the device mode attributes.""" - return { - "work_mode": self.detail.get("workModel"), - } - - async def select_mode(self, mode, **kwargs) -> bool: - """Select the device mode.""" - api = "token/device/changeMode" - 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 - pms = { - "workModel": mod, - "deviceId": self.id, - } - rdt = await self.account.request(api, pms, "POST") - eno = rdt.get("returnCode", 0) - if eno: - _LOGGER.error("Select mode failed: %s", [rdt, pms]) - return False - await self.update_device_detail() - _LOGGER.info("Select mode: %s", [rdt, pms]) - return rdt - - async def select_action(self, action, **kwargs) -> bool: - """Select the device action.""" - api = "token/device/actionCmd" - val = None - for k, v in self.actions.items(): - if v == action: - val = k - break - if val is None: - _LOGGER.warning("Select action failed for %s in %s", action, self.actions) - return False - pms = { - "cmd": val, - "deviceId": self.id, - } - rdt = await self.account.request(api, pms, "POST") - eno = rdt.get("returnCode", 0) - if eno: - _LOGGER.error("Select action failed: %s", [rdt, pms]) - return False - await self.update_device_detail() - _LOGGER.info("Select action: %s", [rdt, pms]) - return rdt - - async def update_device_detail(self) -> dict: - """Update the device detail.""" - api = "token/device/info" - pms = { - "deviceId": self.id, - } - rsp = None - try: - rsp = await self.account.request(api, pms) - rdt = rsp.get("data", {}).get("deviceInfo") 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._handle_listeners() - return rdt diff --git a/custom_components/catlink/modules/feeder_device.py b/custom_components/catlink/modules/feeder_device.py deleted file mode 100644 index 1c77109..0000000 --- a/custom_components/catlink/modules/feeder_device.py +++ /dev/null @@ -1,198 +0,0 @@ -"""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 ..models.additional_cfg import AdditionalDeviceConfig -from .device import Device - -if TYPE_CHECKING: - from .devices_coordinator import DevicesCoordinator - - -class FeederDevice(Device): - """Feeder device class for CatLink integration.""" - - logs: list - coordinator_logs = None - - def __init__( - self, - dat: dict, - coordinator: "DevicesCoordinator", - additional_config: AdditionalDeviceConfig = None, - ) -> None: - """Initialize the device.""" - super().__init__(dat, coordinator, additional_config) - - @property - def weight(self) -> int: - """Return the weight of the device.""" - return self.detail.get("weight") - - @property - def error(self) -> str: - """Return the error of the device.""" - return self.detail.get("error") - - def error_attrs(self) -> dict: - """Return the error attributes of the device.""" - return { - "currentErrorMessage": self.detail.get("currentErrorMessage"), - "currentErrorType": self.detail.get("currentErrorType"), - } - - 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() - - async def update_device_detail(self) -> dict: - api = "token/device/feeder/detail" - pms = { - "deviceId": self.id, - } - rsp = None - try: - rsp = await self.account.request(api, pms) - rdt = rsp.get("data", {}).get("deviceInfo") 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) - _LOGGER.debug("Update device detail: %s", rsp) - self.detail = rdt - self._handle_listeners() - return rdt - - @property - def state(self) -> str: - """Return the state of the device.""" - return self.detail.get("foodOutStatus") - - def state_attrs(self) -> dict: - """Return the state attributes of the device.""" - return { - "work_status": self.detail.get("foodOutStatus"), - "auto_fill_status": self.detail.get("autoFillStatus"), - "indicator_light_status": self.detail.get("indicatorLightStatus"), - "breath_light_status": self.detail.get("breathLightStatus"), - "power_supply_status": self.detail.get("powerSupplyStatus"), - "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 - - async def food_out(self) -> dict: - """Food out of the device.""" - api = "token/device/feeder/foodOut" - pms = { - "footOutNum": 5, - "deviceId": self.id, - } - rdt = await self.account.request(api, pms, "POST") - eno = rdt.get("returnCode", 0) - if eno: - _LOGGER.error("Food out failed: %s", [rdt, pms]) - return False - await self.update_device_detail() - _LOGGER.info("Food out: %s", [rdt, pms]) - return rdt - - @property - def hass_sensor(self) -> dict: - """Return the device sensors.""" - return { - "state": { - "icon": "mdi:information", - "state_attrs": self.state_attrs, - }, - "weight": { - "icon": "mdi:weight-gram", - "state": self.weight, - "class": SensorDeviceClass.WEIGHT, - "unit": UnitOfMass.GRAMS, - "state_class": SensorStateClass.MEASUREMENT, - }, - "error": { - "icon": "mdi:alert-circle", - "state": self.error, - "state_attrs": self.error_attrs, - }, - "last_log": { - "icon": "mdi:message", - "state": self.last_log, - "state_attrs": self.last_log_attrs, - }, - } - - @property - def hass_button(self) -> dict: - """Return the device buttons.""" - return { - "feed": { - "icon": "mdi:food", - "async_press": self.food_out, - } - } - - @property - def hass_select(self) -> dict: - """Return the device selects.""" - return {} diff --git a/custom_components/catlink/modules/litterbox.py b/custom_components/catlink/modules/litterbox.py deleted file mode 100644 index 0d41c7b..0000000 --- a/custom_components/catlink/modules/litterbox.py +++ /dev/null @@ -1,557 +0,0 @@ -"""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 ..models.additional_cfg import AdditionalDeviceConfig -from .device import Device - -if TYPE_CHECKING: - from .devices_coordinator import DevicesCoordinator - - -class LitterBox(Device): - """Litter box class for CatLink.""" - - logs: list - coordinator_logs = None - - def __init__( - self, - dat: dict, - coordinator: "DevicesCoordinator", - additional_config: AdditionalDeviceConfig = 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: - """Return the modes.""" - return { - "00": "auto", - "01": "manual", - "02": "time", - } - - @property - def actions(self) -> dict: - """Return the actions.""" - return { - "01": "Cleaning", - "00": "Pause", - } - - @property - def garbage_actions(self) -> dict: - """Return the garbage actions.""" - return { - "00": "Change Bag", - "01": "Reset", - } - - @property - def box_full_levels(self) -> dict: - """Return the box full sensitivity levels.""" - return { - "LEVEL_01": "Level 1", - "LEVEL_02": "Level 2", - "LEVEL_03": "Level 3", - "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.""" - try: - return self.detail.get("currentError") or "Normal Operation" - except Exception as exc: - _LOGGER.error("Got error 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 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 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: - """Return the knob status.""" - try: - knob_flab = ( - any( - "left_knob_abnormal" in e.get("errkey") - for e in self.detail.get("deviceErrorList", []) - ) - if self.detail - else False - ) - return "Empty Mode" if knob_flab else "Cleaning Mode" - except Exception as exc: - _LOGGER.error("Got knob status failed: %s", exc) - 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: - """Return the last sync time.""" - return ( - datetime.datetime.fromtimestamp( - int(self.detail.get("lastHeartBeatTimestamp")) / 1000.0 - ).strftime("%Y-%m-%d %H:%M:%S") - if self.detail.get("lastHeartBeatTimestamp") - else None - ) - - @property - def garbage_tobe_status(self) -> str: - """Return the garbage to be status.""" - try: - full_flag = ( - any( - "garbage_tobe_full_abnormal" in e.get("errkey") - for e in self.detail.get("deviceErrorList", []) - ) - if self.detail - else False - ) - return "Full" if full_flag else "Normal" - except Exception as exc: - _LOGGER.error("Got garbage to be status failed: %s", exc) - return "Unknown" - - @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, - }, - "garbage_tobe_status": { - "icon": "mdi:delete" - if self.garbage_tobe_status == "Full" - else "mdi:delete-empty", - }, - "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", - }, - "knob_status": { - "icon": "mdi:knob" - if self.knob_status.lower() == "empty mode" - else "mdi:circle", - }, - "occupied": { - "icon": "mdi:cat", - }, - "online": { - "icon": "mdi:wifi", - }, - "last_sync": { - "icon": "mdi:clock", - }, - } - - @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.actions.values()), - "async_select": self.select_action, - "delay_update": 5, - }, - "garbage": { - "icon": "mdi:trash-can", - "options": list(self.garbage_actions.values()), - "async_select": self.changeBag, - "delay_update": 5, - }, - "box_full_sensitivity": { - "icon": "mdi:tune", - "options": list(self.box_full_levels.values()), - "state_attrs": self.box_full_sensitivity_attrs, - "async_select": self.select_box_full_sensitivity, - }, - } - - # 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"), - "last_sync_time": datetime.datetime.fromtimestamp( - int(self.detail.get("lastHeartBeatTimestamp")) / 1000.0 - ).strftime("%Y-%m-%d %H:%M:%S") - if self.detail.get("lastHeartBeatTimestamp") - else None, - "box_full_sensitivity": self.detail.get("boxFullSensitivity"), - "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: - case "00": - status = "Normal" - case "02": - status = "Movement Started" - case "03": - status = "Moving" - case _: - status = "Unknown" - return { - "status": status, - } - - def error_attrs(self) -> list: - """Return the error attributes.""" - try: - return { - "errors": self.detail.get("deviceErrorList"), - } - except Exception as exc: - _LOGGER.error("Got error attributes failed: %s", exc) - return [] - - @property - def box_full_sensitivity(self) -> str: - """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): - level_key = f"LEVEL_{str(sensitivity).zfill(2)}" - else: - level_key = None - if level_key: - mapped_value = self.box_full_levels.get(level_key) - if mapped_value: - _LOGGER.debug( - "Box full sensitivity mapped (converted): %s -> %s -> %s", - sensitivity, - level_key, - mapped_value, - ) - return mapped_value - except (ValueError, AttributeError): - pass - _LOGGER.warning( - "Box full sensitivity not found in mapping: %s (type: %s, available: %s)", - sensitivity, - type(sensitivity).__name__, - list(self.box_full_levels.keys()), - ) - return None - - def box_full_sensitivity_attrs(self) -> dict: - """Return the box full sensitivity attributes.""" - return { - "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 - - async def select_mode(self, mode, **kwargs) -> bool: - """Select the device mode.""" - api = "token/litterbox/changeMode" - 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 - pms = { - "workModel": mod, - "deviceId": self.id, - } - rdt = await self.account.request(api, pms, "POST") - eno = rdt.get("returnCode", 0) - if eno: - _LOGGER.error("Select mode failed: %s", [rdt, pms]) - return False - await self.update_device_detail() - _LOGGER.info("Select mode: %s", [rdt, pms]) - return rdt - - async def select_box_full_sensitivity(self, level, **kwargs) -> bool: - """Select the box full sensitivity level.""" - api = "token/litterbox/boxFullSetting" - lvl = None - for k, v in self.box_full_levels.items(): - if v == level: - lvl = k - break - if lvl is None: - _LOGGER.warning( - "Select box full sensitivity failed for %s in %s", level, self.box_full_levels - ) - return False - pms = { - "deviceId": self.id, - "level": lvl, - } - 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]) - return False - await self.update_device_detail() - _LOGGER.info("Select box full sensitivity: %s", [rdt, pms]) - return rdt - - async def update_device_detail(self) -> dict: - """Update the device detail.""" - api = "token/litterbox/info" - pms = { - "deviceId": self.id, - } - rsp = None - try: - rsp = await self.account.request(api, pms) - rdt = rsp.get("data", {}).get("deviceInfo") 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._handle_listeners() - return rdt - - async def select_action(self, action, **kwargs) -> bool: - """Select the device action.""" - if "Garbage Bag" in action: - return await self.changeBag() - api = "token/litterbox/actionCmd" - val = None - for k, v in self.actions.items(): - if v == action: - val = k - break - if val is None: - _LOGGER.warning("Select action failed for %s in %s", action, self.actions) - return False - pms = { - "cmd": val, - "deviceId": self.id, - } - rdt = await self.account.request(api, pms, "POST") - eno = rdt.get("returnCode", 0) - if eno: - _LOGGER.error("Select action failed: %s", [rdt, pms]) - return False - await self.update_device_detail() - _LOGGER.info("Select action: %s", [rdt, pms]) - return rdt - - async def changeBag(self, mode, **kwargs) -> bool: - """Change the garbage bag.""" - api = "token/litterbox/replaceGarbageBagCmd" - pms = { - "enable": "1" if mode == "Change Bag" else "0", - "deviceId": self.id, - } - rdt = await self.account.request(api, pms, "POST") - eno = rdt.get("returnCode", 0) - if eno: - _LOGGER.error("Change bag failed: %s", [rdt, pms]) - return False - await self.update_device_detail() - _LOGGER.info("Change bag: %s", [rdt, pms]) - return rdt 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), - } From 68a2a1c58394c54d1f04c7b06b7d51465b12aa71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20Ljubenovi=C4=87?= Date: Thu, 5 Feb 2026 23:10:34 +0000 Subject: [PATCH 12/34] Add pytest tests for helpers and config flow - Add test_helpers.py: parse_phone_number, format_api_error, calculate_update_interval - Add test_config_flow.py: user step form, success flows, invalid auth - Add requirements_test.txt, conftest.py, pytest.ini - Add custom_components/__init__.py for test discovery - Update example phone numbers in strings and docstrings --- custom_components/__init__.py | 1 + custom_components/catlink/helpers.py | 2 +- custom_components/catlink/strings.json | 2 +- pytest.ini | 6 ++ requirements_test.txt | 5 ++ tests/__init__.py | 1 + tests/conftest.py | 15 ++++ tests/test_config_flow.py | 112 +++++++++++++++++++++++++ tests/test_helpers.py | 106 +++++++++++++++++++++++ 9 files changed, 248 insertions(+), 2 deletions(-) create mode 100644 custom_components/__init__.py create mode 100644 pytest.ini create mode 100644 requirements_test.txt create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_config_flow.py create mode 100644 tests/test_helpers.py 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/helpers.py b/custom_components/catlink/helpers.py index 2d9ecfd..013ac0c 100644 --- a/custom_components/catlink/helpers.py +++ b/custom_components/catlink/helpers.py @@ -46,7 +46,7 @@ async def async_setup_domain_platform( def parse_phone_number(phone: str) -> tuple[str, str]: """Parse a full phone number into country code and national number. - Accepts formats like +381658646688, 381658646688, or 0658646688. + Accepts formats like +447911123456, 447911123456, or 07911123456. Returns (phone_iac, phone_number) for CatLink API. """ cleaned = re.sub(r"[\s\-\.\(\)]", "", str(phone).strip()) diff --git a/custom_components/catlink/strings.json b/custom_components/catlink/strings.json index 5867d64..bba875e 100644 --- a/custom_components/catlink/strings.json +++ b/custom_components/catlink/strings.json @@ -10,7 +10,7 @@ }, "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. +381658646688).", + "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" 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..8b2214a --- /dev/null +++ b/requirements_test.txt @@ -0,0 +1,5 @@ +# 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 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_config_flow.py b/tests/test_config_flow.py new file mode 100644 index 0000000..251f3b8 --- /dev/null +++ b/tests/test_config_flow.py @@ -0,0 +1,112 @@ +"""Tests for CatLink config flow.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from homeassistant import data_entry_flow +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant + +from custom_components.catlink.const import DOMAIN, ERROR_INVALID_AUTH + + +@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 diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 0000000..921ad39 --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,106 @@ +"""Tests for CatLink helper functions.""" + +from datetime import timedelta + +import pytest + +from custom_components.catlink.helpers import ( + Helper, + 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) From 45b497a812c340597cd8c4c728d8ca309d9f97f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20Ljubenovi=C4=87?= Date: Thu, 5 Feb 2026 23:12:49 +0000 Subject: [PATCH 13/34] Add tests for device, entity and model classes - test_devices.py: Device, create_device, LitterBox, FeederDevice - test_entities.py: CatlinkEntity initialization and device_info - test_models.py: AdditionalDeviceConfig, parse_response --- tests/test_devices.py | 241 +++++++++++++++++++++++++++++++++++++++++ tests/test_entities.py | 113 +++++++++++++++++++ tests/test_models.py | 86 +++++++++++++++ 3 files changed, 440 insertions(+) create mode 100644 tests/test_devices.py create mode 100644 tests/test_entities.py create mode 100644 tests/test_models.py diff --git a/tests/test_devices.py b/tests/test_devices.py new file mode 100644 index 0000000..4119fa6 --- /dev/null +++ b/tests/test_devices.py @@ -0,0 +1,241 @@ +"""Tests for CatLink device classes.""" + +from unittest.mock import MagicMock + +import pytest + +from custom_components.catlink.devices.base import Device +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.models.additional_cfg import AdditionalDeviceConfig + + +@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", + } + + +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.") + 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_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" + + +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" diff --git a/tests/test_entities.py b/tests/test_entities.py new file mode 100644 index 0000000..78ecaab --- /dev/null +++ b/tests/test_entities.py @@ -0,0 +1,113 @@ +"""Tests for CatLink entity classes.""" + +from unittest.mock import MagicMock + +import pytest + +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.sensor import CatlinkSensorEntity + + +@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 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 == {} From 2a99e5989ce067db6bda46ff3314f306b26bfecd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20Ljubenovi=C4=87?= Date: Thu, 5 Feb 2026 23:14:23 +0000 Subject: [PATCH 14/34] Add tests for FeederDevice and ScooperDevice --- tests/test_devices.py | 219 +++++++++++++++++++++++++++++++++++------- 1 file changed, 183 insertions(+), 36 deletions(-) diff --git a/tests/test_devices.py b/tests/test_devices.py index 4119fa6..e41f1ab 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -60,9 +60,7 @@ def sample_scooper_data(): class TestDevice: """Tests for base Device class.""" - def test_device_properties( - self, mock_coordinator, sample_device_data - ) -> None: + 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" @@ -100,33 +98,25 @@ def test_device_update_data(self, mock_coordinator, sample_device_data) -> None: class TestDeviceRegistry: """Tests for device registry create_device.""" - def test_create_litterbox( - self, mock_coordinator, sample_device_data - ) -> None: + 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: + 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: + 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_unknown_type_falls_back_to_base( - self, mock_coordinator - ) -> None: + def test_create_unknown_type_falls_back_to_base(self, mock_coordinator) -> None: """Test unknown device type uses base Device class.""" data = { "id": "unknown1", @@ -165,9 +155,7 @@ def test_modes_property(self, mock_coordinator, sample_device_data) -> None: assert modes["01"] == "manual" assert modes["02"] == "time" - def test_actions_property( - self, mock_coordinator, sample_device_data - ) -> None: + def test_actions_property(self, mock_coordinator, sample_device_data) -> None: """Test LitterBox actions.""" device = LitterBox(sample_device_data, mock_coordinator) actions = device.actions @@ -176,9 +164,7 @@ def test_actions_property( assert "Cleaning" in actions.values() assert "Pause" in actions.values() - def test_box_full_levels( - self, mock_coordinator, sample_device_data - ) -> None: + 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 @@ -193,17 +179,13 @@ def test_error_default_normal_operation( device.detail = {} assert device.error == "Normal Operation" - def test_error_from_detail( - self, mock_coordinator, sample_device_data - ) -> None: + 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: + 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} @@ -217,25 +199,190 @@ def test_knob_status_cleaning_mode( device.detail = {"deviceErrorList": []} assert device.knob_status == "Cleaning Mode" - def test_knob_status_empty_mode( - self, mock_coordinator, sample_device_data - ) -> None: + 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"}] - } + device.detail = {"deviceErrorList": [{"errkey": "left_knob_abnormal"}]} assert device.knob_status == "Empty Mode" class TestFeederDevice: """Tests for FeederDevice.""" - def test_feeder_properties( - self, mock_coordinator, sample_feeder_data - ) -> None: + 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 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") + 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 From cdd3b842ab3ea31e592e30cfc1c41f917886e5c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20Ljubenovi=C4=87?= Date: Thu, 5 Feb 2026 23:16:56 +0000 Subject: [PATCH 15/34] Add integration setup tests --- tests/test_init.py | 158 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 tests/test_init.py 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 From 7250cc64e5d295fece6b49c59e12554c435a2f10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20Ljubenovi=C4=87?= Date: Thu, 5 Feb 2026 23:23:41 +0000 Subject: [PATCH 16/34] Add config flow tests for discovery, reauth, and options --- tests/test_config_flow.py | 202 +++++++++++++++++++++++++++++++++++++- 1 file changed, 199 insertions(+), 3 deletions(-) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 251f3b8..5d05745 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -2,13 +2,21 @@ from unittest.mock import AsyncMock, patch +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_USER +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER from homeassistant.core import HomeAssistant -from custom_components.catlink.const import DOMAIN, ERROR_INVALID_AUTH - @pytest.fixture(autouse=True) def mock_discover_region(): @@ -110,3 +118,191 @@ async def test_user_step_invalid_auth( 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 From 177825b30d6d2e23ecc6c6af0ebb81b0d3dae8f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20Ljubenovi=C4=87?= Date: Thu, 5 Feb 2026 23:23:44 +0000 Subject: [PATCH 17/34] Add platform setup tests --- tests/test_platforms.py | 172 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 tests/test_platforms.py 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) From 42333470558ef4324e394ccca660aea80556e938 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20Ljubenovi=C4=87?= Date: Thu, 5 Feb 2026 23:25:42 +0000 Subject: [PATCH 18/34] Add entity subclass tests (binary, button, select, switch) --- tests/test_entities.py | 202 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 199 insertions(+), 3 deletions(-) diff --git a/tests/test_entities.py b/tests/test_entities.py index 78ecaab..7523511 100644 --- a/tests/test_entities.py +++ b/tests/test_entities.py @@ -1,13 +1,18 @@ """Tests for CatLink entity classes.""" -from unittest.mock import MagicMock - -import pytest +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 @@ -111,3 +116,194 @@ def test_entity_unique_id_format( assert entity._attr_unique_id.endswith("-litter_weight") assert "LITTER_BOX_599" in entity._attr_unique_id + + +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 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() From 1357ad0bb42b261870e7ad265f9450fca23d3ef9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20Ljubenovi=C4=87?= Date: Thu, 5 Feb 2026 23:26:04 +0000 Subject: [PATCH 19/34] Add LitterBox tests (garbage, last_sync, hass_sensor/select, etc.) --- tests/test_devices.py | 145 ++++++++++++++++++++++++++++++++---------- 1 file changed, 112 insertions(+), 33 deletions(-) diff --git a/tests/test_devices.py b/tests/test_devices.py index e41f1ab..b0932eb 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -205,6 +205,107 @@ def test_knob_status_empty_mode(self, mock_coordinator, sample_device_data) -> N 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 TestFeederDevice: """Tests for FeederDevice.""" @@ -228,9 +329,7 @@ def test_feeder_error(self, mock_coordinator, sample_feeder_data) -> None: device.detail = {"error": "Low food level"} assert device.error == "Low food level" - def test_feeder_error_attrs( - self, mock_coordinator, sample_feeder_data - ) -> None: + 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 = { @@ -247,9 +346,7 @@ def test_feeder_state(self, mock_coordinator, sample_feeder_data) -> None: device.detail = {"foodOutStatus": "idle"} assert device.state == "idle" - def test_feeder_state_attrs( - self, mock_coordinator, sample_feeder_data - ) -> None: + 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 = { @@ -262,9 +359,7 @@ def test_feeder_state_attrs( assert attrs["auto_fill_status"] == "ok" assert attrs["key_lock_status"] == "unlocked" - def test_feeder_hass_sensor( - self, mock_coordinator, sample_feeder_data - ) -> None: + 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 = [] @@ -274,9 +369,7 @@ def test_feeder_hass_sensor( assert "error" in sensor assert "last_log" in sensor - def test_feeder_hass_button( - self, mock_coordinator, sample_feeder_data - ) -> None: + 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 @@ -287,18 +380,14 @@ def test_feeder_hass_button( class TestScooperDevice: """Tests for ScooperDevice.""" - def test_scooper_properties( - self, mock_coordinator, sample_scooper_data - ) -> None: + 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: + 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 @@ -307,18 +396,14 @@ def test_scooper_modes( assert modes["02"] == "time" assert modes["03"] == "empty" - def test_scooper_actions( - self, mock_coordinator, sample_scooper_data - ) -> None: + 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: + 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"} @@ -332,9 +417,7 @@ def test_scooper_temperature_default( device.detail = {} assert device.temperature == "-" - def test_scooper_humidity( - self, mock_coordinator, sample_scooper_data - ) -> None: + 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"} @@ -366,18 +449,14 @@ def test_scooper_error_action_takes_precedence( device._set_action_error("New error") assert device.error == "New error" - def test_scooper_error_attrs( - self, mock_coordinator, sample_scooper_data - ) -> None: + 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: + 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 From 8474e5c2e7b7c5f8d66c2530c30bd5f1f5c11fba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20Ljubenovi=C4=87?= Date: Thu, 5 Feb 2026 23:28:54 +0000 Subject: [PATCH 20/34] Add Account and DevicesCoordinator tests --- tests/test_account.py | 332 ++++++++++++++++++++++++++++++ tests/test_devices_coordinator.py | 253 +++++++++++++++++++++++ 2 files changed, 585 insertions(+) create mode 100644 tests/test_account.py create mode 100644 tests/test_devices_coordinator.py diff --git a/tests/test_account.py b/tests/test_account.py new file mode 100644 index 0000000..fe91141 --- /dev/null +++ b/tests/test_account.py @@ -0,0 +1,332 @@ +"""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() diff --git a/tests/test_devices_coordinator.py b/tests/test_devices_coordinator.py new file mode 100644 index 0000000..99b6b74 --- /dev/null +++ b/tests/test_devices_coordinator.py @@ -0,0 +1,253 @@ +"""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() + + +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() From 462ff9a978f7c033fcb7ba7a2f03a472f67f79cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20Ljubenovi=C4=87?= Date: Thu, 5 Feb 2026 23:30:28 +0000 Subject: [PATCH 21/34] Add device async methods, CatlinkSensorEntity, Account async_check_auth and request error tests --- tests/test_account.py | 113 ++++++++++++++++++++++++--- tests/test_devices.py | 123 +++++++++++++++++++++++++++++- tests/test_devices_coordinator.py | 4 +- tests/test_entities.py | 63 +++++++++++++++ 4 files changed, 288 insertions(+), 15 deletions(-) diff --git a/tests/test_account.py b/tests/test_account.py index fe91141..2283197 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -176,6 +176,7 @@ def test_encrypt_password_returns_base64(self) -> None: assert len(result) > 0 try: import base64 + base64.b64decode(result) except Exception: pytest.fail("Result should be valid base64") @@ -262,9 +263,7 @@ class TestAccountAsyncLogin: @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, "request", new_callable=AsyncMock) as mock_request: with patch.object( account, "async_check_auth", new_callable=AsyncMock ) as mock_check: @@ -281,9 +280,7 @@ async def test_login_success(self, account) -> None: @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: + with patch.object(account, "request", new_callable=AsyncMock) as mock_request: mock_request.return_value = {"data": {}, "returnCode": 0} result = await account.async_login() @@ -297,9 +294,7 @@ class TestAccountGetDevices: @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: + with patch.object(account, "request", new_callable=AsyncMock) as mock_request: mock_request.return_value = { "returnCode": 0, "data": { @@ -314,7 +309,9 @@ async def test_get_devices_returns_list(self, account) -> None: 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: + 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", @@ -330,3 +327,99 @@ async def test_get_devices_login_when_no_token(self, hass, mock_http_session) -> assert devices == [] mock_login.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 == {} diff --git a/tests/test_devices.py b/tests/test_devices.py index b0932eb..b41fee9 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -1,6 +1,6 @@ """Tests for CatLink device classes.""" -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import pytest @@ -222,9 +222,7 @@ def test_last_sync_with_timestamp( 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: + 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 = {} @@ -307,6 +305,123 @@ def test_hass_select_structure(self, mock_coordinator, sample_device_data) -> No 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 + + class TestFeederDevice: """Tests for FeederDevice.""" diff --git a/tests/test_devices_coordinator.py b/tests/test_devices_coordinator.py index 99b6b74..1e75e83 100644 --- a/tests/test_devices_coordinator.py +++ b/tests/test_devices_coordinator.py @@ -54,7 +54,9 @@ 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: + 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}, diff --git a/tests/test_entities.py b/tests/test_entities.py index 7523511..da9fa33 100644 --- a/tests/test_entities.py +++ b/tests/test_entities.py @@ -252,6 +252,69 @@ async def test_select_option_calls_async_select( 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.""" From 60bc5a5e97511b00ab3449539b583df2e53de8f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20Ljubenovi=C4=87?= Date: Thu, 5 Feb 2026 23:33:41 +0000 Subject: [PATCH 22/34] Add LogsMixin, LitterDevice, helpers, config flow, device async, and Account tests --- tests/test_account.py | 14 +++ tests/test_config_flow.py | 34 +++++++ tests/test_devices.py | 89 ++++++++++++++++++ tests/test_helpers.py | 83 +++++++++++++++++ tests/test_litter_device.py | 178 ++++++++++++++++++++++++++++++++++++ tests/test_logs_mixin.py | 148 ++++++++++++++++++++++++++++++ 6 files changed, 546 insertions(+) create mode 100644 tests/test_litter_device.py create mode 100644 tests/test_logs_mixin.py diff --git a/tests/test_account.py b/tests/test_account.py index 2283197..c23c847 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -423,3 +423,17 @@ async def mock_request_timeout(*args, **kwargs): 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 index 5d05745..448d5af 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -2,6 +2,7 @@ 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, @@ -18,6 +19,39 @@ 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.""" diff --git a/tests/test_devices.py b/tests/test_devices.py index b41fee9..f087763 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -492,6 +492,95 @@ def test_feeder_hass_button(self, mock_coordinator, sample_feeder_data) -> None: 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 "") + + @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() + 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() + + +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() + 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 TestScooperDevice: """Tests for ScooperDevice.""" diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 921ad39..43a64b7 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -1,11 +1,15 @@ """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, ) @@ -104,3 +108,82 @@ 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_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 == [] From 2baa609b3304dfb921f8c4e90cf4e0b0a5418dc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20Ljubenovi=C4=87?= Date: Thu, 5 Feb 2026 23:37:08 +0000 Subject: [PATCH 23/34] Add GitHub Actions workflow for tests --- .github/workflows/tests.yaml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/tests.yaml diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 0000000..703d204 --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,33 @@ +# .github/workflows/test.yml +name: Tests + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.12", "3.13"] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: "pip" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements_test.txt + + - name: Run tests + run: pytest tests/ -v --timeout=10 \ No newline at end of file From c423a63ecf84f8c1e304a44f46b527afcfdc26d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20Ljubenovi=C4=87?= Date: Thu, 5 Feb 2026 23:42:56 +0000 Subject: [PATCH 24/34] Simplifed TC's Workflow --- .github/workflows/tests.yaml | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 703d204..cc39a32 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -1,19 +1,15 @@ -# .github/workflows/test.yml +# .github/workflows/tests.yaml name: Tests on: push: - branches: [main, master] + branches: [main, master, v2.1.1-beta-tests] pull_request: - branches: [main, master] + branches: [main, master, v2.1.1-beta-tests] jobs: test: runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.12", "3.13"] - steps: - name: Checkout uses: actions/checkout@v4 @@ -21,8 +17,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: ${{ matrix.python-version }} - cache: "pip" + python-version: "3.13" - name: Install dependencies run: | From 25c11a86199c09bd532dce0b747f19f43f011ca0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20Ljubenovi=C4=87?= Date: Thu, 5 Feb 2026 23:45:02 +0000 Subject: [PATCH 25/34] Added missing lib --- requirements_test.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/requirements_test.txt b/requirements_test.txt index 8b2214a..5acda32 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -3,3 +3,5 @@ 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 From 29f09bf87c6f2ef391bbb5b83f7aef0ef06f601f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20Ljubenovi=C4=87?= Date: Thu, 5 Feb 2026 23:47:36 +0000 Subject: [PATCH 26/34] Fixed ConfigEntry Test Issue --- custom_components/catlink/helpers.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/custom_components/catlink/helpers.py b/custom_components/catlink/helpers.py index 013ac0c..af3f436 100644 --- a/custom_components/catlink/helpers.py +++ b/custom_components/catlink/helpers.py @@ -7,6 +7,7 @@ import phonenumbers from phonenumbers import NumberParseException +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from .const import ( @@ -19,8 +20,6 @@ ) if TYPE_CHECKING: - from homeassistant.config_entries import ConfigEntry - from .modules.devices_coordinator import DevicesCoordinator From ea713f13b4450b9a97fcb62de333316482d5259f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20Ljubenovi=C4=87?= Date: Thu, 5 Feb 2026 23:53:14 +0000 Subject: [PATCH 27/34] Add reset litter and reset deodorant buttons for LitterBox - Add reset_consumable() calling token/device/union/consumableReset API - Add hass_button with reset_litter (CAT_LITTER) and reset_deodorant (DEODORIZER_02) - Support display name in entity option for button labels - Add tests for new button methods --- .../catlink/devices/litterbox.py | 43 +++++++++++++++++++ custom_components/catlink/entities/base.py | 3 +- tests/test_devices.py | 43 +++++++++++++++++++ 3 files changed, 88 insertions(+), 1 deletion(-) diff --git a/custom_components/catlink/devices/litterbox.py b/custom_components/catlink/devices/litterbox.py index 2e0455e..03efc00 100644 --- a/custom_components/catlink/devices/litterbox.py +++ b/custom_components/catlink/devices/litterbox.py @@ -430,3 +430,46 @@ async def changeBag(self, mode, **kwargs) -> bool: 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/entities/base.py b/custom_components/catlink/entities/base.py index fc18463..b9e3870 100644 --- a/custom_components/catlink/entities/base.py +++ b/custom_components/catlink/entities/base.py @@ -22,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 diff --git a/tests/test_devices.py b/tests/test_devices.py index f087763..9135505 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -421,6 +421,49 @@ async def test_select_box_full_sensitivity_invalid_returns_false( 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 TestFeederDevice: """Tests for FeederDevice.""" From 99fa39fd3e961ae147a4077e03e054ff0a2f30f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20Ljubenovi=C4=87?= Date: Fri, 6 Feb 2026 00:19:12 +0000 Subject: [PATCH 28/34] Handle device detail parsing failures with raw fallback --- custom_components/catlink/devices/base.py | 9 ++++- custom_components/catlink/devices/feeder.py | 9 ++++- .../catlink/devices/litter_device.py | 17 ++++++++- .../catlink/devices/litterbox.py | 23 +++++++++-- custom_components/catlink/devices/scooper.py | 38 +++++++++++++++++++ .../catlink/models/api/device.py | 6 +-- 6 files changed, 92 insertions(+), 10 deletions(-) diff --git a/custom_components/catlink/devices/base.py b/custom_components/catlink/devices/base.py index dc0a6de..0ef0693 100644 --- a/custom_components/catlink/devices/base.py +++ b/custom_components/catlink/devices/base.py @@ -264,12 +264,19 @@ async def update_device_detail(self) -> dict: try: rsp = await self.account.request(api, pms) data = rsp.get("data", {}) - parsed = parse_response(data, "deviceInfo", DeviceInfoBase, {}) + 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) diff --git a/custom_components/catlink/devices/feeder.py b/custom_components/catlink/devices/feeder.py index 0635859..99a3482 100644 --- a/custom_components/catlink/devices/feeder.py +++ b/custom_components/catlink/devices/feeder.py @@ -69,12 +69,19 @@ async def update_device_detail(self) -> dict: try: rsp = await self.account.request(api, pms) data = rsp.get("data", {}) - parsed = parse_response(data, "deviceInfo", FeederDeviceInfo, {}) + 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) diff --git a/custom_components/catlink/devices/litter_device.py b/custom_components/catlink/devices/litter_device.py index 3b437ab..e1f599f 100644 --- a/custom_components/catlink/devices/litter_device.py +++ b/custom_components/catlink/devices/litter_device.py @@ -53,6 +53,13 @@ def litter_weight(self) -> float: ) 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 @@ -81,7 +88,15 @@ def total_clean_time(self) -> int: def manual_clean_time(self) -> int: """Return the manual clean time.""" try: - return int(self.detail.get("manualTimes", 0)) + 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 diff --git a/custom_components/catlink/devices/litterbox.py b/custom_components/catlink/devices/litterbox.py index 03efc00..92def2c 100644 --- a/custom_components/catlink/devices/litterbox.py +++ b/custom_components/catlink/devices/litterbox.py @@ -76,7 +76,15 @@ def error(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 @@ -284,10 +292,10 @@ def box_full_sensitivity(self) -> str | None: 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 @@ -369,12 +377,19 @@ async def update_device_detail(self) -> dict: try: rsp = await self.account.request(api, pms) data = rsp.get("data", {}) - parsed = parse_response(data, "deviceInfo", LitterDeviceInfo, {}) + 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) diff --git a/custom_components/catlink/devices/scooper.py b/custom_components/catlink/devices/scooper.py index d16ffc1..ab95dd8 100644 --- a/custom_components/catlink/devices/scooper.py +++ b/custom_components/catlink/devices/scooper.py @@ -9,6 +9,8 @@ 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: @@ -140,6 +142,42 @@ def error_attrs(self) -> dict: "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( diff --git a/custom_components/catlink/models/api/device.py b/custom_components/catlink/models/api/device.py index 406a537..3362588 100644 --- a/custom_components/catlink/models/api/device.py +++ b/custom_components/catlink/models/api/device.py @@ -33,8 +33,8 @@ class DeviceInfoBase(BaseModel): safeTime: str = "" catLitterPaveSecond: str = "" catLitterWeight: float = 0.0 - inductionTimes: int = 0 - manualTimes: int = 0 + inductionTimes: Any = 0 + manualTimes: Any = 0 deodorantCountdown: int = 0 litterCountdown: Any = None online: bool = False @@ -48,7 +48,7 @@ class LitterDeviceInfo(DeviceInfoBase): model_config = ConfigDict(extra="allow") deviceErrorList: list[dict[str, Any]] = [] - boxFullSensitivity: str = "" + boxFullSensitivity: Any = "" quietTimes: str = "" garbageStatus: str = "" currentError: str = "" From b33ca3681a45b0513a791ecd8693d4fb0505b7ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20Ljubenovi=C4=87?= Date: Fri, 6 Feb 2026 09:13:07 +0000 Subject: [PATCH 29/34] Add limited support for Scooper Pro Ultra device --- custom_components/catlink/devices/__init__.py | 2 + custom_components/catlink/devices/registry.py | 2 + .../catlink/devices/scooper_pro_ultra.py | 134 ++++++++++++++++++ tests/test_devices.py | 83 +++++++++++ 4 files changed, 221 insertions(+) create mode 100644 custom_components/catlink/devices/scooper_pro_ultra.py diff --git a/custom_components/catlink/devices/__init__.py b/custom_components/catlink/devices/__init__.py index 78983e2..da7f4e2 100644 --- a/custom_components/catlink/devices/__init__.py +++ b/custom_components/catlink/devices/__init__.py @@ -4,10 +4,12 @@ from .feeder import FeederDevice from .litterbox import LitterBox from .scooper import ScooperDevice +from .scooper_pro_ultra import ScooperProUltraDevice __all__ = [ "Device", "FeederDevice", "LitterBox", "ScooperDevice", + "ScooperProUltraDevice", ] diff --git a/custom_components/catlink/devices/registry.py b/custom_components/catlink/devices/registry.py index e0f1027..c189de3 100644 --- a/custom_components/catlink/devices/registry.py +++ b/custom_components/catlink/devices/registry.py @@ -6,6 +6,7 @@ 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 @@ -14,6 +15,7 @@ DEVICE_TYPES: dict[str, type[Device]] = { "SCOOPER": ScooperDevice, "LITTER_BOX_599": LitterBox, # SCOOPER C1 + "VISUAL_PRO_ULTRA": ScooperProUltraDevice, "FEEDER": FeederDevice, } 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/tests/test_devices.py b/tests/test_devices.py index 9135505..0008160 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -9,6 +9,7 @@ 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 @@ -57,6 +58,18 @@ def sample_scooper_data(): } +@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", + } + + class TestDevice: """Tests for base Device class.""" @@ -116,6 +129,13 @@ def test_create_scooper(self, mock_coordinator, sample_scooper_data) -> None: assert isinstance(device, ScooperDevice) assert device.name == "Basement Scooper" + 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_unknown_type_falls_back_to_base(self, mock_coordinator) -> None: """Test unknown device type uses base Device class.""" data = { @@ -624,6 +644,69 @@ async def test_update_logs_fetches_from_api( ) +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.""" From 964ef914e79a2ad7c4a5adb04e4f4f8653bfecbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20Ljubenovi=C4=87?= Date: Fri, 6 Feb 2026 10:40:42 +0000 Subject: [PATCH 30/34] Add Open-X/C08 device support Introduce a C08 device class with full entity mappings and cover it in the device registry and tests. Co-authored-by: Cursor --- custom_components/catlink/const.py | 2 +- custom_components/catlink/devices/__init__.py | 2 + custom_components/catlink/devices/c08.py | 704 ++++++++++++++++++ custom_components/catlink/devices/registry.py | 2 + .../catlink/models/api/device.py | 14 + tests/test_devices.py | 139 +++- 6 files changed, 854 insertions(+), 9 deletions(-) create mode 100644 custom_components/catlink/devices/c08.py diff --git a/custom_components/catlink/const.py b/custom_components/catlink/const.py index c290b1e..acb7d1a 100644 --- a/custom_components/catlink/const.py +++ b/custom_components/catlink/const.py @@ -28,7 +28,7 @@ DEFAULT_API_BASE = "https://app.catlinks.cn/api/" # Device types with full support (sensors, switches, selects, etc.) -SUPPORTED_DEVICE_TYPES = frozenset({"SCOOPER", "LITTER_BOX_599", "FEEDER"}) +SUPPORTED_DEVICE_TYPES = frozenset({"C08", "SCOOPER", "LITTER_BOX_599", "FEEDER"}) # API server regions: value is the API base URL API_SERVERS: dict[str, str] = { diff --git a/custom_components/catlink/devices/__init__.py b/custom_components/catlink/devices/__init__.py index da7f4e2..753bfb9 100644 --- a/custom_components/catlink/devices/__init__.py +++ b/custom_components/catlink/devices/__init__.py @@ -1,6 +1,7 @@ """Device classes for CatLink integration.""" from .base import Device +from .c08 import C08Device from .feeder import FeederDevice from .litterbox import LitterBox from .scooper import ScooperDevice @@ -8,6 +9,7 @@ __all__ = [ "Device", + "C08Device", "FeederDevice", "LitterBox", "ScooperDevice", 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/registry.py b/custom_components/catlink/devices/registry.py index c189de3..efd4fb3 100644 --- a/custom_components/catlink/devices/registry.py +++ b/custom_components/catlink/devices/registry.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING from .base import Device +from .c08 import C08Device from .feeder import FeederDevice from .litterbox import LitterBox from .scooper import ScooperDevice @@ -13,6 +14,7 @@ from ..modules.devices_coordinator import DevicesCoordinator DEVICE_TYPES: dict[str, type[Device]] = { + "C08": C08Device, "SCOOPER": ScooperDevice, "LITTER_BOX_599": LitterBox, # SCOOPER C1 "VISUAL_PRO_ULTRA": ScooperProUltraDevice, diff --git a/custom_components/catlink/models/api/device.py b/custom_components/catlink/models/api/device.py index 3362588..5a26e3e 100644 --- a/custom_components/catlink/models/api/device.py +++ b/custom_components/catlink/models/api/device.py @@ -55,6 +55,20 @@ class LitterDeviceInfo(DeviceInfoBase): 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.""" diff --git a/tests/test_devices.py b/tests/test_devices.py index 0008160..7cc00a0 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -2,15 +2,15 @@ from unittest.mock import AsyncMock, MagicMock -import pytest - from custom_components.catlink.devices.base import Device +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 @@ -58,6 +58,18 @@ def sample_scooper_data(): } +@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.""" @@ -96,7 +108,7 @@ def test_device_error_action_error_takes_precedence( """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.") + 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: @@ -129,6 +141,12 @@ def test_create_scooper(self, mock_coordinator, sample_scooper_data) -> None: 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: @@ -485,6 +503,111 @@ def test_hass_button_structure(self, mock_coordinator, sample_device_data) -> No 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.""" @@ -588,7 +711,7 @@ async def test_food_out_api_error_sets_action_error( result = await device.food_out() assert result is False - assert "Device busy" in (device._action_error or "") + assert "Device busy" in (device._action_error or "") # noqa: SLF001 @pytest.mark.usefixtures("enable_custom_integrations") async def test_update_device_detail_success( @@ -596,7 +719,7 @@ async def test_update_device_detail_success( ) -> None: """Test FeederDevice update_device_detail parses response.""" device = FeederDevice(sample_feeder_data, mock_coordinator) - device._handle_listeners = MagicMock() + device._handle_listeners = MagicMock() # noqa: SLF001 mock_coordinator.account.request = AsyncMock( return_value={ "data": { @@ -613,7 +736,7 @@ async def test_update_device_detail_success( assert result["foodOutStatus"] == "idle" assert result["weight"] == 250 assert device.detail == result - device._handle_listeners.assert_called_once() + device._handle_listeners.assert_called_once() # noqa: SLF001 class TestScooperDeviceAsyncMethods: @@ -625,7 +748,7 @@ async def test_update_logs_fetches_from_api( ) -> None: """Test ScooperDevice update_logs calls correct API.""" device = ScooperDevice(sample_scooper_data, mock_coordinator) - device._handle_listeners = MagicMock() + device._handle_listeners = MagicMock() # noqa: SLF001 mock_coordinator.account.request = AsyncMock( return_value={ "data": { @@ -776,7 +899,7 @@ def test_scooper_error_action_takes_precedence( """Test ScooperDevice _action_error overrides.""" device = ScooperDevice(sample_scooper_data, mock_coordinator) device.detail = {"currentMessage": "Old"} - device._set_action_error("New error") + 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: From 41104cd5a9d356b7de8604f203ee25dbdd77a95b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20Ljubenovi=C4=87?= Date: Fri, 6 Feb 2026 13:39:15 +0000 Subject: [PATCH 31/34] Initial Implementation --- custom_components/catlink/devices/__init__.py | 2 + custom_components/catlink/devices/cat.py | 265 ++++++++++++++++++ custom_components/catlink/devices/registry.py | 2 + custom_components/catlink/modules/account.py | 45 +++ .../catlink/modules/devices_coordinator.py | 48 ++++ tests/test_account.py | 55 ++++ tests/test_devices.py | 32 +++ tests/test_devices_coordinator.py | 29 ++ 8 files changed, 478 insertions(+) create mode 100644 custom_components/catlink/devices/cat.py diff --git a/custom_components/catlink/devices/__init__.py b/custom_components/catlink/devices/__init__.py index da7f4e2..5414294 100644 --- a/custom_components/catlink/devices/__init__.py +++ b/custom_components/catlink/devices/__init__.py @@ -1,12 +1,14 @@ """Device classes for CatLink integration.""" from .base import Device +from .cat import CatDevice from .feeder import FeederDevice from .litterbox import LitterBox from .scooper import ScooperDevice from .scooper_pro_ultra import ScooperProUltraDevice __all__ = [ + "CatDevice", "Device", "FeederDevice", "LitterBox", diff --git a/custom_components/catlink/devices/cat.py b/custom_components/catlink/devices/cat.py new file mode 100644 index 0000000..0615375 --- /dev/null +++ b/custom_components/catlink/devices/cat.py @@ -0,0 +1,265 @@ +"""Cat device class for CatLink integration.""" + +from typing import TYPE_CHECKING, Any + +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import UnitOfMass +from homeassistant.util import dt as dt_util + +from .base import Device + +if TYPE_CHECKING: + from ..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(self) -> str | None: + """Return the pet avatar URL.""" + return self.data.get("avatar") + + 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", + }, + "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/devices/registry.py b/custom_components/catlink/devices/registry.py index c189de3..f5126e6 100644 --- a/custom_components/catlink/devices/registry.py +++ b/custom_components/catlink/devices/registry.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING from .base import Device +from .cat import CatDevice from .feeder import FeederDevice from .litterbox import LitterBox from .scooper import ScooperDevice @@ -13,6 +14,7 @@ from ..modules.devices_coordinator import DevicesCoordinator DEVICE_TYPES: dict[str, type[Device]] = { + "CAT": CatDevice, "SCOOPER": ScooperDevice, "LITTER_BOX_599": LitterBox, # SCOOPER C1 "VISUAL_PRO_ULTRA": ScooperProUltraDevice, diff --git a/custom_components/catlink/modules/account.py b/custom_components/catlink/modules/account.py index 2cf0dcb..f735070 100644 --- a/custom_components/catlink/modules/account.py +++ b/custom_components/catlink/modules/account.py @@ -194,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 0fe5bef..e71af39 100644 --- a/custom_components/catlink/modules/devices_coordinator.py +++ b/custom_components/catlink/modules/devices_coordinator.py @@ -1,7 +1,10 @@ """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 ..const import _LOGGER, CONF_DEVICE_IDS, DOMAIN, SUPPORTED_DOMAINS @@ -58,6 +61,51 @@ async def _async_update_data(self) -> dict: 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: + await self.update_hass_entities(d, dvc) return self.hass.data[DOMAIN][CONF_DEVICES] async def update_hass_entities(self, domain, dvc) -> None: diff --git a/tests/test_account.py b/tests/test_account.py index c23c847..0e3cfb8 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -329,6 +329,61 @@ async def test_get_devices_login_when_no_token( 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.""" diff --git a/tests/test_devices.py b/tests/test_devices.py index 0008160..729c5d6 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -5,6 +5,7 @@ import pytest from custom_components.catlink.devices.base import Device +from custom_components.catlink.devices.cat import CatDevice 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 @@ -70,6 +71,29 @@ def sample_pro_ultra_data(): } +@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.""" @@ -136,6 +160,14 @@ def test_create_scooper_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 = { diff --git a/tests/test_devices_coordinator.py b/tests/test_devices_coordinator.py index 1e75e83..c3d96c7 100644 --- a/tests/test_devices_coordinator.py +++ b/tests/test_devices_coordinator.py @@ -188,6 +188,35 @@ async def test_update_data_skips_device_without_id( 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.""" From 340cfc3c334417b61b2c67451d845f529dbd0e7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20Ljubenovi=C4=87?= Date: Fri, 6 Feb 2026 13:47:19 +0000 Subject: [PATCH 32/34] Set avatar entity picture --- custom_components/catlink/devices/cat.py | 17 ++++++++++++----- custom_components/catlink/entities/base.py | 8 ++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/custom_components/catlink/devices/cat.py b/custom_components/catlink/devices/cat.py index 0615375..961d699 100644 --- a/custom_components/catlink/devices/cat.py +++ b/custom_components/catlink/devices/cat.py @@ -1,15 +1,16 @@ """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 -from .base import Device - if TYPE_CHECKING: - from ..modules.devices_coordinator import DevicesCoordinator + from custom_components.catlink.modules.devices_coordinator import DevicesCoordinator GENDER_LABELS: dict[int, str] = { @@ -26,7 +27,7 @@ class CatDevice(Device): def __init__( self, dat: dict, - coordinator: "DevicesCoordinator", + coordinator: DevicesCoordinator, additional_config: Any | None = None, ) -> None: """Initialize the cat device.""" @@ -91,10 +92,15 @@ def birthday(self) -> str | None: return dt_util.utc_from_timestamp(birthday / 1000).date().isoformat() @property - def avatar(self) -> str | None: + 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 {} @@ -214,6 +220,7 @@ def hass_sensor(self) -> dict: }, "avatar": { "icon": "mdi:image", + "entity_picture": self.avatar_url, }, "toilet_times": { "icon": "mdi:toilet", diff --git a/custom_components/catlink/entities/base.py b/custom_components/catlink/entities/base.py index b9e3870..9ce83b7 100644 --- a/custom_components/catlink/entities/base.py +++ b/custom_components/catlink/entities/base.py @@ -33,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, @@ -66,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): From 266185612edc0664da3e41c7af44d254b1c44f4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20Ljubenovi=C4=87?= Date: Fri, 6 Feb 2026 14:00:40 +0000 Subject: [PATCH 33/34] Add tests for entity pictures --- tests/test_entities.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/test_entities.py b/tests/test_entities.py index da9fa33..957fd9d 100644 --- a/tests/test_entities.py +++ b/tests/test_entities.py @@ -117,6 +117,37 @@ def test_entity_unique_id_format( 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.""" From b0d867ddedb967acac2a3485c4d2f424bf2dd9eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20Ljubenovi=C4=87?= Date: Fri, 6 Feb 2026 14:28:51 +0000 Subject: [PATCH 34/34] Added Changelog and Updated Readme.MD --- CHANGELOG.md | 17 ++++++ README.md | 158 +++++++++++++++++++++++---------------------------- 2 files changed, 87 insertions(+), 88 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6148339 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +## 2.1.1 - 2026-02-06 + +### Added +- Open-X/C08 device support (thanks to this nice repo: https://github.com/eulemitkeule/pycatlink) +- Limited Scooper Pro Ultra support +- Reset litter and reset deodorant buttons for litterbox +- Config flow coverage for discovery, reauthentication, and options +- Test suite additions and GitHub Actions workflow for tests + +### Fixed +- Device detail parsing fallback when API payloads are incomplete +- Home Assistant 2026.2.0 compatibility issues + +### Changed +- Device and entity organization with new helpers and logs mixin diff --git a/README.md b/README.md index 8d100df..28466de 100644 --- a/README.md +++ b/README.md @@ -27,13 +27,12 @@ - [Installation](#installation) - [Easy way](#easy-way) - [Manually](#manually) - - [Configuration Example](#configuration-example) - [Supported Devices and Operations](#supported-devices-and-operations) - [Scooper SE](#supported-devices-and-operations) - [Scooper PRO](#supported-devices-and-operations) - [How to Configure?](#how-to-configure) - - [API Regions](#api-regions) - [Services (Optional)](#services-optional) +- [Changelog](#changelog) - [How to contribute?](#how-to-contribute) - [Disclaimer on Using Logos](#disclaimer-on-using-logos) @@ -83,30 +82,11 @@ wget -O - https://get.hacs.vip | DOMAIN=catlink REPO_PATH=hasscc/catlink ARCHIVE 3. Call this [`service: shell_command.update_catlink`](https://my.home-assistant.io/redirect/developer_call_service/?service=shell_command.update_catlink) in Developer Tools 2. Restart HA core again -### Configuration Example: - -```yaml -catlink: - phone: "xxxxxx" - password: "xxxxxx" - phone_iac: 86 # Default - api_base: "https://app-usa.catlinks.cn/api/" - scan_interval: "00:00:10" - language: "en_GB" - - # Multiple accounts (Optional) - accounts: - - username: 18866660001 - password: password1 - - username: 18866660002 - password: password2 -``` - ## Supported Devices and Operations -
+
-
+

Scooper SE

Scooper SE

Operations

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

    Scooper PRO

    Scooper PRO

    Operations

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

    Feeder Young

    - Feeder Young + Feeder Young

    Operations

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

    Open-X/C08

    + Open-X/C08 +

    Operations

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

    Scooper Pro Ultra (limited support)

    + Scooper Pro Ultra +

    Operations

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

    Smart collars (via the Cats integration)

    + CatLINK smart collar +

    Operations

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

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

    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    RegionAPI Base
    🌎 Global/Recomendedhttps://app.catlinks.cn/api/
    πŸ‡¨πŸ‡³ Mainland China (Sh)https://app-sh.catlinks.cn/api/
    πŸ‡ΊπŸ‡Έ Euroamericahttps://app-usa.catlinks.cn/api/
    πŸ‡ΈπŸ‡¬ Singaporehttps://app-sgp.catlinks.cn/api/
    +Just use ConfigFlow. Enter your phonenumber (eg. +493034994004) and password.
    +That's it.
    +It will automatically discover your Region, Cats & Devices. ## Services (Optional) @@ -240,6 +218,10 @@ data: key: val ``` +## Changelog + +See `CHANGELOG.md` for release notes. + ### How to contribute? Please visit [CONTRIBUTE](/CONTRIBUTE.md), and be aware of [this](/CODE_OF_CONDUCT.md).