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.
- kippy_api-0.1.0/LICENSE +21 -0
- kippy_api-0.1.0/PKG-INFO +94 -0
- kippy_api-0.1.0/README.md +80 -0
- kippy_api-0.1.0/pyproject.toml +41 -0
- kippy_api-0.1.0/setup.cfg +4 -0
- kippy_api-0.1.0/src/kippy_api/__init__.py +17 -0
- kippy_api-0.1.0/src/kippy_api/_base.py +229 -0
- kippy_api-0.1.0/src/kippy_api/_utils.py +116 -0
- kippy_api-0.1.0/src/kippy_api/activity.py +83 -0
- kippy_api-0.1.0/src/kippy_api/client.py +19 -0
- kippy_api-0.1.0/src/kippy_api/const.py +108 -0
- kippy_api-0.1.0/src/kippy_api/exceptions.py +29 -0
- kippy_api-0.1.0/src/kippy_api/kippymap.py +61 -0
- kippy_api-0.1.0/src/kippy_api/pets.py +35 -0
- kippy_api-0.1.0/src/kippy_api/py.typed +0 -0
- kippy_api-0.1.0/src/kippy_api/settings.py +34 -0
- kippy_api-0.1.0/src/kippy_api.egg-info/PKG-INFO +94 -0
- kippy_api-0.1.0/src/kippy_api.egg-info/SOURCES.txt +23 -0
- kippy_api-0.1.0/src/kippy_api.egg-info/dependency_links.txt +1 -0
- kippy_api-0.1.0/src/kippy_api.egg-info/requires.txt +1 -0
- kippy_api-0.1.0/src/kippy_api.egg-info/top_level.txt +1 -0
- kippy_api-0.1.0/tests/test_api_unit.py +289 -0
- kippy_api-0.1.0/tests/test_boundaries.py +335 -0
- kippy_api-0.1.0/tests/test_error_handling.py +61 -0
- kippy_api-0.1.0/tests/test_redaction.py +24 -0
kippy_api-0.1.0/LICENSE
ADDED
|
@@ -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.
|
kippy_api-0.1.0/PKG-INFO
ADDED
|
@@ -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,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
|
+
}
|