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.
- kerbl_iot-0.1.1/.gitignore +24 -0
- kerbl_iot-0.1.1/LICENSE +21 -0
- kerbl_iot-0.1.1/PKG-INFO +98 -0
- kerbl_iot-0.1.1/README.md +67 -0
- kerbl_iot-0.1.1/pyproject.toml +56 -0
- kerbl_iot-0.1.1/src/kerbl_iot/__init__.py +41 -0
- kerbl_iot-0.1.1/src/kerbl_iot/api.py +332 -0
- kerbl_iot-0.1.1/src/kerbl_iot/exceptions.py +21 -0
- kerbl_iot-0.1.1/src/kerbl_iot/kerbl_iot.py +226 -0
- kerbl_iot-0.1.1/src/kerbl_iot/models/__init__.py +23 -0
- kerbl_iot-0.1.1/src/kerbl_iot/models/base.py +20 -0
- kerbl_iot-0.1.1/src/kerbl_iot/models/door_state.py +18 -0
- kerbl_iot-0.1.1/src/kerbl_iot/models/smart_coop.py +145 -0
- kerbl_iot-0.1.1/src/kerbl_iot/models/smart_coop_api.py +26 -0
- kerbl_iot-0.1.1/src/kerbl_iot/models/smart_coop_brightness.py +53 -0
- kerbl_iot-0.1.1/src/kerbl_iot/models/smart_coop_door.py +122 -0
- kerbl_iot-0.1.1/src/kerbl_iot/models/smart_coop_feeder.py +126 -0
- kerbl_iot-0.1.1/src/kerbl_iot/models/smart_coop_light.py +105 -0
- kerbl_iot-0.1.1/src/kerbl_iot/models/smart_coop_log.py +67 -0
- kerbl_iot-0.1.1/src/kerbl_iot/models/smart_coop_water_heater.py +49 -0
- kerbl_iot-0.1.1/src/kerbl_iot/py.typed +0 -0
|
@@ -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/
|
kerbl_iot-0.1.1/LICENSE
ADDED
|
@@ -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.
|
kerbl_iot-0.1.1/PKG-INFO
ADDED
|
@@ -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."""
|