pymammotion 0.2.62__py3-none-any.whl → 0.5.51__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 (135) hide show
  1. pymammotion/__init__.py +9 -6
  2. pymammotion/aliyun/client.py +235 -0
  3. pymammotion/aliyun/cloud_gateway.py +320 -69
  4. pymammotion/aliyun/model/aep_response.py +1 -2
  5. pymammotion/aliyun/model/dev_by_account_response.py +170 -23
  6. pymammotion/aliyun/model/login_by_oauth_response.py +2 -3
  7. pymammotion/aliyun/model/regions_response.py +3 -3
  8. pymammotion/aliyun/model/session_by_authcode_response.py +2 -2
  9. pymammotion/aliyun/model/thing_response.py +12 -0
  10. pymammotion/aliyun/regions.py +62 -0
  11. pymammotion/aliyun/tea/core.py +297 -0
  12. pymammotion/bluetooth/ble.py +11 -15
  13. pymammotion/bluetooth/ble_message.py +389 -106
  14. pymammotion/bluetooth/model/atomic_integer.py +54 -0
  15. pymammotion/const.py +3 -0
  16. pymammotion/data/model/__init__.py +1 -2
  17. pymammotion/data/model/device.py +92 -240
  18. pymammotion/data/model/device_config.py +10 -24
  19. pymammotion/data/model/device_info.py +35 -0
  20. pymammotion/data/model/device_limits.py +49 -0
  21. pymammotion/data/model/enums.py +12 -2
  22. pymammotion/data/model/errors.py +12 -0
  23. pymammotion/data/model/events.py +14 -0
  24. pymammotion/data/model/generate_geojson.py +521 -0
  25. pymammotion/data/model/generate_route_information.py +3 -4
  26. pymammotion/data/model/hash_list.py +384 -48
  27. pymammotion/data/model/location.py +4 -4
  28. pymammotion/data/model/mowing_modes.py +24 -1
  29. pymammotion/data/model/raw_data.py +215 -0
  30. pymammotion/data/model/region_data.py +10 -11
  31. pymammotion/data/model/report_info.py +62 -6
  32. pymammotion/data/model/work.py +27 -0
  33. pymammotion/data/mower_state_manager.py +316 -0
  34. pymammotion/data/mqtt/event.py +73 -28
  35. pymammotion/data/mqtt/mammotion_properties.py +257 -0
  36. pymammotion/data/mqtt/properties.py +93 -78
  37. pymammotion/data/mqtt/status.py +18 -17
  38. pymammotion/event/event.py +32 -8
  39. pymammotion/homeassistant/__init__.py +3 -0
  40. pymammotion/homeassistant/mower_api.py +484 -0
  41. pymammotion/homeassistant/rtk_api.py +54 -0
  42. pymammotion/http/__init__.py +0 -0
  43. pymammotion/http/encryption.py +220 -0
  44. pymammotion/http/http.py +652 -44
  45. pymammotion/http/model/__init__.py +0 -0
  46. pymammotion/{aliyun/model/stream_subscription_response.py → http/model/camera_stream.py} +14 -2
  47. pymammotion/http/model/http.py +160 -9
  48. pymammotion/http/model/response_factory.py +61 -0
  49. pymammotion/http/model/rtk.py +16 -0
  50. pymammotion/mammotion/commands/abstract_message.py +7 -5
  51. pymammotion/mammotion/commands/mammotion_command.py +32 -3
  52. pymammotion/mammotion/commands/messages/basestation.py +43 -0
  53. pymammotion/mammotion/commands/messages/driver.py +61 -29
  54. pymammotion/mammotion/commands/messages/media.py +68 -15
  55. pymammotion/mammotion/commands/messages/navigation.py +61 -25
  56. pymammotion/mammotion/commands/messages/network.py +93 -100
  57. pymammotion/mammotion/commands/messages/ota.py +18 -18
  58. pymammotion/mammotion/commands/messages/system.py +97 -72
  59. pymammotion/mammotion/commands/messages/video.py +17 -12
  60. pymammotion/mammotion/devices/__init__.py +27 -3
  61. pymammotion/mammotion/devices/base.py +50 -127
  62. pymammotion/mammotion/devices/mammotion.py +447 -212
  63. pymammotion/mammotion/devices/mammotion_bluetooth.py +105 -60
  64. pymammotion/mammotion/devices/mammotion_cloud.py +157 -105
  65. pymammotion/mammotion/devices/mammotion_mower_ble.py +49 -0
  66. pymammotion/mammotion/devices/mammotion_mower_cloud.py +39 -0
  67. pymammotion/mammotion/devices/managers/managers.py +81 -0
  68. pymammotion/mammotion/devices/mower_device.py +124 -0
  69. pymammotion/mammotion/devices/mower_manager.py +107 -0
  70. pymammotion/mammotion/devices/rtk_ble.py +89 -0
  71. pymammotion/mammotion/devices/rtk_cloud.py +113 -0
  72. pymammotion/mammotion/devices/rtk_device.py +50 -0
  73. pymammotion/mammotion/devices/rtk_manager.py +122 -0
  74. pymammotion/mqtt/__init__.py +2 -1
  75. pymammotion/mqtt/aliyun_mqtt.py +232 -0
  76. pymammotion/mqtt/linkkit/__init__.py +5 -0
  77. pymammotion/mqtt/linkkit/h2client.py +585 -0
  78. pymammotion/mqtt/linkkit/linkkit.py +3023 -0
  79. pymammotion/mqtt/mammotion_mqtt.py +176 -169
  80. pymammotion/mqtt/mqtt_models.py +66 -0
  81. pymammotion/proto/__init__.py +4839 -4
  82. pymammotion/proto/basestation.proto +8 -0
  83. pymammotion/proto/basestation_pb2.py +11 -9
  84. pymammotion/proto/basestation_pb2.pyi +16 -2
  85. pymammotion/proto/dev_net.proto +79 -55
  86. pymammotion/proto/dev_net_pb2.py +60 -56
  87. pymammotion/proto/dev_net_pb2.pyi +49 -6
  88. pymammotion/proto/luba_msg.proto +2 -1
  89. pymammotion/proto/luba_msg_pb2.py +6 -6
  90. pymammotion/proto/luba_msg_pb2.pyi +1 -0
  91. pymammotion/proto/luba_mul.proto +62 -1
  92. pymammotion/proto/luba_mul_pb2.py +38 -22
  93. pymammotion/proto/luba_mul_pb2.pyi +94 -7
  94. pymammotion/proto/mctrl_driver.proto +44 -4
  95. pymammotion/proto/mctrl_driver_pb2.py +26 -14
  96. pymammotion/proto/mctrl_driver_pb2.pyi +66 -11
  97. pymammotion/proto/mctrl_nav.proto +97 -51
  98. pymammotion/proto/mctrl_nav_pb2.py +75 -67
  99. pymammotion/proto/mctrl_nav_pb2.pyi +142 -56
  100. pymammotion/proto/mctrl_ota.proto +40 -2
  101. pymammotion/proto/mctrl_ota_pb2.py +23 -13
  102. pymammotion/proto/mctrl_ota_pb2.pyi +67 -4
  103. pymammotion/proto/mctrl_pept.proto +8 -3
  104. pymammotion/proto/mctrl_pept_pb2.py +8 -6
  105. pymammotion/proto/mctrl_pept_pb2.pyi +14 -6
  106. pymammotion/proto/mctrl_sys.proto +325 -86
  107. pymammotion/proto/mctrl_sys_pb2.py +162 -98
  108. pymammotion/proto/mctrl_sys_pb2.pyi +451 -25
  109. pymammotion/proto/message_pool.py +3 -0
  110. pymammotion/proto/py.typed +0 -0
  111. pymammotion/utility/constant/device_constant.py +65 -21
  112. pymammotion/utility/datatype_converter.py +13 -12
  113. pymammotion/utility/device_config.py +755 -0
  114. pymammotion/utility/device_type.py +218 -21
  115. pymammotion/utility/map.py +238 -51
  116. pymammotion/utility/mur_mur_hash.py +159 -0
  117. {pymammotion-0.2.62.dist-info → pymammotion-0.5.51.dist-info}/METADATA +27 -31
  118. pymammotion-0.5.51.dist-info/RECORD +152 -0
  119. {pymammotion-0.2.62.dist-info → pymammotion-0.5.51.dist-info}/WHEEL +1 -1
  120. pymammotion/aliyun/cloud_service.py +0 -65
  121. pymammotion/data/model/plan.py +0 -58
  122. pymammotion/data/state_manager.py +0 -130
  123. pymammotion/proto/basestation.py +0 -59
  124. pymammotion/proto/common.py +0 -12
  125. pymammotion/proto/dev_net.py +0 -381
  126. pymammotion/proto/luba_msg.py +0 -81
  127. pymammotion/proto/luba_mul.py +0 -76
  128. pymammotion/proto/mctrl_driver.py +0 -100
  129. pymammotion/proto/mctrl_nav.py +0 -660
  130. pymammotion/proto/mctrl_ota.py +0 -48
  131. pymammotion/proto/mctrl_pept.py +0 -41
  132. pymammotion/proto/mctrl_sys.py +0 -574
  133. pymammotion-0.2.62.dist-info/RECORD +0 -125
  134. /pymammotion/{http/_init_.py → bluetooth/model/__init__.py} +0 -0
  135. {pymammotion-0.2.62.dist-info → pymammotion-0.5.51.dist-info/licenses}/LICENSE +0 -0
