kerblwelt-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.
- kerblwelt_api/__init__.py +47 -0
- kerblwelt_api/auth.py +191 -0
- kerblwelt_api/client.py +272 -0
- kerblwelt_api/const.py +46 -0
- kerblwelt_api/exceptions.py +76 -0
- kerblwelt_api/models.py +254 -0
- kerblwelt_api-0.1.0.dist-info/METADATA +323 -0
- kerblwelt_api-0.1.0.dist-info/RECORD +11 -0
- kerblwelt_api-0.1.0.dist-info/WHEEL +5 -0
- kerblwelt_api-0.1.0.dist-info/licenses/LICENSE +21 -0
- kerblwelt_api-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Python API client for Kerbl Welt IoT platform."""
|
|
2
|
+
|
|
3
|
+
from .client import KerblweltClient
|
|
4
|
+
from .const import __version__
|
|
5
|
+
from .exceptions import (
|
|
6
|
+
APIError,
|
|
7
|
+
AuthenticationError,
|
|
8
|
+
ConnectionError,
|
|
9
|
+
DeviceNotFoundError,
|
|
10
|
+
InvalidCredentialsError,
|
|
11
|
+
KerblweltError,
|
|
12
|
+
RateLimitError,
|
|
13
|
+
TokenExpiredError,
|
|
14
|
+
TokenRefreshError,
|
|
15
|
+
ValidationError,
|
|
16
|
+
)
|
|
17
|
+
from .models import (
|
|
18
|
+
AuthResponse,
|
|
19
|
+
DeviceEventCount,
|
|
20
|
+
DeviceType,
|
|
21
|
+
SmartSatelliteDevice,
|
|
22
|
+
User,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
# Client
|
|
27
|
+
"KerblweltClient",
|
|
28
|
+
# Exceptions
|
|
29
|
+
"KerblweltError",
|
|
30
|
+
"AuthenticationError",
|
|
31
|
+
"InvalidCredentialsError",
|
|
32
|
+
"TokenExpiredError",
|
|
33
|
+
"TokenRefreshError",
|
|
34
|
+
"APIError",
|
|
35
|
+
"ConnectionError",
|
|
36
|
+
"DeviceNotFoundError",
|
|
37
|
+
"RateLimitError",
|
|
38
|
+
"ValidationError",
|
|
39
|
+
# Models
|
|
40
|
+
"AuthResponse",
|
|
41
|
+
"User",
|
|
42
|
+
"DeviceType",
|
|
43
|
+
"SmartSatelliteDevice",
|
|
44
|
+
"DeviceEventCount",
|
|
45
|
+
# Version
|
|
46
|
+
"__version__",
|
|
47
|
+
]
|
kerblwelt_api/auth.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Authentication module for Kerbl Welt API."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import aiohttp
|
|
7
|
+
|
|
8
|
+
from .const import (
|
|
9
|
+
BASE_URL,
|
|
10
|
+
CONTENT_TYPE_JSON,
|
|
11
|
+
ENDPOINT_AUTH_REFRESH,
|
|
12
|
+
ENDPOINT_AUTH_SIGN_IN,
|
|
13
|
+
HEADER_AUTHORIZATION,
|
|
14
|
+
HEADER_CONTENT_TYPE,
|
|
15
|
+
)
|
|
16
|
+
from .exceptions import (
|
|
17
|
+
AuthenticationError,
|
|
18
|
+
InvalidCredentialsError,
|
|
19
|
+
TokenExpiredError,
|
|
20
|
+
TokenRefreshError,
|
|
21
|
+
)
|
|
22
|
+
from .models import AuthResponse
|
|
23
|
+
|
|
24
|
+
_LOGGER = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AuthManager:
|
|
28
|
+
"""Manages authentication with Kerbl Welt API."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, session: aiohttp.ClientSession) -> None:
|
|
31
|
+
"""Initialize authentication manager.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
session: aiohttp client session
|
|
35
|
+
"""
|
|
36
|
+
self._session = session
|
|
37
|
+
self._access_token: str | None = None
|
|
38
|
+
self._refresh_token: str | None = None
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def access_token(self) -> str | None:
|
|
42
|
+
"""Get current access token.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Access token or None if not authenticated
|
|
46
|
+
"""
|
|
47
|
+
return self._access_token
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def refresh_token(self) -> str | None:
|
|
51
|
+
"""Get current refresh token.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Refresh token or None if not authenticated
|
|
55
|
+
"""
|
|
56
|
+
return self._refresh_token
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def is_authenticated(self) -> bool:
|
|
60
|
+
"""Check if currently authenticated.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
True if access token is available
|
|
64
|
+
"""
|
|
65
|
+
return self._access_token is not None
|
|
66
|
+
|
|
67
|
+
def get_auth_headers(self) -> dict[str, str]:
|
|
68
|
+
"""Get authentication headers for API requests.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Dictionary with Authorization header
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
AuthenticationError: If not authenticated
|
|
75
|
+
"""
|
|
76
|
+
if not self._access_token:
|
|
77
|
+
raise AuthenticationError("Not authenticated - access token not available")
|
|
78
|
+
|
|
79
|
+
return {HEADER_AUTHORIZATION: f"Bearer {self._access_token}"}
|
|
80
|
+
|
|
81
|
+
async def authenticate(self, email: str, password: str) -> AuthResponse:
|
|
82
|
+
"""Authenticate with email and password.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
email: User email address
|
|
86
|
+
password: User password
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
AuthResponse containing access and refresh tokens
|
|
90
|
+
|
|
91
|
+
Raises:
|
|
92
|
+
InvalidCredentialsError: If credentials are invalid
|
|
93
|
+
AuthenticationError: If authentication fails for other reasons
|
|
94
|
+
"""
|
|
95
|
+
url = f"{BASE_URL}{ENDPOINT_AUTH_SIGN_IN}"
|
|
96
|
+
payload = {"email": email, "password": password}
|
|
97
|
+
headers = {HEADER_CONTENT_TYPE: CONTENT_TYPE_JSON}
|
|
98
|
+
|
|
99
|
+
_LOGGER.debug("Authenticating user: %s", email)
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
async with self._session.post(url, json=payload, headers=headers) as response:
|
|
103
|
+
if response.status == 201:
|
|
104
|
+
data = await response.json()
|
|
105
|
+
self._access_token = data["accessToken"]
|
|
106
|
+
self._refresh_token = data["refreshToken"]
|
|
107
|
+
|
|
108
|
+
_LOGGER.info("Authentication successful for user: %s", email)
|
|
109
|
+
|
|
110
|
+
return AuthResponse(
|
|
111
|
+
access_token=self._access_token,
|
|
112
|
+
refresh_token=self._refresh_token,
|
|
113
|
+
)
|
|
114
|
+
elif response.status == 401:
|
|
115
|
+
_LOGGER.error("Invalid credentials for user: %s", email)
|
|
116
|
+
raise InvalidCredentialsError("Invalid email or password")
|
|
117
|
+
else:
|
|
118
|
+
error_text = await response.text()
|
|
119
|
+
_LOGGER.error(
|
|
120
|
+
"Authentication failed with status %d: %s", response.status, error_text
|
|
121
|
+
)
|
|
122
|
+
raise AuthenticationError(f"Authentication failed: {error_text}")
|
|
123
|
+
|
|
124
|
+
except aiohttp.ClientError as err:
|
|
125
|
+
_LOGGER.error("Network error during authentication: %s", err)
|
|
126
|
+
raise AuthenticationError(f"Network error during authentication: {err}") from err
|
|
127
|
+
|
|
128
|
+
async def refresh_access_token(self) -> AuthResponse:
|
|
129
|
+
"""Refresh access token using refresh token.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
AuthResponse with new access and refresh tokens
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
TokenRefreshError: If token refresh fails
|
|
136
|
+
AuthenticationError: If no refresh token is available
|
|
137
|
+
"""
|
|
138
|
+
if not self._refresh_token:
|
|
139
|
+
raise AuthenticationError("No refresh token available")
|
|
140
|
+
|
|
141
|
+
url = f"{BASE_URL}{ENDPOINT_AUTH_REFRESH}"
|
|
142
|
+
payload = {"refreshToken": self._refresh_token}
|
|
143
|
+
headers = {HEADER_CONTENT_TYPE: CONTENT_TYPE_JSON}
|
|
144
|
+
|
|
145
|
+
_LOGGER.debug("Refreshing access token")
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
async with self._session.post(url, json=payload, headers=headers) as response:
|
|
149
|
+
if response.status == 201:
|
|
150
|
+
data = await response.json()
|
|
151
|
+
self._access_token = data["accessToken"]
|
|
152
|
+
self._refresh_token = data["refreshToken"]
|
|
153
|
+
|
|
154
|
+
_LOGGER.info("Token refresh successful")
|
|
155
|
+
|
|
156
|
+
return AuthResponse(
|
|
157
|
+
access_token=self._access_token,
|
|
158
|
+
refresh_token=self._refresh_token,
|
|
159
|
+
)
|
|
160
|
+
elif response.status == 401:
|
|
161
|
+
_LOGGER.error("Refresh token expired or invalid")
|
|
162
|
+
self._access_token = None
|
|
163
|
+
self._refresh_token = None
|
|
164
|
+
raise TokenExpiredError("Refresh token expired - re-authentication required")
|
|
165
|
+
else:
|
|
166
|
+
error_text = await response.text()
|
|
167
|
+
_LOGGER.error("Token refresh failed with status %d: %s", response.status, error_text)
|
|
168
|
+
raise TokenRefreshError(f"Token refresh failed: {error_text}")
|
|
169
|
+
|
|
170
|
+
except aiohttp.ClientError as err:
|
|
171
|
+
_LOGGER.error("Network error during token refresh: %s", err)
|
|
172
|
+
raise TokenRefreshError(f"Network error during token refresh: {err}") from err
|
|
173
|
+
|
|
174
|
+
def set_tokens(self, access_token: str, refresh_token: str) -> None:
|
|
175
|
+
"""Manually set authentication tokens.
|
|
176
|
+
|
|
177
|
+
Useful for restoring a previous session.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
access_token: Access token
|
|
181
|
+
refresh_token: Refresh token
|
|
182
|
+
"""
|
|
183
|
+
self._access_token = access_token
|
|
184
|
+
self._refresh_token = refresh_token
|
|
185
|
+
_LOGGER.debug("Tokens set manually")
|
|
186
|
+
|
|
187
|
+
def clear_tokens(self) -> None:
|
|
188
|
+
"""Clear stored authentication tokens."""
|
|
189
|
+
self._access_token = None
|
|
190
|
+
self._refresh_token = None
|
|
191
|
+
_LOGGER.debug("Tokens cleared")
|
kerblwelt_api/client.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Main API client for Kerbl Welt."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import aiohttp
|
|
7
|
+
|
|
8
|
+
from .auth import AuthManager
|
|
9
|
+
from .const import (
|
|
10
|
+
BASE_URL,
|
|
11
|
+
DEFAULT_CONNECT_TIMEOUT,
|
|
12
|
+
DEFAULT_TIMEOUT,
|
|
13
|
+
DEVICE_TYPE_SMART_SATELLITE,
|
|
14
|
+
ENDPOINT_DEVICES,
|
|
15
|
+
ENDPOINT_EVENT_COUNT,
|
|
16
|
+
ENDPOINT_USER,
|
|
17
|
+
)
|
|
18
|
+
from .exceptions import APIError, ConnectionError, DeviceNotFoundError
|
|
19
|
+
from .models import DeviceEventCount, SmartSatelliteDevice, User
|
|
20
|
+
|
|
21
|
+
_LOGGER = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class KerblweltClient:
|
|
25
|
+
"""Client for interacting with Kerbl Welt API."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
session: aiohttp.ClientSession | None = None,
|
|
30
|
+
timeout: int = DEFAULT_TIMEOUT,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Initialize Kerbl Welt API client.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
session: Optional aiohttp client session. If not provided, one will be created.
|
|
36
|
+
timeout: Request timeout in seconds
|
|
37
|
+
"""
|
|
38
|
+
self._session = session
|
|
39
|
+
self._own_session = session is None
|
|
40
|
+
self._timeout = timeout
|
|
41
|
+
self._auth: AuthManager | None = None
|
|
42
|
+
|
|
43
|
+
async def __aenter__(self) -> "KerblweltClient":
|
|
44
|
+
"""Async context manager entry."""
|
|
45
|
+
if self._own_session:
|
|
46
|
+
timeout = aiohttp.ClientTimeout(
|
|
47
|
+
total=self._timeout,
|
|
48
|
+
connect=DEFAULT_CONNECT_TIMEOUT,
|
|
49
|
+
)
|
|
50
|
+
self._session = aiohttp.ClientSession(timeout=timeout)
|
|
51
|
+
|
|
52
|
+
self._auth = AuthManager(self._session) # type: ignore
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
56
|
+
"""Async context manager exit."""
|
|
57
|
+
await self.close()
|
|
58
|
+
|
|
59
|
+
async def close(self) -> None:
|
|
60
|
+
"""Close the client session."""
|
|
61
|
+
if self._own_session and self._session:
|
|
62
|
+
await self._session.close()
|
|
63
|
+
self._session = None
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def is_authenticated(self) -> bool:
|
|
67
|
+
"""Check if client is authenticated.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
True if authenticated, False otherwise
|
|
71
|
+
"""
|
|
72
|
+
return self._auth is not None and self._auth.is_authenticated
|
|
73
|
+
|
|
74
|
+
async def authenticate(self, email: str, password: str) -> None:
|
|
75
|
+
"""Authenticate with Kerbl Welt API.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
email: User email address
|
|
79
|
+
password: User password
|
|
80
|
+
|
|
81
|
+
Raises:
|
|
82
|
+
InvalidCredentialsError: If credentials are invalid
|
|
83
|
+
AuthenticationError: If authentication fails
|
|
84
|
+
"""
|
|
85
|
+
if not self._auth:
|
|
86
|
+
raise RuntimeError("Client not initialized - use async context manager")
|
|
87
|
+
|
|
88
|
+
await self._auth.authenticate(email, password)
|
|
89
|
+
|
|
90
|
+
def set_tokens(self, access_token: str, refresh_token: str) -> None:
|
|
91
|
+
"""Set authentication tokens manually.
|
|
92
|
+
|
|
93
|
+
Useful for restoring a previous session.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
access_token: Access token
|
|
97
|
+
refresh_token: Refresh token
|
|
98
|
+
"""
|
|
99
|
+
if not self._auth:
|
|
100
|
+
raise RuntimeError("Client not initialized - use async context manager")
|
|
101
|
+
|
|
102
|
+
self._auth.set_tokens(access_token, refresh_token)
|
|
103
|
+
|
|
104
|
+
async def refresh_token(self) -> None:
|
|
105
|
+
"""Refresh the access token.
|
|
106
|
+
|
|
107
|
+
Raises:
|
|
108
|
+
TokenRefreshError: If token refresh fails
|
|
109
|
+
"""
|
|
110
|
+
if not self._auth:
|
|
111
|
+
raise RuntimeError("Client not initialized - use async context manager")
|
|
112
|
+
|
|
113
|
+
await self._auth.refresh_access_token()
|
|
114
|
+
|
|
115
|
+
async def _request(
|
|
116
|
+
self,
|
|
117
|
+
method: str,
|
|
118
|
+
endpoint: str,
|
|
119
|
+
**kwargs: Any,
|
|
120
|
+
) -> dict[str, Any]:
|
|
121
|
+
"""Make an authenticated API request.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
method: HTTP method (GET, POST, etc.)
|
|
125
|
+
endpoint: API endpoint path
|
|
126
|
+
**kwargs: Additional arguments for aiohttp request
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Response JSON data
|
|
130
|
+
|
|
131
|
+
Raises:
|
|
132
|
+
APIError: If API request fails
|
|
133
|
+
ConnectionError: If connection fails
|
|
134
|
+
"""
|
|
135
|
+
if not self._session:
|
|
136
|
+
raise RuntimeError("Client not initialized - use async context manager")
|
|
137
|
+
|
|
138
|
+
if not self._auth or not self._auth.is_authenticated:
|
|
139
|
+
raise RuntimeError("Not authenticated - call authenticate() first")
|
|
140
|
+
|
|
141
|
+
url = f"{BASE_URL}{endpoint}"
|
|
142
|
+
headers = kwargs.pop("headers", {})
|
|
143
|
+
headers.update(self._auth.get_auth_headers())
|
|
144
|
+
|
|
145
|
+
_LOGGER.debug("Making %s request to %s", method, endpoint)
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
async with self._session.request(method, url, headers=headers, **kwargs) as response:
|
|
149
|
+
if response.status == 200:
|
|
150
|
+
return await response.json()
|
|
151
|
+
elif response.status == 201:
|
|
152
|
+
return await response.json()
|
|
153
|
+
elif response.status == 401:
|
|
154
|
+
error_text = await response.text()
|
|
155
|
+
_LOGGER.error("Unauthorized request to %s: %s", endpoint, error_text)
|
|
156
|
+
raise APIError(f"Unauthorized: {error_text}", status_code=401)
|
|
157
|
+
elif response.status == 404:
|
|
158
|
+
error_text = await response.text()
|
|
159
|
+
_LOGGER.error("Not found: %s - %s", endpoint, error_text)
|
|
160
|
+
raise APIError(f"Not found: {error_text}", status_code=404)
|
|
161
|
+
elif response.status == 429:
|
|
162
|
+
error_text = await response.text()
|
|
163
|
+
_LOGGER.error("Rate limited: %s", error_text)
|
|
164
|
+
raise APIError(f"Rate limited: {error_text}", status_code=429)
|
|
165
|
+
else:
|
|
166
|
+
error_text = await response.text()
|
|
167
|
+
_LOGGER.error(
|
|
168
|
+
"API request failed with status %d: %s", response.status, error_text
|
|
169
|
+
)
|
|
170
|
+
raise APIError(
|
|
171
|
+
f"API request failed: {error_text}", status_code=response.status
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
except aiohttp.ClientError as err:
|
|
175
|
+
_LOGGER.error("Connection error during request to %s: %s", endpoint, err)
|
|
176
|
+
raise ConnectionError(f"Connection error: {err}") from err
|
|
177
|
+
|
|
178
|
+
async def get_user(self) -> User:
|
|
179
|
+
"""Get current user information.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
User object with account information
|
|
183
|
+
|
|
184
|
+
Raises:
|
|
185
|
+
APIError: If API request fails
|
|
186
|
+
"""
|
|
187
|
+
_LOGGER.debug("Fetching user information")
|
|
188
|
+
data = await self._request("GET", ENDPOINT_USER)
|
|
189
|
+
return User.from_dict(data)
|
|
190
|
+
|
|
191
|
+
async def get_devices(self) -> list[SmartSatelliteDevice]:
|
|
192
|
+
"""Get all Smart Satellite devices for the authenticated user.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
List of SmartSatelliteDevice objects
|
|
196
|
+
|
|
197
|
+
Raises:
|
|
198
|
+
APIError: If API request fails
|
|
199
|
+
"""
|
|
200
|
+
_LOGGER.debug("Fetching devices")
|
|
201
|
+
data = await self._request("GET", ENDPOINT_DEVICES)
|
|
202
|
+
|
|
203
|
+
# Parse Smart Satellite devices
|
|
204
|
+
devices = []
|
|
205
|
+
for device_data in data.get("smartSatellite", []):
|
|
206
|
+
devices.append(SmartSatelliteDevice.from_dict(device_data))
|
|
207
|
+
|
|
208
|
+
_LOGGER.info("Found %d Smart Satellite device(s)", len(devices))
|
|
209
|
+
return devices
|
|
210
|
+
|
|
211
|
+
async def get_device(self, device_id: str) -> SmartSatelliteDevice:
|
|
212
|
+
"""Get a specific Smart Satellite device by ID.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
device_id: Device UUID
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
SmartSatelliteDevice object
|
|
219
|
+
|
|
220
|
+
Raises:
|
|
221
|
+
DeviceNotFoundError: If device is not found
|
|
222
|
+
APIError: If API request fails
|
|
223
|
+
"""
|
|
224
|
+
devices = await self.get_devices()
|
|
225
|
+
|
|
226
|
+
for device in devices:
|
|
227
|
+
if device.id == device_id:
|
|
228
|
+
return device
|
|
229
|
+
|
|
230
|
+
raise DeviceNotFoundError(device_id)
|
|
231
|
+
|
|
232
|
+
async def get_device_event_count(self, device_id: str) -> DeviceEventCount:
|
|
233
|
+
"""Get event count for a Smart Satellite device.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
device_id: Device UUID
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
DeviceEventCount object
|
|
240
|
+
|
|
241
|
+
Raises:
|
|
242
|
+
APIError: If API request fails
|
|
243
|
+
"""
|
|
244
|
+
endpoint = ENDPOINT_EVENT_COUNT.format(
|
|
245
|
+
device_type=DEVICE_TYPE_SMART_SATELLITE,
|
|
246
|
+
device_id=device_id,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
_LOGGER.debug("Fetching event count for device %s", device_id)
|
|
250
|
+
data = await self._request("GET", endpoint)
|
|
251
|
+
return DeviceEventCount.from_dict(data)
|
|
252
|
+
|
|
253
|
+
async def get_all_device_data(self) -> dict[str, tuple[SmartSatelliteDevice, DeviceEventCount]]:
|
|
254
|
+
"""Get all devices with their event counts.
|
|
255
|
+
|
|
256
|
+
Useful for efficient bulk fetching.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
Dictionary mapping device ID to tuple of (device, event_count)
|
|
260
|
+
|
|
261
|
+
Raises:
|
|
262
|
+
APIError: If API request fails
|
|
263
|
+
"""
|
|
264
|
+
devices = await self.get_devices()
|
|
265
|
+
result = {}
|
|
266
|
+
|
|
267
|
+
for device in devices:
|
|
268
|
+
event_count = await self.get_device_event_count(device.id)
|
|
269
|
+
result[device.id] = (device, event_count)
|
|
270
|
+
|
|
271
|
+
_LOGGER.info("Fetched data for %d device(s)", len(result))
|
|
272
|
+
return result
|
kerblwelt_api/const.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Constants for Kerbl Welt API."""
|
|
2
|
+
|
|
3
|
+
# API Configuration
|
|
4
|
+
BASE_URL = "https://backend.kerbl-iot.com/api/v0.1"
|
|
5
|
+
WEBSOCKET_URL = "wss://backend.kerbl-iot.com/ws/v0.1/socket.io/"
|
|
6
|
+
|
|
7
|
+
# API Endpoints
|
|
8
|
+
ENDPOINT_AUTH_SIGN_IN = "/auth/sign-in"
|
|
9
|
+
ENDPOINT_AUTH_REFRESH = "/auth/refresh"
|
|
10
|
+
ENDPOINT_USER = "/user"
|
|
11
|
+
ENDPOINT_DEVICES = "/device"
|
|
12
|
+
ENDPOINT_EVENT_COUNT = "/device/event-count/{device_type}/{device_id}"
|
|
13
|
+
ENDPOINT_ADMIN_MESSAGE = "/admin-message/for-user"
|
|
14
|
+
ENDPOINT_MAINTENANCE_PING = "/maintenance/ping"
|
|
15
|
+
ENDPOINT_MAINTENANCE_BRANCH = "/maintenance/deployed_branch"
|
|
16
|
+
|
|
17
|
+
# Device Types
|
|
18
|
+
DEVICE_TYPE_SMART_SATELLITE = "smart-satellite"
|
|
19
|
+
DEVICE_TYPE_SMART_COOP = "smart-coop"
|
|
20
|
+
DEVICE_TYPE_SMART_ENERGIZER = "smart-energizer"
|
|
21
|
+
DEVICE_TYPE_SMART_MOUSE_TRAP = "smart-mouse-trap"
|
|
22
|
+
DEVICE_TYPE_SMART_LIGHT = "smart-light"
|
|
23
|
+
DEVICE_TYPE_SMART_WEATHER = "smart-weather"
|
|
24
|
+
DEVICE_TYPE_SMART_TRACKER = "smart-tracker"
|
|
25
|
+
DEVICE_TYPE_SMART_SOS = "smart-sos"
|
|
26
|
+
DEVICE_TYPE_SMART_CHICKEN_DOOR = "smart-chicken-door"
|
|
27
|
+
DEVICE_TYPE_SMART_ADS = "smart-ads"
|
|
28
|
+
|
|
29
|
+
# HTTP Headers
|
|
30
|
+
HEADER_AUTHORIZATION = "Authorization"
|
|
31
|
+
HEADER_CONTENT_TYPE = "Content-Type"
|
|
32
|
+
HEADER_ACCEPT = "Accept"
|
|
33
|
+
|
|
34
|
+
# Content Types
|
|
35
|
+
CONTENT_TYPE_JSON = "application/json"
|
|
36
|
+
|
|
37
|
+
# Default Timeouts (seconds)
|
|
38
|
+
DEFAULT_TIMEOUT = 30
|
|
39
|
+
DEFAULT_CONNECT_TIMEOUT = 10
|
|
40
|
+
|
|
41
|
+
# Token Expiration (approximate, in seconds)
|
|
42
|
+
ACCESS_TOKEN_EXPIRY = 15 * 24 * 60 * 60 # ~15 days
|
|
43
|
+
REFRESH_TOKEN_EXPIRY = 40 * 24 * 60 * 60 # ~40 days
|
|
44
|
+
|
|
45
|
+
# Version
|
|
46
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Exceptions for Kerbl Welt API."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class KerblweltError(Exception):
|
|
5
|
+
"""Base exception for Kerbl Welt API errors."""
|
|
6
|
+
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AuthenticationError(KerblweltError):
|
|
11
|
+
"""Raised when authentication fails."""
|
|
12
|
+
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class InvalidCredentialsError(AuthenticationError):
|
|
17
|
+
"""Raised when login credentials are invalid."""
|
|
18
|
+
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TokenExpiredError(AuthenticationError):
|
|
23
|
+
"""Raised when access token has expired."""
|
|
24
|
+
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TokenRefreshError(AuthenticationError):
|
|
29
|
+
"""Raised when token refresh fails."""
|
|
30
|
+
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class APIError(KerblweltError):
|
|
35
|
+
"""Raised when API request fails."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, message: str, status_code: int | None = None) -> None:
|
|
38
|
+
"""Initialize API error.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
message: Error message
|
|
42
|
+
status_code: HTTP status code if available
|
|
43
|
+
"""
|
|
44
|
+
super().__init__(message)
|
|
45
|
+
self.status_code = status_code
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ConnectionError(KerblweltError):
|
|
49
|
+
"""Raised when connection to API fails."""
|
|
50
|
+
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class DeviceNotFoundError(KerblweltError):
|
|
55
|
+
"""Raised when requested device is not found."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, device_id: str) -> None:
|
|
58
|
+
"""Initialize device not found error.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
device_id: ID of the device that was not found
|
|
62
|
+
"""
|
|
63
|
+
super().__init__(f"Device not found: {device_id}")
|
|
64
|
+
self.device_id = device_id
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class RateLimitError(APIError):
|
|
68
|
+
"""Raised when API rate limit is exceeded."""
|
|
69
|
+
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ValidationError(KerblweltError):
|
|
74
|
+
"""Raised when input validation fails."""
|
|
75
|
+
|
|
76
|
+
pass
|
kerblwelt_api/models.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Data models for Kerbl Welt API."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class AuthResponse:
|
|
10
|
+
"""Authentication response containing tokens."""
|
|
11
|
+
|
|
12
|
+
access_token: str
|
|
13
|
+
refresh_token: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class DeviceType:
|
|
18
|
+
"""Device type information."""
|
|
19
|
+
|
|
20
|
+
id: str
|
|
21
|
+
name: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class User:
|
|
26
|
+
"""User account information."""
|
|
27
|
+
|
|
28
|
+
id: str
|
|
29
|
+
email: str
|
|
30
|
+
language: str
|
|
31
|
+
timezone: str
|
|
32
|
+
device_types: str
|
|
33
|
+
is_test_user: bool
|
|
34
|
+
is_support: bool
|
|
35
|
+
username: str | None = None
|
|
36
|
+
temp_email: str | None = None
|
|
37
|
+
telephone_number: str | None = None
|
|
38
|
+
telephone_number_country_code: str | None = None
|
|
39
|
+
image_uri: str | None = None
|
|
40
|
+
push_notification_sound: str | None = None
|
|
41
|
+
privacy_policy_target: str | None = None
|
|
42
|
+
privacy_policy_current: str | None = None
|
|
43
|
+
terms_of_use_target: str | None = None
|
|
44
|
+
terms_of_use_current: str | None = None
|
|
45
|
+
double_opt_in_confirmed_at: datetime | None = None
|
|
46
|
+
telephone_double_opt_in_confirmed_at: datetime | None = None
|
|
47
|
+
allow_ads: bool = True
|
|
48
|
+
owned_service_group: str | None = None
|
|
49
|
+
roles: list[str] | None = None
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_dict(cls, data: dict[str, Any]) -> "User":
|
|
53
|
+
"""Create User from API response dictionary.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
data: API response data
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
User instance
|
|
60
|
+
"""
|
|
61
|
+
# Parse datetime fields
|
|
62
|
+
double_opt_in = None
|
|
63
|
+
if data.get("doubleOptInConfirmedAt"):
|
|
64
|
+
double_opt_in = datetime.fromisoformat(
|
|
65
|
+
data["doubleOptInConfirmedAt"].replace("Z", "+00:00")
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
telephone_opt_in = None
|
|
69
|
+
if data.get("telephoneDoubleOptInConfirmedAt"):
|
|
70
|
+
telephone_opt_in = datetime.fromisoformat(
|
|
71
|
+
data["telephoneDoubleOptInConfirmedAt"].replace("Z", "+00:00")
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
return cls(
|
|
75
|
+
id=data["id"],
|
|
76
|
+
email=data["email"],
|
|
77
|
+
language=data["language"],
|
|
78
|
+
timezone=data["timezone"],
|
|
79
|
+
device_types=data["deviceTypes"],
|
|
80
|
+
is_test_user=data["isTestUser"],
|
|
81
|
+
is_support=data["isSupport"],
|
|
82
|
+
username=data.get("username"),
|
|
83
|
+
temp_email=data.get("tempEmail"),
|
|
84
|
+
telephone_number=data.get("telephoneNumber"),
|
|
85
|
+
telephone_number_country_code=data.get("telephoneNumberCountryCode"),
|
|
86
|
+
image_uri=data.get("imageUri"),
|
|
87
|
+
push_notification_sound=data.get("pushNotificationSound"),
|
|
88
|
+
privacy_policy_target=data.get("privacyPolicyTarget"),
|
|
89
|
+
privacy_policy_current=data.get("privacyPolicyCurrent"),
|
|
90
|
+
terms_of_use_target=data.get("termsOfUseTarget"),
|
|
91
|
+
terms_of_use_current=data.get("termsOfUseCurrent"),
|
|
92
|
+
double_opt_in_confirmed_at=double_opt_in,
|
|
93
|
+
telephone_double_opt_in_confirmed_at=telephone_opt_in,
|
|
94
|
+
allow_ads=data.get("allowAds", True),
|
|
95
|
+
owned_service_group=data.get("ownedServiceGroup"),
|
|
96
|
+
roles=data.get("roles", []),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class SmartSatelliteDevice:
|
|
102
|
+
"""Smart Satellite electric fence monitor device."""
|
|
103
|
+
|
|
104
|
+
id: str
|
|
105
|
+
user_id: str
|
|
106
|
+
description: str
|
|
107
|
+
identifier: str
|
|
108
|
+
registered_at: datetime
|
|
109
|
+
push_notifications: bool
|
|
110
|
+
email_notifications: bool
|
|
111
|
+
email_addresses_notifications: str
|
|
112
|
+
is_online: bool
|
|
113
|
+
timezone: str
|
|
114
|
+
linking_identifier: str
|
|
115
|
+
active: bool
|
|
116
|
+
brand: str
|
|
117
|
+
battery_voltage: float
|
|
118
|
+
fence_voltage: int # Volts
|
|
119
|
+
mode: int
|
|
120
|
+
fence_voltage_alarm_threshold: int # Volts
|
|
121
|
+
signal_quality: int # 0-100%
|
|
122
|
+
battery_state: int # 0-100%
|
|
123
|
+
current_error: str
|
|
124
|
+
device_type: DeviceType
|
|
125
|
+
offline_since: datetime | None = None
|
|
126
|
+
first_online_at: datetime | None = None
|
|
127
|
+
firmware_version: str | None = None
|
|
128
|
+
target_firmware_version: str | None = None
|
|
129
|
+
firmware_update_state: str | None = None
|
|
130
|
+
firmware_release_date: datetime | None = None
|
|
131
|
+
item_number: str | None = None
|
|
132
|
+
offline_notified: datetime | None = None
|
|
133
|
+
image_uri: str | None = None
|
|
134
|
+
do_desired_and_actual_state_match: bool = True
|
|
135
|
+
mac: str | None = None
|
|
136
|
+
connection_version: str | None = None
|
|
137
|
+
fence_voltage_target: int | None = None
|
|
138
|
+
fence_voltage_alarm_threshold_desired: int | None = None
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def is_fence_voltage_ok(self) -> bool:
|
|
142
|
+
"""Check if fence voltage is above alarm threshold.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
True if voltage is above threshold, False otherwise
|
|
146
|
+
"""
|
|
147
|
+
return self.fence_voltage >= self.fence_voltage_alarm_threshold
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def is_battery_low(self) -> bool:
|
|
151
|
+
"""Check if battery is low (below 20%).
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
True if battery is below 20%, False otherwise
|
|
155
|
+
"""
|
|
156
|
+
return self.battery_state < 20
|
|
157
|
+
|
|
158
|
+
@classmethod
|
|
159
|
+
def from_dict(cls, data: dict[str, Any]) -> "SmartSatelliteDevice":
|
|
160
|
+
"""Create SmartSatelliteDevice from API response dictionary.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
data: API response data
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
SmartSatelliteDevice instance
|
|
167
|
+
"""
|
|
168
|
+
# Parse datetime fields
|
|
169
|
+
registered_at = datetime.fromisoformat(data["registeredAt"].replace("Z", "+00:00"))
|
|
170
|
+
|
|
171
|
+
offline_since = None
|
|
172
|
+
if data.get("offlineSince"):
|
|
173
|
+
offline_since = datetime.fromisoformat(data["offlineSince"].replace("Z", "+00:00"))
|
|
174
|
+
|
|
175
|
+
first_online_at = None
|
|
176
|
+
if data.get("firstOnlineAt"):
|
|
177
|
+
first_online_at = datetime.fromisoformat(
|
|
178
|
+
data["firstOnlineAt"].replace("Z", "+00:00")
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
offline_notified = None
|
|
182
|
+
if data.get("offlineNotified"):
|
|
183
|
+
offline_notified = datetime.fromisoformat(
|
|
184
|
+
data["offlineNotified"].replace("Z", "+00:00")
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
firmware_release_date = None
|
|
188
|
+
if data.get("firmwareReleaseDate"):
|
|
189
|
+
firmware_release_date = datetime.fromisoformat(
|
|
190
|
+
data["firmwareReleaseDate"].replace("Z", "+00:00")
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# Parse device type
|
|
194
|
+
device_type = DeviceType(
|
|
195
|
+
id=data["deviceType"]["id"],
|
|
196
|
+
name=data["deviceType"]["name"],
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
return cls(
|
|
200
|
+
id=data["id"],
|
|
201
|
+
user_id=data["userId"],
|
|
202
|
+
description=data["description"],
|
|
203
|
+
identifier=data["identifier"],
|
|
204
|
+
registered_at=registered_at,
|
|
205
|
+
push_notifications=data["pushNotifications"],
|
|
206
|
+
email_notifications=data["emailNotifications"],
|
|
207
|
+
email_addresses_notifications=data["emailAddressesNotifications"],
|
|
208
|
+
is_online=data["isOnline"],
|
|
209
|
+
timezone=data["timezone"],
|
|
210
|
+
linking_identifier=data["linkingIdentifier"],
|
|
211
|
+
active=data["active"],
|
|
212
|
+
brand=data["brand"],
|
|
213
|
+
battery_voltage=data["batteryVoltage"],
|
|
214
|
+
fence_voltage=data["fenceVoltage"],
|
|
215
|
+
mode=data["mode"],
|
|
216
|
+
fence_voltage_alarm_threshold=data["fenceVoltageAlarmThreshold"],
|
|
217
|
+
signal_quality=data["signalQuality"],
|
|
218
|
+
battery_state=data["batteryState"],
|
|
219
|
+
current_error=data["currentError"],
|
|
220
|
+
device_type=device_type,
|
|
221
|
+
offline_since=offline_since,
|
|
222
|
+
first_online_at=first_online_at,
|
|
223
|
+
firmware_version=data.get("firmwareVersion"),
|
|
224
|
+
target_firmware_version=data.get("targetFirmwareVersion"),
|
|
225
|
+
firmware_update_state=data.get("firmwareUpdateState"),
|
|
226
|
+
firmware_release_date=firmware_release_date,
|
|
227
|
+
item_number=data.get("itemNumber"),
|
|
228
|
+
offline_notified=offline_notified,
|
|
229
|
+
image_uri=data.get("imageUri"),
|
|
230
|
+
do_desired_and_actual_state_match=data.get("doDesiredAndActualStateMatch", True),
|
|
231
|
+
mac=data.get("mac"),
|
|
232
|
+
connection_version=data.get("connectionVersion"),
|
|
233
|
+
fence_voltage_target=data.get("fenceVoltageTarget"),
|
|
234
|
+
fence_voltage_alarm_threshold_desired=data.get("fenceVoltageAlarmThresholdDesired"),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@dataclass
|
|
239
|
+
class DeviceEventCount:
|
|
240
|
+
"""Count of new events for a device."""
|
|
241
|
+
|
|
242
|
+
new: int
|
|
243
|
+
|
|
244
|
+
@classmethod
|
|
245
|
+
def from_dict(cls, data: dict[str, Any]) -> "DeviceEventCount":
|
|
246
|
+
"""Create DeviceEventCount from API response dictionary.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
data: API response data
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
DeviceEventCount instance
|
|
253
|
+
"""
|
|
254
|
+
return cls(new=data["new"])
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kerblwelt-api
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python API client for Kerbl Welt IoT platform (AKO Smart Satellite electric fence monitors)
|
|
5
|
+
Author-email: Steve Garrity <sgarrity@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/stgarrity/kerblwelt-api
|
|
8
|
+
Project-URL: Repository, https://github.com/stgarrity/kerblwelt-api
|
|
9
|
+
Project-URL: Issues, https://github.com/stgarrity/kerblwelt-api/issues
|
|
10
|
+
Keywords: kerbl,ako,smart-satellite,electric-fence,iot,api-client
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Home Automation
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: aiohttp>=3.9.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest-aiohttp>=1.0.0; extra == "dev"
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
|
29
|
+
Requires-Dist: black>=23.0.0; extra == "dev"
|
|
30
|
+
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
31
|
+
Requires-Dist: mypy>=1.0.0; extra == "dev"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# Kerblwelt API Client
|
|
35
|
+
|
|
36
|
+
Python API client for [Kerbl Welt IoT platform](https://app.kerbl-iot.com), which powers the AKO Smart Satellite electric fence monitoring system.
|
|
37
|
+
|
|
38
|
+
## Features
|
|
39
|
+
|
|
40
|
+
- **Async/await** support for efficient I/O operations
|
|
41
|
+
- **Full type hints** for better IDE support and type checking
|
|
42
|
+
- **Automatic token management** with refresh capability
|
|
43
|
+
- **Comprehensive error handling** with custom exceptions
|
|
44
|
+
- **Data models** using Python dataclasses
|
|
45
|
+
- **Well-tested** with pytest
|
|
46
|
+
|
|
47
|
+
## Installation
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install kerblwelt-api
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Or for development:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
git clone https://github.com/stgarrity/kerblwelt-api
|
|
57
|
+
cd kerblwelt-api
|
|
58
|
+
pip install -e ".[dev]"
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Quick Start
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
import asyncio
|
|
65
|
+
from kerblwelt_api import KerblweltClient
|
|
66
|
+
|
|
67
|
+
async def main():
|
|
68
|
+
async with KerblweltClient() as client:
|
|
69
|
+
# Authenticate
|
|
70
|
+
await client.authenticate("your-email@example.com", "your-password")
|
|
71
|
+
|
|
72
|
+
# Get all devices
|
|
73
|
+
devices = await client.get_devices()
|
|
74
|
+
|
|
75
|
+
for device in devices:
|
|
76
|
+
print(f"Device: {device.description}")
|
|
77
|
+
print(f" Fence Voltage: {device.fence_voltage}V")
|
|
78
|
+
print(f" Battery: {device.battery_state}%")
|
|
79
|
+
print(f" Signal: {device.signal_quality}%")
|
|
80
|
+
print(f" Online: {device.is_online}")
|
|
81
|
+
print(f" Status: {'OK' if device.is_fence_voltage_ok else 'LOW VOLTAGE!'}")
|
|
82
|
+
|
|
83
|
+
if __name__ == "__main__":
|
|
84
|
+
asyncio.run(main())
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Usage
|
|
88
|
+
|
|
89
|
+
### Authentication
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
async with KerblweltClient() as client:
|
|
93
|
+
# Method 1: Authenticate with credentials
|
|
94
|
+
await client.authenticate("email@example.com", "password")
|
|
95
|
+
|
|
96
|
+
# Method 2: Restore previous session with tokens
|
|
97
|
+
client.set_tokens(
|
|
98
|
+
access_token="eyJhbGci...",
|
|
99
|
+
refresh_token="eyJhbGci..."
|
|
100
|
+
)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Get User Information
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
user = await client.get_user()
|
|
107
|
+
print(f"User: {user.email}")
|
|
108
|
+
print(f"Timezone: {user.timezone}")
|
|
109
|
+
print(f"Language: {user.language}")
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Get Devices
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
# Get all Smart Satellite devices
|
|
116
|
+
devices = await client.get_devices()
|
|
117
|
+
|
|
118
|
+
# Get a specific device by ID
|
|
119
|
+
device = await client.get_device("device-uuid-here")
|
|
120
|
+
|
|
121
|
+
# Get device with event count
|
|
122
|
+
device_data = await client.get_all_device_data()
|
|
123
|
+
for device_id, (device, events) in device_data.items():
|
|
124
|
+
print(f"{device.description}: {events.new} new events")
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Access Device Properties
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
device = devices[0]
|
|
131
|
+
|
|
132
|
+
# Basic info
|
|
133
|
+
print(device.description) # User-defined name
|
|
134
|
+
print(device.id) # Device UUID
|
|
135
|
+
print(device.identifier) # Serial number
|
|
136
|
+
|
|
137
|
+
# Status
|
|
138
|
+
print(device.is_online) # Online/offline
|
|
139
|
+
print(device.is_fence_voltage_ok) # Voltage above threshold
|
|
140
|
+
print(device.is_battery_low) # Battery below 20%
|
|
141
|
+
|
|
142
|
+
# Measurements
|
|
143
|
+
print(device.fence_voltage) # Current voltage (V)
|
|
144
|
+
print(device.battery_voltage) # Battery voltage (V)
|
|
145
|
+
print(device.battery_state) # Battery percentage (0-100)
|
|
146
|
+
print(device.signal_quality) # Signal strength (0-100)
|
|
147
|
+
|
|
148
|
+
# Thresholds
|
|
149
|
+
print(device.fence_voltage_alarm_threshold) # Alert threshold (V)
|
|
150
|
+
|
|
151
|
+
# Timestamps
|
|
152
|
+
print(device.registered_at) # Registration date
|
|
153
|
+
print(device.first_online_at) # First connection
|
|
154
|
+
print(device.offline_since) # Last offline time
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### Token Refresh
|
|
158
|
+
|
|
159
|
+
The client automatically handles token expiration. You can also manually refresh:
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
try:
|
|
163
|
+
devices = await client.get_devices()
|
|
164
|
+
except TokenExpiredError:
|
|
165
|
+
await client.refresh_token()
|
|
166
|
+
devices = await client.get_devices()
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### Error Handling
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
from kerblwelt_api import (
|
|
173
|
+
InvalidCredentialsError,
|
|
174
|
+
DeviceNotFoundError,
|
|
175
|
+
APIError,
|
|
176
|
+
ConnectionError,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
await client.authenticate("email@example.com", "wrong-password")
|
|
181
|
+
except InvalidCredentialsError:
|
|
182
|
+
print("Invalid email or password")
|
|
183
|
+
except ConnectionError:
|
|
184
|
+
print("Cannot connect to Kerbl Welt API")
|
|
185
|
+
except APIError as e:
|
|
186
|
+
print(f"API error: {e}")
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## API Reference
|
|
190
|
+
|
|
191
|
+
### KerblweltClient
|
|
192
|
+
|
|
193
|
+
Main client class for interacting with Kerbl Welt API.
|
|
194
|
+
|
|
195
|
+
**Methods:**
|
|
196
|
+
|
|
197
|
+
- `authenticate(email: str, password: str) -> None` - Authenticate with credentials
|
|
198
|
+
- `set_tokens(access_token: str, refresh_token: str) -> None` - Set tokens manually
|
|
199
|
+
- `refresh_token() -> None` - Refresh the access token
|
|
200
|
+
- `get_user() -> User` - Get current user information
|
|
201
|
+
- `get_devices() -> list[SmartSatelliteDevice]` - Get all Smart Satellite devices
|
|
202
|
+
- `get_device(device_id: str) -> SmartSatelliteDevice` - Get specific device
|
|
203
|
+
- `get_device_event_count(device_id: str) -> DeviceEventCount` - Get event count
|
|
204
|
+
- `get_all_device_data() -> dict` - Get all devices with event counts
|
|
205
|
+
- `close() -> None` - Close the client session
|
|
206
|
+
|
|
207
|
+
**Properties:**
|
|
208
|
+
|
|
209
|
+
- `is_authenticated: bool` - Check if client is authenticated
|
|
210
|
+
|
|
211
|
+
### SmartSatelliteDevice
|
|
212
|
+
|
|
213
|
+
Represents an AKO Smart Satellite electric fence monitor.
|
|
214
|
+
|
|
215
|
+
**Key Attributes:**
|
|
216
|
+
|
|
217
|
+
- `id: str` - Device UUID
|
|
218
|
+
- `description: str` - User-defined device name
|
|
219
|
+
- `fence_voltage: int` - Current fence voltage in volts
|
|
220
|
+
- `battery_voltage: float` - Battery voltage in volts
|
|
221
|
+
- `battery_state: int` - Battery percentage (0-100)
|
|
222
|
+
- `signal_quality: int` - Signal strength (0-100)
|
|
223
|
+
- `is_online: bool` - Device online status
|
|
224
|
+
- `fence_voltage_alarm_threshold: int` - Alert threshold in volts
|
|
225
|
+
|
|
226
|
+
**Helper Properties:**
|
|
227
|
+
|
|
228
|
+
- `is_fence_voltage_ok: bool` - Voltage above threshold
|
|
229
|
+
- `is_battery_low: bool` - Battery below 20%
|
|
230
|
+
|
|
231
|
+
### Exceptions
|
|
232
|
+
|
|
233
|
+
All exceptions inherit from `KerblweltError`:
|
|
234
|
+
|
|
235
|
+
- `AuthenticationError` - Base authentication error
|
|
236
|
+
- `InvalidCredentialsError` - Wrong email/password
|
|
237
|
+
- `TokenExpiredError` - Token has expired
|
|
238
|
+
- `TokenRefreshError` - Token refresh failed
|
|
239
|
+
- `APIError` - API request failed
|
|
240
|
+
- `ConnectionError` - Network connection failed
|
|
241
|
+
- `DeviceNotFoundError` - Device ID not found
|
|
242
|
+
- `RateLimitError` - API rate limit exceeded
|
|
243
|
+
- `ValidationError` - Input validation failed
|
|
244
|
+
|
|
245
|
+
## Development
|
|
246
|
+
|
|
247
|
+
### Setup
|
|
248
|
+
|
|
249
|
+
```bash
|
|
250
|
+
# Clone repository
|
|
251
|
+
git clone https://github.com/stgarrity/kerblwelt-api
|
|
252
|
+
cd kerblwelt-api
|
|
253
|
+
|
|
254
|
+
# Create virtual environment
|
|
255
|
+
python3 -m venv venv
|
|
256
|
+
source venv/bin/activate
|
|
257
|
+
|
|
258
|
+
# Install with dev dependencies
|
|
259
|
+
pip install -e ".[dev]"
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
### Running Tests
|
|
263
|
+
|
|
264
|
+
```bash
|
|
265
|
+
# Run all tests
|
|
266
|
+
pytest
|
|
267
|
+
|
|
268
|
+
# Run with coverage
|
|
269
|
+
pytest --cov=kerblwelt_api
|
|
270
|
+
|
|
271
|
+
# Run specific test file
|
|
272
|
+
pytest tests/test_client.py
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
### Code Quality
|
|
276
|
+
|
|
277
|
+
```bash
|
|
278
|
+
# Format code
|
|
279
|
+
black kerblwelt_api tests
|
|
280
|
+
|
|
281
|
+
# Lint code
|
|
282
|
+
ruff check kerblwelt_api tests
|
|
283
|
+
|
|
284
|
+
# Type checking
|
|
285
|
+
mypy kerblwelt_api
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
## Requirements
|
|
289
|
+
|
|
290
|
+
- Python 3.11 or higher
|
|
291
|
+
- aiohttp 3.9.0 or higher
|
|
292
|
+
|
|
293
|
+
## License
|
|
294
|
+
|
|
295
|
+
MIT License - see [LICENSE](LICENSE) file for details.
|
|
296
|
+
|
|
297
|
+
## Contributing
|
|
298
|
+
|
|
299
|
+
Contributions are welcome! Please:
|
|
300
|
+
|
|
301
|
+
1. Fork the repository
|
|
302
|
+
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
|
303
|
+
3. Commit your changes (`git commit -m 'Add amazing feature'`)
|
|
304
|
+
4. Push to the branch (`git push origin feature/amazing-feature`)
|
|
305
|
+
5. Open a Pull Request
|
|
306
|
+
|
|
307
|
+
## Acknowledgments
|
|
308
|
+
|
|
309
|
+
- Built for the [Kerbl Welt IoT platform](https://app.kerbl-iot.com)
|
|
310
|
+
- Powers AKO Smart Satellite electric fence monitors
|
|
311
|
+
- Designed for integration with Home Assistant
|
|
312
|
+
|
|
313
|
+
## Links
|
|
314
|
+
|
|
315
|
+
- [Kerbl Welt Web App](https://app.kerbl-iot.com)
|
|
316
|
+
- [AKO Smart Satellite Product Info](https://www.kerbl.com/en/product/ako-smart-satellite/)
|
|
317
|
+
- [Home Assistant Integration](https://github.com/stgarrity/homeassistant-kerblwelt)
|
|
318
|
+
|
|
319
|
+
## Support
|
|
320
|
+
|
|
321
|
+
For issues and questions:
|
|
322
|
+
- [GitHub Issues](https://github.com/stgarrity/kerblwelt-api/issues)
|
|
323
|
+
- Email: sgarrity@gmail.com
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
kerblwelt_api/__init__.py,sha256=wXuxZu59Net4TbAta0V3goe5gf_HHIpZlDCU8HSgl7A,943
|
|
2
|
+
kerblwelt_api/auth.py,sha256=gHuvSfUcIajTytKjQvU5sYWob-wqP8bnnGfq7-Qva1k,6561
|
|
3
|
+
kerblwelt_api/client.py,sha256=mMTA1C6uvBEO8mv0PspbOLPrwhwH7Qk6IKYH7goyejI,8882
|
|
4
|
+
kerblwelt_api/const.py,sha256=Ey8osqGHs4M5jyvdc2CP2dPruCrSh-afjqES9lJY__4,1446
|
|
5
|
+
kerblwelt_api/exceptions.py,sha256=ocYOM7k2VE11ZdbPoinz60danZZNOD0FnZgFobCxWEo,1596
|
|
6
|
+
kerblwelt_api/models.py,sha256=n5AUlPJQQ353QFoCIpRVRbvByQDZqsS04JKkhT207lY,8434
|
|
7
|
+
kerblwelt_api-0.1.0.dist-info/licenses/LICENSE,sha256=VT1yKHLweBlpBR7vx-Xyy3jzNcdhCsevHJG5db7ln5E,1070
|
|
8
|
+
kerblwelt_api-0.1.0.dist-info/METADATA,sha256=9NQaNHJDr2h6QnPZpLLG_ox4CdI63-QqXq63uK7PlOc,8859
|
|
9
|
+
kerblwelt_api-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
10
|
+
kerblwelt_api-0.1.0.dist-info/top_level.txt,sha256=7VQM_VwLfGfNdhZXWY4GUv4-GBgZ2Cr9kBDJkquz6ik,14
|
|
11
|
+
kerblwelt_api-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Steve Garrity
|
|
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
|
+
kerblwelt_api
|