kerbl-iot 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,24 @@
1
+ # Python bytecode and tooling caches
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .mypy_cache/
6
+ .ruff_cache/
7
+
8
+ # Virtual environments
9
+ .venv/
10
+ venv/
11
+
12
+ # Test and coverage output
13
+ .coverage
14
+ .coverage.*
15
+ coverage.xml
16
+ htmlcov/
17
+
18
+ # Packaging artifacts
19
+ build/
20
+ dist/
21
+ *.egg-info/
22
+
23
+ # Editor settings
24
+ .vscode/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 derjoerg
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,98 @@
1
+ Metadata-Version: 2.4
2
+ Name: kerbl-iot
3
+ Version: 0.1.1
4
+ Summary: Async client for the Kerbl IoT web API
5
+ Project-URL: Source, https://github.com/derjoerg/kerbl-iot
6
+ Project-URL: Issues, https://github.com/derjoerg/kerbl-iot/issues
7
+ Author: derjoerg
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: home-assistant,iot,kerbl,poultry,smartcoop
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Home Automation
22
+ Requires-Python: >=3.11
23
+ Requires-Dist: aiohttp<4,>=3.10
24
+ Requires-Dist: python-socketio[asyncio-client]<6,>=5.11
25
+ Provides-Extra: dev
26
+ Requires-Dist: build<2,>=1.2; extra == 'dev'
27
+ Requires-Dist: coverage[toml]<8,>=7.6; extra == 'dev'
28
+ Requires-Dist: pytest<9,>=8.3; extra == 'dev'
29
+ Requires-Dist: twine<7,>=6; extra == 'dev'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # Kerbl IoT
33
+
34
+ Async Python client for Kerbl IoT devices.
35
+
36
+ `KerblIOTApi` owns authentication, HTTP, token refresh, and Socket.IO transport.
37
+ `KerblIOT` loads devices and dispatches their live updates. Device actions belong to
38
+ their respective model classes.
39
+
40
+ ```python
41
+ import asyncio
42
+ import os
43
+
44
+ from kerbl_iot import KerblIOT, KerblIOTApi
45
+
46
+
47
+ async def main() -> None:
48
+ async with KerblIOT(
49
+ KerblIOTApi(
50
+ email=os.environ["KERBL_EMAIL"],
51
+ password=os.environ["KERBL_PASSWORD"],
52
+ )
53
+ ) as kerbl:
54
+ await kerbl.connect_websocket()
55
+
56
+ coop = kerbl.smart_coops[0]
57
+ print(coop.name, coop.air_temperature, coop.door.state)
58
+ await coop.light.turn_on()
59
+ await coop.door.close()
60
+
61
+
62
+ asyncio.run(main())
63
+ ```
64
+
65
+ ## Releasing to PyPI
66
+
67
+ Releases are published automatically by GitHub Actions when a GitHub Release is
68
+ marked as published. Before the first release, configure PyPI Trusted Publishing
69
+ for the `derjoerg/kerbl-iot` repository and the `.github/workflows/publish.yml`
70
+ workflow, using the `pypi` environment.
71
+
72
+ To create a release, update the `version` in `pyproject.toml`, commit the change,
73
+ create a matching tag such as `v0.1.1`, and publish a GitHub Release for that tag.
74
+ The version must not already exist on PyPI.
75
+
76
+ ## Error reason reference
77
+
78
+ `SmartCoopLog` exposes the API's raw `error_key` and `error_code`. Applications
79
+ should translate the key in their own presentation layer. The following table
80
+ preserves the German translations previously included in this library for
81
+ reference:
82
+
83
+ | API error key | Former German translation |
84
+ | --- | --- |
85
+ | `errorReason.doorLocked` | Klappe verriegelt |
86
+ | `errorReason.doorClosingSoon` | Klappe schliesst bald |
87
+ | `errorReason.feederLocked` | Futterautomat gesperrt |
88
+ | `errorReason.batteryLow` | Akku schwach |
89
+ | `errorReason.waterHeaterActive` | Wasserheizung aktiv |
90
+ | `errorReason.waterEmpty` | Wasser leer |
91
+ | `errorReason.feederError` | Futterautomatenstoerung |
92
+ | `errorReason.feedEmpty` | Futter leer |
93
+ | `errorReason.batteryEmpty` | Akku leer |
94
+ | `errorReason.doorError` | Klappenstoerung |
95
+ | `errorReason.waterTemperatureLow` | Wassertemperatur zu niedrig |
96
+ | `errorReason.externalLightError` | Fremdlichtstoerung |
97
+ | `errorReason.timeError` | Uhrzeit muss eingestellt werden |
98
+ | `errorReason.flashError` | Flash-Fehler |
@@ -0,0 +1,67 @@
1
+ # Kerbl IoT
2
+
3
+ Async Python client for Kerbl IoT devices.
4
+
5
+ `KerblIOTApi` owns authentication, HTTP, token refresh, and Socket.IO transport.
6
+ `KerblIOT` loads devices and dispatches their live updates. Device actions belong to
7
+ their respective model classes.
8
+
9
+ ```python
10
+ import asyncio
11
+ import os
12
+
13
+ from kerbl_iot import KerblIOT, KerblIOTApi
14
+
15
+
16
+ async def main() -> None:
17
+ async with KerblIOT(
18
+ KerblIOTApi(
19
+ email=os.environ["KERBL_EMAIL"],
20
+ password=os.environ["KERBL_PASSWORD"],
21
+ )
22
+ ) as kerbl:
23
+ await kerbl.connect_websocket()
24
+
25
+ coop = kerbl.smart_coops[0]
26
+ print(coop.name, coop.air_temperature, coop.door.state)
27
+ await coop.light.turn_on()
28
+ await coop.door.close()
29
+
30
+
31
+ asyncio.run(main())
32
+ ```
33
+
34
+ ## Releasing to PyPI
35
+
36
+ Releases are published automatically by GitHub Actions when a GitHub Release is
37
+ marked as published. Before the first release, configure PyPI Trusted Publishing
38
+ for the `derjoerg/kerbl-iot` repository and the `.github/workflows/publish.yml`
39
+ workflow, using the `pypi` environment.
40
+
41
+ To create a release, update the `version` in `pyproject.toml`, commit the change,
42
+ create a matching tag such as `v0.1.1`, and publish a GitHub Release for that tag.
43
+ The version must not already exist on PyPI.
44
+
45
+ ## Error reason reference
46
+
47
+ `SmartCoopLog` exposes the API's raw `error_key` and `error_code`. Applications
48
+ should translate the key in their own presentation layer. The following table
49
+ preserves the German translations previously included in this library for
50
+ reference:
51
+
52
+ | API error key | Former German translation |
53
+ | --- | --- |
54
+ | `errorReason.doorLocked` | Klappe verriegelt |
55
+ | `errorReason.doorClosingSoon` | Klappe schliesst bald |
56
+ | `errorReason.feederLocked` | Futterautomat gesperrt |
57
+ | `errorReason.batteryLow` | Akku schwach |
58
+ | `errorReason.waterHeaterActive` | Wasserheizung aktiv |
59
+ | `errorReason.waterEmpty` | Wasser leer |
60
+ | `errorReason.feederError` | Futterautomatenstoerung |
61
+ | `errorReason.feedEmpty` | Futter leer |
62
+ | `errorReason.batteryEmpty` | Akku leer |
63
+ | `errorReason.doorError` | Klappenstoerung |
64
+ | `errorReason.waterTemperatureLow` | Wassertemperatur zu niedrig |
65
+ | `errorReason.externalLightError` | Fremdlichtstoerung |
66
+ | `errorReason.timeError` | Uhrzeit muss eingestellt werden |
67
+ | `errorReason.flashError` | Flash-Fehler |
@@ -0,0 +1,56 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.31,<1.32"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "kerbl-iot"
7
+ version = "0.1.1"
8
+ description = "Async client for the Kerbl IoT web API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "derjoerg" }]
13
+ keywords = ["kerbl", "iot", "smartcoop", "poultry", "home-assistant"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Programming Language :: Python :: 3.14",
25
+ "Topic :: Home Automation",
26
+ ]
27
+ dependencies = [
28
+ "aiohttp>=3.10,<4",
29
+ "python-socketio[asyncio_client]>=5.11,<6",
30
+ ]
31
+
32
+ [project.optional-dependencies]
33
+ dev = [
34
+ "build>=1.2,<2",
35
+ "coverage[toml]>=7.6,<8",
36
+ "pytest>=8.3,<9",
37
+ "twine>=6,<7",
38
+ ]
39
+
40
+ [project.urls]
41
+ Source = "https://github.com/derjoerg/kerbl-iot"
42
+ Issues = "https://github.com/derjoerg/kerbl-iot/issues"
43
+
44
+ [tool.hatch.build.targets.wheel]
45
+ packages = ["src/kerbl_iot"]
46
+
47
+ [tool.hatch.build.targets.sdist]
48
+ include = [
49
+ "/LICENSE",
50
+ "/README.md",
51
+ "/pyproject.toml",
52
+ "/src",
53
+ ]
54
+
55
+ [tool.pytest.ini_options]
56
+ testpaths = ["tests"]
@@ -0,0 +1,41 @@
1
+ """Asynchronous client for the Kerbl IoT web API."""
2
+
3
+ from .api import KerblIOTApi
4
+ from .kerbl_iot import KerblIOT
5
+ from .exceptions import (
6
+ KerblAuthenticationError,
7
+ KerblConnectionError,
8
+ KerblError,
9
+ KerblProtocolError,
10
+ KerblStateError,
11
+ )
12
+ from .models import (
13
+ CommandResult,
14
+ DoorState,
15
+ SmartCoop,
16
+ SmartCoopBrightness,
17
+ SmartCoopDoor,
18
+ SmartCoopFeeder,
19
+ SmartCoopLight,
20
+ SmartCoopLog,
21
+ SmartCoopWaterHeater,
22
+ )
23
+
24
+ __all__ = [
25
+ "CommandResult",
26
+ "DoorState",
27
+ "KerblIOTApi",
28
+ "KerblIOT",
29
+ "KerblAuthenticationError",
30
+ "KerblConnectionError",
31
+ "KerblError",
32
+ "KerblProtocolError",
33
+ "KerblStateError",
34
+ "SmartCoop",
35
+ "SmartCoopBrightness",
36
+ "SmartCoopDoor",
37
+ "SmartCoopFeeder",
38
+ "SmartCoopLight",
39
+ "SmartCoopLog",
40
+ "SmartCoopWaterHeater",
41
+ ]
@@ -0,0 +1,332 @@
1
+ """HTTP client for the confirmed Kerbl IoT API endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import uuid
7
+ from collections.abc import Awaitable, Callable
8
+ from typing import Any
9
+
10
+ import aiohttp
11
+ import socketio
12
+
13
+ from .exceptions import (
14
+ KerblAuthenticationError,
15
+ KerblConnectionError,
16
+ KerblProtocolError,
17
+ )
18
+ from .models import CommandResult, SmartCoop, SmartCoopLog
19
+
20
+ BASE_URL = "https://app.kerbl-iot.com/api/v0.1/"
21
+ SOCKET_URL = "https://app.kerbl-iot.com"
22
+ SOCKET_PATH = "ws/v0.1/socket.io"
23
+
24
+
25
+ class KerblIOTApi:
26
+ """Authenticate with and retrieve data from Kerbl IoT."""
27
+
28
+ def __init__(
29
+ self,
30
+ email: str,
31
+ password: str,
32
+ timeout: float = 15.0,
33
+ session: aiohttp.ClientSession | None = None,
34
+ ) -> None:
35
+ if timeout <= 0:
36
+ raise ValueError("timeout must be greater than zero.")
37
+ self._email = email
38
+ self._password = password
39
+ self._timeout = timeout
40
+ self._provided_session = session
41
+ self._session = session
42
+ self._session_owned = False
43
+ self._socket: socketio.AsyncClient | None = None
44
+ self._access_token: str | None = None
45
+ self._refresh_token: str | None = None
46
+ self._refresh_lock = asyncio.Lock()
47
+ self._smart_coop_update_callbacks: list[Callable[[SmartCoop], Awaitable[None]]] = []
48
+ self._socket_event_callbacks: list[
49
+ Callable[[str, Any], Awaitable[None]]
50
+ ] = []
51
+ self._socket_disconnect_callbacks: list[Callable[[], Awaitable[None]]] = []
52
+ self._socket_connect_callbacks: list[Callable[[], Awaitable[None]]] = []
53
+ self._subscribed_device_ids: list[str] = []
54
+ self._socket_user_id: str | None = None
55
+
56
+ async def __aenter__(self) -> "KerblIOTApi":
57
+ await self.login()
58
+ return self
59
+
60
+ async def __aexit__(self, *args: object) -> None:
61
+ await self.close()
62
+
63
+ async def close(self) -> None:
64
+ """Close the underlying HTTP session."""
65
+ socket, session = self._socket, self._session
66
+ self._socket = None
67
+ self._session = None
68
+ self._access_token = None
69
+ self._refresh_token = None
70
+ try:
71
+ if socket is not None and socket.connected and self._socket_user_id is not None:
72
+ await socket.emit(
73
+ "leave_room",
74
+ {
75
+ "deviceIds": self._subscribed_device_ids,
76
+ "userId": self._socket_user_id,
77
+ },
78
+ )
79
+ finally:
80
+ self._subscribed_device_ids = []
81
+ self._socket_user_id = None
82
+ try:
83
+ if socket is not None:
84
+ await socket.disconnect()
85
+ finally:
86
+ if session is not None and self._session_owned:
87
+ await session.close()
88
+ self._session_owned = False
89
+
90
+ async def login(self) -> dict[str, Any]:
91
+ """Sign in using the request format captured from the web application."""
92
+ await self.close()
93
+ if self._provided_session is not None:
94
+ self._session = self._provided_session
95
+ else:
96
+ self._session = aiohttp.ClientSession(
97
+ base_url=BASE_URL,
98
+ headers={"Accept": "application/json"},
99
+ timeout=aiohttp.ClientTimeout(total=self._timeout),
100
+ raise_for_status=True,
101
+ )
102
+ self._session_owned = True
103
+ payload = {
104
+ "email": self._email,
105
+ "password": self._password,
106
+ "appBrand": "kerbl",
107
+ "appVersion": "137.6.1",
108
+ "loginId": str(uuid.uuid4()),
109
+ }
110
+ try:
111
+ authentication = await self._request_json(
112
+ "POST", "auth/sign-in", payload, refresh_on_unauthorized=False
113
+ )
114
+ self._set_authentication(authentication)
115
+ except Exception:
116
+ await self.close()
117
+ raise
118
+
119
+ return authentication
120
+
121
+ async def refresh_token(self) -> None:
122
+ """Refresh the access token using the token pair from the last login."""
123
+ async with self._refresh_lock:
124
+ if not self._access_token or not self._refresh_token:
125
+ raise KerblAuthenticationError("No refresh token is available.")
126
+ try:
127
+ authentication = await self._request_json(
128
+ "POST",
129
+ "auth/refresh",
130
+ {
131
+ "accessToken": self._access_token,
132
+ "refreshToken": self._refresh_token,
133
+ },
134
+ refresh_on_unauthorized=False,
135
+ )
136
+ self._set_authentication(authentication)
137
+ except (KerblAuthenticationError, KerblConnectionError, KerblProtocolError):
138
+ raise
139
+
140
+ def get_tokens(self) -> tuple[str, str]:
141
+ """Return the access and refresh tokens for persistent storage."""
142
+ if not self._access_token or not self._refresh_token:
143
+ raise KerblAuthenticationError("No authentication tokens are available.")
144
+ return self._access_token, self._refresh_token
145
+
146
+ def restore_tokens(self, access_token: str, refresh_token: str) -> None:
147
+ """Restore tokens from persistent storage without signing in again."""
148
+ self._set_authentication(
149
+ {"accessToken": access_token, "refreshToken": refresh_token}
150
+ )
151
+
152
+ def _set_authentication(self, authentication: dict[str, Any]) -> None:
153
+ access_token = authentication.get("accessToken")
154
+ refresh_token = authentication.get("refreshToken")
155
+ if not isinstance(access_token, str) or not access_token:
156
+ raise KerblAuthenticationError("Kerbl response did not include an access token.")
157
+ if not isinstance(refresh_token, str) or not refresh_token:
158
+ raise KerblAuthenticationError("Kerbl response did not include a refresh token.")
159
+ self._access_token = access_token
160
+ self._refresh_token = refresh_token
161
+ self._require_session().headers["Authorization"] = f"Bearer {access_token}"
162
+
163
+ async def _request_json(
164
+ self,
165
+ method: str,
166
+ endpoint: str,
167
+ payload: dict[str, Any] | None = None,
168
+ *,
169
+ refresh_on_unauthorized: bool = True,
170
+ ) -> dict[str, Any]:
171
+ """Send one JSON request and refresh the access token once after a 401."""
172
+ session = self._require_session()
173
+ request_url = endpoint if self._provided_session is None else f"{BASE_URL}{endpoint}"
174
+ try:
175
+ async with session.request(method, request_url, json=payload) as response:
176
+ return await response.json()
177
+ except aiohttp.ClientResponseError as error:
178
+ if error.status == 401 and refresh_on_unauthorized:
179
+ await self.refresh_token()
180
+ return await self._request_json(
181
+ method, endpoint, payload, refresh_on_unauthorized=False
182
+ )
183
+ if error.status == 401:
184
+ raise KerblAuthenticationError("Kerbl rejected the request.") from error
185
+ raise KerblConnectionError("Kerbl IoT service returned an HTTP error.") from error
186
+ except (aiohttp.ClientError, asyncio.TimeoutError) as error:
187
+ raise KerblConnectionError("Kerbl IoT service could not be reached.") from error
188
+ except (TypeError, ValueError) as error:
189
+ raise KerblProtocolError("Kerbl returned an invalid JSON response.") from error
190
+
191
+ async def get_smart_coops(self) -> list[SmartCoop]:
192
+ """Retrieve all SmartCoop devices assigned to the current user."""
193
+ devices = await self._request_json("GET", "device")
194
+ return [SmartCoop.from_api(data, self) for data in devices.get("smartCoop", [])]
195
+
196
+ async def get_smart_coop_logs(self, smart_coop_id: str) -> list[SmartCoopLog]:
197
+ """Retrieve error and informational logs for one SmartCoop."""
198
+ endpoint = f"device/smart-coop/{smart_coop_id}/log"
199
+ payload = await self._request_json("GET", endpoint)
200
+ return [SmartCoopLog.from_api(data) for data in payload.get("logs", [])]
201
+
202
+ async def _press_light(self, smart_coop_id: str) -> CommandResult:
203
+ """Press a SmartCoop light using its confirmed manual-control command."""
204
+ endpoint = f"device/smart-coop/{smart_coop_id}/command/lightControl"
205
+ return CommandResult.from_api(await self._request_json("PATCH", endpoint, {"value": 1}))
206
+
207
+ async def _press_feeder(self, smart_coop_id: str) -> CommandResult:
208
+ """Press a SmartCoop feeder using its confirmed manual-control command."""
209
+ endpoint = f"device/smart-coop/{smart_coop_id}/command/feederControl"
210
+ return CommandResult.from_api(await self._request_json("PATCH", endpoint, {"value": 1}))
211
+
212
+ async def _press_door(self, smart_coop_id: str) -> CommandResult:
213
+ """Press a SmartCoop door using its confirmed manual-control command."""
214
+ endpoint = f"device/smart-coop/{smart_coop_id}/command/doorControl"
215
+ return CommandResult.from_api(await self._request_json("PATCH", endpoint, {"value": 1}))
216
+
217
+ async def _acknowledge_errors(
218
+ self, smart_coop_id: str, error_codes: list[int]
219
+ ) -> CommandResult:
220
+ """Acknowledge one or more active SmartCoop error codes."""
221
+ if not error_codes:
222
+ raise ValueError("Provide at least one error code to acknowledge.")
223
+
224
+ endpoint = f"device/smart-coop/{smart_coop_id}/command/acknowledgeErrorControl"
225
+ return CommandResult.from_api(
226
+ await self._request_json("PATCH", endpoint, {"value": error_codes})
227
+ )
228
+
229
+ async def connect_websocket(
230
+ self, smart_coops: list[SmartCoop], debug: bool = False
231
+ ) -> None:
232
+ """Connect to Socket.IO and subscribe to updates for all SmartCoops."""
233
+ if self.websocket_connected:
234
+ return
235
+
236
+ if not smart_coops:
237
+ return
238
+
239
+ session = self._require_session()
240
+ socket = socketio.AsyncClient(
241
+ reconnection=True,
242
+ logger=debug,
243
+ engineio_logger=debug,
244
+ )
245
+ socket.on("smart-coop_update", self._handle_smart_coop_update)
246
+ socket.on("*", self._handle_socket_event)
247
+ socket.on("connect", self._handle_socket_connect)
248
+ socket.on("disconnect", self._handle_socket_disconnect)
249
+ try:
250
+ await socket.connect(
251
+ SOCKET_URL,
252
+ socketio_path=SOCKET_PATH,
253
+ headers={"Authorization": session.headers["Authorization"]},
254
+ )
255
+ self._subscribed_device_ids = [coop.id for coop in smart_coops]
256
+ self._socket_user_id = smart_coops[0].user_id
257
+ await socket.emit(
258
+ "join_room",
259
+ {
260
+ "deviceIds": self._subscribed_device_ids,
261
+ "userId": self._socket_user_id,
262
+ },
263
+ )
264
+ except (aiohttp.ClientError, socketio.exceptions.ConnectionError, asyncio.TimeoutError) as error:
265
+ await socket.disconnect()
266
+ self._subscribed_device_ids = []
267
+ self._socket_user_id = None
268
+ raise KerblConnectionError("Kerbl WebSocket could not be connected.") from error
269
+ self._socket = socket
270
+
271
+ async def _handle_socket_connect(self) -> None:
272
+ """Rejoin subscribed SmartCoop rooms after a Socket.IO reconnect."""
273
+ if self._socket is not None and self._socket_user_id is not None:
274
+ await self._socket.emit(
275
+ "join_room",
276
+ {
277
+ "deviceIds": self._subscribed_device_ids,
278
+ "userId": self._socket_user_id,
279
+ },
280
+ )
281
+ for callback in self._socket_connect_callbacks:
282
+ await callback()
283
+
284
+ @property
285
+ def websocket_connected(self) -> bool:
286
+ """Return whether the Socket.IO connection is active."""
287
+ return self._socket is not None and self._socket.connected
288
+
289
+ def register_smart_coop_update_callback(
290
+ self, callback: Callable[[SmartCoop], Awaitable[None]]
291
+ ) -> None:
292
+ """Register an asynchronous callback for SmartCoop Socket.IO updates."""
293
+ self._smart_coop_update_callbacks.append(callback)
294
+
295
+ def register_socket_event_callback(
296
+ self, callback: Callable[[str, Any], Awaitable[None]]
297
+ ) -> None:
298
+ """Register an asynchronous callback for all Socket.IO server events."""
299
+ self._socket_event_callbacks.append(callback)
300
+
301
+ def register_socket_disconnect_callback(
302
+ self, callback: Callable[[], Awaitable[None]]
303
+ ) -> None:
304
+ """Register an asynchronous callback for Socket.IO disconnects."""
305
+ self._socket_disconnect_callbacks.append(callback)
306
+
307
+ def register_socket_connect_callback(
308
+ self, callback: Callable[[], Awaitable[None]]
309
+ ) -> None:
310
+ """Register an asynchronous callback for Socket.IO connects and reconnects."""
311
+ self._socket_connect_callbacks.append(callback)
312
+
313
+ async def _handle_smart_coop_update(self, data: dict[str, Any]) -> None:
314
+ """Forward a parsed SmartCoop state update from Socket.IO."""
315
+ smart_coop = SmartCoop.from_api(data, self)
316
+ for callback in self._smart_coop_update_callbacks:
317
+ await callback(smart_coop)
318
+
319
+ async def _handle_socket_event(self, event: str, data: Any) -> None:
320
+ """Forward Socket.IO events to registered diagnostic callbacks."""
321
+ for callback in self._socket_event_callbacks:
322
+ await callback(event, data)
323
+
324
+ async def _handle_socket_disconnect(self) -> None:
325
+ """Notify subscribers after Socket.IO loses its connection."""
326
+ for callback in self._socket_disconnect_callbacks:
327
+ await callback()
328
+
329
+ def _require_session(self) -> aiohttp.ClientSession:
330
+ if self._session is None:
331
+ raise RuntimeError("Call login() before requesting devices.")
332
+ return self._session
@@ -0,0 +1,21 @@
1
+ """Exceptions raised by the Kerbl IoT client."""
2
+
3
+
4
+ class KerblError(Exception):
5
+ """Base exception for Kerbl IoT client errors."""
6
+
7
+
8
+ class KerblAuthenticationError(KerblError):
9
+ """Authentication failed or the stored session could not be refreshed."""
10
+
11
+
12
+ class KerblConnectionError(KerblError):
13
+ """The Kerbl IoT service could not be reached."""
14
+
15
+
16
+ class KerblProtocolError(KerblError):
17
+ """The Kerbl IoT service returned an unexpected response."""
18
+
19
+
20
+ class KerblStateError(KerblError):
21
+ """A command cannot safely be applied to the reported device state."""