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.
Files changed (24) hide show
  1. {beatbot_cloud-0.2.0/src/beatbot_cloud.egg-info → beatbot_cloud-0.3.0}/PKG-INFO +1 -1
  2. {beatbot_cloud-0.2.0 → beatbot_cloud-0.3.0}/pyproject.toml +1 -1
  3. {beatbot_cloud-0.2.0 → beatbot_cloud-0.3.0}/src/beatbot_cloud/__init__.py +2 -0
  4. beatbot_cloud-0.3.0/src/beatbot_cloud/auth.py +23 -0
  5. {beatbot_cloud-0.2.0 → beatbot_cloud-0.3.0}/src/beatbot_cloud/const.py +5 -0
  6. beatbot_cloud-0.3.0/src/beatbot_cloud/models.py +106 -0
  7. {beatbot_cloud-0.2.0 → beatbot_cloud-0.3.0}/src/beatbot_cloud/websocket.py +2 -0
  8. {beatbot_cloud-0.2.0 → beatbot_cloud-0.3.0/src/beatbot_cloud.egg-info}/PKG-INFO +1 -1
  9. {beatbot_cloud-0.2.0 → beatbot_cloud-0.3.0}/src/beatbot_cloud.egg-info/SOURCES.txt +2 -0
  10. beatbot_cloud-0.3.0/tests/test_auth.py +28 -0
  11. {beatbot_cloud-0.2.0 → beatbot_cloud-0.3.0}/tests/test_client.py +52 -0
  12. {beatbot_cloud-0.2.0 → beatbot_cloud-0.3.0}/tests/test_websocket.py +8 -0
  13. beatbot_cloud-0.2.0/src/beatbot_cloud/models.py +0 -55
  14. {beatbot_cloud-0.2.0 → beatbot_cloud-0.3.0}/LICENSE +0 -0
  15. {beatbot_cloud-0.2.0 → beatbot_cloud-0.3.0}/README.md +0 -0
  16. {beatbot_cloud-0.2.0 → beatbot_cloud-0.3.0}/setup.cfg +0 -0
  17. {beatbot_cloud-0.2.0 → beatbot_cloud-0.3.0}/src/beatbot_cloud/client.py +0 -0
  18. {beatbot_cloud-0.2.0 → beatbot_cloud-0.3.0}/src/beatbot_cloud/exceptions.py +0 -0
  19. {beatbot_cloud-0.2.0 → beatbot_cloud-0.3.0}/src/beatbot_cloud/protocol.py +0 -0
  20. {beatbot_cloud-0.2.0 → beatbot_cloud-0.3.0}/src/beatbot_cloud/py.typed +0 -0
  21. {beatbot_cloud-0.2.0 → beatbot_cloud-0.3.0}/src/beatbot_cloud.egg-info/dependency_links.txt +0 -0
  22. {beatbot_cloud-0.2.0 → beatbot_cloud-0.3.0}/src/beatbot_cloud.egg-info/requires.txt +0 -0
  23. {beatbot_cloud-0.2.0 → beatbot_cloud-0.3.0}/src/beatbot_cloud.egg-info/top_level.txt +0 -0
  24. {beatbot_cloud-0.2.0 → beatbot_cloud-0.3.0}/tests/test_protocol.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: beatbot-cloud
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Asynchronous Python client for the Beatbot cloud API
5
5
  Author: Beatbot Robotics
6
6
  License-Expression: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "beatbot-cloud"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Asynchronous Python client for the Beatbot cloud API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: beatbot-cloud
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Asynchronous Python client for the Beatbot cloud API
5
5
  Author: Beatbot Robotics
6
6
  License-Expression: Apache-2.0
@@ -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