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 +17 -0
- kippy_api/_base.py +229 -0
- kippy_api/_utils.py +116 -0
- kippy_api/activity.py +83 -0
- kippy_api/client.py +19 -0
- kippy_api/const.py +108 -0
- kippy_api/exceptions.py +29 -0
- kippy_api/kippymap.py +61 -0
- kippy_api/pets.py +35 -0
- kippy_api/py.typed +0 -0
- kippy_api/settings.py +34 -0
- kippy_api-0.1.0.dist-info/METADATA +94 -0
- kippy_api-0.1.0.dist-info/RECORD +16 -0
- kippy_api-0.1.0.dist-info/WHEEL +5 -0
- kippy_api-0.1.0.dist-info/licenses/LICENSE +21 -0
- kippy_api-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
}
|
kippy_api/exceptions.py
ADDED
|
@@ -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,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
|