pymammotion 0.5.21__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 (59) hide show
  1. pymammotion/__init__.py +3 -3
  2. pymammotion/aliyun/client.py +5 -2
  3. pymammotion/aliyun/cloud_gateway.py +137 -20
  4. pymammotion/aliyun/model/dev_by_account_response.py +169 -21
  5. pymammotion/const.py +3 -0
  6. pymammotion/data/model/device.py +1 -0
  7. pymammotion/data/model/device_config.py +1 -1
  8. pymammotion/data/model/device_info.py +4 -0
  9. pymammotion/data/model/enums.py +5 -3
  10. pymammotion/data/model/generate_route_information.py +2 -2
  11. pymammotion/data/model/hash_list.py +113 -33
  12. pymammotion/data/model/mowing_modes.py +8 -0
  13. pymammotion/data/model/region_data.py +4 -4
  14. pymammotion/data/{state_manager.py → mower_state_manager.py} +50 -13
  15. pymammotion/data/mqtt/event.py +47 -22
  16. pymammotion/data/mqtt/mammotion_properties.py +257 -0
  17. pymammotion/data/mqtt/properties.py +32 -29
  18. pymammotion/data/mqtt/status.py +17 -16
  19. pymammotion/homeassistant/__init__.py +3 -0
  20. pymammotion/homeassistant/mower_api.py +446 -0
  21. pymammotion/homeassistant/rtk_api.py +54 -0
  22. pymammotion/http/http.py +433 -18
  23. pymammotion/http/model/http.py +82 -2
  24. pymammotion/http/model/response_factory.py +10 -4
  25. pymammotion/mammotion/commands/mammotion_command.py +20 -0
  26. pymammotion/mammotion/commands/messages/driver.py +25 -0
  27. pymammotion/mammotion/commands/messages/navigation.py +10 -6
  28. pymammotion/mammotion/commands/messages/system.py +0 -14
  29. pymammotion/mammotion/devices/__init__.py +27 -3
  30. pymammotion/mammotion/devices/base.py +22 -146
  31. pymammotion/mammotion/devices/mammotion.py +364 -205
  32. pymammotion/mammotion/devices/mammotion_bluetooth.py +11 -8
  33. pymammotion/mammotion/devices/mammotion_cloud.py +49 -85
  34. pymammotion/mammotion/devices/mammotion_mower_ble.py +49 -0
  35. pymammotion/mammotion/devices/mammotion_mower_cloud.py +39 -0
  36. pymammotion/mammotion/devices/managers/managers.py +81 -0
  37. pymammotion/mammotion/devices/mower_device.py +121 -0
  38. pymammotion/mammotion/devices/mower_manager.py +107 -0
  39. pymammotion/mammotion/devices/rtk_ble.py +89 -0
  40. pymammotion/mammotion/devices/rtk_cloud.py +113 -0
  41. pymammotion/mammotion/devices/rtk_device.py +50 -0
  42. pymammotion/mammotion/devices/rtk_manager.py +122 -0
  43. pymammotion/mqtt/__init__.py +2 -1
  44. pymammotion/mqtt/aliyun_mqtt.py +232 -0
  45. pymammotion/mqtt/mammotion_mqtt.py +174 -192
  46. pymammotion/mqtt/mqtt_models.py +66 -0
  47. pymammotion/proto/__init__.py +2 -2
  48. pymammotion/proto/mctrl_nav.proto +2 -2
  49. pymammotion/proto/mctrl_nav_pb2.py +1 -1
  50. pymammotion/proto/mctrl_nav_pb2.pyi +4 -4
  51. pymammotion/proto/mctrl_sys.proto +1 -1
  52. pymammotion/utility/datatype_converter.py +13 -12
  53. pymammotion/utility/device_type.py +88 -3
  54. pymammotion/utility/mur_mur_hash.py +132 -87
  55. {pymammotion-0.5.21.dist-info → pymammotion-0.5.45.dist-info}/METADATA +25 -30
  56. {pymammotion-0.5.21.dist-info → pymammotion-0.5.45.dist-info}/RECORD +64 -50
  57. {pymammotion-0.5.21.dist-info → pymammotion-0.5.45.dist-info}/WHEEL +1 -1
  58. pymammotion/http/_init_.py +0 -0
  59. {pymammotion-0.5.21.dist-info → pymammotion-0.5.45.dist-info/licenses}/LICENSE +0 -0
