botslab360 0.7.1__tar.gz → 0.7.2__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 (36) hide show
  1. {botslab360-0.7.1/src/botslab360.egg-info → botslab360-0.7.2}/PKG-INFO +1 -1
  2. {botslab360-0.7.1 → botslab360-0.7.2}/pyproject.toml +1 -1
  3. {botslab360-0.7.1 → botslab360-0.7.2}/src/botslab360/client.py +90 -32
  4. {botslab360-0.7.1 → botslab360-0.7.2/src/botslab360.egg-info}/PKG-INFO +1 -1
  5. {botslab360-0.7.1 → botslab360-0.7.2}/src/botslab360.egg-info/SOURCES.txt +1 -0
  6. botslab360-0.7.2/tests/test_push_profiles.py +315 -0
  7. {botslab360-0.7.1 → botslab360-0.7.2}/ATTRIBUTION.md +0 -0
  8. {botslab360-0.7.1 → botslab360-0.7.2}/LICENSE +0 -0
  9. {botslab360-0.7.1 → botslab360-0.7.2}/README.md +0 -0
  10. {botslab360-0.7.1 → botslab360-0.7.2}/setup.cfg +0 -0
  11. {botslab360-0.7.1 → botslab360-0.7.2}/src/botslab360/__init__.py +0 -0
  12. {botslab360-0.7.1 → botslab360-0.7.2}/src/botslab360/auth.py +0 -0
  13. {botslab360-0.7.1 → botslab360-0.7.2}/src/botslab360/commands.py +0 -0
  14. {botslab360-0.7.1 → botslab360-0.7.2}/src/botslab360/exceptions.py +0 -0
  15. {botslab360-0.7.1 → botslab360-0.7.2}/src/botslab360/models.py +0 -0
  16. {botslab360-0.7.1 → botslab360-0.7.2}/src/botslab360/protocol.py +0 -0
  17. {botslab360-0.7.1 → botslab360-0.7.2}/src/botslab360/quc.py +0 -0
  18. {botslab360-0.7.1 → botslab360-0.7.2}/src/botslab360/rooms.py +0 -0
  19. {botslab360-0.7.1 → botslab360-0.7.2}/src/botslab360.egg-info/dependency_links.txt +0 -0
  20. {botslab360-0.7.1 → botslab360-0.7.2}/src/botslab360.egg-info/requires.txt +0 -0
  21. {botslab360-0.7.1 → botslab360-0.7.2}/src/botslab360.egg-info/top_level.txt +0 -0
  22. {botslab360-0.7.1 → botslab360-0.7.2}/tests/test_android_360_diagnostic.py +0 -0
  23. {botslab360-0.7.1 → botslab360-0.7.2}/tests/test_auth.py +0 -0
  24. {botslab360-0.7.1 → botslab360-0.7.2}/tests/test_commands.py +0 -0
  25. {botslab360-0.7.1 → botslab360-0.7.2}/tests/test_compare_android_quc.py +0 -0
  26. {botslab360-0.7.1 → botslab360-0.7.2}/tests/test_credentials_auth_example.py +0 -0
  27. {botslab360-0.7.1 → botslab360-0.7.2}/tests/test_devices.py +0 -0
  28. {botslab360-0.7.1 → botslab360-0.7.2}/tests/test_network_info.py +0 -0
  29. {botslab360-0.7.1 → botslab360-0.7.2}/tests/test_network_info_diagnostic.py +0 -0
  30. {botslab360-0.7.1 → botslab360-0.7.2}/tests/test_protocol.py +0 -0
  31. {botslab360-0.7.1 → botslab360-0.7.2}/tests/test_public_robot360_auth_diagnostic.py +0 -0
  32. {botslab360-0.7.1 → botslab360-0.7.2}/tests/test_public_room_api_diagnostic.py +0 -0
  33. {botslab360-0.7.1 → botslab360-0.7.2}/tests/test_quc.py +0 -0
  34. {botslab360-0.7.1 → botslab360-0.7.2}/tests/test_room_diagnostic.py +0 -0
  35. {botslab360-0.7.1 → botslab360-0.7.2}/tests/test_rooms.py +0 -0
  36. {botslab360-0.7.1 → botslab360-0.7.2}/tests/test_status.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: botslab360
