beatbot-cloud 0.2.0__tar.gz → 0.3.0__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.
- {beatbot_cloud-0.2.0/src/beatbot_cloud.egg-info → beatbot_cloud-0.3.0}/PKG-INFO +1 -1
- {beatbot_cloud-0.2.0 → beatbot_cloud-0.3.0}/pyproject.toml +1 -1
- {beatbot_cloud-0.2.0 → beatbot_cloud-0.3.0}/src/beatbot_cloud/__init__.py +2 -0
- beatbot_cloud-0.3.0/src/beatbot_cloud/auth.py +23 -0
- {beatbot_cloud-0.2.0 → beatbot_cloud-0.3.0}/src/beatbot_cloud/const.py +5 -0
- beatbot_cloud-0.3.0/src/beatbot_cloud/models.py +106 -0
- {beatbot_cloud-0.2.0 → beatbot_cloud-0.3.0}/src/beatbot_cloud/websocket.py +2 -0
- {beatbot_cloud-0.2.0 → beatbot_cloud-0.3.0/src/beatbot_cloud.egg-info}/PKG-INFO +1 -1
- {beatbot_cloud-0.2.0 → beatbot_cloud-0.3.0}/src/beatbot_cloud.egg-info/SOURCES.txt +2 -0
- beatbot_cloud-0.3.0/tests/test_auth.py +28 -0
- {beatbot_cloud-0.2.0 → beatbot_cloud-0.3.0}/tests/test_client.py +52 -0
- {beatbot_cloud-0.2.0 → beatbot_cloud-0.3.0}/tests/test_websocket.py +8 -0
- beatbot_cloud-0.2.0/src/beatbot_cloud/models.py +0 -55
- {beatbot_cloud-0.2.0 → beatbot_cloud-0.3.0}/LICENSE +0 -0
- {beatbot_cloud-0.2.0 → beatbot_cloud-0.3.0}/README.md +0 -0
- {beatbot_cloud-0.2.0 → beatbot_cloud-0.3.0}/setup.cfg +0 -0
- {beatbot_cloud-0.2.0 → beatbot_cloud-0.3.0}/src/beatbot_cloud/client.py +0 -0
- {beatbot_cloud-0.2.0 → beatbot_cloud-0.3.0}/src/beatbot_cloud/exceptions.py +0 -0
- {beatbot_cloud-0.2.0 → beatbot_cloud-0.3.0}/src/beatbot_cloud/protocol.py +0 -0
- {beatbot_cloud-0.2.0 → beatbot_cloud-0.3.0}/src/beatbot_cloud/py.typed +0 -0
- {beatbot_cloud-0.2.0 → beatbot_cloud-0.3.0}/src/beatbot_cloud.egg-info/dependency_links.txt +0 -0
- {beatbot_cloud-0.2.0 → beatbot_cloud-0.3.0}/src/beatbot_cloud.egg-info/requires.txt +0 -0
- {beatbot_cloud-0.2.0 → beatbot_cloud-0.3.0}/src/beatbot_cloud.egg-info/top_level.txt +0 -0
- {beatbot_cloud-0.2.0 → beatbot_cloud-0.3.0}/tests/test_protocol.py +0 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Asynchronous client for the Beatbot cloud API."""
|
|
2
2
|
|
|
3
|
+
from .auth import decode_access_token
|
|
3
4
|
from .client import BeatbotClient
|
|
4
5
|
from .exceptions import (
|
|
5
6
|
BeatbotAuthenticationError,
|
|
@@ -39,4 +40,5 @@ __all__ = [
|
|
|
39
40
|
"STATUS_BY_CATEGORY",
|
|
40
41
|
"error_mask_for",
|
|
41
42
|
"status_for",
|
|
43
|
+
"decode_access_token",
|
|
42
44
|
]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Authentication helpers for Beatbot cloud tokens."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import binascii
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def decode_access_token(access_token: str) -> dict[str, Any] | None:
|
|
10
|
+
"""Decode the claims from a JWT access token without verifying its signature."""
|
|
11
|
+
try:
|
|
12
|
+
payload = access_token.split(".")[1]
|
|
13
|
+
payload += "=" * (-len(payload) % 4)
|
|
14
|
+
claims = json.loads(base64.urlsafe_b64decode(payload))
|
|
15
|
+
except (
|
|
16
|
+
binascii.Error,
|
|
17
|
+
IndexError,
|
|
18
|
+
json.JSONDecodeError,
|
|
19
|
+
UnicodeDecodeError,
|
|
20
|
+
ValueError,
|
|
21
|
+
):
|
|
22
|
+
return None
|
|
23
|
+
return claims if isinstance(claims, dict) else None
|
|
@@ -21,3 +21,8 @@ EVENTS_PATH: Final = "/openapi/v1/ha/ws"
|
|
|
21
21
|
RESULT_SUCCESS_CODE: Final = 200
|
|
22
22
|
|
|
23
23
|
INTERFACE_WORK_MODE: Final = "select.work_mode"
|
|
24
|
+
INTERFACE_VACUUM_STATE: Final = "vacuum.state"
|
|
25
|
+
INTERFACE_VACUUM_BATTERY: Final = "vacuum.battery"
|
|
26
|
+
INTERFACE_SENSOR_ERROR: Final = "sensor.error"
|
|
27
|
+
INTERFACE_CHILD_LOCK: Final = "switch.child_lock"
|
|
28
|
+
INTERFACE_VOICE_DISTURB: Final = "switch.voice_disturb"
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Typed Beatbot cloud models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .const import (
|
|
9
|
+
INTERFACE_CHILD_LOCK,
|
|
10
|
+
INTERFACE_SENSOR_ERROR,
|
|
11
|
+
INTERFACE_VACUUM_BATTERY,
|
|
12
|
+
INTERFACE_VACUUM_STATE,
|
|
13
|
+
INTERFACE_VOICE_DISTURB,
|
|
14
|
+
INTERFACE_WORK_MODE,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(slots=True)
|
|
19
|
+
class FirmwareVersion:
|
|
20
|
+
"""A device firmware version."""
|
|
21
|
+
|
|
22
|
+
channel: int
|
|
23
|
+
version: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(slots=True)
|
|
27
|
+
class BeatbotCapability:
|
|
28
|
+
"""A Home Assistant capability advertised by Beatbot discovery."""
|
|
29
|
+
|
|
30
|
+
interface_info: str
|
|
31
|
+
retrievable: bool = False
|
|
32
|
+
proactively_reported: bool = False
|
|
33
|
+
non_controllable: bool = False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(slots=True)
|
|
37
|
+
class BeatbotDeviceData:
|
|
38
|
+
"""A discovered Beatbot device."""
|
|
39
|
+
|
|
40
|
+
device_id: str
|
|
41
|
+
product_id: str
|
|
42
|
+
product_category: str
|
|
43
|
+
work_status: int
|
|
44
|
+
work_mode: int
|
|
45
|
+
error_code: int
|
|
46
|
+
battery_level: int
|
|
47
|
+
versions: list[FirmwareVersion]
|
|
48
|
+
is_online: bool
|
|
49
|
+
child_lock: bool = False
|
|
50
|
+
voice_disturb: bool = False
|
|
51
|
+
name: str = ""
|
|
52
|
+
model: str = ""
|
|
53
|
+
work_mode_options: dict[int, str] = field(default_factory=dict)
|
|
54
|
+
capabilities: dict[str, BeatbotCapability] = field(default_factory=dict)
|
|
55
|
+
|
|
56
|
+
def apply_state(
|
|
57
|
+
self, states: dict[str, Any] | None, is_online: bool | None = None
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Apply a runtime-state response or event to this device."""
|
|
60
|
+
if states:
|
|
61
|
+
if INTERFACE_VACUUM_STATE in states:
|
|
62
|
+
self.work_status = states[INTERFACE_VACUUM_STATE]
|
|
63
|
+
if INTERFACE_VACUUM_BATTERY in states:
|
|
64
|
+
self.battery_level = states[INTERFACE_VACUUM_BATTERY]
|
|
65
|
+
if INTERFACE_SENSOR_ERROR in states:
|
|
66
|
+
self.error_code = states[INTERFACE_SENSOR_ERROR]
|
|
67
|
+
if INTERFACE_WORK_MODE in states:
|
|
68
|
+
self.work_mode = states[INTERFACE_WORK_MODE]
|
|
69
|
+
if INTERFACE_CHILD_LOCK in states:
|
|
70
|
+
self.child_lock = states[INTERFACE_CHILD_LOCK]
|
|
71
|
+
if INTERFACE_VOICE_DISTURB in states:
|
|
72
|
+
self.voice_disturb = states[INTERFACE_VOICE_DISTURB]
|
|
73
|
+
if is_online is not None:
|
|
74
|
+
self.is_online = is_online
|
|
75
|
+
|
|
76
|
+
def copy_runtime_state_from(self, other: BeatbotDeviceData) -> None:
|
|
77
|
+
"""Copy the last-known runtime state from another instance."""
|
|
78
|
+
self.work_status = other.work_status
|
|
79
|
+
self.work_mode = other.work_mode
|
|
80
|
+
self.error_code = other.error_code
|
|
81
|
+
self.battery_level = other.battery_level
|
|
82
|
+
self.is_online = other.is_online
|
|
83
|
+
self.child_lock = other.child_lock
|
|
84
|
+
self.voice_disturb = other.voice_disturb
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True, slots=True)
|
|
88
|
+
class BeatbotEvent:
|
|
89
|
+
"""A validated Beatbot cloud event envelope."""
|
|
90
|
+
|
|
91
|
+
event_id: str
|
|
92
|
+
event_type: str
|
|
93
|
+
device_id: str
|
|
94
|
+
payload: dict[str, Any] | None
|
|
95
|
+
|
|
96
|
+
def apply_to(self, device: BeatbotDeviceData) -> bool:
|
|
97
|
+
"""Apply a state-bearing event and return whether data changed."""
|
|
98
|
+
if self.event_type == "properties_changed":
|
|
99
|
+
assert self.payload is not None
|
|
100
|
+
device.apply_state({self.payload["interfaceInfo"]: self.payload["value"]})
|
|
101
|
+
return True
|
|
102
|
+
if self.event_type == "status":
|
|
103
|
+
assert self.payload is not None
|
|
104
|
+
device.apply_state(None, self.payload["online"])
|
|
105
|
+
return True
|
|
106
|
+
return False
|
|
@@ -106,6 +106,8 @@ class BeatbotEventStream:
|
|
|
106
106
|
BeatbotEventStream._validate_property_payload(payload)
|
|
107
107
|
elif event_type == "status" and not isinstance(payload.get("online"), bool):
|
|
108
108
|
raise BeatbotEventError("Status event has an invalid online value")
|
|
109
|
+
elif event_type == "device_added" and payload.get("deviceId") != device_id:
|
|
110
|
+
raise BeatbotEventError("device_added payload has a mismatched deviceId")
|
|
109
111
|
return BeatbotEvent(event_id, event_type, device_id, payload)
|
|
110
112
|
|
|
111
113
|
@staticmethod
|
|
@@ -2,6 +2,7 @@ LICENSE
|
|
|
2
2
|
README.md
|
|
3
3
|
pyproject.toml
|
|
4
4
|
src/beatbot_cloud/__init__.py
|
|
5
|
+
src/beatbot_cloud/auth.py
|
|
5
6
|
src/beatbot_cloud/client.py
|
|
6
7
|
src/beatbot_cloud/const.py
|
|
7
8
|
src/beatbot_cloud/exceptions.py
|
|
@@ -14,6 +15,7 @@ src/beatbot_cloud.egg-info/SOURCES.txt
|
|
|
14
15
|
src/beatbot_cloud.egg-info/dependency_links.txt
|
|
15
16
|
src/beatbot_cloud.egg-info/requires.txt
|
|
16
17
|
src/beatbot_cloud.egg-info/top_level.txt
|
|
18
|
+
tests/test_auth.py
|
|
17
19
|
tests/test_client.py
|
|
18
20
|
tests/test_protocol.py
|
|
19
21
|
tests/test_websocket.py
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Tests for Beatbot authentication helpers."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from beatbot_cloud import decode_access_token
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_decode_access_token() -> None:
|
|
12
|
+
"""Decode valid JWT claims."""
|
|
13
|
+
payload = (
|
|
14
|
+
base64.urlsafe_b64encode(json.dumps({"sub": "user-1", "region": "eu"}).encode())
|
|
15
|
+
.decode()
|
|
16
|
+
.rstrip("=")
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
assert decode_access_token(f"header.{payload}.signature") == {
|
|
20
|
+
"sub": "user-1",
|
|
21
|
+
"region": "eu",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.mark.parametrize("token", ["", "invalid", "a.!.c", "a.W10.c"])
|
|
26
|
+
def test_decode_access_token_rejects_invalid_claims(token: str) -> None:
|
|
27
|
+
"""Reject malformed tokens and non-object claims."""
|
|
28
|
+
assert decode_access_token(token) is None
|
|
@@ -12,6 +12,7 @@ from beatbot_cloud import (
|
|
|
12
12
|
BeatbotAuthenticationError,
|
|
13
13
|
BeatbotClient,
|
|
14
14
|
BeatbotConnectionError,
|
|
15
|
+
BeatbotEvent,
|
|
15
16
|
)
|
|
16
17
|
from beatbot_cloud.const import OAUTH2_TOKEN_URL, REGION_API_BASE_URL
|
|
17
18
|
|
|
@@ -188,6 +189,57 @@ async def test_get_devices_parses_models_and_capabilities():
|
|
|
188
189
|
assert devices[0].capabilities["select.work_mode"].retrievable
|
|
189
190
|
|
|
190
191
|
|
|
192
|
+
def test_device_applies_and_copies_runtime_state():
|
|
193
|
+
"""Runtime state is parsed by the library model without dynamic attributes."""
|
|
194
|
+
api, _ = client(envelope())
|
|
195
|
+
device = api._parse_device({"deviceId": "d1"})
|
|
196
|
+
previous = api._parse_device({"deviceId": "d1", "isOnline": True})
|
|
197
|
+
assert device is not None
|
|
198
|
+
assert previous is not None
|
|
199
|
+
previous.apply_state(
|
|
200
|
+
{
|
|
201
|
+
"vacuum.state": 5,
|
|
202
|
+
"vacuum.battery": 80,
|
|
203
|
+
"sensor.error": 2,
|
|
204
|
+
"select.work_mode": 1,
|
|
205
|
+
"switch.child_lock": True,
|
|
206
|
+
"switch.voice_disturb": True,
|
|
207
|
+
}
|
|
208
|
+
)
|
|
209
|
+
device.copy_runtime_state_from(previous)
|
|
210
|
+
|
|
211
|
+
assert device.work_status == 5
|
|
212
|
+
assert device.battery_level == 80
|
|
213
|
+
assert device.error_code == 2
|
|
214
|
+
assert device.work_mode == 1
|
|
215
|
+
assert device.child_lock
|
|
216
|
+
assert device.voice_disturb
|
|
217
|
+
assert device.is_online
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_state_events_apply_to_device():
|
|
221
|
+
"""State-bearing events interpret their payload in the library."""
|
|
222
|
+
api, _ = client(envelope())
|
|
223
|
+
device = api._parse_device({"deviceId": "d1", "isOnline": True})
|
|
224
|
+
assert device is not None
|
|
225
|
+
|
|
226
|
+
changed = BeatbotEvent(
|
|
227
|
+
"1",
|
|
228
|
+
"properties_changed",
|
|
229
|
+
"d1",
|
|
230
|
+
{"interfaceInfo": "vacuum.battery", "value": 42},
|
|
231
|
+
).apply_to(device)
|
|
232
|
+
online_changed = BeatbotEvent("2", "status", "d1", {"online": False}).apply_to(
|
|
233
|
+
device
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
assert changed
|
|
237
|
+
assert online_changed
|
|
238
|
+
assert device.battery_level == 42
|
|
239
|
+
assert not device.is_online
|
|
240
|
+
assert not BeatbotEvent("3", "unknown", "d1", {}).apply_to(device)
|
|
241
|
+
|
|
242
|
+
|
|
191
243
|
@pytest.mark.parametrize("configuration", ["bad json", [], None])
|
|
192
244
|
def test_invalid_work_mode_configuration(configuration):
|
|
193
245
|
assert (
|
|
@@ -85,6 +85,14 @@ def test_parse_removed_event():
|
|
|
85
85
|
assert event.payload is None
|
|
86
86
|
|
|
87
87
|
|
|
88
|
+
def test_parse_rejects_mismatched_added_device():
|
|
89
|
+
with pytest.raises(BeatbotEventError, match="mismatched"):
|
|
90
|
+
BeatbotEventStream.parse_event(
|
|
91
|
+
'{"eventId":"1","type":"device_added","deviceId":"d",'
|
|
92
|
+
'"payload":{"deviceId":"other"}}'
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
88
96
|
async def test_connect_and_close():
|
|
89
97
|
websocket = SimpleNamespace(closed=False, close=AsyncMock())
|
|
90
98
|
session = SimpleNamespace(ws_connect=AsyncMock(return_value=websocket))
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
"""Typed Beatbot cloud models."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from dataclasses import dataclass, field
|
|
6
|
-
from typing import Any
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
@dataclass(slots=True)
|
|
10
|
-
class FirmwareVersion:
|
|
11
|
-
"""A device firmware version."""
|
|
12
|
-
|
|
13
|
-
channel: int
|
|
14
|
-
version: str
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
@dataclass(slots=True)
|
|
18
|
-
class BeatbotCapability:
|
|
19
|
-
"""A Home Assistant capability advertised by Beatbot discovery."""
|
|
20
|
-
|
|
21
|
-
interface_info: str
|
|
22
|
-
retrievable: bool = False
|
|
23
|
-
proactively_reported: bool = False
|
|
24
|
-
non_controllable: bool = False
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
@dataclass(slots=True)
|
|
28
|
-
class BeatbotDeviceData:
|
|
29
|
-
"""A discovered Beatbot device."""
|
|
30
|
-
|
|
31
|
-
device_id: str
|
|
32
|
-
product_id: str
|
|
33
|
-
product_category: str
|
|
34
|
-
work_status: int
|
|
35
|
-
work_mode: int
|
|
36
|
-
error_code: int
|
|
37
|
-
battery_level: int
|
|
38
|
-
versions: list[FirmwareVersion]
|
|
39
|
-
is_online: bool
|
|
40
|
-
child_lock: bool = False
|
|
41
|
-
voice_disturb: bool = False
|
|
42
|
-
name: str = ""
|
|
43
|
-
model: str = ""
|
|
44
|
-
work_mode_options: dict[int, str] = field(default_factory=dict)
|
|
45
|
-
capabilities: dict[str, BeatbotCapability] = field(default_factory=dict)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
@dataclass(frozen=True, slots=True)
|
|
49
|
-
class BeatbotEvent:
|
|
50
|
-
"""A validated Beatbot cloud event envelope."""
|
|
51
|
-
|
|
52
|
-
event_id: str
|
|
53
|
-
event_type: str
|
|
54
|
-
device_id: str
|
|
55
|
-
payload: dict[str, Any] | None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|