pymammotion/http/http.py CHANGED
@@ -1,34 +1,190 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ from collections.abc import Awaitable, Callable
1
5
  import csv
2
- from typing import cast
6
+ from functools import wraps
7
+ import hashlib
8
+ import hmac
9
+ import json
10
+ import random
11
+ import time
12
+ from typing import Any, TypeVar, cast
3
13
 
4
14
  from aiohttp import ClientSession
15
+ import jwt
5
16
 
6
- 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
+ )
7
25
  from pymammotion.http.encryption import EncryptionUtils
8
26
  from pymammotion.http.model.camera_stream import StreamSubscriptionResponse, VideoResourceResponse
9
- 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
+ )
10
38
  from pymammotion.http.model.response_factory import response_factory
11
39
  from pymammotion.http.model.rtk import RTK
12
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
+
13
118
 
14
119
  class MammotionHTTP:
15
- def __init__(self) -> None:
16
- self.code = None
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
125
+ self.code = 0
17
126
  self.msg = None
18
- self.account = None
19
- self._password = None
20
- self.response: Response | None = None
127
+ self.account = account
128
+ self._password = password
129
+ self._response: Response | None = None
21
130
  self.login_info: LoginResponseData | None = None
131
+ self.jwt_info: JWTTokenInfo = JWTTokenInfo("", "")
22
132
  self._headers = {"User-Agent": "okhttp/4.9.3", "App-Version": "Home Assistant,1.14.2.29"}
23
133
  self.encryption_utils = EncryptionUtils()
24
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
+
25
154
  @staticmethod
26
155
  def generate_headers(token: str) -> dict:
27
156
  return {"Authorization": f"Bearer {token}"}
28
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
+
179
+ async def handle_expiry(self, resp: Response) -> Response:
180
+ if resp.code == 401 and self.account and self._password:
181
+ return await self.login_v2(self.account, self._password)
182
+ return resp
183
+
29
184
  async def login_by_email(self, email: str, password: str) -> Response[LoginResponseData]:
30
- return await self.login(email, password)
185
+ return await self.login_v2(email, password)
31
186
 
187
+ @refresh_token_decorator
32
188
  async def get_all_error_codes(self) -> dict[str, ErrorInfo]:
33
189
  """Retrieves and parses all error codes from the MAMMOTION API."""
34
190
  async with ClientSession(MAMMOTION_API_DOMAIN) as session:
@@ -54,11 +210,41 @@ class MammotionHTTP:
54
210
 
55
211
  Returns 401 if token is invalid. We then need to re-authenticate, can try to refresh token first