3
- Version: 0.7.1
3
+ Version: 0.7.2
4
4
  Summary: Async Python client for Botslab/360 robot vacuums
5
5
  License-Expression: MIT
6
6
  Project-URL: Source, https://github.com/placix/python-botslab360
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "botslab360"
7
- version = "0.7.1"
7
+ version = "0.7.2"
8
8
  description = "Async Python client for Botslab/360 robot vacuums"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -5,8 +5,10 @@ from __future__ import annotations
5
5
  import asyncio
6
6
  import hashlib
7
7
  import json
8
- from collections.abc import Awaitable, Callable, Mapping
9
- from contextlib import suppress
8
+ import logging
9
+ from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
10
+ from contextlib import AsyncExitStack, asynccontextmanager, suppress
11
+ from dataclasses import dataclass
10
12
  from numbers import Integral
11
13
  from typing import Any, TypeVar
12
14
  from urllib.parse import quote_plus
@@ -71,6 +73,27 @@ ANDROID_DEVICE_MANUFACTURER = "Google"
71
73
  ANDROID_DEVICE_MODEL = "sdk_gphone64_x86_64"
72
74
  ANDROID_ROOM_USER_AGENT = "okhttp/4.10.0"
73
75
  _ResultT = TypeVar("_ResultT")
76
+ _LOGGER = logging.getLogger(__name__)
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class _PushProfile:
81
+ """Transport settings for one initial push registration attempt."""
82
+
83
+ name: str
84
+ port: int
85
+ client_version: str
86
+ heartbeat_timeout: int
87
+ heartbeat_interval: float
88
+
89
+
90
+ _LEGACY_PUSH_PROFILE = _PushProfile(
91
+ "legacy",
92
+ DEFAULT_PUSH_PORT,
93
+ DEFAULT_PUSH_CLIENT_VERSION,
94
+ DEFAULT_PUSH_HEARTBEAT_TIMEOUT,
95
+ DEFAULT_PUSH_HEARTBEAT_INTERVAL,
96
+ )
74
97
 
75
98
 
76
99
  def _numeric_api_code(value: Any, *, status_code: int) -> int:
@@ -204,6 +227,7 @@ class Botslab360Client:
204
227
  push_client_version: str,
205
228
  push_heartbeat_timeout: int,
206
229
  push_heartbeat_interval: float,
230
+ push_profile_name: str = "legacy",
207
231
  ) -> None:
208
232
  self._request_timeout = timeout
209
233
  self._http_client = (
@@ -219,6 +243,15 @@ class Botslab360Client:
219
243
  self._push_client_version = push_client_version
220
244
  self._push_heartbeat_timeout = push_heartbeat_timeout
221
245
  self._push_heartbeat_interval = push_heartbeat_interval
246
+ self._push_profiles = (
247
+ _PushProfile(
248
+ push_profile_name,
249
+ push_port,
250
+ push_client_version,
251
+ push_heartbeat_timeout,
252
+ push_heartbeat_interval,
253
+ ),
254
+ )
222
255
  self._auth = BotslabAuth(
223
256
  self._http_client,
224
257
  base_url=base_url,
@@ -282,7 +315,13 @@ class Botslab360Client:
282
315
  push_client_version=push_client_version,
283
316
  push_heartbeat_timeout=push_heartbeat_timeout,
284
317
  push_heartbeat_interval=push_heartbeat_interval,
318
+ push_profile_name="android"
319
+ if backend is AuthBackend.ROBOT360
320
+ else "legacy",
285
321
  )
322
+ # An explicit port override remains authoritative; do not redirect it.
323
+ if backend is AuthBackend.ROBOT360 and push_port is None:
324
+ client._push_profiles += (_LEGACY_PUSH_PROFILE,)
286
325
  identity = device_identity or DeviceIdentity.generate()
287
326
  client._credentials = None
288
327
  client._account_fingerprint = None
@@ -528,6 +567,52 @@ class Botslab360Client:
528
567
  )
