pymammotion 0.5.32__py3-none-any.whl → 0.5.45__py3-none-any.whl

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.

Potentially problematic release.


This version of pymammotion might be problematic. Click here for more details.

Files changed (54) hide show
  1. pymammotion/__init__.py +3 -3
  2. pymammotion/aliyun/cloud_gateway.py +114 -18
  3. pymammotion/aliyun/model/dev_by_account_response.py +169 -21
  4. pymammotion/const.py +3 -0
  5. pymammotion/data/model/device.py +1 -0
  6. pymammotion/data/model/device_config.py +1 -1
  7. pymammotion/data/model/enums.py +5 -3
  8. pymammotion/data/model/generate_route_information.py +2 -2
  9. pymammotion/data/model/hash_list.py +113 -33
  10. pymammotion/data/model/region_data.py +4 -4
  11. pymammotion/data/{state_manager.py → mower_state_manager.py} +17 -7
  12. pymammotion/data/mqtt/event.py +47 -22
  13. pymammotion/data/mqtt/mammotion_properties.py +257 -0
  14. pymammotion/data/mqtt/properties.py +32 -29
  15. pymammotion/data/mqtt/status.py +17 -16
  16. pymammotion/homeassistant/__init__.py +3 -0
  17. pymammotion/homeassistant/mower_api.py +446 -0
  18. pymammotion/homeassistant/rtk_api.py +54 -0
  19. pymammotion/http/http.py +392 -16
  20. pymammotion/http/model/http.py +82 -2
  21. pymammotion/http/model/response_factory.py +10 -4
  22. pymammotion/mammotion/commands/mammotion_command.py +6 -0
  23. pymammotion/mammotion/commands/messages/navigation.py +10 -6
  24. pymammotion/mammotion/devices/__init__.py +27 -3
  25. pymammotion/mammotion/devices/base.py +16 -139
  26. pymammotion/mammotion/devices/mammotion.py +361 -203
  27. pymammotion/mammotion/devices/mammotion_bluetooth.py +7 -5
  28. pymammotion/mammotion/devices/mammotion_cloud.py +42 -83
  29. pymammotion/mammotion/devices/mammotion_mower_ble.py +49 -0
  30. pymammotion/mammotion/devices/mammotion_mower_cloud.py +39 -0
  31. pymammotion/mammotion/devices/managers/managers.py +81 -0
  32. pymammotion/mammotion/devices/mower_device.py +121 -0
  33. pymammotion/mammotion/devices/mower_manager.py +107 -0
  34. pymammotion/mammotion/devices/rtk_ble.py +89 -0
  35. pymammotion/mammotion/devices/rtk_cloud.py +113 -0
  36. pymammotion/mammotion/devices/rtk_device.py +50 -0
  37. pymammotion/mammotion/devices/rtk_manager.py +122 -0
  38. pymammotion/mqtt/__init__.py +2 -1
  39. pymammotion/mqtt/aliyun_mqtt.py +232 -0
  40. pymammotion/mqtt/mammotion_mqtt.py +174 -192
  41. pymammotion/mqtt/mqtt_models.py +66 -0
  42. pymammotion/proto/__init__.py +1 -1
  43. pymammotion/proto/mctrl_nav.proto +1 -1
  44. pymammotion/proto/mctrl_nav_pb2.py +1 -1
  45. pymammotion/proto/mctrl_nav_pb2.pyi +4 -4
  46. pymammotion/proto/mctrl_sys.proto +1 -1
  47. pymammotion/utility/datatype_converter.py +13 -12
  48. pymammotion/utility/device_type.py +88 -3
  49. pymammotion/utility/mur_mur_hash.py +132 -87
  50. {pymammotion-0.5.32.dist-info → pymammotion-0.5.45.dist-info}/METADATA +25 -31
  51. {pymammotion-0.5.32.dist-info → pymammotion-0.5.45.dist-info}/RECORD +59 -45
  52. {pymammotion-0.5.32.dist-info → pymammotion-0.5.45.dist-info}/WHEEL +1 -1
  53. pymammotion/http/_init_.py +0 -0
  54. {pymammotion-0.5.32.dist-info → pymammotion-0.5.45.dist-info/licenses}/LICENSE +0 -0
pymammotion/http/http.py CHANGED
@@ -1,41 +1,190 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ from collections.abc import Awaitable, Callable
1
5
  import csv