56
212
  """
57
- async with ClientSession(MAMMOTION_API_DOMAIN) as session:
58
- async with session.post("/user-server/v1/user/oauth/check", headers=self._headers) as resp:
213
+ async with ClientSession(MAMMOTION_DOMAIN) as session:
214
+ async with session.post(
215
+ "/user-server/v1/user/oauth/check",
216
+ headers={
217
+ **self._headers,
218
+ "Authorization": f"Bearer {self.login_info.access_token}",
219
+ "Content-Type": "application/json",
220
+ "User-Agent": "okhttp/4.9.3",
221
+ },
222
+ ) as resp:
59
223
  data = await resp.json()
60
224
  return Response.from_dict(data)
61
225
 
226
+ @refresh_token_decorator
227
+ async def refresh_authorization_code(self) -> Response:
228
+ """Refresh token."""
229
+ async with ClientSession(MAMMOTION_DOMAIN) as session:
230
+ async with session.post(
231
+ "/authorization/code",
232
+ headers={
233
+ **self._headers,
234
+ "Authorization": f"Bearer {self.login_info.access_token}",
235
+ "Content-Type": "application/json",
236
+ "User-Agent": "okhttp/4.9.3",
237
+ },
238
+ json={"clientId": MAMMOTION_CLIENT_ID},
239
+ ) as resp:
240
+ data = await resp.json()
241
+ print(data)
242
+ self.login_info.access_token = data["data"].get("accessToken", self.login_info.access_token)
243
+ self.login_info.authorization_code = data["data"].get("code", self.login_info.authorization_code)
244
+ await self.get_mqtt_credentials()
245
+ return Response.from_dict(data)
246
+
247
+ @refresh_token_decorator
62
248
  async def pair_devices_mqtt(self, mower_name: str, rtk_name: str) -> Response:
63
249
  async with ClientSession(MAMMOTION_API_DOMAIN) as session:
64
250
  async with session.post(
@@ -72,7 +258,9 @@ class MammotionHTTP:
72
258
  return Response.from_dict(data)
73
259
  else:
74
260
  print(data)
261
+ return Response.from_dict(data)
75
262
 
263
+ @refresh_token_decorator
76
264
  async def unpair_devices_mqtt(self, mower_name: str, rtk_name: str) -> Response:
77
265
  async with ClientSession(MAMMOTION_API_DOMAIN) as session:
78
266
  async with session.post(
@@ -86,7 +274,9 @@ class MammotionHTTP:
86
274
  return Response.from_dict(data)
87
275
  else:
88
276
  print(data)
277
+ return Response.from_dict(data)
89
278
 
279
+ @refresh_token_decorator
90
280
  async def net_rtk_enable(self, device_id: str) -> Response:
91
281
  async with ClientSession(MAMMOTION_API_DOMAIN) as session:
92
282
  async with session.post(
@@ -98,7 +288,9 @@ class MammotionHTTP:
98
288
  return Response.from_dict(data)
99
289
  else:
100
290
  print(data)
291
+ return Response.from_dict(data)
101
292
 
293
+ @refresh_token_decorator
102
294
  async def get_stream_subscription(self, iot_id: str) -> Response[StreamSubscriptionResponse]:
103
295
  """Fetches stream subscription data from agora.io for a given IoT device."""
104
296
  async with ClientSession(MAMMOTION_API_DOMAIN) as session:
@@ -116,16 +308,19 @@ class MammotionHTTP:
116
308
  # TODO catch errors from mismatch like token expire etc
117
309
  # Assuming the data format matches the expected structure
118
310
  response = Response[StreamSubscriptionResponse].from_dict(data)
311
+ await self.handle_expiry(response)
119
312
  if response.code != 0:
120
313
  return response
121
314
  response.data = StreamSubscriptionResponse.from_dict(data.get("data", {}))
122
315
  return response
123
316
 
317
+ @refresh_token_decorator
124
318
  async def get_stream_subscription_mini_or_x_series(
125
319
  self, iot_id: str, is_yuka: bool
126
320
  ) -> Response[StreamSubscriptionResponse]:
127
321
  # Prepare the payload with cameraStates based on is_yuka flag
128
322
  """Fetches stream subscription data for a given IoT device."""
323
+
129
324
  payload = {"deviceId": iot_id, "mode": 0, "cameraStates": []}
130
325
 
131
326
  # Add appropriate cameraStates based on the is_yuka flag
@@ -149,11 +344,13 @@ class MammotionHTTP:
149
344
  # TODO catch errors from mismatch like token expire etc
150
345
  # Assuming the data format matches the expected structure
151
346
  response = Response[StreamSubscriptionResponse].from_dict(data)
347
+ await self.handle_expiry(response)
152
348
  if response.code != 0:
153
349
  return response
154
350
  response.data = StreamSubscriptionResponse.from_dict(data.get("data", {}))
155
351
  return response
156
352
 
353
+ @refresh_token_decorator
157
354
  async def get_video_resource(self, iot_id: str) -> Response[VideoResourceResponse]:
158
355
  """Fetch video resource for a given IoT ID."""
159
356
  async with ClientSession(MAMMOTION_API_DOMAIN) as session:
@@ -174,6 +371,7 @@ class MammotionHTTP:
174
371
  response.data = VideoResourceResponse.from_dict(data.get("data", {}))
175
372
  return response
176
373
 
374
+ @refresh_token_decorator
177
375
  async def get_device_ota_firmware(self, iot_ids: list[str]) -> Response[list[CheckDeviceVersion]]:
178
376
  """Checks device firmware versions for a list of IoT IDs."""
179
377
  async with ClientSession(MAMMOTION_API_DOMAIN) as session:
@@ -192,6 +390,7 @@ class MammotionHTTP:
192
390
  # Assuming the data format matches the expected structure
193
391
  return response_factory(Response[list[CheckDeviceVersion]], data)
194
392
 
393
+ @refresh_token_decorator
195
394
  async def start_ota_upgrade(self, iot_id: str, version: str) -> Response[str]:
196
395
  """Initiates an OTA upgrade for a device."""
197
396
  async with ClientSession(MAMMOTION_API_DOMAIN) as session:
@@ -210,6 +409,7 @@ class MammotionHTTP:
210
409
  # Assuming the data format matches the expected structure
211
410
  return response_factory(Response[str], data)
212
411
 
412
+ @refresh_token_decorator
213
413
  async def get_rtk_devices(self) -> Response[list[RTK]]:
214
414
  """Fetches stream subscription data from agora.io for a given IoT device."""
215
415
  async with ClientSession(MAMMOTION_API_DOMAIN) as session:
@@ -226,12 +426,128 @@ class MammotionHTTP:
226
426
 
227
427
  return response_factory(Response[list[RTK]], data)
228
428
 
229
- async def refresh_login(self, account: str, password: str | None = None) -> Response[LoginResponseData]:
230
- if self._password is None and password is not None:
231
- self._password = password
232
- if self._password is None:
233
- raise ValueError("Password is required for refresh login")
234
- return await self.login(account, self._password)
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
+
545
+ async def refresh_login(self) -> Response[LoginResponseData]:
546
+ if self.expires_in > time.time():
547
+ res = await self.refresh_token_v2()
548
+ if res.code == 0:
549
+ return res
550
+ return await self.login_v2(self.account, self._password)
235
551
 
236
552
  async def login(self, account: str, password: str) -> Response[LoginResponseData]:
237
553
  """Logs in to the service using provided account and password."""
@@ -259,10 +575,109 @@ class MammotionHTTP:
259
575
  return Response.from_dict({"code": resp.status, "msg": "Login failed"})
260
576
  data = await resp.json()
261
577
  login_response = response_factory(Response[LoginResponseData], data)
262
- if login_response.data is None:
578
+ if login_response is None or login_response.data is None:
263
579
  print(login_response)
264
580
  return Response.from_dict({"code": resp.status, "msg": "Login failed"})
265
581
  self.login_info = login_response.data
582
+ self.expires_in = login_response.data.expires_in + time.time()
583
+ self._headers["Authorization"] = (
584
+ f"Bearer {self.login_info.access_token}" if login_response.data else None
585
+ )
586
+ self.response = login_response
587
+ self.msg = login_response.msg
588
+ self.code = login_response.code
589
+ # TODO catch errors from mismatch user / password elsewhere
590
+ # Assuming the data format matches the expected structure
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()
266
681
  self._headers["Authorization"] = (
267
682
  f"Bearer {self.login_info.access_token}" if login_response.data else None
268
683
  )
@@ -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