pymammotion 0.3.8__py3-none-any.whl → 0.4.0a0__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.
- pymammotion/__init__.py +2 -2
- pymammotion/data/model/device.py +0 -218
- pymammotion/data/model/device_config.py +0 -10
- pymammotion/data/model/device_limits.py +49 -0
- pymammotion/data/model/raw_data.py +223 -0
- pymammotion/http/encryption.py +221 -0
- pymammotion/http/http.py +91 -33
- pymammotion/mammotion/devices/base.py +4 -2
- pymammotion/mammotion/devices/mammotion.py +2 -2
- pymammotion/utility/device_config.py +363 -0
- {pymammotion-0.3.8.dist-info → pymammotion-0.4.0a0.dist-info}/METADATA +2 -1
- {pymammotion-0.3.8.dist-info → pymammotion-0.4.0a0.dist-info}/RECORD +14 -10
- {pymammotion-0.3.8.dist-info → pymammotion-0.4.0a0.dist-info}/LICENSE +0 -0
- {pymammotion-0.3.8.dist-info → pymammotion-0.4.0a0.dist-info}/WHEEL +0 -0
@@ -0,0 +1,221 @@
|
|
1
|
+
import base64
|
2
|
+
import logging
|
3
|
+
import secrets
|
4
|
+
import string
|
5
|
+
from typing import Optional
|
6
|
+
|
7
|
+
from cryptography.hazmat.backends import default_backend
|
8
|
+
from cryptography.hazmat.primitives import padding, serialization
|
9
|
+
from cryptography.hazmat.primitives.asymmetric import padding as rsa_padding
|
10
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
11
|
+
|
12
|
+
_LOGGER = logging.getLogger(__name__)
|
13
|
+
|
14
|
+
|
15
|
+
class EncryptionUtils:
|
16
|
+
PRIVATE_KEY = """MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBAOFizbd1fC5XNKJ89u0XNvPZNR/L
|
17
|
+
0h547iSWjOCuvvMu76ZSaS3/Tu2C1C+XmlnmBWTyY4ON+xECiNUXm/aWQ3P0g+wf60zjPbNzgL2Q
|
18
|
+
7njXJG6wka4KkbdQxUdS0TTpL256LnV1LsG855bsbJIJiQPbfUq6HbB5xH7sXdrmFu1DAgMBAAEC
|
19
|
+
gYEAoT2TGE1ncquWjyxBZup1uMvKkp25C23OSMSfslmxZ75LWjyY3HxK1eYDsKyPkwLZFxfFE6du
|
20
|
+
VwPuKiyCuk1ToPfnb4niTGzXPyC2PbO4SFrWL8n1YZ80M0bfTGI9dMCZvpmZJ41WYUsBaf2374lt
|
21
|
+
oEiDEHJp7MeXk/970xiKP1ECQQD65rLHk840q+FZS6kZVexJucPZj/YAII6klU1E20ctioe8Pi5m
|
22
|
+
WSPqclH27/t4FqdvP7tFqaavyXg+CEQpxmxLAkEA5fddDuzcjWgF9pl9fP7/baFMYjUS9z1Vc3gx
|
23
|
+
CnvAgCnv71wjDQhvsUc6sAiidsBGFDyud06RyyLcOlQchMb36QJBAIui/Xjpn+fciQxjeXcqRNk7
|
24
|
+
U+6vml+zvu+GUHyz9Uc5RBXWHYjEr6J5gXiHU1MgeIsH0zgQFT7cR9luTFFbp0UCQFIntfogCocG
|
25
|
+
E6NOoHMoUi5jQnuPRHBJXB69YJ/DKDlhQhN8EhWU3voxXTkITKop9J9EMnvy+MjecljwNaQFxQkC
|
26
|
+
QB9lz67iDe9Gj8NxSElVZxUm9EfbL1RPqTZPx/lADR06CPB8pP3Bl5/5/5RGzc+UTZ+wX5GWKvC7
|
27
|
+
zUJaROxQB+E=""".replace(" ", "")
|
28
|
+
|
29
|
+
PUBLIC_KEY_PROD = """MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApLbeSgOvnwLTWbhaBQWNnnHMtSDAi
|
30
|
+
Gz0PEDbrtd1tLYoO0hukW5PSa6eHykch0Hc6etiqEx1xziS+vNf+iOXds70I4htaYit6yRToZlQ
|
31
|
+
Mim3DQxaZX68nIHIZogur0zGv9U8j01v5l/rHRxyDdlVx3+JkBg6Cqx4U1PXEnAJriqcyg0B8Gm
|
32
|
+
V8Lnmfng+aJLRyq5MkhstYCRv9AsmWu8NpZDJ1ffbkaS02Z9/wpubXTiFP6DG3V2mDw2VvzEcHi
|
33
|
+
cchw49oXmTi92yui+kBgSYlNygssOAyU6H071AfmRUeH3+TsV5u5rg+bCiKyHemVmcKdd3hhZB+
|
34
|
+
HjA8o3On6rg5wIDAQAB""".replace(" ", "")
|
35
|
+
|
36
|
+
PUBLIC_KEY_TEST = """MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC1nAzH31arNBmYKvTlvKgkxI1MIr4HpfLbmM
|
37
|
+
XPIhd8D/cXB0dYY1ppUq4a/ezq41YShN88e0elyZgqdnFrkhiLpnKWa7jXtVRgXi9eS18PLO8ns
|
38
|
+
eHude9URaj7relK1AZ0xovKsbLKHd01PpmngLXZfnKA06J2ru/zH+cnpXdy8QIDAQAB""".replace(" ", "")
|
39
|
+
|
40
|
+
def __init__(self) -> None:
|
41
|
+
self.AES_PASW = self.get_aes_key() # Get from previous implementation
|
42
|
+
self.IV = self.get_iv() # Get from previous implementation
|
43
|
+
self._public_key = self.load_public_key()
|
44
|
+
self._private_key = self.load_private_key()
|
45
|
+
|
46
|
+
@staticmethod
|
47
|
+
def load_private_key():
|
48
|
+
"""Load the private key from base64 encoded string"""
|
49
|
+
try:
|
50
|
+
private_key_bytes = base64.b64decode(EncryptionUtils.PRIVATE_KEY)
|
51
|
+
return serialization.load_der_private_key(private_key_bytes, password=None, backend=default_backend())
|
52
|
+
except Exception as e:
|
53
|
+
raise Exception(f"Failed to load private key: {str(e)}")
|
54
|
+
|
55
|
+
@staticmethod
|
56
|
+
def load_public_key(is_production: bool = True):
|
57
|
+
"""Load the public key from base64 encoded string
|
58
|
+
|
59
|
+
Args:
|
60
|
+
is_production (bool): If True, uses production key, else uses test key
|
61
|
+
|
62
|
+
"""
|
63
|
+
try:
|
64
|
+
key_string = EncryptionUtils.PUBLIC_KEY_PROD if is_production else EncryptionUtils.PUBLIC_KEY_TEST
|
65
|
+
public_key_bytes = base64.b64decode(key_string)
|
66
|
+
return serialization.load_der_public_key(public_key_bytes, backend=default_backend())
|
67
|
+
except Exception as e:
|
68
|
+
raise Exception(f"Failed to load public key: {str(e)}")
|
69
|
+
|
70
|
+
@staticmethod
|
71
|
+
def encrypt(plaintext: str, key: str, iv: str) -> str:
|
72
|
+
"""Encrypt text using AES/CBC/PKCS5Padding
|
73
|
+
|
74
|
+
Args:
|
75
|
+
plaintext (str): Text to encrypt
|
76
|
+
key (str): Encryption key
|
77
|
+
iv (str): Initialization vector
|
78
|
+
|
79
|
+
Returns:
|
80
|
+
str: Base64 encoded encrypted string
|
81
|
+
|
82
|
+
Raises:
|
83
|
+
Exception: If encryption fails
|
84
|
+
|
85
|
+
"""
|
86
|
+
try:
|
87
|
+
# Convert strings to bytes
|
88
|
+
plaintext_bytes = plaintext.encode("utf-8")
|
89
|
+
key_bytes = key.encode("utf-8")
|
90
|
+
iv_bytes = iv.encode("utf-8")
|
91
|
+
|
92
|
+
# Create padder
|
93
|
+
padder = padding.PKCS7(128).padder()
|
94
|
+
padded_data = padder.update(plaintext_bytes) + padder.finalize()
|
95
|
+
|
96
|
+
# Create cipher
|
97
|
+
cipher = Cipher(algorithms.AES(key_bytes), modes.CBC(iv_bytes), backend=default_backend())
|
98
|
+
|
99
|
+
# Encrypt
|
100
|
+
encryptor = cipher.encryptor()
|
101
|
+
encrypted_bytes = encryptor.update(padded_data) + encryptor.finalize()
|
102
|
+
|
103
|
+
# Encode to base64
|
104
|
+
return base64.b64encode(encrypted_bytes).decode("utf-8")
|
105
|
+
|
106
|
+
except Exception as e:
|
107
|
+
raise Exception(f"Encryption failed: {str(e)}")
|
108
|
+
|
109
|
+
def encryption_by_aes(self, text: str) -> str:
|
110
|
+
"""Encrypt text using AES with class-level key and IV
|
111
|
+
|
112
|
+
Args:
|
113
|
+
text (str): Text to encrypt
|
114
|
+
|
115
|
+
Returns:
|
116
|
+
str: Encrypted text or None if encryption fails
|
117
|
+
|
118
|
+
"""
|
119
|
+
try:
|
120
|
+
# Perform encryption
|
121
|
+
encrypted = self.encrypt(text, self.AES_PASW, self.IV)
|
122
|
+
|
123
|
+
return encrypted
|
124
|
+
|
125
|
+
except Exception as e:
|
126
|
+
_LOGGER.error(f"Encryption failed: {str(e)}")
|
127
|
+
return None
|
128
|
+
|
129
|
+
def encrypt_by_public_key(self) -> Optional[str]:
|
130
|
+
"""Encrypt data using RSA public key.
|
131
|
+
|
132
|
+
Args:
|
133
|
+
|
134
|
+
Returns:
|
135
|
+
Optional[str]: Base64 encoded encrypted data or None if encryption fails
|
136
|
+
|
137
|
+
"""
|
138
|
+
|
139
|
+
data = f"{self.AES_PASW},{self.IV}"
|
140
|
+
|
141
|
+
if not self._public_key:
|
142
|
+
_LOGGER.error("Public key not initialized")
|
143
|
+
return None
|
144
|
+
|
145
|
+
try:
|
146
|
+
# Convert input string to bytes
|
147
|
+
data_bytes = data.encode("utf-8")
|
148
|
+
|
149
|
+
# Encrypt the data padding.PKCS7(128).padder()
|
150
|
+
encrypted_bytes = self._public_key.encrypt(data_bytes, rsa_padding.PKCS1v15())
|
151
|
+
|
152
|
+
# Convert to base64 string
|
153
|
+
encrypted_str = base64.b64encode(encrypted_bytes).decode("utf-8")
|
154
|
+
_LOGGER.debug("Data encrypted successfully")
|
155
|
+
|
156
|
+
return encrypted_str
|
157
|
+
|
158
|
+
except Exception as err:
|
159
|
+
_LOGGER.error("Encryption failed: %s", str(err))
|
160
|
+
return None
|
161
|
+
|
162
|
+
@staticmethod
|
163
|
+
def get_random_string(length: int) -> str:
|
164
|
+
"""Generate a random string of specified length using alphanumeric characters.
|
165
|
+
|
166
|
+
Args:
|
167
|
+
length (int): The desired length of the random string
|
168
|
+
|
169
|
+
Returns:
|
170
|
+
str: A random alphanumeric string of specified length
|
171
|
+
|
172
|
+
Raises:
|
173
|
+
ValueError: If length is less than 1
|
174
|
+
|
175
|
+
"""
|
176
|
+
if length < 1:
|
177
|
+
raise ValueError("Length must be positive")
|
178
|
+
|
179
|
+
charset = string.ascii_letters + string.digits
|
180
|
+
return "".join(secrets.choice(charset) for _ in range(length))
|
181
|
+
|
182
|
+
@staticmethod
|
183
|
+
def get_random_int(length: int) -> str:
|
184
|
+
"""Generate a random string of specified length containing only digits.
|
185
|
+
|
186
|
+
Args:
|
187
|
+
length (int): The desired length of the random number string
|
188
|
+
|
189
|
+
Returns:
|
190
|
+
str: A string of random digits of specified length
|
191
|
+
|
192
|
+
Raises:
|
193
|
+
ValueError: If length is less than 1
|
194
|
+
|
195
|
+
"""
|
196
|
+
if length < 1:
|
197
|
+
raise ValueError("Length must be positive")
|
198
|
+
|
199
|
+
return "".join(secrets.choice(string.digits) for _ in range(length))
|
200
|
+
|
201
|
+
@staticmethod
|
202
|
+
def get_aes_key() -> str:
|
203
|
+
"""Generate a random AES key of 16 characters using alphanumeric characters.
|
204
|
+
Matches Java implementation behavior.
|
205
|
+
|
206
|
+
Returns:
|
207
|
+
str: A 16-character random string for AES key
|
208
|
+
|
209
|
+
"""
|
210
|
+
return EncryptionUtils.get_random_string(16)
|
211
|
+
|
212
|
+
@staticmethod
|
213
|
+
def get_iv() -> str:
|
214
|
+
"""Generate a random initialization vector of 16 digits.
|
215
|
+
Matches Java implementation behavior.
|
216
|
+
|
217
|
+
Returns:
|
218
|
+
str: A 16-digit random string for initialization vector
|
219
|
+
|
220
|
+
"""
|
221
|
+
return EncryptionUtils.get_random_int(16)
|
pymammotion/http/http.py
CHANGED
@@ -10,17 +10,25 @@ from pymammotion.const import (
|
|
10
10
|
MAMMOTION_CLIENT_SECRET,
|
11
11
|
MAMMOTION_DOMAIN,
|
12
12
|
)
|
13
|
+
from pymammotion.http.encryption import EncryptionUtils
|
13
14
|
from pymammotion.http.model.http import ErrorInfo, LoginResponseData, Response
|
14
15
|
|
15
16
|
|
16
17
|
class MammotionHTTP:
|
17
|
-
def __init__(self
|
18
|
+
def __init__(self) -> None:
|
19
|
+
self.code = None
|
20
|
+
self.msg = None
|
21
|
+
self.response: Response | None = None
|
22
|
+
self.login_info: LoginResponseData | None = None
|
18
23
|
self._headers = {"User-Agent": "okhttp/3.14.9", "App-Version": "google Pixel 2 XL taimen-Android 11,1.11.332"}
|
19
|
-
self.
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
+
self.encryption_utils = EncryptionUtils()
|
25
|
+
|
26
|
+
@staticmethod
|
27
|
+
def generate_headers(token: str) -> dict:
|
28
|
+
return {"Authorization": f"Bearer {token}"}
|
29
|
+
|
30
|
+
async def login_by_email(self, email: str, password: str) -> Response[LoginResponseData]:
|
31
|
+
return await self.login(email, password)
|
24
32
|
|
25
33
|
async def get_all_error_codes(self) -> dict[str, ErrorInfo]:
|
26
34
|
async with ClientSession(MAMMOTION_API_DOMAIN) as session:
|
@@ -36,15 +44,55 @@ class MammotionHTTP:
|
|
36
44
|
codes[error_info.code] = error_info
|
37
45
|
return codes
|
38
46
|
|
39
|
-
async def oauth_check(self) ->
|
47
|
+
async def oauth_check(self) -> Response:
|
40
48
|
"""Check if token is valid.
|
41
49
|
|
42
50
|
Returns 401 if token is invalid. We then need to re-authenticate, can try to refresh token first
|
43
51
|
"""
|
44
52
|
async with ClientSession(MAMMOTION_API_DOMAIN) as session:
|
45
|
-
async with session.post("/user-server/v1/user/oauth/check") as resp:
|
53
|
+
async with session.post("/user-server/v1/user/oauth/check", headers=self._headers) as resp:
|
54
|
+
data = await resp.json()
|
55
|
+
return Response.from_dict(data)
|
56
|
+
|
57
|
+
async def pair_devices_mqtt(self, mower_name: str, rtk_name: str) -> Response:
|
58
|
+
async with ClientSession(MAMMOTION_API_DOMAIN) as session:
|
59
|
+
async with session.post(
|
60
|
+
"/device-server/v1/iot/device/pairing",
|
61
|
+
headers=self._headers,
|
62
|
+
json={"mowerName": mower_name, "rtkName": rtk_name},
|
63
|
+
) as resp:
|
64
|
+
data = await resp.json()
|
65
|
+
if data.get("status") == 200:
|
66
|
+
print(data)
|
67
|
+
return Response.from_dict(data)
|
68
|
+
else:
|
69
|
+
print(data)
|
70
|
+
|
71
|
+
async def unpair_devices_mqtt(self, mower_name: str, rtk_name: str) -> Response:
|
72
|
+
async with ClientSession(MAMMOTION_API_DOMAIN) as session:
|
73
|
+
async with session.post(
|
74
|
+
"/device-server/v1/iot/device/unpairing",
|
75
|
+
headers=self._headers,
|
76
|
+
json={"mowerName": mower_name, "rtkName": rtk_name},
|
77
|
+
) as resp:
|
78
|
+
data = await resp.json()
|
79
|
+
if data.get("status") == 200:
|
80
|
+
print(data)
|
81
|
+
return Response.from_dict(data)
|
82
|
+
else:
|
83
|
+
print(data)
|
84
|
+
|
85
|
+
async def net_rtk_enable(self, device_id: str) -> Response:
|
86
|
+
async with ClientSession(MAMMOTION_API_DOMAIN) as session:
|
87
|
+
async with session.post(
|
88
|
+
"/device-server/v1/iot/net-rtk/enable", headers=self._headers, json={"deviceId": device_id}
|
89
|
+
) as resp:
|
46
90
|
data = await resp.json()
|
47
|
-
|
91
|
+
if data.get("status") == 200:
|
92
|
+
print(data)
|
93
|
+
return Response.from_dict(data)
|
94
|
+
else:
|
95
|
+
print(data)
|
48
96
|
|
49
97
|
async def get_stream_subscription(self, iot_id: str) -> Response[StreamSubscriptionResponse]:
|
50
98
|
"""Get agora.io data for view camera stream"""
|
@@ -63,27 +111,37 @@ class MammotionHTTP:
|
|
63
111
|
# Assuming the data format matches the expected structure
|
64
112
|
return Response[StreamSubscriptionResponse].from_dict(data)
|
65
113
|
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
114
|
+
async def login(self, username: str, password: str) -> Response[LoginResponseData]:
|
115
|
+
async with ClientSession(MAMMOTION_DOMAIN) as session:
|
116
|
+
async with session.post(
|
117
|
+
"/oauth/token",
|
118
|
+
headers={
|
119
|
+
"User-Agent": "okhttp/3.14.9",
|
120
|
+
"App-Version": "google Pixel 2 XL taimen-Android 11,1.11.332",
|
121
|
+
"Encrypt-Key": self.encryption_utils.encrypt_by_public_key(),
|
122
|
+
"Decrypt-Type": "3",
|
123
|
+
"Ec-Version": "v1",
|
124
|
+
},
|
125
|
+
params=dict(
|
126
|
+
username=self.encryption_utils.encryption_by_aes(username),
|
127
|
+
password=self.encryption_utils.encryption_by_aes(password),
|
128
|
+
client_id=self.encryption_utils.encryption_by_aes(MAMMOTION_CLIENT_ID),
|
129
|
+
client_secret=self.encryption_utils.encryption_by_aes(MAMMOTION_CLIENT_SECRET),
|
130
|
+
grant_type=self.encryption_utils.encryption_by_aes("password"),
|
131
|
+
),
|
132
|
+
) as resp:
|
133
|
+
if resp.status != 200:
|
134
|
+
print(resp.json())
|
135
|
+
return Response.from_dict({"status": resp.status, "msg": "Login failed"})
|
136
|
+
data = await resp.json()
|
137
|
+
login_response = Response[LoginResponseData].from_dict(data)
|
138
|
+
self.login_info = LoginResponseData.from_dict(login_response.data)
|
139
|
+
self._headers["Authorization"] = (
|
140
|
+
f"Bearer {self.login_info.access_token}" if login_response.data else None
|
141
|
+
)
|
142
|
+
self.response = login_response
|
143
|
+
self.msg = login_response.msg
|
144
|
+
self.code = login_response.code
|
145
|
+
# TODO catch errors from mismatch user / password elsewhere
|
146
|
+
# Assuming the data format matches the expected structure
|
147
|
+
return login_response
|
@@ -8,6 +8,7 @@ import betterproto
|
|
8
8
|
from pymammotion.aliyun.model.dev_by_account_response import Device
|
9
9
|
from pymammotion.data.model import RegionData
|
10
10
|
from pymammotion.data.model.device import MowingDevice
|
11
|
+
from pymammotion.data.model.raw_data import RawMowerData
|
11
12
|
from pymammotion.data.state_manager import StateManager
|
12
13
|
from pymammotion.proto.luba_msg import LubaMsg
|
13
14
|
from pymammotion.proto.mctrl_nav import NavGetCommDataAck, NavGetHashListAck, SvgMessageAckT
|
@@ -36,8 +37,9 @@ class MammotionBaseDevice:
|
|
36
37
|
def __init__(self, state_manager: StateManager, cloud_device: Device | None = None) -> None:
|
37
38
|
"""Initialize MammotionBaseDevice."""
|
38
39
|
self.loop = asyncio.get_event_loop()
|
39
|
-
self._raw_data = LubaMsg().to_dict(casing=betterproto.Casing.SNAKE)
|
40
40
|
self._state_manager = state_manager
|
41
|
+
self._raw_data = dict()
|
42
|
+
self._raw_mower_data: RawMowerData = RawMowerData()
|
41
43
|
self._state_manager.gethash_ack_callback = self.datahash_response
|
42
44
|
self._state_manager.get_commondata_ack_callback = self.commdata_response
|
43
45
|
self._notify_future: asyncio.Future[bytes] | None = None
|
@@ -108,7 +110,7 @@ class MammotionBaseDevice:
|
|
108
110
|
case "ota":
|
109
111
|
self._update_ota_data(tmp_msg)
|
110
112
|
|
111
|
-
self.
|
113
|
+
self._raw_mower_data.update_raw(self._raw_data)
|
112
114
|
|
113
115
|
def _update_nav_data(self, tmp_msg) -> None:
|
114
116
|
"""Update navigation data."""
|
@@ -98,7 +98,7 @@ class MammotionMixedDeviceManager:
|
|
98
98
|
return self._ble_device is not None
|
99
99
|
|
100
100
|
|
101
|
-
class
|
101
|
+
class MammotionDeviceManager:
|
102
102
|
devices: dict[str, MammotionMixedDeviceManager] = {}
|
103
103
|
|
104
104
|
def add_device(self, mammotion_device: MammotionMixedDeviceManager) -> None:
|
@@ -151,7 +151,7 @@ async def create_devices(
|
|
151
151
|
class Mammotion:
|
152
152
|
"""Represents a Mammotion account and its devices."""
|
153
153
|
|
154
|
-
|
154
|
+
device_manager = MammotionDeviceManager()
|
155
155
|
mqtt_list: dict[str, MammotionCloud] = dict()
|
156
156
|
|
157
157
|
_instance: Mammotion = None
|