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.
- mg_saic_client-0.9.4.dist-info/METADATA +50 -0
- mg_saic_client-0.9.4.dist-info/RECORD +34 -0
- mg_saic_client-0.9.4.dist-info/WHEEL +4 -0
- mg_saic_client-0.9.4.dist-info/licenses/LICENSE +21 -0
- saic_ismart_client_ng/__init__.py +21 -0
- saic_ismart_client_ng/api/__init__.py +0 -0
- saic_ismart_client_ng/api/base.py +429 -0
- saic_ismart_client_ng/api/message/__init__.py +87 -0
- saic_ismart_client_ng/api/message/schema.py +82 -0
- saic_ismart_client_ng/api/schema.py +74 -0
- saic_ismart_client_ng/api/serialization_utils.py +17 -0
- saic_ismart_client_ng/api/user/__init__.py +13 -0
- saic_ismart_client_ng/api/user/schema.py +8 -0
- saic_ismart_client_ng/api/vehicle/__init__.py +94 -0
- saic_ismart_client_ng/api/vehicle/alarm/__init__.py +39 -0
- saic_ismart_client_ng/api/vehicle/alarm/schema.py +28 -0
- saic_ismart_client_ng/api/vehicle/climate/__init__.py +107 -0
- saic_ismart_client_ng/api/vehicle/locks/__init__.py +60 -0
- saic_ismart_client_ng/api/vehicle/locks/schema.py +8 -0
- saic_ismart_client_ng/api/vehicle/schema.py +220 -0
- saic_ismart_client_ng/api/vehicle/windows/__init__.py +59 -0
- saic_ismart_client_ng/api/vehicle/windows/schema.py +13 -0
- saic_ismart_client_ng/api/vehicle_charging/__init__.py +256 -0
- saic_ismart_client_ng/api/vehicle_charging/schema.py +614 -0
- saic_ismart_client_ng/crypto_utils.py +94 -0
- saic_ismart_client_ng/exceptions.py +31 -0
- saic_ismart_client_ng/listener.py +13 -0
- saic_ismart_client_ng/model.py +62 -0
- saic_ismart_client_ng/net/__init__.py +1 -0
- saic_ismart_client_ng/net/client/__init__.py +102 -0
- saic_ismart_client_ng/net/crypto.py +240 -0
- saic_ismart_client_ng/net/httpx/__init__.py +81 -0
- saic_ismart_client_ng/net/utils.py +11 -0
- 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,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
|
+
)
|