6
+ from functools import wraps
7
+ import hashlib
8
+ import hmac
9
+ import json
10
+ import random
2
11
  import time
3
- from typing import cast
12
+ from typing import Any, TypeVar, cast
4
13
 
5
14
  from aiohttp import ClientSession
15
+ import jwt
6
16
 
7
- from pymammotion.const import MAMMOTION_API_DOMAIN, MAMMOTION_CLIENT_ID, MAMMOTION_CLIENT_SECRET, MAMMOTION_DOMAIN
17
+ from pymammotion.const import (
18
+ MAMMOTION_API_DOMAIN,
19
+ MAMMOTION_CLIENT_ID,
20
+ MAMMOTION_CLIENT_SECRET,
21
+ MAMMOTION_DOMAIN,
22
+ MAMMOTION_OUATH2_CLIENT_ID,
23
+ MAMMOTION_OUATH2_CLIENT_SECRET,
24
+ )
8
25
  from pymammotion.http.encryption import EncryptionUtils
9
26
  from pymammotion.http.model.camera_stream import StreamSubscriptionResponse, VideoResourceResponse
10
- from pymammotion.http.model.http import CheckDeviceVersion, ErrorInfo, LoginResponseData, Response
27
+ from pymammotion.http.model.http import (
28
+ CheckDeviceVersion,
29
+ DeviceInfo,
30
+ DeviceRecords,
31
+ ErrorInfo,
32
+ JWTTokenInfo,
33
+ LoginResponseData,
34
+ MQTTConnection,
35
+ Response,
36
+ UnauthorizedException,
37
+ )
11
38
  from pymammotion.http.model.response_factory import response_factory
12
39
  from pymammotion.http.model.rtk import RTK
13
40
 
41
+ T = TypeVar("T")
42
+
43
+
44
+ def sign_with_hmac_sha256(data: str, app_secret: str) -> str:
45
+ """Sign data with HMAC-SHA256 algorithm.
46
+
47
+ Args:
48
+ data: The data to sign
49
+ app_secret: The secret key for signing
50
+
51
+ Returns:
52
+ Hex string of the signature
53
+
54
+ Raises:
55
+ RuntimeError: If signing fails
56
+
57
+ """
58
+ if data is None:
59
+ raise ValueError("data cannot be None")
60
+ if app_secret is None:
61
+ raise ValueError("app_secret cannot be None")
62
+
63
+ try:
64
+ # Convert strings to bytes using UTF-8 encoding
65
+ data_bytes = data.encode("utf-8")
66
+ secret_bytes = app_secret.encode("utf-8")
67
+
68
+ # Create HMAC-SHA256 hash
69
+ hmac_obj = hmac.new(secret_bytes, data_bytes, hashlib.sha256)
70
+
71
+ # Get the digest
72
+ digest = hmac_obj.digest()
73
+
74
+ # Convert to hex string
75
+ hex_string = digest.hex()
76
+
77
+ return hex_string
78
+
79
+ except Exception as e:
80
+ raise RuntimeError(f"toSignWithHmacSha256 error: {e}") from e
81
+
82
+
83
+ def create_oauth_signature(login_req: dict, client_id: str, client_secret: str, token_endpoint: str) -> str:
84
+ """Create OAuth signature for login request.
85
+
86
+ Args:
87
+ login_req: Login request data as dictionary
88
+ client_id: OAuth client ID
89
+ client_secret: OAuth client secret
90
+ token_endpoint: Token endpoint path
91
+
92
+ Returns:
93
+ HMAC-SHA256 signature
94
+
95
+ """
96
+ # Convert dict to JSON without HTML escaping (ensure_ascii=False handles this)
97
+ json_data = json.dumps(login_req, ensure_ascii=False, separators=(",", ":"))
98
+
99
+ # Get current timestamp in milliseconds
100
+ timestamp = str(int(time.time() * 1000))
101
+
102
+ # Construct the string to sign
103
+ str_to_sign = f"{client_id}{timestamp}{token_endpoint}{json_data}"
104
+
105
+ # Create MD5 hash of client secret
106
+ try:
107
+ md5_hash = hashlib.md5(client_secret.encode("utf-8")).digest()
108
+ # Convert to hex string
109
+ hashed_secret = md5_hash.hex()
110
+ except Exception:
111
+ hashed_secret = ""
112
+
113
+ # Sign with HMAC-SHA256
114
+ signature = sign_with_hmac_sha256(str_to_sign, hashed_secret)
115
+
116
+ return signature
117
+
14
118
 