529
568
  )
530
569
 
570
+ @asynccontextmanager
571
+ async def _push_connection(self) -> AsyncIterator[PushClient]:
572
+ """Try alternate profiles only before yielding an established channel."""
573
+
574
+ if self._session is None:
575
+ raise AuthenticationError(
576
+ "Authentication is required before opening push",
577
+ phase="authentication",
578
+ )
579
+ for index, profile in enumerate(self._push_profiles):
580
+ push = PushClient(
581
+ self._session.sid,
582
+ self._session.push_key,
583
+ host=self._push_host,
584
+ port=profile.port,
585
+ client_version=profile.client_version,
586
+ heartbeat_timeout=profile.heartbeat_timeout,
587
+ heartbeat_interval=profile.heartbeat_interval,
588
+ )
589
+ async with AsyncExitStack() as stack:
590
+ try:
591
+ await stack.enter_async_context(push)
592
+ except ApiError as error:
593
+ # Failed __aenter__ calls do not register an exit callback.
594
+ await push.close()
595
+ retry = (
596
+ not isinstance(
597
+ error, (AuthenticationError, InvalidSessionError)
598
+ )
599
+ and error.phase in {"transport", "timeout", "protocol"}
600
+ and index + 1 < len(self._push_profiles)
601
+ )
602
+ _LOGGER.debug(
603
+ "Push setup: profile=%s result=failed fallback=%s",
604
+ profile.name,
605
+ retry,
606
+ )
607
+ if not retry:
608
+ raise
609
+ continue
610
+ _LOGGER.debug("Push setup: profile=%s result=succeeded", profile.name)
611
+ # The operation runs outside the establishment exception handler:
612
+ # no profile retry can replay a command or a map trigger.
613
+ yield push
614
+ return
615
+
531
616
  async def _get_status_once(
532
617
  self,
533
618
  *,
@@ -541,16 +626,7 @@ class Botslab360Client:
541
626
  phase="authentication",
542
627
  )
543
628
 
544
- push = PushClient(
545
- self._session.sid,
546
- self._session.push_key,
547
- host=self._push_host,
548
- port=self._push_port,
549
- client_version=self._push_client_version,
550
- heartbeat_timeout=self._push_heartbeat_timeout,
551
- heartbeat_interval=self._push_heartbeat_interval,
552
- )
553
- async with push:
629
+ async with self._push_connection() as push:
554
630
  await self._request_status(device_id=device_id, task_id=task_id)
555
631
  return await push.wait_for_status(
556
632
  device_id=device_id,
@@ -603,16 +679,7 @@ class Botslab360Client:
603
679
  phase="authentication",
604
680
  )
605
681
 
606
- push = PushClient(
607
- self._session.sid,
608
- self._session.push_key,
609
- host=self._push_host,
610
- port=self._push_port,
611
- client_version=self._push_client_version,
612
- heartbeat_timeout=self._push_heartbeat_timeout,
613
- heartbeat_interval=self._push_heartbeat_interval,
614
- )
615
- async with push:
682
+ async with self._push_connection() as push:
616
683
  await self._post_robot_request(
617
684
  device_id=device_id,
618
685
  info_type=NETWORK_INFO_TYPE,
@@ -695,16 +762,7 @@ class Botslab360Client:
695
762
  "Authentication is required before requesting rooms",
696
763
  phase="authentication",
697
764
  )
