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.
@@ -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, response: Response) -> None:
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.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.response = response
22
- self.msg = response.msg
23
- self.code = response.code
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) -> None:
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
- response = Response.from_dict(data)
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
- @classmethod
67
- async def login(cls, session: ClientSession, username: str, password: str) -> Response[LoginResponseData]:
68
- async with session.post(
69
- "/oauth/token",
70
- headers={"User-Agent": "okhttp/3.14.9", "App-Version": "google Pixel 2 XL taimen-Android 11,1.11.332"},
71
- params=dict(
72
- username=username,
73
- password=password,
74
- client_id=MAMMOTION_CLIENT_ID,
75
- client_secret=MAMMOTION_CLIENT_SECRET,
76
- grant_type="password",
77
- ),
78
- ) as resp:
79
- data = await resp.json()
80
- response = Response[LoginResponseData].from_dict(data)
81
- # TODO catch errors from mismatch user / password elsewhere
82
- # Assuming the data format matches the expected structure
83
- return response
84
-
85
-
86
- async def connect_http(username: str, password: str) -> MammotionHTTP:
87
- async with ClientSession(MAMMOTION_DOMAIN) as session:
88
- login_response = await MammotionHTTP.login(session, username, password)
89
- return MammotionHTTP(login_response)
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.mower.update_raw(self._raw_data)
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 MammotionDevices:
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
- devices = MammotionDevices()
154
+ device_manager = MammotionDeviceManager()
155
155
  mqtt_list: dict[str, MammotionCloud] = dict()
156
156
 
157
157
  _instance: Mammotion = None