kippy-api 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Thomas Wright
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,94 @@
1
+ Metadata-Version: 2.4
2
+ Name: kippy-api
3
+ Version: 0.1.0
4
+ Summary: Asynchronous Python client for the Kippy pet tracker API
5
+ Author: Thomas Wright
6
+ License-Expression: MIT
7
+ Project-URL: Source, https://github.com/ThomasHFWright/kippy-api
8
+ Project-URL: Issues, https://github.com/ThomasHFWright/kippy-api/issues
9
+ Requires-Python: >=3.13
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: aiohttp<4,>=3.11
13
+ Dynamic: license-file
14
+
15
+ # Kippy API
16
+
17
+ An asynchronous Python client for Kippy pet trackers, extracted from
18
+ [the Kippy Home Assistant integration](https://github.com/ThomasHFWright/kippy-homeassistant).
19
+ Python 3.13 or newer is required. `aiohttp` is the only runtime dependency.
20
+
21
+ ## Use
22
+
23
+ ```python
24
+ import asyncio
25
+ import os
26
+ from zoneinfo import ZoneInfo
27
+
28
+ from aiohttp import ClientSession
29
+ from kippy_api import KippyApi
30
+
31
+
32
+ async def main():
33
+ async with ClientSession() as session:
34
+ api = await KippyApi.async_create(session)
35
+ await api.login(os.environ["KIPPY_EMAIL"], os.environ["KIPPY_PASSWORD"])
36
+ pets = await api.get_pet_kippy_list()
37
+ # Pass the reporting timezone explicitly; the machine timezone is ignored.
38
+ if pets:
39
+ await api.get_activity_categories(
40
+ pets[0]["petID"],
41
+ "2026-09-01",
42
+ "2026-09-02",
43
+ 2,
44
+ 1,
45
+ timezone=ZoneInfo("Europe/London"),
46
+ )
47
+
48
+
49
+ asyncio.run(main())
50
+ ```
51
+
52
+ You own the session and close it. The client never closes or replaces it. Existing
53
+ endpoint names and dictionary results are preserved: `get_pet_kippy_list`,
54
+ `kippymap_action`, `modify_kippy_settings`, and `get_activity_categories`.
55
+ Use `do_sms=False` for cached map reads; device commands can change your tracker.
56
+
57
+ Catch `KippyAuthError` for missing/invalid credentials or exhausted authentication
58
+ refresh, `KippyConnectionError` for transport failures, and `KippyResponseError`
59
+ for rejected or malformed responses. All inherit `KippyError` and expose optional
60
+ `status` and `return_code` metadata. Requests time out after 30 seconds and only
61
+ confirmed authentication failures receive one retry after login. Network timeouts
62
+ never retry commands. Concurrent token expiry shares a login refresh.
63
+
64
+ The client preserves known successful HTTP 401 bodies and the vendor's existing
65
+ TLS cipher compatibility setting, with certificate and hostname checks enabled.
66
+ It logs response status only, never account or location payloads. API result
67
+ metadata may be vendor supplied; avoid logging whole response objects.
68
+
69
+ The package has no Home Assistant imports, polling, entities, translation loading,
70
+ or credential file handling. See [the protocol reference](https://github.com/ThomasHFWright/kippyAPIs)
71
+ for the reverse-engineered API.
72
+
73
+ ## Development
74
+
75
+ ```sh
76
+ uv sync --frozen --group dev
77
+ uv run ruff check .
78
+ uv run ruff format --check .
79
+ uv run mypy
80
+ uv run pytest
81
+ uv run python -m build
82
+ uv run twine check dist/*
83
+ ```
84
+
85
+ Tests use synthetic responses and a local HTTP server; they do not require
86
+ credentials or contact Kippy. CI also installs the built wheel into an environment
87
+ without Home Assistant. `uv.lock` pins development tooling; the library dependency
88
+ range remains compatible with the consuming application's aiohttp version.
89
+
90
+ For development with the adjacent Home Assistant integration, install this folder
91
+ editable in its environment. Do not release an integration dependency on `0.1.0`
92
+ until that version is published and its installation from PyPI is verified.
93
+
94
+ Original source copyright (c) 2025 Thomas Wright, MIT license.
@@ -0,0 +1,80 @@
1
+ # Kippy API
2
+
3
+ An asynchronous Python client for Kippy pet trackers, extracted from
4
+ [the Kippy Home Assistant integration](https://github.com/ThomasHFWright/kippy-homeassistant).
5
+ Python 3.13 or newer is required. `aiohttp` is the only runtime dependency.
6
+
7
+ ## Use
8
+
9
+ ```python
10
+ import asyncio
11
+ import os
12
+ from zoneinfo import ZoneInfo
13
+
14
+ from aiohttp import ClientSession
15
+ from kippy_api import KippyApi
16
+
17
+
18
+ async def main():
19
+ async with ClientSession() as session:
20
+ api = await KippyApi.async_create(session)
21
+ await api.login(os.environ["KIPPY_EMAIL"], os.environ["KIPPY_PASSWORD"])
22
+ pets = await api.get_pet_kippy_list()
23
+ # Pass the reporting timezone explicitly; the machine timezone is ignored.
24
+ if pets:
25
+ await api.get_activity_categories(
26
+ pets[0]["petID"],
27
+ "2026-09-01",
28
+ "2026-09-02",
29
+ 2,
30
+ 1,
31
+ timezone=ZoneInfo("Europe/London"),
32
+ )
33
+
34
+
35
+ asyncio.run(main())
36
+ ```
37
+
38
+ You own the session and close it. The client never closes or replaces it. Existing
39
+ endpoint names and dictionary results are preserved: `get_pet_kippy_list`,
40
+ `kippymap_action`, `modify_kippy_settings`, and `get_activity_categories`.
41
+ Use `do_sms=False` for cached map reads; device commands can change your tracker.
42
+
43
+ Catch `KippyAuthError` for missing/invalid credentials or exhausted authentication
44
+ refresh, `KippyConnectionError` for transport failures, and `KippyResponseError`
45
+ for rejected or malformed responses. All inherit `KippyError` and expose optional
46
+ `status` and `return_code` metadata. Requests time out after 30 seconds and only
47
+ confirmed authentication failures receive one retry after login. Network timeouts
48
+ never retry commands. Concurrent token expiry shares a login refresh.
49
+
50
+ The client preserves known successful HTTP 401 bodies and the vendor's existing
51
+ TLS cipher compatibility setting, with certificate and hostname checks enabled.
52
+ It logs response status only, never account or location payloads. API result
53
+ metadata may be vendor supplied; avoid logging whole response objects.
54
+
55
+ The package has no Home Assistant imports, polling, entities, translation loading,
56
+ or credential file handling. See [the protocol reference](https://github.com/ThomasHFWright/kippyAPIs)
57
+ for the reverse-engineered API.
58
+
59
+ ## Development
60
+
61
+ ```sh
62
+ uv sync --frozen --group dev
63
+ uv run ruff check .
64
+ uv run ruff format --check .
65
+ uv run mypy
66
+ uv run pytest
67
+ uv run python -m build
68
+ uv run twine check dist/*
69
+ ```
70
+
71
+ Tests use synthetic responses and a local HTTP server; they do not require
72
+ credentials or contact Kippy. CI also installs the built wheel into an environment
73
+ without Home Assistant. `uv.lock` pins development tooling; the library dependency
74
+ range remains compatible with the consuming application's aiohttp version.
75
+
76
+ For development with the adjacent Home Assistant integration, install this folder
77
+ editable in its environment. Do not release an integration dependency on `0.1.0`
78
+ until that version is published and its installation from PyPI is verified.
79
+
80
+ Original source copyright (c) 2025 Thomas Wright, MIT license.
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "kippy-api"
7
+ version = "0.1.0"
8
+ description = "Asynchronous Python client for the Kippy pet tracker API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.13"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{name = "Thomas Wright"}]
14
+ dependencies = ["aiohttp>=3.11,<4"]
15
+
16
+ [project.urls]
17
+ Source = "https://github.com/ThomasHFWright/kippy-api"
18
+ Issues = "https://github.com/ThomasHFWright/kippy-api/issues"
19
+
20
+ [dependency-groups]
21
+ dev = ["pytest>=8", "pytest-asyncio>=1", "pytest-cov>=6", "ruff>=0.12", "mypy>=1.15", "build>=1.2", "twine>=6"]
22
+
23
+ [tool.setuptools.package-data]
24
+ kippy_api = ["py.typed"]
25
+
26
+ [tool.pytest.ini_options]
27
+ asyncio_mode = "auto"
28
+ asyncio_default_fixture_loop_scope = "function"
29
+ testpaths = ["tests"]
30
+ addopts = "--cov=kippy_api --cov-report=term-missing --cov-fail-under=95"
31
+
32
+ [tool.ruff]
33
+ target-version = "py313"
34
+
35
+ [tool.ruff.lint]
36
+ select = ["E4", "E7", "E9", "F", "I", "UP"]
37
+
38
+ [tool.mypy]
39
+ python_version = "3.13"
40
+ files = ["src/kippy_api"]
41
+ check_untyped_defs = true
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,17 @@
1
+ """Asynchronous client for the Kippy pet tracker API."""
2
+
3
+ from .client import KippyApi
4
+ from .exceptions import (
5
+ KippyAuthError,
6
+ KippyConnectionError,
7
+ KippyError,
8
+ KippyResponseError,
9
+ )
10
+
11
+ __all__ = [
12
+ "KippyApi",
13
+ "KippyAuthError",
14
+ "KippyConnectionError",
15
+ "KippyError",
16
+ "KippyResponseError",
17
+ ]
@@ -0,0 +1,229 @@
1
+ """HTTP requests and authentication for the Kippy API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import hashlib
7
+ import json
8
+ import logging
9
+ import ssl
10
+ from collections.abc import Mapping
11
+ from typing import Any, Self
12
+
13
+ from aiohttp import ClientError, ClientSession, ClientTimeout
14
+
15
+ from ._utils import (
16
+ _decode_json,
17
+ _get_return_code,
18
+ _return_code_error,
19
+ _treat_401_as_success,
20
+ )
21
+ from .const import (
22
+ APP_IDENTITY,
23
+ APP_IDENTITY_EVO,
24
+ APP_VERSION,
25
+ DEFAULT_HOST,
26
+ DEVICE_NAME,
27
+ LOGIN_PATH,
28
+ PHONE_COUNTRY_CODE,
29
+ PLATFORM_DEVICE,
30
+ REQUEST_HEADERS,
31
+ RETURN_VALUES,
32
+ TIMEZONE,
33
+ TOKEN_DEVICE,
34
+ )
35
+ from .exceptions import KippyAuthError, KippyConnectionError, KippyResponseError
36
+
37
+ _LOGGER = logging.getLogger(__name__)
38
+ _REQUEST_TIMEOUT = ClientTimeout(total=30)
39
+
40
+
41
+ class BaseKippyApi:
42
+ """API wrapper using a session owned and closed by the caller."""
43
+
44
+ def __init__(
45
+ self,
46
+ session: ClientSession,
47
+ host: str = DEFAULT_HOST,
48
+ ssl_context: ssl.SSLContext | None = None,
49
+ ) -> None:
50
+ """Initialize the API client without creating network resources."""
51
+ self._session = session
52
+ self._host = host.rstrip("/")
53
+ self._auth: dict[str, Any] | None = None
54
+ self._credentials: tuple[str, str] | None = None
55
+ self._ssl_context = ssl_context
56
+ self._login_lock = asyncio.Lock()
57
+
58
+ @classmethod
59
+ async def async_create(
60
+ cls, session: ClientSession, host: str = DEFAULT_HOST
61
+ ) -> Self:
62
+ """Create a client retaining the original endpoint TLS compatibility."""
63
+ ctx = await asyncio.to_thread(ssl.create_default_context)
64
+ # Keep certificate/hostname verification; retain the existing cipher policy
65
+ # until the vendor endpoint can be validated without this workaround.
66
+ ctx.set_ciphers("DEFAULT@SECLEVEL=1")
67
+ if hasattr(ssl, "OP_LEGACY_SERVER_CONNECT"):
68
+ ctx.options |= ssl.OP_LEGACY_SERVER_CONNECT
69
+ return cls(session, host, ctx)
70
+
71
+ @property
72
+ def session(self) -> ClientSession:
73
+ """Return the session owned by the caller."""
74
+ return self._session
75
+
76
+ @property
77
+ def app_code(self) -> str | None:
78
+ """Return the cached app code."""
79
+ return self._auth.get("app_code") if self._auth else None
80
+
81
+ @property
82
+ def app_verification_code(self) -> str | None:
83
+ """Return the cached app verification code."""
84
+ return self._auth.get("app_verification_code") if self._auth else None
85
+
86
+ async def login(
87
+ self, email: str, password: str, force: bool = False
88
+ ) -> dict[str, Any]:
89
+ """Authenticate and cache tokens, serializing concurrent logins."""
90
+ async with self._login_lock:
91
+ if not force and self._auth is not None:
92
+ return self._auth
93
+ self._auth = None
94
+ data = await self._post(
95
+ LOGIN_PATH,
96
+ {
97
+ "login_email": email,
98
+ "login_password_hash": hashlib.sha256(
99
+ password.encode()
100
+ ).hexdigest(),
101
+ "login_password_hash_md5": hashlib.md5(
102
+ password.encode()
103
+ ).hexdigest(),
104
+ "app_identity": APP_IDENTITY,
105
+ "app_identity_evo": APP_IDENTITY_EVO,
106
+ "platform_device": PLATFORM_DEVICE,
107
+ "app_version": APP_VERSION,
108
+ "timezone": TIMEZONE,
109
+ "phone_country_code": PHONE_COUNTRY_CODE,
110
+ "token_device": TOKEN_DEVICE,
111
+ "device_name": DEVICE_NAME,
112
+ },
113
+ REQUEST_HEADERS,
114
+ )
115
+ if not all(data.get(key) for key in ("app_code", "app_verification_code")):
116
+ raise KippyResponseError(
117
+ "Login response is missing authentication tokens"
118
+ )
119
+ self._auth = data
120
+ self._credentials = (email, password)
121
+ return data
122
+
123
+ async def ensure_login(self) -> None:
124
+ """Authenticate if cached tokens are unavailable."""
125
+ if self._auth is not None:
126
+ return
127
+ if self._credentials is None:
128
+ raise KippyAuthError("No credentials available")
129
+ await self.login(*self._credentials)
130
+
131
+ def cache_authentication(
132
+ self, auth: Mapping[str, Any], *, credentials: tuple[str, str] | None = None
133
+ ) -> None:
134
+ """Seed authentication for callers restoring a cached session."""
135
+ self._auth = dict(auth)
136
+ if credentials is not None:
137
+ self._credentials = credentials
138
+
139
+ async def _authenticated_payload(
140
+ self,
141
+ *,
142
+ identity: str | None = APP_IDENTITY,
143
+ extra: Mapping[str, Any] | None = None,
144
+ ) -> dict[str, Any]:
145
+ """Build an endpoint payload using cached authentication."""
146
+ await self.ensure_login()
147
+ if not self.app_code or not self.app_verification_code:
148
+ raise KippyAuthError("No authentication tokens available")
149
+ payload = {
150
+ "app_code": self.app_code,
151
+ "app_verification_code": self.app_verification_code,
152
+ }
153
+ if identity is not None:
154
+ payload["app_identity"] = identity
155
+ if extra:
156
+ payload.update(extra)
157
+ return payload
158
+
159
+ async def _post(
160
+ self, path: str, payload: dict[str, Any], headers: dict[str, str]
161
+ ) -> dict[str, Any]:
162
+ """Send one bounded request and classify errors without logging payloads."""
163
+ try:
164
+ async with self._session.post(
165
+ f"{self._host}{path}",
166
+ data=json.dumps(payload),
167
+ headers=headers,
168
+ ssl=self._ssl_context or True,
169
+ timeout=_REQUEST_TIMEOUT,
170
+ allow_redirects=False,
171
+ raise_for_status=False,
172
+ ) as response:
173
+ text = await response.text()
174
+ status = response.status
175
+ except (ClientError, TimeoutError) as err:
176
+ raise KippyConnectionError("Request failed or timed out") from err
177
+
178
+ _LOGGER.debug("API response status=%s", status)
179
+ data = _decode_json(text)
180
+ code = _get_return_code(data)
181
+ success = data is not None and _treat_401_as_success(path, data)
182
+ if status == 401 and success and data is not None:
183
+ return data
184
+ # Explicit non-authentication result codes take precedence over the
185
+ # vendor's unreliable HTTP 401 status (e.g. inactive subscriptions).
186
+ auth_error = code in (
187
+ RETURN_VALUES.AUTHORIZATION_EXPIRED,
188
+ RETURN_VALUES.INVALID_CREDENTIALS,
189
+ )
190
+ if (200 <= status < 300 or status in (401, 403)) and (
191
+ (status in (401, 403) and code is None)
192
+ or auth_error
193
+ or (path == LOGIN_PATH and code is False)
194
+ ):
195
+ raise KippyAuthError(
196
+ "Authentication failed", status=status, return_code=code
197
+ )
198
+ if not 200 <= status < 300:
199
+ raise KippyResponseError(
200
+ "Server rejected request", status=status, return_code=code
201
+ )
202
+ if data is None:
203
+ raise KippyResponseError("Response is not a JSON object", status=status)
204
+ if not success:
205
+ raise KippyResponseError(
206
+ _return_code_error(code), status=status, return_code=code
207
+ )
208
+ return data
209
+
210
+ async def post_with_refresh(
211
+ self, path: str, payload: dict[str, Any], headers: dict[str, str]
212
+ ) -> dict[str, Any]:
213
+ """Retry once only after a confirmed authentication failure."""
214
+ auth = self._auth
215
+ try:
216
+ return await self._post(path, payload, headers)
217
+ except KippyAuthError:
218
+ if self._credentials is None:
219
+ raise
220
+ # Invalidate only the tokens used by this request. Concurrent expired
221
+ # requests share the login lock and reuse the first refreshed session.
222
+ if self._auth is auth:
223
+ self._auth = None
224
+ await self.ensure_login()
225
+ payload.update(
226
+ app_code=self.app_code,
227
+ app_verification_code=self.app_verification_code,
228
+ )
229
+ return await self._post(path, payload, headers)
@@ -0,0 +1,116 @@
1
+ """Utility helpers shared across Kippy API modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import datetime, timedelta
7
+ from typing import Any, cast
8
+
9
+ from .const import RETURN_CODE_ERRORS, RETURN_CODES_SUCCESS, SENSITIVE_LOG_FIELDS
10
+
11
+
12
+ def _redact_tree(data: Any, sensitive: set[str]) -> Any:
13
+ """Recursively redact sensitive fields within ``data``."""
14
+
15
+ if isinstance(data, dict):
16
+ return {
17
+ key: ("***" if key in sensitive else _redact_tree(value, sensitive))
18
+ for key, value in data.items()
19
+ }
20
+ if isinstance(data, list):
21
+ return [_redact_tree(item, sensitive) for item in data]
22
+ return data
23
+
24
+
25
+ def _redact(data: dict[str, Any], extra: set[str] | None = None) -> dict[str, Any]:
26
+ """Return a copy of ``data`` with sensitive fields redacted."""
27
+
28
+ sensitive = SENSITIVE_LOG_FIELDS | (extra or set())
29
+ return cast(dict[str, Any], _redact_tree(data, sensitive))
30
+
31
+
32
+ def _redact_json(text: str) -> str:
33
+ """Redact sensitive fields from JSON ``text`` if possible."""
34
+
35
+ try:
36
+ data = json.loads(text)
37
+ except json.JSONDecodeError:
38
+ return "<non-JSON response>"
39
+ return json.dumps(_redact_tree(data, SENSITIVE_LOG_FIELDS))
40
+
41
+
42
+ def _decode_json(text: str) -> dict[str, Any] | None:
43
+ """Decode ``text`` as JSON, returning ``None`` on failure."""
44
+
45
+ try:
46
+ data = json.loads(text)
47
+ return data if isinstance(data, dict) else None
48
+ except json.JSONDecodeError:
49
+ return None
50
+
51
+
52
+ def _get_return_code(data: dict[str, Any] | None) -> int | bool | str | None:
53
+ """Extract the API ``return`` code from ``data`` if present."""
54
+
55
+ if not isinstance(data, dict):
56
+ return None
57
+ if (code := data.get("return")) is None:
58
+ code = data.get("Result")
59
+ if code is None:
60
+ return None
61
+ if isinstance(code, bool):
62
+ return code
63
+ if isinstance(code, int):
64
+ return code
65
+ if isinstance(code, str):
66
+ try:
67
+ return int(code)
68
+ except ValueError:
69
+ return code
70
+ return None
71
+
72
+
73
+ def _return_code_error(code: Any) -> str:
74
+ """Return a human readable error for ``code``.
75
+
76
+ If ``code`` is unknown, include the code in the message.
77
+ """
78
+
79
+ if not isinstance(code, (int, str, bool)):
80
+ return "Missing or invalid API return code"
81
+ if (msg := RETURN_CODE_ERRORS.get(code)) is not None:
82
+ return f"{msg} (code {code})"
83
+ return (
84
+ f"Unknown error code {code}"
85
+ if isinstance(code, int)
86
+ else "Unknown API return code"
87
+ )
88
+
89
+
90
+ def _treat_401_as_success(path: str, data: dict[str, Any]) -> bool:
91
+ """Determine if a 401 response should be treated as a success."""
92
+
93
+ return_code = _get_return_code(data)
94
+ if isinstance(return_code, bool):
95
+ return return_code
96
+ return return_code in RETURN_CODES_SUCCESS
97
+
98
+
99
+ def _weeks_param(start: datetime, end: datetime) -> str:
100
+ """Return a JSON list of ISO weeks between ``start`` and ``end``."""
101
+
102
+ weeks_list: list[dict[str, str]] = []
103
+ current = start - timedelta(days=start.weekday())
104
+ while current <= end:
105
+ year, week, _ = current.isocalendar()
106
+ entry = {"year": str(year), "number": str(week)}
107
+ weeks_list.append(entry)
108
+ current += timedelta(days=7)
109
+ return json.dumps(weeks_list)
110
+
111
+
112
+ def _tz_hours(dt: datetime) -> float:
113
+ """Return timezone offset in hours for ``dt``."""
114
+
115
+ tz_offset = dt.utcoffset() or timedelta()
116
+ return tz_offset.total_seconds() / 3600
@@ -0,0 +1,83 @@
1
+ """API endpoint for retrieving activity statistics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, tzinfo
6
+ from typing import Any
7
+
8
+ from ._base import BaseKippyApi
9
+ from ._utils import _tz_hours, _weeks_param
10
+ from .const import (
11
+ ACTIVITY_ID,
12
+ FORMULA_GROUP,
13
+ GET_ACTIVITY_CATEGORIES_PATH,
14
+ REQUEST_HEADERS,
15
+ T_ID,
16
+ )
17
+ from .exceptions import KippyResponseError
18
+
19
+
20
+ class ActivityEndpoint(BaseKippyApi):
21
+ """Mixin implementing the activity category endpoint."""
22
+
23
+ async def get_activity_categories(
24
+ self,
25
+ pet_id: int | str,
26
+ from_date: str,
27
+ to_date: str,
28
+ time_division: int,
29
+ _weeks: int,
30
+ *,
31
+ timezone: tzinfo,
32
+ ) -> dict[str, Any]:
33
+ """Retrieve activity categories for a pet."""
34
+
35
+ start = datetime.strptime(from_date, "%Y-%m-%d")
36
+ end = datetime.strptime(to_date, "%Y-%m-%d")
37
+
38
+ if not isinstance(timezone, tzinfo):
39
+ raise ValueError("timezone must be a tzinfo instance")
40
+ if end < start:
41
+ raise ValueError("to_date must not precede from_date")
42
+ start_ts = int(start.replace(tzinfo=timezone).timestamp())
43
+ end_ts = int(end.replace(tzinfo=timezone).timestamp())
44
+
45
+ tz_hours_value = _tz_hours(start.replace(tzinfo=timezone))
46
+ weeks_value = _weeks_param(start, end)
47
+
48
+ time_divisions = {1: "h", 2: "d", 3: "w"}.get(time_division, "h")
49
+
50
+ payload = await self._authenticated_payload(
51
+ extra={
52
+ "petID": pet_id,
53
+ "activityID": ACTIVITY_ID.ALL,
54
+ "fromDate": start_ts,
55
+ "toDate": end_ts,
56
+ "timeDivisions": time_divisions,
57
+ "formulaGroup": FORMULA_GROUP.SUM,
58
+ "tID": T_ID,
59
+ "timezone": tz_hours_value,
60
+ "weeks": weeks_value,
61
+ }
62
+ )
63
+
64
+ data = await self.post_with_refresh(
65
+ GET_ACTIVITY_CATEGORIES_PATH, payload, REQUEST_HEADERS
66
+ )
67
+
68
+ if "data" in data:
69
+ payload = data.get("data") or {}
70
+ if not isinstance(payload, dict):
71
+ raise KippyResponseError("Activity data must be an object")
72
+ else:
73
+ payload = {
74
+ "activities": data.get("ActivitiesData"),
75
+ "avg": data.get("AVGData"),
76
+ "health": data.get("HealthData"),
77
+ }
78
+
79
+ return {
80
+ "activities": payload.get("activities"),
81
+ "avg": payload.get("avg"),
82
+ "health": payload.get("health"),
83
+ }