pymammotion/http/http.py CHANGED
@@ -1,85 +1,693 @@
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.aliyun.model.stream_subscription_response import StreamSubscriptionResponse
7
17
  from pymammotion.const import (
8
18
  MAMMOTION_API_DOMAIN,
9
19
  MAMMOTION_CLIENT_ID,
10
20
  MAMMOTION_CLIENT_SECRET,
11
21
  MAMMOTION_DOMAIN,
22
+ MAMMOTION_OUATH2_CLIENT_ID,
23
+ MAMMOTION_OUATH2_CLIENT_SECRET,
24
+ )
25
+ from pymammotion.http.encryption import EncryptionUtils
26
+ from pymammotion.http.model.camera_stream import StreamSubscriptionResponse, VideoResourceResponse
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,
12
37
  )
13
- from pymammotion.http.model.http import ErrorInfo, LoginResponseData, Response
38
+ from pymammotion.http.model.response_factory import response_factory
39
+ from pymammotion.http.model.rtk import RTK
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
14
117
 
15
118
 
16
119
  class MammotionHTTP:
17
- def __init__(self, response: Response) -> None:
18
- self._headers = dict()
19
- self.login_info = LoginResponseData.from_dict(response.data) if response.data else None
20
- self._headers["Authorization"] = f"Bearer {self.login_info.access_token}" if response.data else None
21
- self.msg = response.msg
22
- self.code = response.code
23
-
24
- async def get_all_error_codes(self) -> list[ErrorInfo]:
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
126
+ self.msg = None
127
+ self.account = account
128
+ self._password = password
129
+ self._response: Response | None = None
130
+ self.login_info: LoginResponseData | None = None
131
+ self.jwt_info: JWTTokenInfo = JWTTokenInfo("", "")
132
+ self._headers = {"User-Agent": "okhttp/4.9.3", "App-Version": "Home Assistant,1.15.6.14"}
133
+ self.encryption_utils = EncryptionUtils()
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
+
154
+ @staticmethod
155
+ def generate_headers(token: str) -> dict:
156
+ return {"Authorization": f"Bearer {token}"}
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
+
184
+ async def login_by_email(self, email: str, password: str) -> Response[LoginResponseData]:
185
+ return await self.login_v2(email, password)
186
+
187
+ @refresh_token_decorator
188
+ async def get_all_error_codes(self) -> dict[str, ErrorInfo]:
189
+ """Retrieves and parses all error codes from the MAMMOTION API."""
25
190
  async with ClientSession(MAMMOTION_API_DOMAIN) as session:
26
191
  async with session.post(
27
192
  "/user-server/v1/code/record/export-data",
28
- headers=self._headers,
193
+ headers={
194
+ **self._headers,
195
+ "Authorization": f"Bearer {self.login_info.access_token}",
196
+ "Content-Type": "application/json",
197
+ "User-Agent": "okhttp/4.9.3",
198
+ },
29
199
  ) as resp:
30
200
  data = await resp.json()
31
201
  reader = csv.DictReader(data.get("data", "").split("\n"), delimiter=",")
32
- codes = []
202
+ codes = dict()
33
203
  for row in reader:
34
- codes.append(ErrorInfo(**cast(row, dict)))
204
+ error_info = ErrorInfo(**cast(dict, row))
205
+ codes[error_info.code] = error_info
35
206
  return codes
36
207
 
37
- async def oauth_check(self) -> None:
208
+ async def oauth_check(self) -> Response:
38
209
  """Check if token is valid.
39
210
 
40
211
  Returns 401 if token is invalid. We then need to re-authenticate, can try to refresh token first
41
212
  """
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:
223
+ data = await resp.json()
224
+ return Response.from_dict(data)
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
248
+ async def pair_devices_mqtt(self, mower_name: str, rtk_name: str) -> Response:
249
+ async with ClientSession(MAMMOTION_API_DOMAIN) as session:
250
+ async with session.post(
251
+ "/device-server/v1/iot/device/pairing",
252
+ headers=self._headers,
253
+ json={"mowerName": mower_name, "rtkName": rtk_name},
254
+ ) as resp:
255
+ data = await resp.json()
256
+ if data.get("status") == 200:
257
+ print(data)
258
+ return Response.from_dict(data)
259
+ else:
260
+ print(data)
261
+ return Response.from_dict(data)
262
+
263
+ @refresh_token_decorator
264
+ async def unpair_devices_mqtt(self, mower_name: str, rtk_name: str) -> Response:
265
+ async with ClientSession(MAMMOTION_API_DOMAIN) as session:
266
+ async with session.post(
267
+ "/device-server/v1/iot/device/unpairing",
268
+ headers=self._headers,
269
+ json={"mowerName": mower_name, "rtkName": rtk_name},
270
+ ) as resp:
271
+ data = await resp.json()
272
+ if data.get("status") == 200:
273
+ print(data)
274
+ return Response.from_dict(data)
275
+ else:
276
+ print(data)
277
+ return Response.from_dict(data)
278
+
279
+ @refresh_token_decorator
280
+ async def net_rtk_enable(self, device_id: str) -> Response:
42
281
  async with ClientSession(MAMMOTION_API_DOMAIN) as session:
43
- async with session.post("/user-server/v1/user/oauth/check") as resp:
282
+ async with session.post(
283
+ "/device-server/v1/iot/net-rtk/enable", headers=self._headers, json={"deviceId": device_id}
284
+ ) as resp:
44
285
  data = await resp.json()
45
- response = Response.from_dict(data)
286
+ if data.get("status") == 200:
287
+ print(data)
288
+ return Response.from_dict(data)
289
+ else:
290
+ print(data)
291
+ return Response.from_dict(data)
46
292
 
293
+ @refresh_token_decorator
47
294
  async def get_stream_subscription(self, iot_id: str) -> Response[StreamSubscriptionResponse]:
48
- """Get agora.io data for view camera stream"""
295
+ """Fetches stream subscription data from agora.io for a given IoT device."""
49
296
  async with ClientSession(MAMMOTION_API_DOMAIN) as session:
50
297
  async with session.post(
51
298
  "/device-server/v1/stream/subscription",
52
299
  json={"deviceId": iot_id},
53
300
  headers={
54
- "Authorization": f"{self._headers.get('Authorization', "")}",
301
+ **self._headers,
302
+ "Authorization": f"Bearer {self.login_info.access_token}",
55
303
  "Content-Type": "application/json",
304
+ "User-Agent": "okhttp/4.9.3",
56
305
  },
57
306
  ) as resp:
58
307
  data = await resp.json()
59
308
  # TODO catch errors from mismatch like token expire etc
60
309
  # Assuming the data format matches the expected structure
