mg-saic-client 0.9.4__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.
Files changed (34) hide show
  1. mg_saic_client-0.9.4.dist-info/METADATA +50 -0
  2. mg_saic_client-0.9.4.dist-info/RECORD +34 -0
  3. mg_saic_client-0.9.4.dist-info/WHEEL +4 -0
  4. mg_saic_client-0.9.4.dist-info/licenses/LICENSE +21 -0
  5. saic_ismart_client_ng/__init__.py +21 -0
  6. saic_ismart_client_ng/api/__init__.py +0 -0
  7. saic_ismart_client_ng/api/base.py +429 -0
  8. saic_ismart_client_ng/api/message/__init__.py +87 -0
  9. saic_ismart_client_ng/api/message/schema.py +82 -0
  10. saic_ismart_client_ng/api/schema.py +74 -0
  11. saic_ismart_client_ng/api/serialization_utils.py +17 -0
  12. saic_ismart_client_ng/api/user/__init__.py +13 -0
  13. saic_ismart_client_ng/api/user/schema.py +8 -0
  14. saic_ismart_client_ng/api/vehicle/__init__.py +94 -0
  15. saic_ismart_client_ng/api/vehicle/alarm/__init__.py +39 -0
  16. saic_ismart_client_ng/api/vehicle/alarm/schema.py +28 -0
  17. saic_ismart_client_ng/api/vehicle/climate/__init__.py +107 -0
  18. saic_ismart_client_ng/api/vehicle/locks/__init__.py +60 -0
  19. saic_ismart_client_ng/api/vehicle/locks/schema.py +8 -0
  20. saic_ismart_client_ng/api/vehicle/schema.py +220 -0
  21. saic_ismart_client_ng/api/vehicle/windows/__init__.py +59 -0
  22. saic_ismart_client_ng/api/vehicle/windows/schema.py +13 -0
  23. saic_ismart_client_ng/api/vehicle_charging/__init__.py +256 -0
  24. saic_ismart_client_ng/api/vehicle_charging/schema.py +614 -0
  25. saic_ismart_client_ng/crypto_utils.py +94 -0
  26. saic_ismart_client_ng/exceptions.py +31 -0
  27. saic_ismart_client_ng/listener.py +13 -0
  28. saic_ismart_client_ng/model.py +62 -0
  29. saic_ismart_client_ng/net/__init__.py +1 -0
  30. saic_ismart_client_ng/net/client/__init__.py +102 -0
  31. saic_ismart_client_ng/net/crypto.py +240 -0
  32. saic_ismart_client_ng/net/httpx/__init__.py +81 -0
  33. saic_ismart_client_ng/net/utils.py +11 -0
  34. saic_ismart_client_ng/py.typed +0 -0
