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.
@@ -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")
@@ -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
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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