698
- push = PushClient(
699
- self._session.sid,
700
- self._session.push_key,
701
- host=self._push_host,
702
- port=self._push_port,
703
- client_version=self._push_client_version,
704
- heartbeat_timeout=self._push_heartbeat_timeout,
705
- heartbeat_interval=self._push_heartbeat_interval,
706
- )
707
- async with push:
765
+ async with self._push_connection() as push:
708
766
  waiter = asyncio.create_task(
709
767
  wait_for_room_map(
710
768
  push,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: botslab360
3
- Version: 0.7.1
3
+ Version: 0.7.2
4
4
  Summary: Async Python client for Botslab/360 robot vacuums
5
5
  License-Expression: MIT
6
6
  Project-URL: Source, https://github.com/placix/python-botslab360
@@ -27,6 +27,7 @@ tests/test_network_info_diagnostic.py
27
27
  tests/test_protocol.py
28
28
  tests/test_public_robot360_auth_diagnostic.py
29
29
  tests/test_public_room_api_diagnostic.py
30
+ tests/test_push_profiles.py
30
31
  tests/test_quc.py
31
32
  tests/test_room_diagnostic.py
32
33
  tests/test_rooms.py
@@ -0,0 +1,315 @@
1
+ """Offline coverage of push establishment fallback without command replay."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ from urllib.parse import parse_qs
9
+
10
+ import httpx
11
+ import pytest
12
+
13
+ import botslab360.client as client_module
14
+ from botslab360 import (
15
+ ApiError,
16
+ AuthBackend,
17
+ AuthenticationError,
18
+ Botslab360Client,
19
+ DeviceIdentity,
20
+ InvalidSessionError,
21
+ NetworkInfo,
22
+ QihooCredentials,
23
+ RobotStatus,
24
+ SmartSession,
25
+ )
26
+
27
+ QID = "1234567890"
28
+ Q = f"u=360H{QID}&n=synthetic&m=not-a-real-token"
29
+ T = "s=synthetic-session&t=1700000000&v=2.0"
30
+ SID = "synthetic-private-sid"
31
+ PUSH_KEY = "synthetic-private-push-key"
32
+ DEVICE_ID = "synthetic-private-device"
33
+ IDENTITY = DeviceIdentity(
34
+ mid="0123456789abcdef0123456789abcdef",
35
+ android_id="0123456789abcdef",
36
+ m2="00000000-0000-4000-8000-000000000001",
37
+ )
38
+ ANDROID = (80, "1.21", 20, 15.0)
39
+ LEGACY = (443, "1.7", 30, 25.0)
40
+
41
+
42
+ def make_client(http, backend=AuthBackend.ROBOT360, **kwargs):
43
+ if backend is None:
44
+ client = Botslab360Client(Q, T, http_client=http, **kwargs)
45
+ else:
46
+ client = Botslab360Client.from_credentials(
47
+ "synthetic@example.invalid",
48
+ "synthetic-private-password",
49
+ backend=backend,
50
+ device_identity=IDENTITY,
51
+ http_client=http,
52
+ **kwargs,
53
+ )
54
+ client._set_credentials(QihooCredentials(q=Q, t=T, qid=QID))
55
+ client._session = SmartSession(qid=QID, sid=SID, push_key=PUSH_KEY)
56
+ return client
57
+
58
+
59
+ @pytest.mark.parametrize(
60
+ ("operation", "scenario"),
61
+ [
62
+ (operation, scenario)
63
+ for operation in ("rooms", "status", "network", "clean_rooms")
64
+ for scenario in ("android", "fallback", "both", "post_push", "post_http")
65
+ ]
66
+ + [("clean_rooms", "cleaning_http"), ("rooms", "map_parse")],
67
+ )
68
+ def test_profile_fallback_never_replays_operations(
69
+ monkeypatch, caplog, operation, scenario
70
+ ):
71
+ async def run():
72
+ attempts = []
73
+ posts = []
74
+ issued = asyncio.Event()
75
+ connected = False
76
+ final_error = ApiError("Push connection closed", phase="transport")
77
+
78
+ class FakePush:
79
+ def __init__(self, sid, push_key, **kwargs):
80
+ assert (sid, push_key) == (SID, PUSH_KEY)
81
+ self.profile = tuple(
82
+ kwargs[key]
83
+ for key in (
84
+ "port",
85
+ "client_version",
86
+ "heartbeat_timeout",
87
+ "heartbeat_interval",
88
+ )
89
+ )
90
+ self.closed = False
91
+ attempts.append(self)
92
+
93
+ async def __aenter__(self):
94
+ nonlocal connected
95
+ if scenario == "both" or (
96
+ scenario == "fallback" and len(attempts) == 1
97
+ ):
98
+ raise final_error
99
+ connected = True
100
+ return self
101
+
102
+ async def close(self):
103
+ nonlocal connected
104
+ self.closed = True
105
+ connected = False
106
+
107
+ async def __aexit__(self, *args):
108
+ await self.close()
109
+
110
+ async def receive(self):
111
+ await issued.wait()
112
+ if scenario == "post_push":
113
+ raise final_error
114
+
115
+ async def read_event(self):
116
+ await self.receive()
117
+ if scenario == "map_parse":
118
+ return {"sn": DEVICE_ID, "data": {"infoType": "20002", "data": []}}
119
+ return {
120
+ "sn": DEVICE_ID,
121
+ "data": json.dumps(
122
+ {
123
+ "infoType": "20002",
124
+ "data": {
125
+ "mapId": 1,
126
+ "cleanId": "synthetic-private-clean-id",
127
+ "smartArea": {
128
+ "activeIds": [],
129
+ "value": [{"id": 1, "name": "Um9vbQ=="}],
130
+ },
131
+ },
132
+ }
133
+ ),
134
+ }
135
+
136
+ async def wait_for_network_info(self, **kwargs):
137
+ await self.receive()
138
+ return NetworkInfo("192.0.2.1", None, None, None)
139
+
140
+ async def wait_for_status(self, **kwargs):
141
+ await self.receive()
142
+ return RobotStatus(DEVICE_ID, True, 80, "idle", False, "auto", 0, 0, 0)
143
+
144
+ monkeypatch.setattr(client_module, "PushClient", FakePush)
145
+
146
+ async def handler(request):
147
+ if request.url.path == "/clean/cmd/send":
148
+ assert connected
149
+ posts.append((request.url.path, parse_qs(request.content.decode())))
150
+ issued.set()
151
+ if scenario == "post_http" or (
152
+ scenario == "cleaning_http"
153
+ and request.url.path == "/clean/record/setAreaAndCleaning"
154
+ ):
155
+ return httpx.Response(200, json={"errno": 999})
156
+ return httpx.Response(200, json={"errno": 0})
157
+
158
+ async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
159
+ client = make_client(http)
160
+ method = {
161
+ "rooms": client.get_rooms,
162
+ "status": client.get_status,
163
+ "network": client.get_network_info,
164
+ "clean_rooms": lambda device: client.clean_rooms(device, [1]),
165
+ }[operation]
166
+ if scenario in {
167
+ "both",
168
+ "post_push",
169
+ "post_http",
170
+ "cleaning_http",
171
+ "map_parse",
172
+ }:
173
+ with pytest.raises(ApiError) as raised:
174
+ await method(DEVICE_ID)
175
+ if scenario in {"both", "post_push"}:
176
+ assert raised.value is final_error
177
+ else:
178
+ result = await method(DEVICE_ID)
179
+ if operation == "rooms":
180
+ assert [(room.id, room.name) for room in result] == [(1, "Room")]
181
+ elif operation == "status":
182
+ assert result.state == "idle"
183
+ elif operation == "network":
184
+ assert result.station_ip == "192.0.2.1"
185
+ await client.close()
186
+ assert not http.is_closed
187
+
188
+ assert [push.profile for push in attempts] == (
189
+ [ANDROID, LEGACY] if scenario in {"fallback", "both"} else [ANDROID]
190
+ )
191
+ assert all(push.closed for push in attempts)
192
+ assert not connected
193
+ expected_posts = 0 if scenario == "both" else 1
194
+ if operation == "clean_rooms" and scenario in {
195
+ "android",
196
+ "fallback",
197
+ "cleaning_http",
198
+ }:
199
+ expected_posts = 2
200
+ assert posts[1][0] == "/clean/record/setAreaAndCleaning"
201
+ assert json.loads(posts[1][1]["areaSetting"][0])["activeIds"] == [1]
202
+ assert len(posts) == expected_posts
203
+ if posts:
204
+ assert posts[0][0] == "/clean/cmd/send"
205
+ assert posts[0][1]["infoType"] == [
206
+ {"status": "20001", "network": "21019"}.get(operation, "30000")
207
+ ]
208
+
209
+ caplog.set_level(logging.DEBUG, logger="botslab360.client")
210
+ asyncio.run(run())
211
+ diagnostics = "\n".join(
212
+ record.getMessage()
213
+ for record in caplog.records
214
+ if record.name == "botslab360.client"
215
+ )
216
+ assert "profile=android" in diagnostics
217
+ if scenario == "fallback":
218
+ assert "profile=legacy result=succeeded" in diagnostics
219
+ for secret in (
220
+ Q,
221
+ T,
222
+ QID,
223
+ SID,
224
+ PUSH_KEY,
225
+ DEVICE_ID,
226
+ "synthetic-private-password",
227
+ "synthetic-private-clean-id",
228
+ "Push connection closed",
229
+ ):
230
+ assert secret not in diagnostics
231
+ assert all(record.exc_info is None for record in caplog.records)
232
+
233
+
234
+ @pytest.mark.parametrize("phase", ["transport", "timeout", "protocol"])
235
+ def test_initial_registration_phases_allow_one_fallback(monkeypatch, phase):
236
+ _check_setup_error(
237
+ monkeypatch, ApiError("synthetic-error", phase=phase), expected_ports=[80, 443]
238
+ )
239
+
240
+
241
+ @pytest.mark.parametrize(
242
+ "error",
243
+ [
244
+ ApiError("synthetic-error", phase="json"),
245
+ ApiError("synthetic-error", phase="api"),
246
+ ApiError("synthetic-error", phase="response-validation"),
247
+ AuthenticationError("synthetic-error", phase="transport"),
248
+ InvalidSessionError("synthetic-error", phase="transport"),
249
+ ],
250
+ )
251
+ def test_non_transport_or_session_errors_do_not_switch_profiles(monkeypatch, error):
252
+ _check_setup_error(monkeypatch, error, expected_ports=[80])
253
+
254
+
255
+ @pytest.mark.parametrize(
256
+ "backend,override,expected_ports",
257
+ [
258
+ (None, None, [443]),
259
+ (AuthBackend.BOTSLAB, None, [443]),
260
+ (AuthBackend.ROBOT360, 1234, [1234]),
261
+ ],
262
+ )
263
+ def test_default_backends_and_explicit_port_remain_authoritative(
264
+ monkeypatch, backend, override, expected_ports
265
+ ):
266
+ _check_setup_error(
267
+ monkeypatch,
268
+ ApiError("synthetic-error", phase="transport"),
269
+ expected_ports=expected_ports,
270
+ backend=backend,
271
+ override=override,
272
+ )
273
+
274
+
275
+ def _check_setup_error(
276
+ monkeypatch, error, *, expected_ports, backend=AuthBackend.ROBOT360, override=None
277
+ ):
278
+ async def run():
279
+ attempts = []
280
+ closed = []
281
+
282
+ class FailingPush:
283
+ def __init__(self, sid, push_key, **kwargs):
284
+ self.port = kwargs["port"]
285
+ attempts.append(self.port)
286
+
287
+ async def __aenter__(self):
288
+ raise error
289
+
290
+ async def close(self):
291
+ closed.append(self.port)
292
+
293
+ async def __aexit__(self, *args):
294
+ pytest.fail("A failed entry must not register an exit callback")
295
+
296
+ monkeypatch.setattr(client_module, "PushClient", FailingPush)
297
+ async with httpx.AsyncClient(
298
+ transport=httpx.MockTransport(
299
+ lambda request: pytest.fail("No HTTP expected")
300
+ )
301
+ ) as http:
302
+ client = make_client(
303
+ http,
304
+ backend,
305
+ **({"push_port": override} if override is not None else {}),
306
+ )
307
+ with pytest.raises(ApiError) as raised:
308
+ async with client._push_connection():
309
+ pytest.fail("No established channel expected")
310
+ assert raised.value is error
311
+ await client.close()
312
+ assert attempts == expected_ports
313
+ assert closed == expected_ports
314
+
315
+ asyncio.run(run())
File without changes
File without changes
File without changes
File without changes
File without changes