61
- return Response[StreamSubscriptionResponse].from_dict(data)
62
-
63
- @classmethod
64
- async def login(cls, session: ClientSession, username: str, password: str) -> Response[LoginResponseData]:
65
- async with session.post(
66
- "/oauth/token",
67
- params=dict(
68
- username=username,
69
- password=password,
70
- client_id=MAMMOTION_CLIENT_ID,
71
- client_secret=MAMMOTION_CLIENT_SECRET,
72
- grant_type="password",
73
- ),
74
- ) as resp:
75
- data = await resp.json()
76
- response = Response.from_dict(data)
77
- # TODO catch errors from mismatch user / password elsewhere
78
- # Assuming the data format matches the expected structure
79
- return response
80
-
81
-
82
- async def connect_http(username: str, password: str) -> MammotionHTTP:
83
- async with ClientSession(MAMMOTION_DOMAIN) as session:
84
- login_response = await MammotionHTTP.login(session, username, password)
85
- return MammotionHTTP(login_response)
310
+ response = Response[StreamSubscriptionResponse].from_dict(data)
311
+ await self.handle_expiry(response)
312
+ if response.code != 0:
313
+ return response
314
+ response.data = StreamSubscriptionResponse.from_dict(data.get("data", {}))
315
+ return response
316
+
317
+ @refresh_token_decorator
318
+ async def get_stream_subscription_mini_or_x_series(
319
+ self, iot_id: str, is_yuka: bool
320
+ ) -> Response[StreamSubscriptionResponse]:
321
+ # Prepare the payload with cameraStates based on is_yuka flag
322
+ """Fetches stream subscription data for a given IoT device."""
323
+
324
+ payload = {"deviceId": iot_id, "mode": 0, "cameraStates": []}
325
+
326
+ # Add appropriate cameraStates based on the is_yuka flag
327
+ if is_yuka:
328
+ payload["cameraStates"] = [{"cameraState": 1}, {"cameraState": 0}, {"cameraState": 1}]
329
+ else:
330
+ payload["cameraStates"] = [{"cameraState": 1}, {"cameraState": 0}, {"cameraState": 0}]
331
+
332
+ async with ClientSession(MAMMOTION_API_DOMAIN) as session:
333
+ async with session.post(
334
+ "/device-server/v1/stream/token",
335
+ json=payload,
336
+ headers={
337
+ **self._headers,
338
+ "Authorization": f"Bearer {self.login_info.access_token}",
339
+ "Content-Type": "application/json",
340
+ "User-Agent": "okhttp/4.9.3",
341
+ },
342
+ ) as resp:
343
+ data = await resp.json()
344
+ # TODO catch errors from mismatch like token expire etc
345
+ # Assuming the data format matches the expected structure
346
+ response = Response[StreamSubscriptionResponse].from_dict(data)
347
+ await self.handle_expiry(response)
348
+ if response.code != 0:
349
+ return response
350
+ response.data = StreamSubscriptionResponse.from_dict(data.get("data", {}))
351
+ return response
352
+
353
+ @refresh_token_decorator
354
+ async def get_video_resource(self, iot_id: str) -> Response[VideoResourceResponse]:
355
+ """Fetch video resource for a given IoT ID."""
356
+ async with ClientSession(MAMMOTION_API_DOMAIN) as session:
357
+ async with session.get(
358
+ f"/device-server/v1/video-resource/{iot_id}",
359
+ headers={
360
+ "Authorization": f"Bearer {self.login_info.access_token}",
361
+ "Content-Type": "application/json",
362
+ "User-Agent": "okhttp/4.9.3",
363
+ },
364
+ ) as resp:
365
+ data = await resp.json()
366
+ # TODO catch errors from mismatch like token expire etc
367
+ # Assuming the data format matches the expected structure
368
+ response = Response[VideoResourceResponse].from_dict(data)
369
+ if response.code != 0:
370
+ return response
371
+ response.data = VideoResourceResponse.from_dict(data.get("data", {}))
372
+ return response
373
+
374
+ @refresh_token_decorator
375
+ async def get_device_ota_firmware(self, iot_ids: list[str]) -> Response[list[CheckDeviceVersion]]:
376
+ """Checks device firmware versions for a list of IoT IDs."""
377
+ async with ClientSession(MAMMOTION_API_DOMAIN) as session:
378
+ async with session.post(
379
+ "/device-server/v1/devices/version/check",
380
+ json={"deviceIds": iot_ids},
381
+ headers={
382
+ **self._headers,
383
+ "Authorization": f"Bearer {self.login_info.access_token}",
384
+ "Content-Type": "application/json",
385
+ "User-Agent": "okhttp/4.9.3",
386
+ "Client-Id": self.client_id,
387
+ "Client-Type": "1",
388
+ },
389
+ ) as resp:
390
+ data = await resp.json()
391
+ # TODO catch errors from mismatch like token expire etc
392
+ # Assuming the data format matches the expected structure
393
+ return response_factory(Response[list[CheckDeviceVersion]], data)
394
+
395
+ @refresh_token_decorator
396
+ async def start_ota_upgrade(self, iot_id: str, version: str) -> Response[str]:
397
+ """Initiates an OTA upgrade for a device."""
398
+ async with ClientSession(MAMMOTION_API_DOMAIN) as session:
399
+ async with session.post(
400
+ "/device-server/v1/ota/device/upgrade",
401
+ json={"deviceId": iot_id, "version": version},
402
+ headers={
403
+ **self._headers,
404
+ "Authorization": f"Bearer {self.login_info.access_token}",
405
+ "Content-Type": "application/json",
406
+ "User-Agent": "okhttp/4.9.3",
407
+ "Client-Id": self.client_id,
408
+ "Client-Type": "1",
409
+ },
410
+ ) as resp:
411
+ data = await resp.json()
412
+ # TODO catch errors from mismatch like token expire etc
413
+ # Assuming the data format matches the expected structure
414
+ return response_factory(Response[str], data)
415
+
416
+ @refresh_token_decorator
417
+ async def get_rtk_devices(self) -> Response[list[RTK]]:
418
+ """Fetches stream subscription data from agora.io for a given IoT device."""
419
+ async with ClientSession(MAMMOTION_API_DOMAIN) as session:
420
+ async with session.get(
421
+ "/device-server/v1/rtk/devices",
422
+ headers={
423
+ **self._headers,
424
+ "Authorization": f"Bearer {self.login_info.access_token}",
425
+ "Content-Type": "application/json",
426
+ "User-Agent": "okhttp/4.9.3",
427
+ },
428
+ ) as resp:
429
+ data = await resp.json()
430
+
431
+ return response_factory(Response[list[RTK]], data)
432
+
433
+ @refresh_token_decorator
434
+ async def get_user_device_list(self) -> Response[list[DeviceInfo]]:
435
+ """Fetches device list for a user (owned not shared, shared returns nothing)."""
436
+ async with ClientSession(MAMMOTION_API_DOMAIN) as session:
437
+ async with session.get(
438
+ "/device-server/v1/device/list",
439
+ headers={
440
+ **self._headers,
441
+ "Authorization": f"Bearer {self.login_info.access_token}",
442
+ "Content-Type": "application/json",
443
+ "User-Agent": "okhttp/4.9.3",
444
+ "Client-Id": self.client_id,
445
+ "Client-Type": "1",
446
+ },
447
+ ) as resp:
448
+ resp_dict = await resp.json()
449
+ response = response_factory(Response[list[DeviceInfo]], resp_dict)
450
+ self.device_info = response.data if response.data else self.device_info
451
+ return response
452
+
453
+ @refresh_token_decorator
454
+ async def get_user_shared_device_page(self) -> Response[DeviceRecords]:
455
+ """Fetches device list for a user (shared) but not accepted."""
456
+ """Can set owned to zero or one to possibly check for not accepted mowers?"""
457
+ async with ClientSession(MAMMOTION_API_DOMAIN) as session:
458
+ async with session.post(
459
+ "/user-server/v1/share/device/page",
460
+ json={"iotId": "", "owned": 0, "pageNumber": 1, "pageSize": 200, "statusList": [-1]},
461
+ headers={
462
+ **self._headers,
463
+ "Authorization": f"Bearer {self.login_info.access_token}",
464
+ "Content-Type": "application/json",
465
+ "User-Agent": "okhttp/4.9.3",
466
+ },
467
+ ) as resp:
468
+ resp_dict = await resp.json()
469
+ response = response_factory(Response[DeviceRecords], resp_dict)
470
+ self.devices_shared_info = response.data if response.data else self.devices_shared_info
471
+ return response
472
+
473
+ @refresh_token_decorator
474
+ async def get_user_device_page(self) -> Response[DeviceRecords]:
475
+ """Fetches device list for a user, is either new API or for newer devices."""
476
+ async with ClientSession(self.jwt_info.iot) as session:
477
+ async with session.post(
478
+ "/v1/user/device/page",
479
+ json={
480
+ "iotId": "",
481
+ "pageNumber": 1,
482
+ "pageSize": 100,
483
+ },
484
+ headers={
485
+ **self._headers,
486
+ "Authorization": f"Bearer {self.login_info.access_token}",
487
+ "Content-Type": "application/json",
488
+ "User-Agent": "okhttp/4.9.3",
489
+ "Client-Id": self.client_id,
490
+ "Client-Type": "1",
491
+ },
492
+ ) as resp:
493
+ if resp.status != 200:
494
+ return Response.from_dict({"code": resp.status, "msg": "get device list failed"})
495
+ resp_dict = await resp.json()
496
+ response = response_factory(Response[DeviceRecords], resp_dict)
497
+ self.device_records = response.data if response.data else self.device_records
498
+ return response
499
+
500
+ @refresh_token_decorator
501
+ async def get_mqtt_credentials(self) -> Response[MQTTConnection]:
502
+ """Get mammotion mqtt credentials"""
503
+ async with ClientSession(self.jwt_info.iot) as session:
504
+ async with session.post(
505
+ "/v1/mqtt/auth/jwt",
506
+ headers={
507
+ **self._headers,
508
+ "Authorization": f"Bearer {self.login_info.access_token}",
509
+ "Content-Type": "application/json",
510
+ "User-Agent": "okhttp/4.9.3",
511
+ },
512
+ ) as resp:
513
+ if resp.status != 200:
514
+ return Response.from_dict({"code": resp.status, "msg": "get mqtt failed"})
515
+ resp_dict = await resp.json()
516
+ response = response_factory(Response[MQTTConnection], resp_dict)
517
+ self.mqtt_credentials = response.data
518
+ return response
519
+
520
+ @refresh_token_decorator
521
+ async def mqtt_invoke(self, content: str, device_name: str, iot_id: str) -> Response[dict]:
522
+ """Send mqtt commands to devices."""
523
+ async with ClientSession(self.jwt_info.iot) as session:
524
+ async with session.post(
525
+ "/v1/mqtt/rpc/thing/service/invoke",
526
+ json={
527
+ "args": {"content": content},
528
+ "deviceName": device_name,
529
+ "identifier": "device_protobuf_sync_service",
530
+ "iotId": iot_id,
531
+ "productKey": "",
532
+ },
533
+ headers={
534
+ **self._headers,
535
+ "Authorization": f"Bearer {self.login_info.access_token}",
536
+ "Content-Type": "application/json",
537
+ "User-Agent": "okhttp/4.9.3",
538
+ "Client-Id": self.client_id,
539
+ "Client-Type": "1",
540
+ },
541
+ ) as resp:
542
+ if resp.status != 200:
543
+ return Response.from_dict({"code": resp.status, "msg": "invoke mqtt failed"})
544
+ if resp.status == 401:
545
+ raise UnauthorizedException("Access Token expired")
546
+ resp_dict = await resp.json()
547
+ return response_factory(Response[dict], resp_dict)
548
+
549
+ async def refresh_login(self) -> Response[LoginResponseData]:
550
+ if self.expires_in > time.time():
551
+ res = await self.refresh_token_v2()
552
+ if res.code == 0:
553
+ return res
554
+ return await self.login_v2(self.account, self._password)
555
+
556
+ async def login(self, account: str, password: str) -> Response[LoginResponseData]:
557
+ """Logs in to the service using provided account and password."""
558
+ self.account = account
559
+ self._password = password
560
+ async with ClientSession(MAMMOTION_DOMAIN) as session:
561
+ async with session.post(
562
+ "/oauth/token",
563
+ headers={
564
+ **self._headers,
565
+ "Encrypt-Key": self.encryption_utils.encrypt_by_public_key(),
566
+ "Decrypt-Type": "3",
567
+ "Ec-Version": "v1",
568
+ },
569
+ params={
570
+ "username": self.encryption_utils.encryption_by_aes(account),
571
+ "password": self.encryption_utils.encryption_by_aes(password),
572
+ "client_id": self.encryption_utils.encryption_by_aes(MAMMOTION_CLIENT_ID),
573
+ "client_secret": self.encryption_utils.encryption_by_aes(MAMMOTION_CLIENT_SECRET),
574
+ "grant_type": self.encryption_utils.encryption_by_aes("password"),
575
+ },
576
+ ) as resp:
577
+ if resp.status != 200:
578
+ print(resp.json())
579
+ return Response.from_dict({"code": resp.status, "msg": "Login failed"})
580
+ data = await resp.json()
581
+ login_response = response_factory(Response[LoginResponseData], data)
582
+ if login_response is None or login_response.data is None:
583
+ print(login_response)
584
+ return Response.from_dict({"code": resp.status, "msg": "Login failed"})
585
+ self.login_info = login_response.data
586
+ self.expires_in = login_response.data.expires_in + time.time()
587
+ self._headers["Authorization"] = (
588
+ f"Bearer {self.login_info.access_token}" if login_response.data else None
589
+ )
590
+ self.response = login_response
591
+ self.msg = login_response.msg
592
+ self.code = login_response.code
593
+ # TODO catch errors from mismatch user / password elsewhere
594
+ # Assuming the data format matches the expected structure
595
+ return login_response
596
+
597
+ async def refresh_token_v2(self) -> Response[LoginResponseData]:
598
+ """Refresh token v2."""
599
+
600
+ refresh_request = {
601
+ "client_id": MAMMOTION_OUATH2_CLIENT_ID,
602
+ "refresh_token": self.login_info.refresh_token,
603
+ "grant_type": "refresh_token",
604
+ }
605
+
606
+ oauth_signature = create_oauth_signature(
607
+ login_req=refresh_request,
608
+ client_id=MAMMOTION_OUATH2_CLIENT_ID,
609
+ client_secret=MAMMOTION_OUATH2_CLIENT_SECRET,
610
+ token_endpoint="/oauth2/token",
611
+ )
612
+
613
+ async with ClientSession(MAMMOTION_DOMAIN) as session:
614
+ async with session.post(
615
+ "/oauth2/token",
616
+ headers={
617
+ **self._headers,
618
+ "Ma-Iot-Signature": oauth_signature,
619
+ "Ma-Timestamp": str(int(time.time())),
620
+ "Client-Id": self.client_id,
621
+ "Client-Type": "1",
622
+ },
623
+ params={
624
+ **refresh_request,
625
+ },
626
+ ) as resp:
627
+ data = await resp.json()
628
+ refresh_response = response_factory(Response[LoginResponseData], data)
629
+ if refresh_response is None or refresh_response.data is None:
630
+ return Response.from_dict({"code": resp.status, "msg": "Login failed"})
631
+ self.login_info = refresh_response.data
632
+ self.expires_in = refresh_response.data.expires_in + time.time()
633
+ self._headers["Authorization"] = (
634
+ f"Bearer {self.login_info.access_token}" if refresh_response.data else None
635
+ )
636
+ self.response = refresh_response
637
+ self.msg = refresh_response.msg
638
+ self.code = refresh_response.code
639
+ return refresh_response
640
+
641
+ async def login_v2(self, account: str, password: str) -> Response[LoginResponseData]:
642
+ """Logs in to the service using provided account and password."""
643
+ self.account = account
644
+ self._password = password
645
+
646
+ login_request = {
647
+ "username": account,
648
+ "password": base64.b64encode(password.encode("utf-8")).decode("utf-8"),
649
+ "client_id": MAMMOTION_OUATH2_CLIENT_ID,
650
+ "grant_type": "password",
651
+ "authType": "0",
652
+ }
653
+
654
+ oauth_signature = create_oauth_signature(
655
+ login_req=login_request,
656
+ client_id=MAMMOTION_OUATH2_CLIENT_ID,
657
+ client_secret=MAMMOTION_OUATH2_CLIENT_SECRET,
658
+ token_endpoint="/oauth2/token",
659
+ )
660
+
661
+ async with ClientSession(MAMMOTION_DOMAIN) as session:
662
+ async with session.post(
663
+ "/oauth2/token",
664
+ headers={
665
+ **self._headers,
666
+ "Ma-App-Key": MAMMOTION_OUATH2_CLIENT_ID,
667
+ "Ma-Signature": oauth_signature,
668
+ "Ma-Timestamp": str(int(time.time())),
669
+ "Client-Id": self.client_id,
670
+ "Client-Type": "1",
671
+ },
672
+ params={
673
+ **login_request,
674
+ },
675
+ ) as resp:
676
+ if resp.status != 200:
677
+ print(resp.json())
678
+ return Response.from_dict({"code": resp.status, "msg": "Login failed"})
679
+ data = await resp.json()
680
+ login_response = response_factory(Response[LoginResponseData], data)
681
+ if login_response is None or login_response.data is None:
682
+ return Response.from_dict({"code": resp.status, "msg": "Login failed"})
683
+ self.login_info = login_response.data
684
+ self.expires_in = login_response.data.expires_in + time.time()
685
+ self._headers["Authorization"] = (
686
+ f"Bearer {self.login_info.access_token}" if login_response.data else None
687
+ )
688
+ self.response = login_response
689
+ self.msg = login_response.msg
690
+ self.code = login_response.code
691
+ # TODO catch errors from mismatch user / password elsewhere
692
+ # Assuming the data format matches the expected structure
693
+ return login_response