15
119
  class MammotionHTTP:
16
- def __init__(self) -> None:
17
- self.expires_in = 0
120
+ def __init__(self, account: str | None = None, password: str | None = None) -> None:
121
+ self.device_info: list[DeviceInfo] = []
122
+ self.mqtt_credentials: MQTTConnection | None = None
123
+ self.device_records: DeviceRecords = DeviceRecords(records=[], current=0, total=0, size=0, pages=0)
124
+ self.expires_in = 0.0
18
125
  self.code = 0
19
126
  self.msg = None
20
- self.account = None
21
- self._password = None
22
- self.response: Response | None = None
127
+ self.account = account
128
+ self._password = password
129
+ self._response: Response | None = None
23
130
  self.login_info: LoginResponseData | None = None
131
+ self.jwt_info: JWTTokenInfo = JWTTokenInfo("", "")
24
132
  self._headers = {"User-Agent": "okhttp/4.9.3", "App-Version": "Home Assistant,1.14.2.29"}
25
133
  self.encryption_utils = EncryptionUtils()
26
134
 
135
+ # Add this method to generate a 10-digit random number
136
+ def get_10_random() -> str:
137
+ """Generate a 10-digit random number as a string."""
138
+ return "".join([str(random.randint(0, 9)) for _ in range(7)])
139
+
140
+ # Replace the line in the __init__ method with:
141
+ self.client_id = f"{int(time.time() * 1000)}_{get_10_random()}_1"
142
+
143
+ @property
144
+ def response(self) -> Response | None:
145
+ return self._response
146
+
147
+ @response.setter
148
+ def response(self, response: Response) -> None:
149
+ self._response = response
150
+ decoded_token = jwt.decode(response.data.access_token, options={"verify_signature": False})
151
+ if isinstance(decoded_token, dict):
152
+ self.jwt_info = JWTTokenInfo(iot=decoded_token.get("iot", ""), robot=decoded_token.get("robot", ""))
153
+
27
154
  @staticmethod
28
155
  def generate_headers(token: str) -> dict:
29
156
  return {"Authorization": f"Bearer {token}"}
30
157
 
158
+ @staticmethod
159
+ def refresh_token_decorator(func: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]:
160
+ """Decorator to handle token refresh before executing a function.
161
+
162
+ Args:
163
+ func: The async function to be decorated
164
+
165
+ Returns:
166
+ The wrapped async function that handles token refresh
167
+
168
+ """
169
+
170
+ @wraps(func)
171
+ async def wrapper(self: MammotionHTTP, *args: Any, **kwargs: Any) -> T:
172
+ # Check if token will expire in the next 5 minutes
173
+ if self.expires_in < time.time() + 300: # 300 seconds = 5 minutes
174
+ await self.refresh_login()
175
+ return await func(self, *args, **kwargs)
176
+
177
+ return wrapper
178
+
31
179
  async def handle_expiry(self, resp: Response) -> Response:
32
- if resp.code == 401:
33
- return await self.login(self.account, self._password)
180
+ if resp.code == 401 and self.account and self._password:
181
+ return await self.login_v2(self.account, self._password)
34
182
  return resp
35
183
 
36
184
  async def login_by_email(self, email: str, password: str) -> Response[LoginResponseData]:
37
- return await self.login(email, password)
185
+ return await self.login_v2(email, password)
38
186
 
187
+ @refresh_token_decorator
39
188
  async def get_all_error_codes(self) -> dict[str, ErrorInfo]:
40
189
  """Retrieves and parses all error codes from the MAMMOTION API."""
41
190
  async with ClientSession(MAMMOTION_API_DOMAIN) as session:
@@ -74,7 +223,8 @@ class MammotionHTTP:
74
223
  data = await resp.json()
75
224
  return Response.from_dict(data)
76
225
 
77
- async def refresh_token(self) -> Response:
226
+ @refresh_token_decorator
227
+ async def refresh_authorization_code(self) -> Response:
78
228
  """Refresh token."""
79
229
  async with ClientSession(MAMMOTION_DOMAIN) as session:
80
230
  async with session.post(
@@ -88,10 +238,13 @@ class MammotionHTTP:
88
238
  json={"clientId": MAMMOTION_CLIENT_ID},
89
239
  ) as resp:
90
240
  data = await resp.json()
91
-
241
+ print(data)
242
+ self.login_info.access_token = data["data"].get("accessToken", self.login_info.access_token)
92
243
  self.login_info.authorization_code = data["data"].get("code", self.login_info.authorization_code)
244
+ await self.get_mqtt_credentials()
93
245
  return Response.from_dict(data)
94
246
 
247
+ @refresh_token_decorator
95
248
  async def pair_devices_mqtt(self, mower_name: str, rtk_name: str) -> Response:
96
249
  async with ClientSession(MAMMOTION_API_DOMAIN) as session:
97
250
  async with session.post(
@@ -107,6 +260,7 @@ class MammotionHTTP:
107
260
  print(data)
108
261
  return Response.from_dict(data)
109
262
 
263
+ @refresh_token_decorator
110
264
  async def unpair_devices_mqtt(self, mower_name: str, rtk_name: str) -> Response:
111
265
  async with ClientSession(MAMMOTION_API_DOMAIN) as session:
112
266
  async with session.post(
@@ -122,6 +276,7 @@ class MammotionHTTP:
122
276
  print(data)
123
277
  return Response.from_dict(data)
124
278
 
279
+ @refresh_token_decorator
125
280
  async def net_rtk_enable(self, device_id: str) -> Response:
126
281
  async with ClientSession(MAMMOTION_API_DOMAIN) as session:
127
282
  async with session.post(
@@ -135,6 +290,7 @@ class MammotionHTTP:
135
290
  print(data)
136
291
  return Response.from_dict(data)
137
292
 
293
+ @refresh_token_decorator
138
294
  async def get_stream_subscription(self, iot_id: str) -> Response[StreamSubscriptionResponse]:
139
295
  """Fetches stream subscription data from agora.io for a given IoT device."""
140
296
  async with ClientSession(MAMMOTION_API_DOMAIN) as session:
@@ -158,11 +314,13 @@ class MammotionHTTP:
158
314
  response.data = StreamSubscriptionResponse.from_dict(data.get("data", {}))
159
315
  return response
160
316
 
317
+ @refresh_token_decorator
161
318
  async def get_stream_subscription_mini_or_x_series(
162
319
  self, iot_id: str, is_yuka: bool
163
320
  ) -> Response[StreamSubscriptionResponse]:
164
321
  # Prepare the payload with cameraStates based on is_yuka flag
165
322
  """Fetches stream subscription data for a given IoT device."""
323
+
166
324
  payload = {"deviceId": iot_id, "mode": 0, "cameraStates": []}
167
325
 
168
326
  # Add appropriate cameraStates based on the is_yuka flag
@@ -192,6 +350,7 @@ class MammotionHTTP:
192
350
  response.data = StreamSubscriptionResponse.from_dict(data.get("data", {}))
193
351
  return response
194
352
 
353
+ @refresh_token_decorator
195
354
  async def get_video_resource(self, iot_id: str) -> Response[VideoResourceResponse]:
196
355
  """Fetch video resource for a given IoT ID."""
197
356
  async with ClientSession(MAMMOTION_API_DOMAIN) as session:
@@ -212,6 +371,7 @@ class MammotionHTTP:
212
371
  response.data = VideoResourceResponse.from_dict(data.get("data", {}))
213
372
  return response
214
373
 
374
+ @refresh_token_decorator
215
375
  async def get_device_ota_firmware(self, iot_ids: list[str]) -> Response[list[CheckDeviceVersion]]:
216
376
  """Checks device firmware versions for a list of IoT IDs."""
217
377
  async with ClientSession(MAMMOTION_API_DOMAIN) as session:
@@ -230,6 +390,7 @@ class MammotionHTTP:
230
390
  # Assuming the data format matches the expected structure
231
391
  return response_factory(Response[list[CheckDeviceVersion]], data)
232
392
 
393
+ @refresh_token_decorator
233
394
  async def start_ota_upgrade(self, iot_id: str, version: str) -> Response[str]:
234
395
  """Initiates an OTA upgrade for a device."""
235
396
  async with ClientSession(MAMMOTION_API_DOMAIN) as session:
@@ -248,6 +409,7 @@ class MammotionHTTP:
248
409
  # Assuming the data format matches the expected structure
249
410
  return response_factory(Response[str], data)
250
411
 
412
+ @refresh_token_decorator
251
413
  async def get_rtk_devices(self) -> Response[list[RTK]]:
252
414
  """Fetches stream subscription data from agora.io for a given IoT device."""
253
415
  async with ClientSession(MAMMOTION_API_DOMAIN) as session:
@@ -264,12 +426,128 @@ class MammotionHTTP:
264
426
 
265
427
  return response_factory(Response[list[RTK]], data)
266
428
 
429
+ @refresh_token_decorator
430
+ async def get_user_device_list(self) -> Response[list[DeviceInfo]]:
431
+ """Fetches device list for a user (owned not shared, shared returns nothing)."""
432
+ async with ClientSession(MAMMOTION_API_DOMAIN) as session:
433
+ async with session.get(
434
+ "/device-server/v1/device/list",
435
+ headers={
436
+ **self._headers,
437
+ "Authorization": f"Bearer {self.login_info.access_token}",
438
+ "Content-Type": "application/json",
439
+ "User-Agent": "okhttp/4.9.3",
440
+ "Client-Id": self.client_id,
441
+ "Client-Type": "1",
442
+ },
443
+ ) as resp:
444
+ resp_dict = await resp.json()
445
+ response = response_factory(Response[list[DeviceInfo]], resp_dict)
446
+ self.device_info = response.data if response.data else self.device_info
447
+ return response
448
+
449
+ @refresh_token_decorator
450
+ async def get_user_shared_device_page(self) -> Response[DeviceRecords]:
451
+ """Fetches device list for a user (shared) but not accepted."""
452
+ """Can set owned to zero or one to possibly check for not accepted mowers?"""
453
+ async with ClientSession(MAMMOTION_API_DOMAIN) as session:
454
+ async with session.post(
455
+ "/user-server/v1/share/device/page",
456
+ json={"iotId": "", "owned": 0, "pageNumber": 1, "pageSize": 200, "statusList": [-1]},
457
+ headers={
458
+ **self._headers,
459
+ "Authorization": f"Bearer {self.login_info.access_token}",
460
+ "Content-Type": "application/json",
461
+ "User-Agent": "okhttp/4.9.3",
462
+ },
463
+ ) as resp:
464
+ resp_dict = await resp.json()
465
+ response = response_factory(Response[DeviceRecords], resp_dict)
466
+ self.devices_shared_info = response.data if response.data else self.devices_shared_info
467
+ return response
468
+
469
+ @refresh_token_decorator
470
+ async def get_user_device_page(self) -> Response[DeviceRecords]:
471
+ """Fetches device list for a user, is either new API or for newer devices."""
472
+ async with ClientSession(self.jwt_info.iot) as session:
473
+ async with session.post(
474
+ "/v1/user/device/page",
475
+ json={
476
+ "iotId": "",
477
+ "pageNumber": 1,
478
+ "pageSize": 100,
479
+ },
480
+ headers={
481
+ **self._headers,
482
+ "Authorization": f"Bearer {self.login_info.access_token}",
483
+ "Content-Type": "application/json",
484
+ "User-Agent": "okhttp/4.9.3",
485
+ "Client-Id": self.client_id,
486
+ "Client-Type": "1",
487
+ },
488
+ ) as resp:
489
+ if resp.status != 200:
490
+ return Response.from_dict({"code": resp.status, "msg": "get device list failed"})
491
+ resp_dict = await resp.json()
492
+ response = response_factory(Response[DeviceRecords], resp_dict)
493
+ self.device_records = response.data if response.data else self.device_records
494
+ return response
495
+
496
+ @refresh_token_decorator
497
+ async def get_mqtt_credentials(self) -> Response[MQTTConnection]:
498
+ """Get mammotion mqtt credentials"""
499
+ async with ClientSession(self.jwt_info.iot) as session:
500
+ async with session.post(
501
+ "/v1/mqtt/auth/jwt",
502
+ headers={
503
+ **self._headers,
504
+ "Authorization": f"Bearer {self.login_info.access_token}",
505
+ "Content-Type": "application/json",
506
+ "User-Agent": "okhttp/4.9.3",
507
+ },
508
+ ) as resp:
509
+ if resp.status != 200:
510
+ return Response.from_dict({"code": resp.status, "msg": "get mqtt failed"})
511
+ resp_dict = await resp.json()
512
+ response = response_factory(Response[MQTTConnection], resp_dict)
513
+ self.mqtt_credentials = response.data
514
+ return response
515
+
516
+ @refresh_token_decorator
517
+ async def mqtt_invoke(self, content: str, device_name: str, iot_id: str) -> Response[dict]:
518
+ """Send mqtt commands to devices."""
519
+ async with ClientSession(self.jwt_info.iot) as session:
520
+ async with session.post(
521
+ "/v1/mqtt/rpc/thing/service/invoke",
522
+ json={
523
+ "args": {"content": content},
524
+ "deviceName": device_name,
525
+ "identifier": "device_protobuf_sync_service",
526
+ "iotId": iot_id,
527
+ "productKey": "",
528
+ },
529
+ headers={
530
+ **self._headers,
531
+ "Authorization": f"Bearer {self.login_info.access_token}",
532
+ "Content-Type": "application/json",
533
+ "User-Agent": "okhttp/4.9.3",
534
+ "Client-Id": self.client_id,
535
+ "Client-Type": "1",
536
+ },
537
+ ) as resp:
538
+ if resp.status != 200:
539
+ return Response.from_dict({"code": resp.status, "msg": "invoke mqtt failed"})
540
+ if resp.status == 401:
541
+ raise UnauthorizedException("Access Token expired")
542
+ resp_dict = await resp.json()
543
+ return response_factory(Response[dict], resp_dict)
544
+
267
545
  async def refresh_login(self) -> Response[LoginResponseData]:
268
546
  if self.expires_in > time.time():
269
- res = await self.refresh_token()
547
+ res = await self.refresh_token_v2()
270
548
  if res.code == 0:
271
549
  return res
272
- return await self.login(self.account, self._password)
550
+ return await self.login_v2(self.account, self._password)
273
551
 
274
552
  async def login(self, account: str, password: str) -> Response[LoginResponseData]:
275
553
  """Logs in to the service using provided account and password."""
@@ -297,7 +575,7 @@ class MammotionHTTP:
297
575
  return Response.from_dict({"code": resp.status, "msg": "Login failed"})
298
576
  data = await resp.json()
299
577
  login_response = response_factory(Response[LoginResponseData], data)
300
- if login_response.data is None:
578
+ if login_response is None or login_response.data is None:
301
579
  print(login_response)
302
580
  return Response.from_dict({"code": resp.status, "msg": "Login failed"})
303
581
  self.login_info = login_response.data
@@ -311,3 +589,101 @@ class MammotionHTTP:
311
589
  # TODO catch errors from mismatch user / password elsewhere
312
590
  # Assuming the data format matches the expected structure
313
591
  return login_response
592
+
593
+ async def refresh_token_v2(self) -> Response[LoginResponseData]:
594
+ """Refresh token v2."""
595
+
596
+ refresh_request = {
597
+ "client_id": MAMMOTION_OUATH2_CLIENT_ID,
598
+ "refresh_token": self.login_info.refresh_token,
599
+ "grant_type": "refresh_token",
600
+ }
601
+
602
+ oauth_signature = create_oauth_signature(
603
+ login_req=refresh_request,
604
+ client_id=MAMMOTION_OUATH2_CLIENT_ID,
605
+ client_secret=MAMMOTION_OUATH2_CLIENT_SECRET,
606
+ token_endpoint="/oauth2/token",
607
+ )
608
+
609
+ async with ClientSession(MAMMOTION_DOMAIN) as session:
610
+ async with session.post(
611
+ "/oauth2/token",
612
+ headers={
613
+ **self._headers,
614
+ "Ma-Iot-Signature": oauth_signature,
615
+ "Ma-Timestamp": str(int(time.time())),
616
+ "Client-Id": self.client_id,
617
+ "Client-Type": "1",
618
+ },
619
+ params={
620
+ **refresh_request,
621
+ },
622
+ ) as resp:
623
+ data = await resp.json()
624
+ refresh_response = response_factory(Response[LoginResponseData], data)
625
+ if refresh_response is None or refresh_response.data is None:
626
+ return Response.from_dict({"code": resp.status, "msg": "Login failed"})
627
+ self.login_info = refresh_response.data
628
+ self.expires_in = refresh_response.data.expires_in + time.time()
629
+ self._headers["Authorization"] = (
630
+ f"Bearer {self.login_info.access_token}" if refresh_response.data else None
631
+ )
632
+ self.response = refresh_response
633
+ self.msg = refresh_response.msg
634
+ self.code = refresh_response.code
635
+ return refresh_response
636
+
637
+ async def login_v2(self, account: str, password: str) -> Response[LoginResponseData]:
638
+ """Logs in to the service using provided account and password."""
639
+ self.account = account
640
+ self._password = password
641
+
642
+ login_request = {
643
+ "username": account,
644
+ "password": base64.b64encode(password.encode("utf-8")).decode("utf-8"),
645
+ "client_id": MAMMOTION_OUATH2_CLIENT_ID,
646
+ "grant_type": "password",
647
+ "authType": "0",
648
+ }
649
+
650
+ oauth_signature = create_oauth_signature(
651
+ login_req=login_request,
652
+ client_id=MAMMOTION_OUATH2_CLIENT_ID,
653
+ client_secret=MAMMOTION_OUATH2_CLIENT_SECRET,
654
+ token_endpoint="/oauth2/token",
655
+ )
656
+
657
+ async with ClientSession(MAMMOTION_DOMAIN) as session:
658
+ async with session.post(
659
+ "/oauth2/token",
660
+ headers={
661
+ **self._headers,
662
+ "Ma-App-Key": MAMMOTION_OUATH2_CLIENT_ID,
663
+ "Ma-Signature": oauth_signature,
664
+ "Ma-Timestamp": str(int(time.time())),
665
+ "Client-Id": self.client_id,
666
+ "Client-Type": "1",
667
+ },
668
+ params={
669
+ **login_request,
670
+ },
671
+ ) as resp:
672
+ if resp.status != 200:
673
+ print(resp.json())
674
+ return Response.from_dict({"code": resp.status, "msg": "Login failed"})
675
+ data = await resp.json()
676
+ login_response = response_factory(Response[LoginResponseData], data)
677
+ if login_response is None or login_response.data is None:
678
+ return Response.from_dict({"code": resp.status, "msg": "Login failed"})
679
+ self.login_info = login_response.data
680
+ self.expires_in = login_response.data.expires_in + time.time()
681
+ self._headers["Authorization"] = (
682
+ f"Bearer {self.login_info.access_token}" if login_response.data else None
683
+ )
684
+ self.response = login_response
685
+ self.msg = login_response.msg
686
+ self.code = login_response.code
687
+ # TODO catch errors from mismatch user / password elsewhere
688
+ # Assuming the data format matches the expected structure
689
+ return login_response
@@ -1,4 +1,4 @@
1
- from dataclasses import dataclass
1
+ from dataclasses import dataclass, field
2
2
  from typing import Annotated, Generic, Literal, TypeVar
3
3
 
4
4
  from mashumaro import DataClassDictMixin
@@ -6,6 +6,11 @@ from mashumaro.config import BaseConfig
6
6
  from mashumaro.mixins.orjson import DataClassORJSONMixin
7
7
  from mashumaro.types import Alias
8
8
 
9
+
10
+ class UnauthorizedException(Exception):
11
+ pass
12
+
13
+
9
14
  DataT = TypeVar("DataT")
10
15
 
11
16
 
@@ -68,9 +73,76 @@ class ErrorInfo(DataClassDictMixin):
68
73
 
69
74
 
70
75
  @dataclass
71
- class Response(DataClassDictMixin, Generic[DataT]):
76
+ class SettingVo(DataClassORJSONMixin):
77
+ """Device setting configuration."""
78
+
79
+ type: int = 0
80
+ is_switch: Annotated[int, Alias("isSwitch")] = 0
81
+
82
+
83
+ @dataclass
84
+ class LocationVo(DataClassORJSONMixin):
85
+ """Device location information."""
86
+
87
+ date_time: Annotated[str, Alias("dateTime")] = ""
88
+ date_timestamp: Annotated[int, Alias("dateTimestamp")] = 0
89
+ location: list[float] = field(default_factory=lambda: [0.0, 0.0])
90
+
91
+
92
+ @dataclass
93
+ class DeviceInfo:
94
+ """Complete device information."""
95
+
96
+ iot_id: Annotated[str, Alias("iotId")] = ""
97
+ device_id: Annotated[str, Alias("deviceId")] = ""
98
+ device_name: Annotated[str, Alias("deviceName")] = ""
99
+ device_type: Annotated[str, Alias("deviceType")] = ""
100
+ series: str = ""
101
+ product_series: Annotated[str, Alias("productSeries")] = ""
102
+ icon_code: Annotated[str, Alias("iconCode")] = ""
103
+ generation: int = 0
104
+ status: int = 0
105
+ is_subscribe: Annotated[int, Alias("isSubscribe")] = 0
106
+ setting_vos: Annotated[list[SettingVo], Alias("settingVos")] = field(default_factory=list)
107
+ active_time: Annotated[str, Alias("activeTime")] = ""
108
+ active_timestamp: Annotated[int, Alias("activeTimestamp")] = 0
109
+ location_vo: Annotated[LocationVo | None, Alias("locationVo")] = None
110
+
111
+
112
+ @dataclass
113
+ class DeviceRecord(DataClassORJSONMixin):
114
+ identity_id: Annotated[str, Alias("identityId")]
115
+ iot_id: Annotated[str, Alias("iotId")]
116
+ product_key: Annotated[str, Alias("productKey")]
117
+ device_name: Annotated[str, Alias("deviceName")]
118
+ owned: int
119
+ status: int
120
+ bind_time: Annotated[int, Alias("bindTime")]
121
+ create_time: Annotated[str, Alias("createTime")]
122
+
123
+
124
+ @dataclass
125
+ class DeviceRecords(DataClassORJSONMixin):
126
+ records: list[DeviceRecord]
127
+ total: int
128
+ size: int
129
+ current: int
130
+ pages: int
131
+
132
+
133
+ @dataclass
134
+ class MQTTConnection(DataClassORJSONMixin):
135
+ host: str
136
+ jwt: str
137
+ client_id: Annotated[str, Alias("clientId")]
138
+ username: str
139
+
140
+
141
+ @dataclass
142
+ class Response(DataClassORJSONMixin, Generic[DataT]):
72
143
  code: int
73
144
  msg: str
145
+ request_id: Annotated[str, Alias("requestId")] | None = None
74
146
  data: DataT | None = None
75
147
 
76
148
  class Config(BaseConfig):
@@ -90,6 +162,14 @@ class LoginResponseUserInformation(DataClassORJSONMixin):
90
162
  omit_none = True
91
163
 
92
164
 
165
+ @dataclass
166
+ class JWTTokenInfo(DataClassORJSONMixin):
167
+ """specifically for newer devices and mqtt"""
168
+
169
+ iot: str # iot domain e.g api-iot-business-eu-dcdn.mammotion.com
170
+ robot: str # e.g api-robot-eu.mammotion.com
171
+
172
+
93
173
  @dataclass
94
174
  class LoginResponseData(DataClassORJSONMixin):
95
175
  access_token: str
@@ -7,19 +7,20 @@ T = TypeVar("T")
7
7
 
8
8
  def deserialize_data(value, target_type):
9
9
  """Deserialize data into a specified target type.
10
-
10
+
11
11
  The function handles deserialization of basic types, lists, and unions. It
12
12
  recursively processes list elements and supports optional types by handling
13
13
  Union[T, None]. For custom types with a `from_dict` method, it calls this
14
14
  method for deserialization. If the target type is unknown or unsupported, it
15
15
  returns the value unchanged.
16
-
16
+
17
17
  Args:
18
18
  value: The data to be deserialized.
19
19
  target_type (type): The desired type into which the data should be deserialized.
20
-
20
+
21
21
  Returns:
22
22
  The deserialized data in the specified target type.
23
+
23
24
  """
24
25
  if value is None:
25
26
  return None
@@ -35,7 +36,12 @@ def deserialize_data(value, target_type):
35
36
  # Support Optional[T] = Union[T, None]
36
37
  non_none_types = [t for t in args if t is not type(None)]
37
38
  if len(non_none_types) == 1:
38
- return deserialize_data(value, non_none_types[0])
39
+ target = non_none_types[0]
40
+ # Handle Response[list[type]] case
41
+ if get_origin(target) is list and get_args(target):
42
+ item_type = get_args(target)[0]
43
+ return [deserialize_data(v, item_type) for v in value]
44
+ return deserialize_data(value, target)
39
45
 
40
46
  if hasattr(target_type, "from_dict"):
41
47
  return target_type.from_dict(value)