kippy-api 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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
+ ]
kippy_api/_base.py ADDED
@@ -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)
kippy_api/_utils.py ADDED
@@ -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
kippy_api/activity.py ADDED
@@ -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
+ }
kippy_api/client.py ADDED
@@ -0,0 +1,19 @@
1
+ """Concrete API client composed of endpoint mixins."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .activity import ActivityEndpoint
6
+ from .kippymap import KippyMapEndpoint
7
+ from .pets import PetsEndpoint
8
+ from .settings import SettingsEndpoint
9
+
10
+ __all__ = ["KippyApi"]
11
+
12
+
13
+ class KippyApi(
14
+ ActivityEndpoint,
15
+ SettingsEndpoint,
16
+ KippyMapEndpoint,
17
+ PetsEndpoint,
18
+ ):
19
+ """Full-featured Kippy API client used by the integration."""
kippy_api/const.py ADDED
@@ -0,0 +1,108 @@
1
+ """Kippy wire protocol constants."""
2
+
3
+ from types import SimpleNamespace
4
+
5
+ # API endpoints.
6
+ DEFAULT_HOST = "https://prod.kippyapi.eu"
7
+ LOGIN_PATH = "/v2/login.php"
8
+ GET_PETS_PATH = "/v2/GetPetKippyList.php"
9
+ KIPPYMAP_ACTION_PATH = "/v2/kippymap_action.php"
10
+ KIPPYMAP_MODIFY_SETTINGS_PATH = "/v2/kippymap_modifyKippySettings.php"
11
+ GET_ACTIVITY_CATEGORIES_PATH = "/v2/vita/get_activities_cat.php"
12
+
13
+ # Default request headers.
14
+ REQUEST_HEADERS: dict[str, str] = {
15
+ "Content-Type": "text/plain; charset=utf-8",
16
+ "Accept": "application/json, */*;q=0.8",
17
+ "User-Agent": "kippy-ha/0.1 (+aiohttp)",
18
+ }
19
+
20
+ # Default app/device configuration.
21
+ APP_IDENTITY = "evo"
22
+ APP_SUB_IDENTITY = "evo"
23
+ APP_IDENTITY_EVO = "1"
24
+ PLATFORM_DEVICE = "10"
25
+ APP_VERSION = "2.9.9"
26
+ TIMEZONE = 1.0
27
+ PHONE_COUNTRY_CODE = "1"
28
+ TOKEN_DEVICE = None
29
+ DEVICE_NAME = "homeassistant"
30
+ T_ID = 1
31
+
32
+ # Formula group and activity identifiers.
33
+ FORMULA_GROUP = SimpleNamespace(SUM="SUM")
34
+ ACTIVITY_ID = SimpleNamespace(ALL=0)
35
+
36
+ # Values for the API ``return`` field.
37
+ RETURN_VALUES = SimpleNamespace(
38
+ SUCCESS=0,
39
+ SUCCESS_TRUE=True,
40
+ # Returned when a kippymap action is performed on a device without an
41
+ # active subscription.
42
+ SUBSCRIPTION_FAILURE=False,
43
+ MALFORMED_REQUEST=[4, 13],
44
+ AUTHORIZATION_EXPIRED=6,
45
+ INVALID_CREDENTIALS=108,
46
+ )
47
+
48
+ # Return codes grouped by outcome.
49
+ RETURN_CODES_SUCCESS = {
50
+ RETURN_VALUES.SUCCESS,
51
+ RETURN_VALUES.SUCCESS_TRUE,
52
+ }
53
+
54
+ # Mapping of failure codes to human readable errors.
55
+ RETURN_CODE_ERRORS = {
56
+ **{code: "Malformed request" for code in RETURN_VALUES.MALFORMED_REQUEST},
57
+ RETURN_VALUES.AUTHORIZATION_EXPIRED: "Authorization expired",
58
+ RETURN_VALUES.INVALID_CREDENTIALS: "Invalid credentials",
59
+ RETURN_VALUES.SUBSCRIPTION_FAILURE: "Subscription inactive",
60
+ }
61
+
62
+ RETURN_CODES_FAILURE = set(RETURN_CODE_ERRORS)
63
+
64
+ # Fields to redact from logs.
65
+ SENSITIVE_LOG_FIELDS = {
66
+ "app_code",
67
+ "app_verification_code",
68
+ "petID",
69
+ "kippy_id",
70
+ "login_email",
71
+ "login_password_hash",
72
+ "login_password_hash_md5",
73
+ "lat",
74
+ "lng",
75
+ "gps_latitude",
76
+ "gps_longitude",
77
+ }
78
+ LOGIN_SENSITIVE_FIELDS = {
79
+ "login_email",
80
+ "login_password_hash",
81
+ "login_password_hash_md5",
82
+ }
83
+
84
+ # Mapping of operating status codes returned by the API.
85
+ OPERATING_STATUS = SimpleNamespace(
86
+ IDLE=1,
87
+ UNKNOWN=2,
88
+ LIVE=5,
89
+ ENERGY_SAVING=18,
90
+ )
91
+
92
+ # App action identifiers used by the API.
93
+ APP_ACTION = SimpleNamespace(
94
+ TURN_LIVE_TRACKING_ON=2,
95
+ TURN_LIVE_TRACKING_OFF=1,
96
+ )
97
+
98
+ # Names used by the API for location technologies.
99
+ LOCALIZATION_TECHNOLOGY_LBS = "LBS (Low accuracy)"
100
+ LOCALIZATION_TECHNOLOGY_GPS = "GPS"
101
+ LOCALIZATION_TECHNOLOGY_WIFI = "Wifi"
102
+
103
+ # Mapping of localization technology codes returned by the API.
104
+ LOCALIZATION_TECHNOLOGY_MAP: dict[str, str] = {
105
+ "1": LOCALIZATION_TECHNOLOGY_LBS,
106
+ "2": LOCALIZATION_TECHNOLOGY_GPS,
107
+ "3": LOCALIZATION_TECHNOLOGY_WIFI,
108
+ }
@@ -0,0 +1,29 @@
1
+ """Errors returned by the Kippy client."""
2
+
3
+
4
+ class KippyError(Exception):
5
+ """Base error with optional HTTP and API result metadata."""
6
+
7
+ def __init__(
8
+ self,
9
+ message: str,
10
+ *,
11
+ status: int | None = None,
12
+ return_code: int | bool | str | None = None,
13
+ ) -> None:
14
+ """Store safe error metadata without retaining response bodies."""
15
+ super().__init__(message)
16
+ self.status = status
17
+ self.return_code = return_code
18
+
19
+
20
+ class KippyAuthError(KippyError):
21
+ """Credentials are missing, invalid, or expired."""
22
+
23
+
24
+ class KippyConnectionError(KippyError):
25
+ """A network request failed or timed out."""
26
+
27
+
28
+ class KippyResponseError(KippyError):
29
+ """The server rejected the request or returned an invalid response."""
kippy_api/kippymap.py ADDED
@@ -0,0 +1,61 @@
1
+ """API endpoint for fetching Kippy Map location data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ._base import BaseKippyApi
8
+ from .const import KIPPYMAP_ACTION_PATH, LOCALIZATION_TECHNOLOGY_MAP, REQUEST_HEADERS
9
+
10
+
11
+ class KippyMapEndpoint(BaseKippyApi):
12
+ """Mixin implementing the Kippy Map action endpoint."""
13
+
14
+ async def kippymap_action(
15
+ self,
16
+ kippy_id: int,
17
+ do_sms: bool = True,
18
+ app_action: int | None = None,
19
+ geofence_id: int | None = None,
20
+ ) -> dict[str, Any]:
21
+ """Perform a Kippy Map action for a specific device."""
22
+
23
+ payload = await self._authenticated_payload(
24
+ extra={
25
+ "kippy_id": kippy_id,
26
+ "do_sms": int(do_sms),
27
+ }
28
+ )
29
+ if app_action is not None:
30
+ payload["app_action"] = app_action
31
+ if geofence_id is not None:
32
+ payload["geofence_id"] = geofence_id
33
+
34
+ data = await self.post_with_refresh(
35
+ KIPPYMAP_ACTION_PATH, payload, REQUEST_HEADERS
36
+ )
37
+
38
+ response = data.get("data")
39
+ if not isinstance(response, dict):
40
+ response = dict(data)
41
+
42
+ lat = response.pop("lat", None)
43
+ if lat is not None:
44
+ response["gps_latitude"] = lat
45
+ lng = response.pop("lng", None)
46
+ if lng is not None:
47
+ response["gps_longitude"] = lng
48
+ radius = response.pop("radius", None)
49
+ if radius is not None:
50
+ response["gps_accuracy"] = radius
51
+ altitude = response.pop("altitude", None)
52
+ if altitude is not None:
53
+ response["gps_altitude"] = altitude
54
+
55
+ tech = response.get("localization_tecnology")
56
+ if tech is not None:
57
+ response["localization_technology"] = LOCALIZATION_TECHNOLOGY_MAP.get(
58
+ str(tech), str(tech)
59
+ )
60
+
61
+ return response
kippy_api/pets.py ADDED
@@ -0,0 +1,35 @@
1
+ """API endpoint dealing with pet metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ._base import BaseKippyApi
8
+ from .const import APP_SUB_IDENTITY, GET_PETS_PATH, REQUEST_HEADERS
9
+ from .exceptions import KippyResponseError
10
+
11
+
12
+ class PetsEndpoint(BaseKippyApi):
13
+ """Mixin providing access to the pet list endpoint."""
14
+
15
+ async def get_pet_kippy_list(self) -> list[dict[str, Any]]:
16
+ """Retrieve the list of pets associated with the account."""
17
+
18
+ payload = await self._authenticated_payload(
19
+ extra={"app_sub_identity": APP_SUB_IDENTITY}
20
+ )
21
+
22
+ data = await self.post_with_refresh(GET_PETS_PATH, payload, REQUEST_HEADERS)
23
+ pets = data.get("data", [])
24
+ if not isinstance(pets, list) or any(not isinstance(pet, dict) for pet in pets):
25
+ raise KippyResponseError("Pet data must be a list of objects")
26
+ for pet in pets:
27
+ if "enableGPSOnDefault" in pet and "gpsOnDefault" not in pet:
28
+ value = pet.pop("enableGPSOnDefault")
29
+ if isinstance(value, str):
30
+ try:
31
+ value = int(value)
32
+ except ValueError:
33
+ value = 1 if value.lower() in ("true", "1") else 0
34
+ pet["gpsOnDefault"] = int(bool(value))
35
+ return pets
kippy_api/py.typed ADDED
File without changes
kippy_api/settings.py ADDED
@@ -0,0 +1,34 @@
1
+ """API endpoint for updating tracker settings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ._base import BaseKippyApi
8
+ from .const import KIPPYMAP_MODIFY_SETTINGS_PATH, REQUEST_HEADERS
9
+
10
+
11
+ class SettingsEndpoint(BaseKippyApi):
12
+ """Mixin implementing device settings updates."""
13
+
14
+ async def modify_kippy_settings(
15
+ self,
16
+ kippy_id: int,
17
+ *,
18
+ update_frequency: float | None = None,
19
+ gps_on_default: bool | None = None,
20
+ energy_saving_mode: bool | None = None,
21
+ ) -> dict[str, Any]:
22
+ """Modify settings for a specific device."""
23
+
24
+ payload = await self._authenticated_payload(extra={"modify_kippy_id": kippy_id})
25
+ if update_frequency is not None:
26
+ payload["update_frequency"] = float(f"{float(update_frequency):.1f}")
27
+ if gps_on_default is not None:
28
+ payload["gps_on_default"] = bool(gps_on_default)
29
+ if energy_saving_mode is not None:
30
+ payload["energy_saving_mode"] = int(energy_saving_mode)
31
+
32
+ return await self.post_with_refresh(
33
+ KIPPYMAP_MODIFY_SETTINGS_PATH, payload, REQUEST_HEADERS
34
+ )
@@ -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,16 @@
1
+ kippy_api/__init__.py,sha256=wf7fV1mgVxZoDJX2neTxbwu7_g_Xma2hGRxL9CXbdQ0,326
2
+ kippy_api/_base.py,sha256=A32xGkgp75l7q8q2HuqKNxaHhdlyLSzwZcsCddPt_Bc,8391
3
+ kippy_api/_utils.py,sha256=-POr9VSjGhbKV8rOPIwceY_uBixoS9iCD89yrVpSeDA,3494
4
+ kippy_api/activity.py,sha256=B2uD0HCawsvFKKhj2aAPopnF5M19i5ELpbNgAnqPoAA,2573
5
+ kippy_api/client.py,sha256=BT8ISYH1kFV6Mcc-Rf-GaIP3t3_9zb6kOZjfBzpCllA,435
6
+ kippy_api/const.py,sha256=ikwXDmW7kALvFXiAYYFvSZo1mJU9V8H6FCVP5S3KMDY,2898
7
+ kippy_api/exceptions.py,sha256=_j9UiLNpJm3bopEXfCwo5BrAxyXE2gKb9UzSEnNEfWo,783
8
+ kippy_api/kippymap.py,sha256=ffikIJOd1K70hOCLC6IeH5dG1pN3b71M4kF6NtVuZpM,1894
9
+ kippy_api/pets.py,sha256=7CIOtJzOpPXJ1CtVJgtSf8k9KvudNOGj5OEztQ75nj4,1339
10
+ kippy_api/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ kippy_api/settings.py,sha256=wK30pqyTxzdlU3sPIHRdItxnbe1l_H1T_dFYKmJEocA,1154
12
+ kippy_api-0.1.0.dist-info/licenses/LICENSE,sha256=LqqX94CPe2sqmUem3SGH6CsiKaPHLHdsk9USCg4GgpI,1070
13
+ kippy_api-0.1.0.dist-info/METADATA,sha256=E-9QDDEHxZQfaay_nDILymR_QO4J_QVYF98q-E9Xqq4,3573
14
+ kippy_api-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
15
+ kippy_api-0.1.0.dist-info/top_level.txt,sha256=Pg3XRpEwaf1_kbOW0cZRpqI07tqeQIpAXWX7uvwd-BY,10
16
+ kippy_api-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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 @@
1
+ kippy_api