pymammotion 0.5.34__py3-none-any.whl → 0.5.44__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.
- pymammotion/__init__.py +3 -3
- pymammotion/aliyun/cloud_gateway.py +106 -18
- pymammotion/aliyun/model/dev_by_account_response.py +198 -20
- pymammotion/const.py +3 -0
- pymammotion/data/model/device.py +1 -0
- pymammotion/data/model/device_config.py +1 -1
- pymammotion/data/model/enums.py +5 -3
- pymammotion/data/model/generate_route_information.py +2 -2
- pymammotion/data/model/hash_list.py +113 -33
- pymammotion/data/model/region_data.py +4 -4
- pymammotion/data/{state_manager.py → mower_state_manager.py} +17 -7
- pymammotion/data/mqtt/event.py +47 -22
- pymammotion/data/mqtt/mammotion_properties.py +257 -0
- pymammotion/data/mqtt/properties.py +32 -29
- pymammotion/data/mqtt/status.py +17 -16
- pymammotion/homeassistant/__init__.py +3 -0
- pymammotion/homeassistant/mower_api.py +446 -0
- pymammotion/homeassistant/rtk_api.py +54 -0
- pymammotion/http/http.py +387 -13
- pymammotion/http/model/http.py +82 -2
- pymammotion/http/model/response_factory.py +10 -4
- pymammotion/mammotion/commands/mammotion_command.py +6 -0
- pymammotion/mammotion/commands/messages/navigation.py +10 -6
- pymammotion/mammotion/devices/__init__.py +27 -3
- pymammotion/mammotion/devices/base.py +16 -138
- pymammotion/mammotion/devices/mammotion.py +364 -204
- pymammotion/mammotion/devices/mammotion_bluetooth.py +7 -5
- pymammotion/mammotion/devices/mammotion_cloud.py +42 -83
- pymammotion/mammotion/devices/mammotion_mower_ble.py +49 -0
- pymammotion/mammotion/devices/mammotion_mower_cloud.py +39 -0
- pymammotion/mammotion/devices/managers/managers.py +81 -0
- pymammotion/mammotion/devices/mower_device.py +121 -0
- pymammotion/mammotion/devices/mower_manager.py +107 -0
- pymammotion/mammotion/devices/rtk_ble.py +89 -0
- pymammotion/mammotion/devices/rtk_cloud.py +113 -0
- pymammotion/mammotion/devices/rtk_device.py +50 -0
- pymammotion/mammotion/devices/rtk_manager.py +122 -0
- pymammotion/mqtt/__init__.py +2 -1
- pymammotion/mqtt/aliyun_mqtt.py +232 -0
- pymammotion/mqtt/mammotion_mqtt.py +174 -192
- pymammotion/mqtt/mqtt_models.py +66 -0
- pymammotion/proto/__init__.py +1 -1
- pymammotion/proto/mctrl_nav.proto +1 -1
- pymammotion/proto/mctrl_nav_pb2.py +1 -1
- pymammotion/proto/mctrl_nav_pb2.pyi +4 -4
- pymammotion/proto/mctrl_sys.proto +1 -1
- pymammotion/utility/datatype_converter.py +13 -12
- pymammotion/utility/device_type.py +88 -3
- pymammotion/utility/mur_mur_hash.py +132 -87
- {pymammotion-0.5.34.dist-info → pymammotion-0.5.44.dist-info}/METADATA +25 -31
- {pymammotion-0.5.34.dist-info → pymammotion-0.5.44.dist-info}/RECORD +59 -45
- {pymammotion-0.5.34.dist-info → pymammotion-0.5.44.dist-info}/WHEEL +1 -1
- pymammotion/http/_init_.py +0 -0
- {pymammotion-0.5.34.dist-info → pymammotion-0.5.44.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
|
|
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
|
|
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
120
|
def __init__(self, account: str | None = None, password: str | None = None) -> None:
|
|
17
|
-
self.
|
|
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
127
|
self.account = account
|
|
21
128
|
self._password = password
|
|
22
|
-
self.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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,126 @@ 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)."""
|
|
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
|
+
},
|
|
441
|
+
) as resp:
|
|
442
|
+
resp_dict = await resp.json()
|
|
443
|
+
response = response_factory(Response[list[DeviceInfo]], resp_dict)
|
|
444
|
+
self.device_info = response.data if response.data else self.device_info
|
|
445
|
+
return response
|
|
446
|
+
|
|
447
|
+
@refresh_token_decorator
|
|
448
|
+
async def get_user_shared_device_page(self) -> Response[DeviceRecords]:
|
|
449
|
+
"""Fetches device list for a user (shared) but not accepted."""
|
|
450
|
+
"""Can set owned to zero or one to possibly check for not accepted mowers?"""
|
|
451
|
+
async with ClientSession(MAMMOTION_API_DOMAIN) as session:
|
|
452
|
+
async with session.post(
|
|
453
|
+
"/user-server/v1/share/device/page",
|
|
454
|
+
json={"iotId": "", "owned": 0, "pageNumber": 1, "pageSize": 200, "statusList": [-1]},
|
|
455
|
+
headers={
|
|
456
|
+
**self._headers,
|
|
457
|
+
"Authorization": f"Bearer {self.login_info.access_token}",
|
|
458
|
+
"Content-Type": "application/json",
|
|
459
|
+
"User-Agent": "okhttp/4.9.3",
|
|
460
|
+
},
|
|
461
|
+
) as resp:
|
|
462
|
+
resp_dict = await resp.json()
|
|
463
|
+
response = response_factory(Response[DeviceRecords], resp_dict)
|
|
464
|
+
self.devices_shared_info = response.data if response.data else self.devices_shared_info
|
|
465
|
+
return response
|
|
466
|
+
|
|
467
|
+
@refresh_token_decorator
|
|
468
|
+
async def get_user_device_page(self) -> Response[DeviceRecords]:
|
|
469
|
+
"""Fetches device list for a user, is either new API or for newer devices."""
|
|
470
|
+
async with ClientSession(self.jwt_info.iot) as session:
|
|
471
|
+
async with session.post(
|
|
472
|
+
"/v1/user/device/page",
|
|
473
|
+
json={
|
|
474
|
+
"iotId": "",
|
|
475
|
+
"pageNumber": 1,
|
|
476
|
+
"pageSize": 100,
|
|
477
|
+
},
|
|
478
|
+
headers={
|
|
479
|
+
**self._headers,
|
|
480
|
+
"Authorization": f"Bearer {self.login_info.access_token}",
|
|
481
|
+
"Content-Type": "application/json",
|
|
482
|
+
"User-Agent": "okhttp/4.9.3",
|
|
483
|
+
"Client-Id": self.client_id,
|
|
484
|
+
"Client-Type": "1",
|
|
485
|
+
},
|
|
486
|
+
) as resp:
|
|
487
|
+
if resp.status != 200:
|
|
488
|
+
return Response.from_dict({"code": resp.status, "msg": "get device list failed"})
|
|
489
|
+
resp_dict = await resp.json()
|
|
490
|
+
response = response_factory(Response[DeviceRecords], resp_dict)
|
|
491
|
+
self.device_records = response.data if response.data else self.device_records
|
|
492
|
+
return response
|
|
493
|
+
|
|
494
|
+
@refresh_token_decorator
|
|
495
|
+
async def get_mqtt_credentials(self) -> Response[MQTTConnection]:
|
|
496
|
+
"""Get mammotion mqtt credentials"""
|
|
497
|
+
async with ClientSession(self.jwt_info.iot) as session:
|
|
498
|
+
async with session.post(
|
|
499
|
+
"/v1/mqtt/auth/jwt",
|
|
500
|
+
headers={
|
|
501
|
+
**self._headers,
|
|
502
|
+
"Authorization": f"Bearer {self.login_info.access_token}",
|
|
503
|
+
"Content-Type": "application/json",
|
|
504
|
+
"User-Agent": "okhttp/4.9.3",
|
|
505
|
+
},
|
|
506
|
+
) as resp:
|
|
507
|
+
if resp.status != 200:
|
|
508
|
+
return Response.from_dict({"code": resp.status, "msg": "get mqtt failed"})
|
|
509
|
+
resp_dict = await resp.json()
|
|
510
|
+
response = response_factory(Response[MQTTConnection], resp_dict)
|
|
511
|
+
self.mqtt_credentials = response.data
|
|
512
|
+
return response
|
|
513
|
+
|
|
514
|
+
@refresh_token_decorator
|
|
515
|
+
async def mqtt_invoke(self, content: str, device_name: str, iot_id: str) -> Response[dict]:
|
|
516
|
+
"""Send mqtt commands to devices."""
|
|
517
|
+
async with ClientSession(self.jwt_info.iot) as session:
|
|
518
|
+
async with session.post(
|
|
519
|
+
"/v1/mqtt/rpc/thing/service/invoke",
|
|
520
|
+
json={
|
|
521
|
+
"args": {"content": content},
|
|
522
|
+
"deviceName": device_name,
|
|
523
|
+
"identifier": "device_protobuf_sync_service",
|
|
524
|
+
"iotId": iot_id,
|
|
525
|
+
"productKey": "",
|
|
526
|
+
},
|
|
527
|
+
headers={
|
|
528
|
+
**self._headers,
|
|
529
|
+
"Authorization": f"Bearer {self.login_info.access_token}",
|
|
530
|
+
"Content-Type": "application/json",
|
|
531
|
+
"User-Agent": "okhttp/4.9.3",
|
|
532
|
+
"Client-Id": self.client_id,
|
|
533
|
+
"Client-Type": "1",
|
|
534
|
+
},
|
|
535
|
+
) as resp:
|
|
536
|
+
if resp.status != 200:
|
|
537
|
+
return Response.from_dict({"code": resp.status, "msg": "invoke mqtt failed"})
|
|
538
|
+
if resp.status == 401:
|
|
539
|
+
raise UnauthorizedException("Access Token expired")
|
|
540
|
+
resp_dict = await resp.json()
|
|
541
|
+
return response_factory(Response[dict], resp_dict)
|
|
542
|
+
|
|
267
543
|
async def refresh_login(self) -> Response[LoginResponseData]:
|
|
268
544
|
if self.expires_in > time.time():
|
|
269
|
-
res = await self.
|
|
545
|
+
res = await self.refresh_token_v2()
|
|
270
546
|
if res.code == 0:
|
|
271
547
|
return res
|
|
272
|
-
return await self.
|
|
548
|
+
return await self.login_v2(self.account, self._password)
|
|
273
549
|
|
|
274
550
|
async def login(self, account: str, password: str) -> Response[LoginResponseData]:
|
|
275
551
|
"""Logs in to the service using provided account and password."""
|
|
@@ -297,7 +573,7 @@ class MammotionHTTP:
|
|
|
297
573
|
return Response.from_dict({"code": resp.status, "msg": "Login failed"})
|
|
298
574
|
data = await resp.json()
|
|
299
575
|
login_response = response_factory(Response[LoginResponseData], data)
|
|
300
|
-
if login_response.data is None:
|
|
576
|
+
if login_response is None or login_response.data is None:
|
|
301
577
|
print(login_response)
|
|
302
578
|
return Response.from_dict({"code": resp.status, "msg": "Login failed"})
|
|
303
579
|
self.login_info = login_response.data
|
|
@@ -311,3 +587,101 @@ class MammotionHTTP:
|
|
|
311
587
|
# TODO catch errors from mismatch user / password elsewhere
|
|
312
588
|
# Assuming the data format matches the expected structure
|
|
313
589
|
return login_response
|
|
590
|
+
|
|
591
|
+
async def refresh_token_v2(self) -> Response[LoginResponseData]:
|
|
592
|
+
"""Refresh token v2."""
|
|
593
|
+
|
|
594
|
+
refresh_request = {
|
|
595
|
+
"client_id": MAMMOTION_OUATH2_CLIENT_ID,
|
|
596
|
+
"refresh_token": self.login_info.refresh_token,
|
|
597
|
+
"grant_type": "refresh_token",
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
oauth_signature = create_oauth_signature(
|
|
601
|
+
login_req=refresh_request,
|
|
602
|
+
client_id=MAMMOTION_OUATH2_CLIENT_ID,
|
|
603
|
+
client_secret=MAMMOTION_OUATH2_CLIENT_SECRET,
|
|
604
|
+
token_endpoint="/oauth2/token",
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
async with ClientSession(MAMMOTION_DOMAIN) as session:
|
|
608
|
+
async with session.post(
|
|
609
|
+
"/oauth2/token",
|
|
610
|
+
headers={
|
|
611
|
+
**self._headers,
|
|
612
|
+
"Ma-Iot-Signature": oauth_signature,
|
|
613
|
+
"Ma-Timestamp": str(int(time.time())),
|
|
614
|
+
"Client-Id": self.client_id,
|
|
615
|
+
"Client-Type": "1",
|
|
616
|
+
},
|
|
617
|
+
params={
|
|
618
|
+
**refresh_request,
|
|
619
|
+
},
|
|
620
|
+
) as resp:
|
|
621
|
+
data = await resp.json()
|
|
622
|
+
refresh_response = response_factory(Response[LoginResponseData], data)
|
|
623
|
+
if refresh_response is None or refresh_response.data is None:
|
|
624
|
+
return Response.from_dict({"code": resp.status, "msg": "Login failed"})
|
|
625
|
+
self.login_info = refresh_response.data
|
|
626
|
+
self.expires_in = refresh_response.data.expires_in + time.time()
|
|
627
|
+
self._headers["Authorization"] = (
|
|
628
|
+
f"Bearer {self.login_info.access_token}" if refresh_response.data else None
|
|
629
|
+
)
|
|
630
|
+
self.response = refresh_response
|
|
631
|
+
self.msg = refresh_response.msg
|
|
632
|
+
self.code = refresh_response.code
|
|
633
|
+
return refresh_response
|
|
634
|
+
|
|
635
|
+
async def login_v2(self, account: str, password: str) -> Response[LoginResponseData]:
|
|
636
|
+
"""Logs in to the service using provided account and password."""
|
|
637
|
+
self.account = account
|
|
638
|
+
self._password = password
|
|
639
|
+
|
|
640
|
+
login_request = {
|
|
641
|
+
"username": account,
|
|
642
|
+
"password": base64.b64encode(password.encode("utf-8")).decode("utf-8"),
|
|
643
|
+
"client_id": MAMMOTION_OUATH2_CLIENT_ID,
|
|
644
|
+
"grant_type": "password",
|
|
645
|
+
"authType": "0",
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
oauth_signature = create_oauth_signature(
|
|
649
|
+
login_req=login_request,
|
|
650
|
+
client_id=MAMMOTION_OUATH2_CLIENT_ID,
|
|
651
|
+
client_secret=MAMMOTION_OUATH2_CLIENT_SECRET,
|
|
652
|
+
token_endpoint="/oauth2/token",
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
async with ClientSession(MAMMOTION_DOMAIN) as session:
|
|
656
|
+
async with session.post(
|
|
657
|
+
"/oauth2/token",
|
|
658
|
+
headers={
|
|
659
|
+
**self._headers,
|
|
660
|
+
"Ma-App-Key": MAMMOTION_OUATH2_CLIENT_ID,
|
|
661
|
+
"Ma-Signature": oauth_signature,
|
|
662
|
+
"Ma-Timestamp": str(int(time.time())),
|
|
663
|
+
"Client-Id": self.client_id,
|
|
664
|
+
"Client-Type": "1",
|
|
665
|
+
},
|
|
666
|
+
params={
|
|
667
|
+
**login_request,
|
|
668
|
+
},
|
|
669
|
+
) as resp:
|
|
670
|
+
if resp.status != 200:
|
|
671
|
+
print(resp.json())
|
|
672
|
+
return Response.from_dict({"code": resp.status, "msg": "Login failed"})
|
|
673
|
+
data = await resp.json()
|
|
674
|
+
login_response = response_factory(Response[LoginResponseData], data)
|
|
675
|
+
if login_response is None or login_response.data is None:
|
|
676
|
+
return Response.from_dict({"code": resp.status, "msg": "Login failed"})
|
|
677
|
+
self.login_info = login_response.data
|
|
678
|
+
self.expires_in = login_response.data.expires_in + time.time()
|
|
679
|
+
self._headers["Authorization"] = (
|
|
680
|
+
f"Bearer {self.login_info.access_token}" if login_response.data else None
|
|
681
|
+
)
|
|
682
|
+
self.response = login_response
|
|
683
|
+
self.msg = login_response.msg
|
|
684
|
+
self.code = login_response.code
|
|
685
|
+
# TODO catch errors from mismatch user / password elsewhere
|
|
686
|
+
# Assuming the data format matches the expected structure
|
|
687
|
+
return login_response
|
pymammotion/http/model/http.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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)
|
|
@@ -44,6 +44,12 @@ class MammotionCommand(
|
|
|
44
44
|
# 1 multipoint turn
|
|
45
45
|
return self.read_write_device(6, context, 1)
|
|
46
46
|
|
|
47
|
+
def get_error_code(self) -> bytes:
|
|
48
|
+
return self.read_write_device(5, 2, 1)
|
|
49
|
+
|
|
50
|
+
def get_error_timestamp(self) -> bytes:
|
|
51
|
+
return self.read_write_device(5, 3, 1)
|
|
52
|
+
|
|
47
53
|
def get_device_product_key(self) -> str:
|
|
48
54
|
return self._product_key
|
|
49
55
|
|