@@ -0,0 +1,50 @@
1
+ Metadata-Version: 2.4
2
+ Name: mg-saic-client
3
+ Version: 0.9.4
4
+ Summary: SAIC next gen client library (MG iSMART)
5
+ License-File: LICENSE
6
+ Author: Giovanni Condello
7
+ Author-email: saic-python-client@nanomad.net
8
+ Requires-Python: >=3.11
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Requires-Dist: dacite (>=1.8.1,<2.0.0)
13
+ Requires-Dist: httpx (>=0.27.0,<0.29.0)
14
+ Requires-Dist: pycryptodome (>=3.20.0,<4.0.0)
15
+ Requires-Dist: tenacity (>=9.0.0,<10.0.0)
16
+ Project-URL: Homepage, https://github.com/SAIC-iSmart-API/saic-python-client-ng
17
+ Project-URL: Issues, https://github.com/SAIC-iSmart-API/saic-python-client-ng/issues
18
+ Description-Content-Type: text/markdown
19
+
20
+ # SAIC Python client library
21
+
22
+ ## About
23
+
24
+ A Python package for interacting with the MG iSmart API.
25
+
26
+ MG iSmart is the connectivity system in your MG car (MG5 MG4, ZS, MGS5, MGS6 ...).
27
+
28
+ Supported functionality (partial list, check the code for more)
29
+
30
+ * login
31
+ * set alarm switches
32
+ * get message list
33
+ * get vehicle status
34
+ * get charging status
35
+ * lock vehicle
36
+ * unlock vehicle
37
+ * start rear window heating
38
+ * stop rear window heating
39
+ * remote climate
40
+ * heated seats
41
+
42
+
43
+ ## Prerequisites
44
+
45
+ You have an iSmart account (can be created in the iSmart app)
46
+
47
+ ## Credit
48
+
49
+ This is a maintained fork of [SAIC-iSmart-API/saic-python-client-ng](https://github.com/SAIC-iSmart-API/saic-python-client-ng) by Giovanni Condello, used by the [mg-saic-ha](https://github.com/townsmcp/mg-saic-ha) Home Assistant integration. Licensed under MIT; see [LICENSE](LICENSE).
50
+
@@ -0,0 +1,34 @@
1
+ saic_ismart_client_ng/__init__.py,sha256=ec7PLuwM-B1EmZkeP6jrmgzSNUkl5j3OiHhkM7i0ZGk,746
2
+ saic_ismart_client_ng/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ saic_ismart_client_ng/api/base.py,sha256=8xP3rrR2MSm6dR6sPtxfmUIpI5FwUH_5uNRXkjBWGAU,14655
4
+ saic_ismart_client_ng/api/message/__init__.py,sha256=X0i1FLUnt6A7j4LQNML4YvTNOLKkPGBLRQIK4n-SlDs,2837
5
+ saic_ismart_client_ng/api/message/schema.py,sha256=p-LTlvDHCD7QOn-e4qfyppiSbHz9UtA6Rv6FGC0qklk,2417
6
+ saic_ismart_client_ng/api/schema.py,sha256=pTkbfpnUQ0_0kzfLdhuwnLPj1YbOidgevLwCJmkztpg,1836
7
+ saic_ismart_client_ng/api/serialization_utils.py,sha256=ckeDO9mUTd_7qABGlyBSKXpl2tEv2bxocRFiLXhtIYo,546
8
+ saic_ismart_client_ng/api/user/__init__.py,sha256=DXQAutvp8JObTxHjjU3IC7iho6-190ZfjzesKCGAPw4,408
9
+ saic_ismart_client_ng/api/user/schema.py,sha256=TqUSkpk7p8VpuAEn8825yeOa3zY95lrzs9kc3_zBvZ4,139
10
+ saic_ismart_client_ng/api/vehicle/__init__.py,sha256=0TEpuhhPiBOwctD2f0WIJ1glh_9XYXCWNe0DsF7mUJ8,2766
11
+ saic_ismart_client_ng/api/vehicle/alarm/__init__.py,sha256=FyYQ1HKlF2UMHFPf7Oa5sN2hCosctZdAlZbmxFGpOHU,1180
12
+ saic_ismart_client_ng/api/vehicle/alarm/schema.py,sha256=HFOo5bL2_0Kcz0fFmg-QGT5GTPwfKqjiC4IyeLojQM0,578
13
+ saic_ismart_client_ng/api/vehicle/climate/__init__.py,sha256=A8a93ZGQK0KWtM1JV9dP8LHT7A1hoIg9Y5Xvxo5oOXI,3519
14
+ saic_ismart_client_ng/api/vehicle/locks/__init__.py,sha256=fHaI3x1MsYwxeLOE2ArmzcgZrIPsCGMTYSIuaJRlBsg,2125
15
+ saic_ismart_client_ng/api/vehicle/locks/schema.py,sha256=6s8a3wJUbvpFdP6VC1KBR7TnfZXzuG25DzqA2Nbykk4,118
16
+ saic_ismart_client_ng/api/vehicle/schema.py,sha256=q7eZp5PL8ahHEyQStU63ogZIE23KT3a-EAWwSmtMts0,6337
17
+ saic_ismart_client_ng/api/vehicle/windows/__init__.py,sha256=uMns151TD4AWEXZ2bIGWHQf53UgSp0n-hMgZ8b7AGp4,1930
18
+ saic_ismart_client_ng/api/vehicle/windows/schema.py,sha256=SV4SZRy6M4C7CKvvQNoAeHnjWKX3AayVlIKCW8a8fL4,336
19
+ saic_ismart_client_ng/api/vehicle_charging/__init__.py,sha256=6gbs9HNo1uXR-Npxhlubr2Z6OA7ntbm7xQ1Pon5SVEk,8603
20
+ saic_ismart_client_ng/api/vehicle_charging/schema.py,sha256=8f6E-xc7jp4vKtLih3pAlRB_nQQ25M2gUfNiha6q1eo,19557
21
+ saic_ismart_client_ng/crypto_utils.py,sha256=WTDFZZLnkw0OzgxdyMRY7eCuRf1VNXnu8DxLHWABnX4,2929
22
+ saic_ismart_client_ng/exceptions.py,sha256=HXPKmCULQN3elE2RPe5dASBSyvPMi1XFtLIOwFifviw,816
23
+ saic_ismart_client_ng/listener.py,sha256=uCnhbJ1Ga0JKKBJfdw1jtq1coGVXU9aD_NMp_Rtg-E0,346
24
+ saic_ismart_client_ng/model.py,sha256=b4j9Ly1Cn0DAVeYERPrfEcAPUA84lbfjzyRPSnYtPJA,1655
25
+ saic_ismart_client_ng/net/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
26
+ saic_ismart_client_ng/net/client/__init__.py,sha256=sO1DVXVR0PfELPR3j2XOhdo0MRQwcUZ-dpfwcNn7Tjs,3486
27
+ saic_ismart_client_ng/net/crypto.py,sha256=V-8a4h_aGO2Tx5OhPJjPA9I0f5gVo-D6wvnUumg_ivQ,8865
28
+ saic_ismart_client_ng/net/httpx/__init__.py,sha256=Ak6gt-xjkno9wOUAjE-gDNc6B5T03S0gex1xPJEQouE,2639
29
+ saic_ismart_client_ng/net/utils.py,sha256=uCbRWR3oEX4MD99Lkrn26bk65sajPop9_YegPHaqx70,415
30
+ saic_ismart_client_ng/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
31
+ mg_saic_client-0.9.4.dist-info/METADATA,sha256=fWIRkmfn_A38Fz3jmb-bnTinOpgCeeMSH-XACEHcfRc,1582
32
+ mg_saic_client-0.9.4.dist-info/WHEEL,sha256=eY7nduwzv-ldUxpzbRlxwvC693Hg6PX8bWDjEHjZ_dk,88
33
+ mg_saic_client-0.9.4.dist-info/licenses/LICENSE,sha256=MLaavCZompIU1_X68Dps3CpXejMn19ESxP2IZPLTUhc,1083
34
+ mg_saic_client-0.9.4.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.4.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 The SAIC-iSmart-API project
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ from saic_ismart_client_ng.api.message import SaicMessageApi
4
+ from saic_ismart_client_ng.api.user import SaicUserApi
5
+ from saic_ismart_client_ng.api.vehicle.alarm import SaicVehicleAlarmApi
6
+ from saic_ismart_client_ng.api.vehicle.climate import SaicVehicleClimateApi
7
+ from saic_ismart_client_ng.api.vehicle.locks import SaicVehicleLocksApi
8
+ from saic_ismart_client_ng.api.vehicle.windows import SaicVehicleWindowsApi
9
+ from saic_ismart_client_ng.api.vehicle_charging import SaicVehicleChargingApi
10
+
11
+
12
+ class SaicApi(
13
+ SaicUserApi,
14
+ SaicMessageApi,
15
+ SaicVehicleAlarmApi,
16
+ SaicVehicleLocksApi,
17
+ SaicVehicleWindowsApi,
18
+ SaicVehicleClimateApi,
19
+ SaicVehicleChargingApi,
20
+ ):
21
+ """The SAIC Api client."""
File without changes
@@ -0,0 +1,429 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict
4
+ import datetime
5
+ import logging
6
+ from typing import (
7
+ TYPE_CHECKING,
8
+ Any,
9
+ ClassVar,
10
+ Protocol,
11
+ TypeVar,
12
+ )
13
+
14
+ import dacite
15
+ import httpx
16
+ import tenacity
17
+ from tenacity import RetryCallState, retry_if_exception
18
+
19
+ from saic_ismart_client_ng.api.schema import LoginResp
20
+ from saic_ismart_client_ng.crypto_utils import sha1_hex_digest
21
+ from saic_ismart_client_ng.exceptions import (
22
+ SaicApiException,
23
+ SaicApiRetryException,
24
+ SaicLogoutException,
25
+ )
26
+ from saic_ismart_client_ng.net.client import SaicApiClient
27
+
28
+ if TYPE_CHECKING:
29
+ from collections.abc import MutableMapping
30
+
31
+ from httpx._types import HeaderTypes, QueryParamTypes
32
+
33
+ from saic_ismart_client_ng.listener import SaicApiListener
34
+ from saic_ismart_client_ng.model import SaicApiConfiguration
35
+
36
+ class IsDataclass(Protocol):
37
+ # as already noted in comments, checking for this attribute is currently
38
+ # the most reliable way to ascertain that something is a dataclass
39
+ __dataclass_fields__: ClassVar[dict[str, Any]]
40
+
41
+ T = TypeVar("T", bound=IsDataclass)
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+
46
+ class AbstractSaicApi:
47
+ def __init__(
48
+ self,
49
+ configuration: SaicApiConfiguration,
50
+ listener: SaicApiListener | None = None,
51
+ ) -> None:
52
+ self.__configuration = configuration
53
+ self.__api_client = SaicApiClient(configuration, listener=listener)
54
+ self.__token_expiration: datetime.datetime | None = None
55
+
56
+ async def close(self) -> None:
57
+ """Close the API client and release its HTTP resources."""
58
+ await self.__api_client.close()
59
+
60
+ async def login(self) -> LoginResp:
61
+ headers = {
62
+ "Content-Type": "application/x-www-form-urlencoded",
63
+ "Accept": "application/json",
64
+ "Authorization": "Basic c3dvcmQ6c3dvcmRfc2VjcmV0",
65
+ }
66
+ firebase_device_id = (
67
+ "simulator*********************************************"
68
+ + str(int(datetime.datetime.now().timestamp()))
69
+ )
70
+ form_body = {
71
+ "grant_type": "password",
72
+ "username": self.__configuration.username,
73
+ "password": sha1_hex_digest(self.__configuration.password),
74
+ "scope": "all",
75
+ "deviceId": f"{firebase_device_id}###com.saicmotor.europecar",
76
+ "deviceType": "0", # 2 for huawei
77
+ "language": "EN",
78
+ }
79
+
80
+ if self.__configuration.username_is_email:
81
+ form_body.update({"loginType": "2"})
82
+ elif self.__configuration.phone_country_code is not None:
83
+ form_body.update(
84
+ {
85
+ "loginType": "1",
86
+ "countryCode": self.__configuration.phone_country_code,
87
+ }
88
+ )
89
+ else:
90
+ raise SaicApiException(
91
+ "Username is not an email but no phone number country code has been provided."
92
+ )
93
+
94
+ result = await self.execute_api_call(
95
+ "POST",
96
+ "/oauth/token",
97
+ form_body=form_body,
98
+ out_type=LoginResp,
99
+ headers=headers,
100
+ )
101
+ # Update the user token
102
+ if not (access_token := result.access_token) or not (
103
+ expiration := result.expires_in
104
+ ):
105
+ raise SaicApiException(
106
+ "Failed to get an access token, please check your credentials"
107
+ )
108
+
109
+ self.__api_client.user_token = access_token
110
+ self.__token_expiration = datetime.datetime.now() + datetime.timedelta(
111
+ seconds=expiration
112
+ )
113
+ return result
114
+
115
+ async def execute_api_call(
116
+ self,
117
+ method: str,
118
+ path: str,
119
+ *,
120
+ body: Any | None = None,
121
+ form_body: Any | None = None,
122
+ out_type: type[T],
123
+ params: QueryParamTypes | None = None,
124
+ headers: HeaderTypes | None = None,
125
+ ) -> T:
126
+ result = await self.__execute_api_call(
127
+ method,
128
+ path,
129
+ body=body,
130
+ form_body=form_body,
131
+ out_type=out_type,
132
+ params=params,
133
+ headers=headers,
134
+ allow_null_body=False,
135
+ )
136
+ if result is None:
137
+ msg = f"Failed to execute api call {method} {path}, was expecting a result of type {out_type} got None instead"
138
+ raise SaicApiException(msg)
139
+ return result
140
+
141
+ async def execute_api_call_with_optional_result(
142
+ self,
143
+ method: str,
144
+ path: str,
145
+ *,
146
+ body: Any | None = None,
147
+ form_body: Any | None = None,
148
+ out_type: type[T],
149
+ params: QueryParamTypes | None = None,
150
+ headers: HeaderTypes | None = None,
151
+ ) -> T | None:
152
+ return await self.__execute_api_call(
153
+ method,
154
+ path,
155
+ body=body,
156
+ form_body=form_body,
157
+ out_type=out_type,
158
+ params=params,
159
+ headers=headers,
160
+ allow_null_body=True,
161
+ )
162
+
163
+ async def execute_api_call_no_result(
164
+ self,
165
+ method: str,
166
+ path: str,
167
+ *,
168
+ body: Any | None = None,
169
+ form_body: Any | None = None,
170
+ params: QueryParamTypes | None = None,
171
+ headers: HeaderTypes | None = None,
172
+ ) -> None:
173
+ await self.__execute_api_call(
174
+ method,
175
+ path,
176
+ body=body,
177
+ form_body=form_body,
178
+ params=params,
179
+ headers=headers,
180
+ allow_null_body=True,
181
+ )
182
+
183
+ async def __execute_api_call(
184
+ self,
185
+ method: str,
186
+ path: str,
187
+ *,
188
+ body: Any | None = None,
189
+ form_body: Any | None = None,
190
+ out_type: type[T] | None = None,
191
+ params: QueryParamTypes | None = None,
192
+ headers: HeaderTypes | None = None,
193
+ allow_null_body: bool = False,
194
+ ) -> T | None:
195
+ try:
196
+ url = f"{self.__configuration.base_uri}{path.removeprefix('/')}"
197
+ json_body = asdict(body) if body else None
198
+ req = httpx.Request(
199
+ method,
200
+ url,
201
+ params=params,
202
+ headers=headers,
203
+ data=form_body,
204
+ json=json_body,
205
+ )
206
+ response = await self.__api_client.send(req)
207
+ return await self.__deserialize(req, response, out_type, allow_null_body)
208
+ except SaicApiException as e:
209
+ raise e
210
+ except Exception as e:
211
+ msg = f"API call {method} {path} failed unexpectedly"
212
+ raise SaicApiException(msg, return_code=500) from e
213
+
214
+ async def execute_api_call_with_event_id(
215
+ self,
216
+ method: str,
217
+ path: str,
218
+ *,
219
+ body: Any | None = None,
220
+ out_type: type[T],
221
+ params: QueryParamTypes | None = None,
222
+ headers: MutableMapping[str, str] | None = None,
223
+ delay: tenacity.wait.WaitBaseT | None = None,
224
+ ) -> T:
225
+ result = await self.__execute_api_call_with_event_id(
226
+ method,
227
+ path,
228
+ body=body,
229
+ out_type=out_type,
230
+ params=params,
231
+ headers=headers,
232
+ delay=delay,
233
+ )
234
+ if result is None:
235
+ msg = f"Failed to execute api call {method} {path}, was expecting a result of type {out_type} got None instead"
236
+ raise SaicApiException(msg)
237
+ return result
238
+
239
+ async def execute_api_call_with_event_id_no_result(
240
+ self,
241
+ method: str,
242
+ path: str,
243
+ *,
244
+ body: Any | None = None,
245
+ params: QueryParamTypes | None = None,
246
+ headers: MutableMapping[str, str] | None = None,
247
+ delay: tenacity.wait.WaitBaseT | None = None,
248
+ ) -> None:
249
+ await self.__execute_api_call_with_event_id(
250
+ method,
251
+ path,
252
+ body=body,
253
+ params=params,
254
+ headers=headers,
255
+ delay=delay,
256
+ )
257
+
258
+ async def __execute_api_call_with_event_id(
259
+ self,
260
+ method: str,
261
+ path: str,
262
+ *,
263
+ body: Any | None = None,
264
+ out_type: type[T] | None = None,
265
+ params: QueryParamTypes | None = None,
266
+ headers: MutableMapping[str, str] | None = None,
267
+ delay: tenacity.wait.WaitBaseT | None = None,
268
+ ) -> T | None:
269
+ @tenacity.retry(
270
+ stop=tenacity.stop_after_delay(30),
271
+ wait=delay or tenacity.wait_fixed(self.__configuration.sms_delivery_delay),
272
+ retry=SaicApiRetryPolicy(),
273
+ after=saic_api_after_retry,
274
+ reraise=True,
275
+ )
276
+ async def execute_api_call_with_event_id_inner(*, event_id: str) -> T | None:
277
+ actual_headers = headers or {}
278
+ actual_headers.update({"event-id": event_id})
279
+ return await self.__execute_api_call(
280
+ method,
281
+ path,
282
+ body=body,
283
+ out_type=out_type,
284
+ params=params,
285
+ headers=actual_headers,
286
+ )
287
+
288
+ return await execute_api_call_with_event_id_inner(event_id="0")
289
+
290
+ async def __deserialize(
291
+ self,
292
+ request: httpx.Request,
293
+ response: httpx.Response,
294
+ data_class: type[T] | None,
295
+ allow_null_body: bool,
296
+ ) -> T | None:
297
+ try:
298
+ request_event_id = request.headers.get("event-id")
299
+ json_data = response.json()
300
+ return_code = json_data.get("code", -1)
301
+ error_message = json_data.get("message", "Unknown error")
302
+ logger.debug("Response code: %s %s", return_code, response.text)
303
+
304
+ if return_code in (401, 403) or response.status_code in (401, 403):
305
+ self.logout()
306
+ raise SaicLogoutException(response.text, return_code)
307
+
308
+ if return_code in (2, 3, 7):
309
+ logger.error(
310
+ "API call return code is not acceptable: %s: %s",
311
+ return_code,
312
+ response.text,
313
+ )
314
+ raise SaicApiException(error_message, return_code=return_code)
315
+
316
+ if "event-id" in response.headers and "data" not in json_data:
317
+ event_id = response.headers["event-id"]
318
+ logger.info(
319
+ "Retrying since we got event-id in headers: %s, but no data",
320
+ event_id,
321
+ )
322
+ raise SaicApiRetryException(
323
+ error_message, event_id=event_id, return_code=return_code
324
+ )
325
+
326
+ if return_code != 0:
327
+ if request_event_id is not None and request_event_id != "0":
328
+ logger.info(
329
+ "API call asked us to retry: %s %s. Event id was: %s",
330
+ return_code,
331
+ response.text,
332
+ request_event_id,
333
+ )
334
+ raise SaicApiRetryException(
335
+ error_message,
336
+ event_id=request_event_id,
337
+ return_code=return_code,
338
+ )
339
+ logger.error(
340
+ "API call return code is not acceptable: %s %s. Headers: %s",
341
+ return_code,
342
+ response.text,
343
+ response.headers,
344
+ )
345
+ raise SaicApiException(error_message, return_code=return_code)
346
+
347
+ if data_class is None:
348
+ return None
349
+ if "data" in json_data:
350
+ return dacite.from_dict(data_class, json_data["data"])
351
+ if allow_null_body:
352
+ return None
353
+ msg = (
354
+ f"Failed to deserialize response, missing 'data' field: {response.text}"
355
+ )
356
+ raise SaicApiException(msg)
357
+
358
+ except SaicApiException as se:
359
+ raise se
360
+ except Exception as e:
361
+ if response.is_error:
362
+ if response.status_code in (401, 403):
363
+ logger.exception(
364
+ "API call failed due to an authentication failure: %s %s",
365
+ response.status_code,
366
+ response.text,
367
+ )
368
+ self.logout()
369
+ raise SaicLogoutException(
370
+ response.text, response.status_code
371
+ ) from e
372
+ logger.exception(
373
+ "API call failed: %s %s",
374
+ response.status_code,
375
+ response.text,
376
+ )
377
+ raise SaicApiException(response.text, response.status_code) from e
378
+ msg = f"Failed to deserialize response: {e}. Original json was {response.text}"
379
+ raise SaicApiException(msg) from e
380
+
381
+ def logout(self) -> None:
382
+ self.__api_client.user_token = ""
383
+ self.__token_expiration = None
384
+
385
+ @property
386
+ def is_logged_in(self) -> bool:
387
+ return (
388
+ self.__api_client.user_token is not None
389
+ and self.__token_expiration is not None
390
+ and self.__token_expiration > datetime.datetime.now()
391
+ )
392
+
393
+ @property
394
+ def token_expiration(self) -> datetime.datetime | None:
395
+ return self.__token_expiration
396
+
397
+
398
+ def saic_api_after_retry(retry_state: RetryCallState) -> None:
399
+ if not retry_state.outcome:
400
+ return
401
+ wrapped_exception = retry_state.outcome.exception()
402
+ if isinstance(wrapped_exception, SaicApiRetryException):
403
+ if "event_id" in retry_state.kwargs:
404
+ event_id = wrapped_exception.event_id
405
+ logger.debug(
406
+ "Updating event_id to the newly obtained value %d",
407
+ event_id,
408
+ )
409
+ retry_state.kwargs["event_id"] = event_id
410
+ else:
411
+ logger.debug("Retrying without an event_id")
412
+
413
+
414
+ class SaicApiRetryPolicy(retry_if_exception):
415
+ def __init__(self) -> None:
416
+ def __retry_policy(wrapped_exception: BaseException) -> bool:
417
+ if isinstance(wrapped_exception, SaicApiRetryException):
418
+ logger.debug("Retrying since we got SaicApiRetryException")
419
+ return True
420
+ if isinstance(wrapped_exception, SaicLogoutException):
421
+ logger.error("Not retrying since we got logged out")
422
+ return False
423
+ if isinstance(wrapped_exception, SaicApiException):
424
+ logger.error("Not retrying since we got a generic exception")
425
+ return False
426
+ logger.error("Not retrying", exc_info=wrapped_exception)
427
+ return False
428
+
429
+ super().__init__(__retry_policy)
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ from saic_ismart_client_ng.api.base import AbstractSaicApi
4
+ from saic_ismart_client_ng.api.message.schema import (
5
+ MessageEntity,
6
+ MessageResp,
7
+ UpateMessageRequest,
8
+ )
9
+
10
+ __all__ = [
11
+ "MessageEntity",
12
+ "MessageResp",
13
+ "UpateMessageRequest",
14
+ ]
15
+
16
+
17
+ class SaicMessageApi(AbstractSaicApi):
18
+ async def get_alarm_list(
19
+ self, *, page_num: int, page_size: int
20
+ ) -> MessageResp | None:
21
+ return await self.get_message_list(
22
+ page_num=page_num, page_size=page_size, message_group="ALARM"
23
+ )
24
+
25
+ async def get_command_list(
26
+ self, *, page_num: int, page_size: int
27
+ ) -> MessageResp | None:
28
+ return await self.get_message_list(
29
+ page_num=page_num, page_size=page_size, message_group="COMMAND"
30
+ )
31
+
32
+ async def get_news_list(
33
+ self, *, page_num: int, page_size: int
34
+ ) -> MessageResp | None:
35
+ return await self.get_message_list(
36
+ page_num=page_num, page_size=page_size, message_group="NEWS"
37
+ )
38
+
39
+ async def get_message_list(
40
+ self, *, page_num: int, page_size: int, message_group: str
41
+ ) -> MessageResp | None:
42
+ return await self.execute_api_call_with_optional_result(
43
+ "GET",
44
+ "/message/list",
45
+ params={
46
+ "pageNum": page_num,
47
+ "pageSize": page_size,
48
+ "messageGroup": message_group,
49
+ },
50
+ out_type=MessageResp,
51
+ )
52
+
53
+ async def delete_all_alarms(self) -> None:
54
+ await self.__change_message_status(action="DELETE_ALARM")
55
+
56
+ async def delete_all_commands(self) -> None:
57
+ await self.__change_message_status(action="DELETE_COMMAND")
58
+
59
+ async def delete_all_news(self) -> None:
60
+ await self.__change_message_status(action="DELETE_NEWS")
61
+
62
+ async def read_message(self, *, message_id: str | int) -> None:
63
+ await self.__change_message_status(message_id=message_id, action="READ")
64
+
65
+ async def delete_message(self, *, message_id: str | int) -> None:
66
+ await self.__change_message_status(message_id=message_id, action="DELETE")
67
+
68
+ async def __change_message_status(
69
+ self, *, action: str, message_id: str | int | None = None
70
+ ) -> None:
71
+ request = UpateMessageRequest(
72
+ actionType=action,
73
+ messageId=message_id,
74
+ )
75
+ await self.update_message_status(request)
76
+
77
+ async def update_message_status(self, data: UpateMessageRequest) -> None:
78
+ await self.execute_api_call_no_result(
79
+ "PUT",
80
+ "/message/status",
81
+ body=data,
82
+ )
83
+
84
+ async def get_unread_messages_count(self) -> MessageResp | None:
85
+ return await self.execute_api_call_with_optional_result(
86
+ "GET", "/message/unreadCount", out_type=MessageResp
87
+ )