wz-opensdk 0.1.0__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.
- wz_opensdk-0.1.0.dist-info/METADATA +104 -0
- wz_opensdk-0.1.0.dist-info/RECORD +17 -0
- wz_opensdk-0.1.0.dist-info/WHEEL +5 -0
- wz_opensdk-0.1.0.dist-info/top_level.txt +1 -0
- wzopensdk/__init__.py +13 -0
- wzopensdk/_internal/__init__.py +1 -0
- wzopensdk/_internal/http.py +207 -0
- wzopensdk/_internal/params.py +52 -0
- wzopensdk/_internal/signer.py +96 -0
- wzopensdk/callback.py +66 -0
- wzopensdk/charge.py +34 -0
- wzopensdk/client.py +71 -0
- wzopensdk/device.py +312 -0
- wzopensdk/endpoints.py +6 -0
- wzopensdk/errors.py +47 -0
- wzopensdk/invoice.py +84 -0
- wzopensdk/renew.py +61 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wz-opensdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 万桩开放平台 Python OpenSDK
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://gitee.com/wz-open-platform/wz-opensdk-python
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: httpx>=0.27
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
12
|
+
|
|
13
|
+
# wz-opensdk
|
|
14
|
+
|
|
15
|
+
万桩开放平台 Python SDK。覆盖 Apifox 项目 `wz-open` 中文目录下的公开 API(设备、充电、续费、开票、回调日志)。`inner/*` 内部接口不在本 SDK 中。SDK 只做 client,不解析平台推送到合作方的入站回调。
|
|
16
|
+
|
|
17
|
+
发行名:`wz-opensdk`。导入名:`wzopensdk`。Python **3.9+**。
|
|
18
|
+
|
|
19
|
+
## 安装
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install wz-opensdk
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
尚未发布到 PyPI 时,可从 Gitee 安装:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install git+https://gitee.com/wz-open-platform/wz-opensdk-python.git@v0.1.0
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
本地开发:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
python3 -m venv .venv
|
|
35
|
+
source .venv/bin/activate
|
|
36
|
+
pip install -e ".[dev]"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## 快速开始
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
import os
|
|
43
|
+
from wzopensdk import WzOpenClient, Endpoints
|
|
44
|
+
from wzopensdk.device import DeviceNumRequest
|
|
45
|
+
|
|
46
|
+
client = WzOpenClient(
|
|
47
|
+
endpoint=Endpoints.PROD, # 测试环境用 Endpoints.TEST
|
|
48
|
+
app_id=os.environ["WZ_OPEN_APP_ID"],
|
|
49
|
+
app_secret=os.environ["WZ_OPEN_APP_SECRET"],
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
device = client.device.find_one(DeviceNumRequest("18000001"))
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
也可 `WzOpenClient.from_env()`(读 `WZ_OPEN_APP_ID` / `WZ_OPEN_APP_SECRET`,可选 `WZ_OPEN_ENDPOINT`,缺省测试环境)。
|
|
56
|
+
|
|
57
|
+
按域调用,方法不堆在 `WzOpenClient` 上:
|
|
58
|
+
|
|
59
|
+
- `client.device` 设备绑定、计费模式、状态、在线日志
|
|
60
|
+
- `client.charge.start` / `stop`
|
|
61
|
+
- `client.renew`
|
|
62
|
+
- `client.invoice`
|
|
63
|
+
- `client.callback.log` / `retry_finish_order`
|
|
64
|
+
|
|
65
|
+
默认请求超时 10s。
|
|
66
|
+
|
|
67
|
+
## 签名
|
|
68
|
+
|
|
69
|
+
SDK 按接入指引自动加 `app_id`、`timestamp`、`sign`(HMAC-SHA256 大写 hex)。调用方不要自己拼签,也不要传 `company_id`。金额单位为分。
|
|
70
|
+
|
|
71
|
+
契约快照:[../api/openapi.json](../api/openapi.json)(汽车桩 `saveMode`/`getMode` 在 SDK 中拆成独立方法;`/open/device/setting/sync` 属于 inner,本 SDK 不实现)。公开能力合计 40 条出站 API。
|
|
72
|
+
|
|
73
|
+
## 日志
|
|
74
|
+
|
|
75
|
+
使用标准库 `logging`,logger 名 `wzopensdk`。可传入自定义 logger。
|
|
76
|
+
|
|
77
|
+
成功请求的 info 日志会带 `method`、`path`;响应头有 `X-B3-TraceId` 时再带 `traceId`。失败时同一字段出现在 error 日志里,并可从 `WzOpenAPIError.trace_id` 读取。
|
|
78
|
+
|
|
79
|
+
## HTTP
|
|
80
|
+
|
|
81
|
+
可配 timeout(默认 10s)、retry_count(失败重试且每次重签)、retry_wait、user_agent、proxy_url,或直接传入 `httpx.Client`。
|
|
82
|
+
|
|
83
|
+
## 环境
|
|
84
|
+
|
|
85
|
+
| 环境 | Base URL |
|
|
86
|
+
| --- | --- |
|
|
87
|
+
| 测试 | `https://dev-api-open.wanzhuangkj.com` |
|
|
88
|
+
| 正式 | `https://api-open.wanzhuangkj.com` |
|
|
89
|
+
|
|
90
|
+
## 开发
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
pip install -e ".[dev]"
|
|
94
|
+
python3 -m pytest -q
|
|
95
|
+
# 或 make test
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
现网验证(需要凭证):
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
export WZ_OPEN_APP_ID=...
|
|
102
|
+
export WZ_OPEN_APP_SECRET=...
|
|
103
|
+
make verify
|
|
104
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
wzopensdk/__init__.py,sha256=113LusWCYLL8ynMfXnlgmM7ps3hfxCQGtqtRzqCwQ_Q,322
|
|
2
|
+
wzopensdk/callback.py,sha256=mhnNxkaiXMGYm8tSq2KwdKz22VvLMiBlhR8N6zBTLuc,2012
|
|
3
|
+
wzopensdk/charge.py,sha256=uAVjuD8tYktCX5YzLvL_39YTtsZzTZ-Z4-fSg5dOIWg,880
|
|
4
|
+
wzopensdk/client.py,sha256=Ipr1rBmHkwijvvVRLXqxgupwm_k5LZP5Y66MytbvStg,2342
|
|
5
|
+
wzopensdk/device.py,sha256=dCC2zKiAWe82Q1pPNLANvIeN6-U_hJSzeup7En230mY,11254
|
|
6
|
+
wzopensdk/endpoints.py,sha256=JrReG5nVhJ8-_vAUWUPXW0VNqrKIU_4fieHvS1xtCKU,146
|
|
7
|
+
wzopensdk/errors.py,sha256=prEXr0FNn7WLGB-W4mYXHRNXEq6jGbQkcYVp1_-GLBY,1504
|
|
8
|
+
wzopensdk/invoice.py,sha256=nck0pgW09zxmZkjd3Qxi6RcuhdyCWDJ5ge7Zfj4Ic1c,2540
|
|
9
|
+
wzopensdk/renew.py,sha256=j9d2iCwwNsOJv-I0rA4k5Vb7Sh4wnA7a0i7DzOWl3Bo,1790
|
|
10
|
+
wzopensdk/_internal/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
11
|
+
wzopensdk/_internal/http.py,sha256=9M7CLYbovHuNroFXYZt7h5ici3m1ySCOSC8UzfHmAGE,6841
|
|
12
|
+
wzopensdk/_internal/params.py,sha256=6Tdckct8TyM_WodDYQEHsn0pq9SjFhzwkQH499faLf0,1593
|
|
13
|
+
wzopensdk/_internal/signer.py,sha256=k49hcX7tSbprnHslO2JLZZM1ue9xhciQPO35yLyWb9E,2464
|
|
14
|
+
wz_opensdk-0.1.0.dist-info/METADATA,sha256=hshmA9321JDveL-lW0xyHggrG-C0Lm5SRNr3Zb066F4,2978
|
|
15
|
+
wz_opensdk-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
16
|
+
wz_opensdk-0.1.0.dist-info/top_level.txt,sha256=4--5oBhCOH3qUaCMca1XDJ8s7g9MqV7qM1tgYVx8JfI,10
|
|
17
|
+
wz_opensdk-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
wzopensdk
|
wzopensdk/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""万桩开放平台 Python OpenSDK。"""
|
|
2
|
+
|
|
3
|
+
from wzopensdk.client import WzOpenClient
|
|
4
|
+
from wzopensdk.endpoints import Endpoints
|
|
5
|
+
from wzopensdk.errors import WzOpenAPIError, WzOpenConfigError, WzOpenError
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"Endpoints",
|
|
9
|
+
"WzOpenAPIError",
|
|
10
|
+
"WzOpenClient",
|
|
11
|
+
"WzOpenConfigError",
|
|
12
|
+
"WzOpenError",
|
|
13
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Sign, send, retry, and decode open-platform envelopes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any, Callable, TypeVar
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from wzopensdk._internal import params as paramx
|
|
13
|
+
from wzopensdk._internal import signer
|
|
14
|
+
from wzopensdk.errors import WzOpenAPIError, WzOpenError
|
|
15
|
+
|
|
16
|
+
CODE_SUCCESS = 1
|
|
17
|
+
HEADER_B3_TRACE_ID = "X-B3-TraceId"
|
|
18
|
+
T = TypeVar("T")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class HttpInvoker:
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
*,
|
|
25
|
+
endpoint: str,
|
|
26
|
+
app_id: str,
|
|
27
|
+
app_secret: str,
|
|
28
|
+
timeout: float = 10.0,
|
|
29
|
+
retry_count: int = 0,
|
|
30
|
+
retry_wait: float = 0.2,
|
|
31
|
+
user_agent: str = "wz-opensdk/python",
|
|
32
|
+
proxy_url: str | None = None,
|
|
33
|
+
insecure_skip_verify: bool = False,
|
|
34
|
+
logger: logging.Logger | None = None,
|
|
35
|
+
http_client: httpx.Client | None = None,
|
|
36
|
+
clock: Callable[[], int] | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
self.endpoint = endpoint.rstrip("/")
|
|
39
|
+
self.app_id = app_id
|
|
40
|
+
self.app_secret = app_secret
|
|
41
|
+
self.timeout = timeout if timeout > 0 else 10.0
|
|
42
|
+
self.retry_count = max(0, retry_count)
|
|
43
|
+
self.retry_wait = retry_wait if retry_wait > 0 else 0.2
|
|
44
|
+
self.user_agent = user_agent or "wz-opensdk/python"
|
|
45
|
+
self.log = logger or logging.getLogger("wzopensdk")
|
|
46
|
+
self.clock = clock or (lambda: int(time.time()))
|
|
47
|
+
if http_client is not None:
|
|
48
|
+
self.http = http_client
|
|
49
|
+
self._owns_client = False
|
|
50
|
+
else:
|
|
51
|
+
self.http = httpx.Client(
|
|
52
|
+
timeout=self.timeout,
|
|
53
|
+
proxy=proxy_url,
|
|
54
|
+
verify=not insecure_skip_verify,
|
|
55
|
+
)
|
|
56
|
+
self._owns_client = True
|
|
57
|
+
|
|
58
|
+
def invoke(
|
|
59
|
+
self,
|
|
60
|
+
method: str,
|
|
61
|
+
path: str,
|
|
62
|
+
operation: str,
|
|
63
|
+
incoming: Any,
|
|
64
|
+
decode: Callable[[Any], T],
|
|
65
|
+
) -> T:
|
|
66
|
+
business = paramx.to_params(incoming)
|
|
67
|
+
wait = self.retry_wait
|
|
68
|
+
attempts = self.retry_count + 1
|
|
69
|
+
last: WzOpenError | None = None
|
|
70
|
+
for attempt in range(attempts):
|
|
71
|
+
if attempt > 0:
|
|
72
|
+
time.sleep(wait)
|
|
73
|
+
wait *= 2
|
|
74
|
+
try:
|
|
75
|
+
return self._attempt_once(method, path, operation, business, decode)
|
|
76
|
+
except WzOpenError as exc:
|
|
77
|
+
last = exc
|
|
78
|
+
if not self._should_retry(exc) or attempt == attempts - 1:
|
|
79
|
+
raise
|
|
80
|
+
raise last or WzOpenError("request failed")
|
|
81
|
+
|
|
82
|
+
def invoke_map(self, method: str, path: str, operation: str, incoming: Any) -> dict[str, Any] | None:
|
|
83
|
+
return self.invoke(method, path, operation, incoming, lambda data: data)
|
|
84
|
+
|
|
85
|
+
def invoke_void(self, method: str, path: str, operation: str, incoming: Any) -> None:
|
|
86
|
+
self.invoke(method, path, operation, incoming, lambda data: data)
|
|
87
|
+
|
|
88
|
+
def _attempt_once(
|
|
89
|
+
self,
|
|
90
|
+
method: str,
|
|
91
|
+
path: str,
|
|
92
|
+
operation: str,
|
|
93
|
+
business: dict[str, Any],
|
|
94
|
+
decode: Callable[[Any], T],
|
|
95
|
+
) -> T:
|
|
96
|
+
payload = dict(business)
|
|
97
|
+
payload["app_id"] = self.app_id
|
|
98
|
+
payload["timestamp"] = self.clock()
|
|
99
|
+
payload["sign"] = signer.sign(payload, self.app_secret)
|
|
100
|
+
|
|
101
|
+
method = method.upper()
|
|
102
|
+
url = self.endpoint + path
|
|
103
|
+
headers = {
|
|
104
|
+
"Accept": "application/json",
|
|
105
|
+
"User-Agent": self.user_agent,
|
|
106
|
+
}
|
|
107
|
+
request_kwargs: dict[str, Any] = {
|
|
108
|
+
"headers": headers,
|
|
109
|
+
"timeout": self.timeout,
|
|
110
|
+
}
|
|
111
|
+
if method in {"GET", "HEAD"}:
|
|
112
|
+
qs = paramx.encode_query(payload)
|
|
113
|
+
if qs:
|
|
114
|
+
url = f"{url}?{qs}"
|
|
115
|
+
else:
|
|
116
|
+
request_kwargs["json"] = payload
|
|
117
|
+
headers["Content-Type"] = "application/json"
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
resp = self.http.request(method, url, **request_kwargs)
|
|
121
|
+
except httpx.TimeoutException as exc:
|
|
122
|
+
self.log.error(
|
|
123
|
+
"open api request failed method=%s path=%s operation=%s err=%s",
|
|
124
|
+
method,
|
|
125
|
+
path,
|
|
126
|
+
operation,
|
|
127
|
+
exc,
|
|
128
|
+
)
|
|
129
|
+
raise WzOpenError(f"request timed out: {exc}") from exc
|
|
130
|
+
except httpx.HTTPError as exc:
|
|
131
|
+
self.log.error(
|
|
132
|
+
"open api request failed method=%s path=%s operation=%s err=%s",
|
|
133
|
+
method,
|
|
134
|
+
path,
|
|
135
|
+
operation,
|
|
136
|
+
exc,
|
|
137
|
+
)
|
|
138
|
+
raise WzOpenError(f"network error: {exc}") from exc
|
|
139
|
+
|
|
140
|
+
status = resp.status_code
|
|
141
|
+
trace_id = _response_trace_id(resp)
|
|
142
|
+
if status < 200 or status >= 300:
|
|
143
|
+
self._log_failure(method, path, operation, status, trace_id)
|
|
144
|
+
raise WzOpenAPIError.http_status(status, operation, trace_id)
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
env = resp.json()
|
|
148
|
+
except json.JSONDecodeError as exc:
|
|
149
|
+
raise WzOpenError(f"decode envelope: {exc}") from exc
|
|
150
|
+
if not isinstance(env, dict):
|
|
151
|
+
raise WzOpenError("decode envelope: not an object")
|
|
152
|
+
|
|
153
|
+
code = int(env.get("code") or 0)
|
|
154
|
+
if code != CODE_SUCCESS:
|
|
155
|
+
raise WzOpenAPIError(status, code, str(env.get("message") or ""), operation, trace_id)
|
|
156
|
+
|
|
157
|
+
self._log_success(method, path, operation, status, trace_id)
|
|
158
|
+
data = env.get("data")
|
|
159
|
+
return decode(data)
|
|
160
|
+
|
|
161
|
+
def _log_success(self, method: str, path: str, operation: str, status: int, trace_id: str) -> None:
|
|
162
|
+
if trace_id:
|
|
163
|
+
self.log.info(
|
|
164
|
+
"open api request method=%s path=%s operation=%s status=%s traceId=%s",
|
|
165
|
+
method,
|
|
166
|
+
path,
|
|
167
|
+
operation,
|
|
168
|
+
status,
|
|
169
|
+
trace_id,
|
|
170
|
+
)
|
|
171
|
+
return
|
|
172
|
+
self.log.info(
|
|
173
|
+
"open api request method=%s path=%s operation=%s status=%s",
|
|
174
|
+
method,
|
|
175
|
+
path,
|
|
176
|
+
operation,
|
|
177
|
+
status,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def _log_failure(self, method: str, path: str, operation: str, status: int, trace_id: str) -> None:
|
|
181
|
+
if trace_id:
|
|
182
|
+
self.log.error(
|
|
183
|
+
"open api request failed method=%s path=%s operation=%s status=%s traceId=%s",
|
|
184
|
+
method,
|
|
185
|
+
path,
|
|
186
|
+
operation,
|
|
187
|
+
status,
|
|
188
|
+
trace_id,
|
|
189
|
+
)
|
|
190
|
+
return
|
|
191
|
+
self.log.error(
|
|
192
|
+
"open api request failed method=%s path=%s operation=%s status=%s",
|
|
193
|
+
method,
|
|
194
|
+
path,
|
|
195
|
+
operation,
|
|
196
|
+
status,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
def _should_retry(self, err: WzOpenError) -> bool:
|
|
200
|
+
if isinstance(err, WzOpenAPIError):
|
|
201
|
+
return err.status >= 500
|
|
202
|
+
cause = err.__cause__
|
|
203
|
+
return isinstance(cause, httpx.HTTPError)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _response_trace_id(resp: httpx.Response) -> str:
|
|
207
|
+
return (resp.headers.get(HEADER_B3_TRACE_ID) or "").strip()
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Convert request models to signed parameter maps and GET query strings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict, is_dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import quote
|
|
8
|
+
|
|
9
|
+
from wzopensdk._internal import signer
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def to_params(value: Any) -> dict[str, Any]:
|
|
13
|
+
if value is None:
|
|
14
|
+
return {}
|
|
15
|
+
if isinstance(value, dict):
|
|
16
|
+
raw = value
|
|
17
|
+
elif is_dataclass(value) and not isinstance(value, type):
|
|
18
|
+
raw = asdict(value)
|
|
19
|
+
elif hasattr(value, "__dict__"):
|
|
20
|
+
raw = {k: v for k, v in vars(value).items() if not k.startswith("_")}
|
|
21
|
+
else:
|
|
22
|
+
raw = {}
|
|
23
|
+
out: dict[str, Any] = {}
|
|
24
|
+
for key, item in raw.items():
|
|
25
|
+
if signer.is_blank(item):
|
|
26
|
+
continue
|
|
27
|
+
out[str(key)] = item
|
|
28
|
+
return out
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def encode_query(params: dict[str, Any]) -> str:
|
|
32
|
+
if not params:
|
|
33
|
+
return ""
|
|
34
|
+
parts: list[str] = []
|
|
35
|
+
for key in sorted(params.keys()):
|
|
36
|
+
value = params[key]
|
|
37
|
+
if signer.is_blank(value):
|
|
38
|
+
continue
|
|
39
|
+
items = _as_slice(value)
|
|
40
|
+
if items is not None:
|
|
41
|
+
q_key = key if key.endswith("[]") else key + "[]"
|
|
42
|
+
for item in items:
|
|
43
|
+
parts.append(f"{quote(q_key, safe='')}={quote(item, safe='')}")
|
|
44
|
+
continue
|
|
45
|
+
parts.append(f"{quote(str(key), safe='')}={quote(signer.stringify(value), safe='')}")
|
|
46
|
+
return "&".join(parts)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _as_slice(value: Any) -> list[str] | None:
|
|
50
|
+
if isinstance(value, (list, tuple)):
|
|
51
|
+
return [signer.stringify(item) for item in value if not signer.is_blank(item)]
|
|
52
|
+
return None
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""HMAC-SHA256 signing aligned with api-open AuthLogic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hmac
|
|
6
|
+
import math
|
|
7
|
+
from hashlib import sha256
|
|
8
|
+
from typing import Any, Mapping
|
|
9
|
+
|
|
10
|
+
KEY_SIGN = "sign"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def content(params: Mapping[str, Any] | None) -> str:
|
|
14
|
+
if not params:
|
|
15
|
+
return ""
|
|
16
|
+
keys: list[str] = []
|
|
17
|
+
for key, value in params.items():
|
|
18
|
+
if key == KEY_SIGN:
|
|
19
|
+
continue
|
|
20
|
+
if is_blank(value):
|
|
21
|
+
continue
|
|
22
|
+
keys.append(str(key))
|
|
23
|
+
keys.sort()
|
|
24
|
+
return "&".join(f"{key}={stringify(params[key])}" for key in keys)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def sum_hmac(text: str, secret: str) -> str:
|
|
28
|
+
digest = hmac.new(secret.encode("utf-8"), text.encode("utf-8"), sha256).hexdigest()
|
|
29
|
+
return digest.upper()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def sign(params: Mapping[str, Any], secret: str) -> str:
|
|
33
|
+
return sum_hmac(content(params), secret)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_blank(value: Any) -> bool:
|
|
37
|
+
if value is None:
|
|
38
|
+
return True
|
|
39
|
+
if isinstance(value, str):
|
|
40
|
+
return value == ""
|
|
41
|
+
if isinstance(value, (bytes, bytearray)):
|
|
42
|
+
return len(value) == 0
|
|
43
|
+
if isinstance(value, dict):
|
|
44
|
+
return len(value) == 0
|
|
45
|
+
if isinstance(value, (list, tuple, set)):
|
|
46
|
+
return len(value) == 0
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def stringify(value: Any) -> str:
|
|
51
|
+
if value is None:
|
|
52
|
+
return ""
|
|
53
|
+
if isinstance(value, bool):
|
|
54
|
+
return "1" if value else ""
|
|
55
|
+
if isinstance(value, str):
|
|
56
|
+
return value
|
|
57
|
+
if isinstance(value, int):
|
|
58
|
+
return str(value)
|
|
59
|
+
if isinstance(value, float):
|
|
60
|
+
return format_float(value)
|
|
61
|
+
if isinstance(value, (list, tuple)):
|
|
62
|
+
return ",".join(flatten(value))
|
|
63
|
+
return str(value)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def format_float(f: float) -> str:
|
|
67
|
+
if math.isnan(f):
|
|
68
|
+
return "NaN"
|
|
69
|
+
if math.isinf(f):
|
|
70
|
+
return "+Inf" if f > 0 else "-Inf"
|
|
71
|
+
if f == math.trunc(f) and math.isfinite(f) and -(2**63) <= f <= 2**63 - 1:
|
|
72
|
+
return str(int(f))
|
|
73
|
+
s = format(f, ".15f").rstrip("0")
|
|
74
|
+
return s.rstrip(".")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def flatten(values: Any) -> list[str]:
|
|
78
|
+
out: list[str] = []
|
|
79
|
+
for v in values:
|
|
80
|
+
if is_blank(v):
|
|
81
|
+
continue
|
|
82
|
+
if isinstance(v, (list, tuple)):
|
|
83
|
+
out.extend(flatten(v))
|
|
84
|
+
continue
|
|
85
|
+
out.append(stringify(v))
|
|
86
|
+
return out
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def clone_without_sign(params: Mapping[str, Any] | None) -> dict[str, Any]:
|
|
90
|
+
out = dict(params) if params else {}
|
|
91
|
+
out.pop(KEY_SIGN, None)
|
|
92
|
+
return out
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def sign_key(key: str) -> str:
|
|
96
|
+
return key[:-2] if key.endswith("[]") else key
|
wzopensdk/callback.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Callback-log domain models and service."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from wzopensdk._internal.http import HttpInvoker
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class CallbackLogRequest:
|
|
13
|
+
charge_id: str | None = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class CallbackLogEntry:
|
|
18
|
+
id: int = 0
|
|
19
|
+
charge_id: int = 0
|
|
20
|
+
path: str = ""
|
|
21
|
+
status: int = 0
|
|
22
|
+
http_status_code: int = 0
|
|
23
|
+
err_code: int = 0
|
|
24
|
+
err_msg: str = ""
|
|
25
|
+
req: str = ""
|
|
26
|
+
rep: str = ""
|
|
27
|
+
created_at: str = ""
|
|
28
|
+
updated_at: str = ""
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def from_dict(cls, data: dict[str, Any] | None) -> CallbackLogEntry:
|
|
32
|
+
data = data or {}
|
|
33
|
+
return cls(
|
|
34
|
+
id=int(data.get("id") or 0),
|
|
35
|
+
charge_id=int(data.get("chargeId") or 0),
|
|
36
|
+
path=str(data.get("path") or ""),
|
|
37
|
+
status=int(data.get("status") or 0),
|
|
38
|
+
http_status_code=int(data.get("httpStatusCode") or 0),
|
|
39
|
+
err_code=int(data.get("errCode") or 0),
|
|
40
|
+
err_msg=str(data.get("errMsg") or ""),
|
|
41
|
+
req=str(data.get("req") or ""),
|
|
42
|
+
rep=str(data.get("rep") or ""),
|
|
43
|
+
created_at=str(data.get("createdAt") or ""),
|
|
44
|
+
updated_at=str(data.get("updatedAt") or ""),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class CallbackRetryFinishOrderRequest:
|
|
50
|
+
charge_id: int | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class CallbackService:
|
|
54
|
+
def __init__(self, http: HttpInvoker) -> None:
|
|
55
|
+
self._http = http
|
|
56
|
+
|
|
57
|
+
def log(self, req: CallbackLogRequest) -> list[CallbackLogEntry]:
|
|
58
|
+
def decode(data: Any) -> list[CallbackLogEntry]:
|
|
59
|
+
if not isinstance(data, list):
|
|
60
|
+
return []
|
|
61
|
+
return [CallbackLogEntry.from_dict(item) for item in data if isinstance(item, dict)]
|
|
62
|
+
|
|
63
|
+
return self._http.invoke("GET", "/open/callback/log", "callback.log", req, decode)
|
|
64
|
+
|
|
65
|
+
def retry_finish_order(self, req: CallbackRetryFinishOrderRequest) -> None:
|
|
66
|
+
self._http.invoke_void("POST", "/open/callback/retry/finish_order", "callback.retry_finish_order", req)
|
wzopensdk/charge.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Charge domain models and service."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from wzopensdk._internal.http import HttpInvoker
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class ChargeStartRequest:
|
|
12
|
+
device_num: str | None = None
|
|
13
|
+
device_port: int | None = None
|
|
14
|
+
charge_id: int | None = None
|
|
15
|
+
charge_mode: int | None = None
|
|
16
|
+
total_fee: int | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ChargeStopRequest:
|
|
21
|
+
device_num: str | None = None
|
|
22
|
+
device_port: int | None = None
|
|
23
|
+
charge_id: int | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ChargeService:
|
|
27
|
+
def __init__(self, http: HttpInvoker) -> None:
|
|
28
|
+
self._http = http
|
|
29
|
+
|
|
30
|
+
def start(self, req: ChargeStartRequest) -> None:
|
|
31
|
+
self._http.invoke_void("POST", "/open/charge/start", "charge.start", req)
|
|
32
|
+
|
|
33
|
+
def stop(self, req: ChargeStopRequest) -> None:
|
|
34
|
+
self._http.invoke_void("POST", "/open/charge/stop", "charge.stop", req)
|
wzopensdk/client.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Shared runtime. Business methods live on the service attributes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from typing import Callable
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from wzopensdk._internal.http import HttpInvoker
|
|
12
|
+
from wzopensdk.callback import CallbackService
|
|
13
|
+
from wzopensdk.charge import ChargeService
|
|
14
|
+
from wzopensdk.device import DeviceService
|
|
15
|
+
from wzopensdk.endpoints import Endpoints
|
|
16
|
+
from wzopensdk.errors import WzOpenConfigError
|
|
17
|
+
from wzopensdk.invoice import InvoiceService
|
|
18
|
+
from wzopensdk.renew import RenewService
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class WzOpenClient:
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
*,
|
|
25
|
+
endpoint: str,
|
|
26
|
+
app_id: str,
|
|
27
|
+
app_secret: str,
|
|
28
|
+
timeout: float = 10.0,
|
|
29
|
+
retry_count: int = 0,
|
|
30
|
+
retry_wait: float = 0.2,
|
|
31
|
+
user_agent: str = "wz-opensdk/python",
|
|
32
|
+
proxy_url: str | None = None,
|
|
33
|
+
insecure_skip_verify: bool = False,
|
|
34
|
+
logger: logging.Logger | None = None,
|
|
35
|
+
http_client: httpx.Client | None = None,
|
|
36
|
+
clock: Callable[[], int] | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
if not (endpoint or "").strip():
|
|
39
|
+
raise WzOpenConfigError(WzOpenConfigError.MISSING_ENDPOINT)
|
|
40
|
+
if not (app_id or "").strip():
|
|
41
|
+
raise WzOpenConfigError(WzOpenConfigError.MISSING_APP_ID)
|
|
42
|
+
if app_secret == "" or app_secret is None:
|
|
43
|
+
raise WzOpenConfigError(WzOpenConfigError.MISSING_APP_SECRET)
|
|
44
|
+
|
|
45
|
+
http = HttpInvoker(
|
|
46
|
+
endpoint=endpoint,
|
|
47
|
+
app_id=app_id,
|
|
48
|
+
app_secret=app_secret,
|
|
49
|
+
timeout=timeout,
|
|
50
|
+
retry_count=retry_count,
|
|
51
|
+
retry_wait=retry_wait,
|
|
52
|
+
user_agent=user_agent,
|
|
53
|
+
proxy_url=proxy_url,
|
|
54
|
+
insecure_skip_verify=insecure_skip_verify,
|
|
55
|
+
logger=logger,
|
|
56
|
+
http_client=http_client,
|
|
57
|
+
clock=clock,
|
|
58
|
+
)
|
|
59
|
+
self.device = DeviceService(http)
|
|
60
|
+
self.charge = ChargeService(http)
|
|
61
|
+
self.renew = RenewService(http)
|
|
62
|
+
self.invoice = InvoiceService(http)
|
|
63
|
+
self.callback = CallbackService(http)
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def from_env(cls) -> WzOpenClient:
|
|
67
|
+
return cls(
|
|
68
|
+
endpoint=os.environ.get("WZ_OPEN_ENDPOINT") or Endpoints.TEST,
|
|
69
|
+
app_id=os.environ.get("WZ_OPEN_APP_ID") or "",
|
|
70
|
+
app_secret=os.environ.get("WZ_OPEN_APP_SECRET") or "",
|
|
71
|
+
)
|
wzopensdk/device.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""Device domain models and service."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from wzopensdk._internal.http import HttpInvoker
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Device:
|
|
13
|
+
id: float = 0
|
|
14
|
+
device_num: str = ""
|
|
15
|
+
iot_phone: str = ""
|
|
16
|
+
device_type: float = 0
|
|
17
|
+
maturity_at: str = ""
|
|
18
|
+
created_at: str = ""
|
|
19
|
+
updated_at: str = ""
|
|
20
|
+
specification: dict[str, Any] = field(default_factory=dict)
|
|
21
|
+
status: dict[str, Any] = field(default_factory=dict)
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def from_dict(cls, data: dict[str, Any] | None) -> Device:
|
|
25
|
+
data = data or {}
|
|
26
|
+
spec = data.get("specification")
|
|
27
|
+
status = data.get("status")
|
|
28
|
+
return cls(
|
|
29
|
+
id=float(data.get("id") or 0),
|
|
30
|
+
device_num=str(data.get("deviceNum") or ""),
|
|
31
|
+
iot_phone=str(data.get("iotPhone") or ""),
|
|
32
|
+
device_type=float(data.get("deviceType") or 0),
|
|
33
|
+
maturity_at=str(data.get("maturityAt") or ""),
|
|
34
|
+
created_at=str(data.get("createdAt") or ""),
|
|
35
|
+
updated_at=str(data.get("updatedAt") or ""),
|
|
36
|
+
specification=spec if isinstance(spec, dict) else {},
|
|
37
|
+
status=status if isinstance(status, dict) else {},
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class DeviceAddRequest:
|
|
43
|
+
device_num: str | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class DeviceAddResult:
|
|
48
|
+
device_id: str = ""
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def from_dict(cls, data: dict[str, Any] | None) -> DeviceAddResult:
|
|
52
|
+
data = data or {}
|
|
53
|
+
return cls(device_id=str(data.get("deviceId") or ""))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class DeviceNumRequest:
|
|
58
|
+
device_num: str | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class DeviceFindListRequest:
|
|
63
|
+
page: int | None = None
|
|
64
|
+
page_size: int | None = None
|
|
65
|
+
device_type: int | None = None
|
|
66
|
+
device_num: str | None = None
|
|
67
|
+
iot_phone: str | None = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class DeviceFindListResult:
|
|
72
|
+
count: float = 0
|
|
73
|
+
list: list[dict[str, Any]] = field(default_factory=list)
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def from_dict(cls, data: dict[str, Any] | None) -> DeviceFindListResult:
|
|
77
|
+
data = data or {}
|
|
78
|
+
items = data.get("list")
|
|
79
|
+
return cls(
|
|
80
|
+
count=float(data.get("count") or 0),
|
|
81
|
+
list=list(items) if isinstance(items, list) else [],
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class DeviceFindMultiRequest:
|
|
87
|
+
device_nums: list[str] | None = None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class DeviceSaveModeRequest:
|
|
92
|
+
charge_mode: int | None = None
|
|
93
|
+
device_num: str | None = None
|
|
94
|
+
charge_type: str | None = None
|
|
95
|
+
charge_value: str | list[str] | None = None
|
|
96
|
+
max_power: int | None = None
|
|
97
|
+
start_fee: int | None = None
|
|
98
|
+
max_pay_fee: int | None = None
|
|
99
|
+
payment_value: str | list[str] | None = None
|
|
100
|
+
time_value: str | list[str] | None = None
|
|
101
|
+
work_charge: int | None = None
|
|
102
|
+
work_charge_fourdigit: int | None = None
|
|
103
|
+
power_value: str | None = None
|
|
104
|
+
price_value: str | None = None
|
|
105
|
+
check_time: int | None = None
|
|
106
|
+
inc_time: int | None = None
|
|
107
|
+
type: int | None = None
|
|
108
|
+
count_mode: int | None = None
|
|
109
|
+
display_delay: str | None = None
|
|
110
|
+
is_not_disturb: str | None = None
|
|
111
|
+
not_disturb_start: str | None = None
|
|
112
|
+
not_disturb_end: str | None = None
|
|
113
|
+
not_disturb_volume: int | None = None
|
|
114
|
+
is_outage: int | None = None
|
|
115
|
+
auto_off_wait_time: int | None = None
|
|
116
|
+
auto_off_lower_power: int | None = None
|
|
117
|
+
floating_charge_time: str | None = None
|
|
118
|
+
floating_charge_power: int | None = None
|
|
119
|
+
whether_refund: int | None = None
|
|
120
|
+
is_free_charge: int | None = None
|
|
121
|
+
detect_time: int | None = None
|
|
122
|
+
is_pkvy: str | None = None
|
|
123
|
+
pkvy_time_value: str | None = None
|
|
124
|
+
pkvy_price_value: str | None = None
|
|
125
|
+
pkvy_service_type: str | None = None
|
|
126
|
+
pkvy_service_value_at_work: str | None = None
|
|
127
|
+
pkvy_service_value_at_time: str | None = None
|
|
128
|
+
pkvy_service_value_at_num: str | None = None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class DeviceSaveCarModeRequest:
|
|
133
|
+
charge_mode: int | None = None
|
|
134
|
+
volume: int | None = None
|
|
135
|
+
parking_word: str | None = None
|
|
136
|
+
time_value: str | None = None
|
|
137
|
+
price_value: str | None = None
|
|
138
|
+
total_value: str | None = None
|
|
139
|
+
device_num: str | None = None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass
|
|
143
|
+
class DeviceStateRequest:
|
|
144
|
+
device_num: str | None = None
|
|
145
|
+
port_fields: list[str] | None = None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass
|
|
149
|
+
class DeviceState:
|
|
150
|
+
is_online: bool = False
|
|
151
|
+
csq: int = 0
|
|
152
|
+
connection_type: int = 0
|
|
153
|
+
state: dict[str, Any] = field(default_factory=dict)
|
|
154
|
+
|
|
155
|
+
@classmethod
|
|
156
|
+
def from_dict(cls, data: dict[str, Any] | None) -> DeviceState:
|
|
157
|
+
data = data or {}
|
|
158
|
+
state = data.get("state")
|
|
159
|
+
return cls(
|
|
160
|
+
is_online=bool(data.get("isOnline")),
|
|
161
|
+
csq=int(data.get("csq") or 0),
|
|
162
|
+
connection_type=int(data.get("connectionType") or 0),
|
|
163
|
+
state=state if isinstance(state, dict) else {},
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@dataclass
|
|
168
|
+
class DeviceStateListRequest:
|
|
169
|
+
device_nums: list[str] | None = None
|
|
170
|
+
port_fields: list[str] | None = None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass
|
|
174
|
+
class DeviceRestartRequest:
|
|
175
|
+
device_nums: list[str] | None = None
|
|
176
|
+
type: int | None = None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass
|
|
180
|
+
class DeviceOfflineRequest:
|
|
181
|
+
device_nums: list[str] | None = None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass
|
|
185
|
+
class DeviceOfflineProfitRequest:
|
|
186
|
+
device_nums: list[str] | None = None
|
|
187
|
+
coin: int | None = None
|
|
188
|
+
card: int | None = None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@dataclass
|
|
192
|
+
class DevicePortErrorRequest:
|
|
193
|
+
device_num: str | None = None
|
|
194
|
+
device_port: str | None = None
|
|
195
|
+
is_error: str | None = None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@dataclass
|
|
199
|
+
class DeviceOnlineLogRequest:
|
|
200
|
+
device_num: str | None = None
|
|
201
|
+
page: int | None = None
|
|
202
|
+
limit: int | None = None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@dataclass
|
|
206
|
+
class DeviceOnlineLogResult:
|
|
207
|
+
logs: list[dict[str, Any]] = field(default_factory=list)
|
|
208
|
+
count: float = 0
|
|
209
|
+
|
|
210
|
+
@classmethod
|
|
211
|
+
def from_dict(cls, data: dict[str, Any] | None) -> DeviceOnlineLogResult:
|
|
212
|
+
data = data or {}
|
|
213
|
+
logs = data.get("logs")
|
|
214
|
+
return cls(
|
|
215
|
+
logs=list(logs) if isinstance(logs, list) else [],
|
|
216
|
+
count=float(data.get("count") or 0),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class DeviceService:
|
|
221
|
+
def __init__(self, http: HttpInvoker) -> None:
|
|
222
|
+
self._http = http
|
|
223
|
+
|
|
224
|
+
def add(self, req: DeviceAddRequest) -> DeviceAddResult:
|
|
225
|
+
return self._http.invoke("POST", "/open/device/add", "device.add", req, DeviceAddResult.from_dict)
|
|
226
|
+
|
|
227
|
+
def delete(self, req: DeviceNumRequest) -> None:
|
|
228
|
+
self._http.invoke_void("GET", "/open/device/del", "device.delete", req)
|
|
229
|
+
|
|
230
|
+
def find_one(self, req: DeviceNumRequest) -> Device:
|
|
231
|
+
return self._http.invoke("GET", "/open/device/findOne", "device.find_one", req, Device.from_dict)
|
|
232
|
+
|
|
233
|
+
def find_list(self, req: DeviceFindListRequest) -> DeviceFindListResult:
|
|
234
|
+
return self._http.invoke("GET", "/open/device/findList", "device.find_list", req, DeviceFindListResult.from_dict)
|
|
235
|
+
|
|
236
|
+
def find_multi(self, req: DeviceFindMultiRequest) -> dict[str, Any] | None:
|
|
237
|
+
return self._http.invoke_map("POST", "/open/device/findMultiDevices", "device.find_multi", req)
|
|
238
|
+
|
|
239
|
+
def save_mode(self, req: DeviceSaveModeRequest) -> None:
|
|
240
|
+
self._http.invoke_void("POST", "/open/device/saveMode", "device.save_mode", req)
|
|
241
|
+
|
|
242
|
+
def save_car_mode(self, req: DeviceSaveCarModeRequest) -> None:
|
|
243
|
+
self._http.invoke_void("POST", "/open/device/saveMode", "device.save_car_mode", req)
|
|
244
|
+
|
|
245
|
+
def get_mode(self, req: DeviceNumRequest) -> dict[str, Any] | None:
|
|
246
|
+
return self._http.invoke_map("GET", "/open/device/getMode", "device.get_mode", req)
|
|
247
|
+
|
|
248
|
+
def get_car_mode(self, req: DeviceNumRequest) -> dict[str, Any] | None:
|
|
249
|
+
return self._http.invoke_map("GET", "/open/device/getMode", "device.get_car_mode", req)
|
|
250
|
+
|
|
251
|
+
def get_setting_not_disturb_info(self, req: DeviceNumRequest) -> dict[str, Any] | None:
|
|
252
|
+
return self._http.invoke_map(
|
|
253
|
+
"GET", "/open/device/getDeviceSettingNotDisturbInfo", "device.setting.not_disturb", req
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
def get_setting_online_card_info(self, req: DeviceNumRequest) -> dict[str, Any] | None:
|
|
257
|
+
return self._http.invoke_map(
|
|
258
|
+
"GET", "/open/device/getDeviceSettingOnlineCardInfo", "device.setting.online_card", req
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
def get_setting_offline_card_info(self, req: DeviceNumRequest) -> dict[str, Any] | None:
|
|
262
|
+
return self._http.invoke_map(
|
|
263
|
+
"GET", "/open/device/getDeviceSettingOfflineCardInfo", "device.setting.offline_card", req
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
def get_setting_safety_info(self, req: DeviceNumRequest) -> dict[str, Any] | None:
|
|
267
|
+
return self._http.invoke_map("GET", "/open/device/getDeviceSettingSafetyInfo", "device.setting.safety", req)
|
|
268
|
+
|
|
269
|
+
def get_setting_coin_info(self, req: DeviceNumRequest) -> dict[str, Any] | None:
|
|
270
|
+
return self._http.invoke_map("GET", "/open/device/getDeviceSettingCoinInfo", "device.setting.coin", req)
|
|
271
|
+
|
|
272
|
+
def get_setting_user_info(self, req: DeviceNumRequest) -> dict[str, Any] | None:
|
|
273
|
+
return self._http.invoke_map("GET", "/open/device/getDeviceSettingUserInfo", "device.setting.user", req)
|
|
274
|
+
|
|
275
|
+
def get_mode_time_info(self, req: DeviceNumRequest) -> dict[str, Any] | None:
|
|
276
|
+
return self._http.invoke_map("GET", "/open/device/getDeviceModeTimeInfo", "device.mode.time", req)
|
|
277
|
+
|
|
278
|
+
def get_mode_time1_info(self, req: DeviceNumRequest) -> dict[str, Any] | None:
|
|
279
|
+
return self._http.invoke_map("GET", "/open/device/getDeviceModeTime1Info", "device.mode.time2", req)
|
|
280
|
+
|
|
281
|
+
def get_mode_work_info(self, req: DeviceNumRequest) -> dict[str, Any] | None:
|
|
282
|
+
return self._http.invoke_map("GET", "/open/device/getDeviceModeWorkInfo", "device.mode.work", req)
|
|
283
|
+
|
|
284
|
+
def get_mode_work1_info(self, req: DeviceNumRequest) -> dict[str, Any] | None:
|
|
285
|
+
return self._http.invoke_map("GET", "/open/device/getDeviceModeWork1Info", "device.mode.work2", req)
|
|
286
|
+
|
|
287
|
+
def get_mode_power_info(self, req: DeviceNumRequest) -> dict[str, Any] | None:
|
|
288
|
+
return self._http.invoke_map("GET", "/open/device/getDeviceModePowerInfo", "device.mode.power", req)
|
|
289
|
+
|
|
290
|
+
def get_mode_power1_info(self, req: DeviceNumRequest) -> dict[str, Any] | None:
|
|
291
|
+
return self._http.invoke_map("GET", "/open/device/getDeviceModePower1Info", "device.mode.power2", req)
|
|
292
|
+
|
|
293
|
+
def get_state(self, req: DeviceStateRequest) -> DeviceState:
|
|
294
|
+
return self._http.invoke("GET", "/open/device/state", "device.state.get", req, DeviceState.from_dict)
|
|
295
|
+
|
|
296
|
+
def list_state(self, req: DeviceStateListRequest) -> dict[str, Any] | None:
|
|
297
|
+
return self._http.invoke_map("GET", "/open/device/state/list", "device.state.list", req)
|
|
298
|
+
|
|
299
|
+
def restart(self, req: DeviceRestartRequest) -> None:
|
|
300
|
+
self._http.invoke_void("POST", "/open/device/state/restart", "device.state.restart", req)
|
|
301
|
+
|
|
302
|
+
def offline(self, req: DeviceOfflineRequest) -> None:
|
|
303
|
+
self._http.invoke_void("POST", "/open/device/state/offline", "device.state.offline", req)
|
|
304
|
+
|
|
305
|
+
def set_offline_profit(self, req: DeviceOfflineProfitRequest) -> None:
|
|
306
|
+
self._http.invoke_void("POST", "/open/device/state/offline_profit", "device.state.offline_profit", req)
|
|
307
|
+
|
|
308
|
+
def set_port_error(self, req: DevicePortErrorRequest) -> None:
|
|
309
|
+
self._http.invoke_void("POST", "/open/device/state/port/error", "device.state.port_error", req)
|
|
310
|
+
|
|
311
|
+
def online_log(self, req: DeviceOnlineLogRequest) -> DeviceOnlineLogResult:
|
|
312
|
+
return self._http.invoke("GET", "/open/device/log/online_log", "device.log.online", req, DeviceOnlineLogResult.from_dict)
|
wzopensdk/endpoints.py
ADDED
wzopensdk/errors.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""SDK exceptions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class WzOpenError(Exception):
|
|
7
|
+
"""Base SDK error."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class WzOpenConfigError(WzOpenError):
|
|
11
|
+
"""Invalid constructor configuration."""
|
|
12
|
+
|
|
13
|
+
MISSING_ENDPOINT = "endpoint is required"
|
|
14
|
+
MISSING_APP_ID = "app id is required"
|
|
15
|
+
MISSING_APP_SECRET = "app secret is required"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class WzOpenAPIError(WzOpenError):
|
|
19
|
+
"""HTTP non-2xx or envelope code != 1."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, status: int, code: int, message: str, operation: str, trace_id: str = "") -> None:
|
|
22
|
+
self.status = status
|
|
23
|
+
self.code = code
|
|
24
|
+
self.api_message = message
|
|
25
|
+
self.operation = operation
|
|
26
|
+
self.trace_id = (trace_id or "").strip()
|
|
27
|
+
if message:
|
|
28
|
+
text = f"wzopensdk: {operation}: http {status} code {code}: {message}"
|
|
29
|
+
else:
|
|
30
|
+
text = f"wzopensdk: {operation}: http {status} code {code}"
|
|
31
|
+
super().__init__(text)
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def http_status(cls, status: int, operation: str, trace_id: str = "") -> WzOpenAPIError:
|
|
35
|
+
texts = {
|
|
36
|
+
400: "Bad Request",
|
|
37
|
+
401: "Unauthorized",
|
|
38
|
+
403: "Forbidden",
|
|
39
|
+
404: "Not Found",
|
|
40
|
+
408: "Request Timeout",
|
|
41
|
+
429: "Too Many Requests",
|
|
42
|
+
500: "Internal Server Error",
|
|
43
|
+
502: "Bad Gateway",
|
|
44
|
+
503: "Service Unavailable",
|
|
45
|
+
504: "Gateway Timeout",
|
|
46
|
+
}
|
|
47
|
+
return cls(status, status, texts.get(status, f"HTTP {status}"), operation, trace_id)
|
wzopensdk/invoice.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Invoice domain models and service."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from wzopensdk._internal.http import HttpInvoker
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class InvoiceTitle:
|
|
13
|
+
buyer_name: str = ""
|
|
14
|
+
buyer_tax_num: str = ""
|
|
15
|
+
notify_email: str = ""
|
|
16
|
+
is_cron: int = 0
|
|
17
|
+
cron_job: str = ""
|
|
18
|
+
created_at: str = ""
|
|
19
|
+
updated_at: str = ""
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def from_dict(cls, data: dict[str, Any] | None) -> InvoiceTitle:
|
|
23
|
+
data = data or {}
|
|
24
|
+
return cls(
|
|
25
|
+
buyer_name=str(data.get("buyerName") or ""),
|
|
26
|
+
buyer_tax_num=str(data.get("buyerTaxNum") or ""),
|
|
27
|
+
notify_email=str(data.get("notifyEmail") or ""),
|
|
28
|
+
is_cron=int(data.get("isCron") or 0),
|
|
29
|
+
cron_job=str(data.get("cronJob") or ""),
|
|
30
|
+
created_at=str(data.get("createdAt") or ""),
|
|
31
|
+
updated_at=str(data.get("updatedAt") or ""),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class InvoiceSetTitleRequest:
|
|
37
|
+
buyer_name: str | None = None
|
|
38
|
+
buyer_tax_num: str | None = None
|
|
39
|
+
notify_email: str | None = None
|
|
40
|
+
is_cron: int | None = None
|
|
41
|
+
cron_job: str | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class InvoicePendingAmount:
|
|
46
|
+
amount: int = 0
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def from_dict(cls, data: dict[str, Any] | None) -> InvoicePendingAmount:
|
|
50
|
+
data = data or {}
|
|
51
|
+
return cls(amount=int(data.get("amount") or 0))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class InvoiceRecordsRequest:
|
|
56
|
+
page: str | None = None
|
|
57
|
+
limit: str | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class InvoiceIssueRequest:
|
|
62
|
+
price: int | None = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class InvoiceService:
|
|
66
|
+
def __init__(self, http: HttpInvoker) -> None:
|
|
67
|
+
self._http = http
|
|
68
|
+
|
|
69
|
+
def title(self) -> InvoiceTitle:
|
|
70
|
+
return self._http.invoke("GET", "/open/invoice/getInvoiceTitle", "invoice.title.get", None, InvoiceTitle.from_dict)
|
|
71
|
+
|
|
72
|
+
def set_title(self, req: InvoiceSetTitleRequest) -> None:
|
|
73
|
+
self._http.invoke_void("POST", "/open/invoice/setInvoiceTitle", "invoice.title.set", req)
|
|
74
|
+
|
|
75
|
+
def pending_amount(self) -> InvoicePendingAmount:
|
|
76
|
+
return self._http.invoke(
|
|
77
|
+
"GET", "/open/invoice/getPendingAmount", "invoice.pending_amount", None, InvoicePendingAmount.from_dict
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def records(self, req: InvoiceRecordsRequest) -> dict[str, Any] | None:
|
|
81
|
+
return self._http.invoke_map("GET", "/open/invoice/getInvoiceRecords", "invoice.records", req)
|
|
82
|
+
|
|
83
|
+
def issue(self, req: InvoiceIssueRequest) -> dict[str, Any] | None:
|
|
84
|
+
return self._http.invoke_map("POST", "/open/invoice/issueInvoice", "invoice.issue", req)
|
wzopensdk/renew.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Renew domain models and service."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from wzopensdk._internal.http import HttpInvoker
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class RenewExpireListRequest:
|
|
13
|
+
page: int | None = None
|
|
14
|
+
limit: str | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class RenewCreateOrderRequest:
|
|
19
|
+
out_trade_no: str | None = None
|
|
20
|
+
device_nums: list[str] | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class RenewCreateOrderResult:
|
|
25
|
+
qr_code: str = ""
|
|
26
|
+
trans_amt: str = ""
|
|
27
|
+
trans_stat: str = ""
|
|
28
|
+
resp_code: str = ""
|
|
29
|
+
resp_desc: str = ""
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_dict(cls, data: dict[str, Any] | None) -> RenewCreateOrderResult:
|
|
33
|
+
data = data or {}
|
|
34
|
+
return cls(
|
|
35
|
+
qr_code=str(data.get("qrCode") or ""),
|
|
36
|
+
trans_amt=str(data.get("transAmt") or ""),
|
|
37
|
+
trans_stat=str(data.get("transStat") or ""),
|
|
38
|
+
resp_code=str(data.get("respCode") or ""),
|
|
39
|
+
resp_desc=str(data.get("respDesc") or ""),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class RenewStatusRequest:
|
|
45
|
+
out_trade_no: str | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RenewService:
|
|
49
|
+
def __init__(self, http: HttpInvoker) -> None:
|
|
50
|
+
self._http = http
|
|
51
|
+
|
|
52
|
+
def expire_device_list(self, req: RenewExpireListRequest) -> dict[str, Any] | None:
|
|
53
|
+
return self._http.invoke_map("GET", "/open/renew/getCompanyExpireDeviceList", "renew.expire_list", req)
|
|
54
|
+
|
|
55
|
+
def create_order(self, req: RenewCreateOrderRequest) -> RenewCreateOrderResult:
|
|
56
|
+
return self._http.invoke(
|
|
57
|
+
"POST", "/open/renew/createCompanyRenewOrder", "renew.create_order", req, RenewCreateOrderResult.from_dict
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def status(self, req: RenewStatusRequest) -> dict[str, Any] | None:
|
|
61
|
+
return self._http.invoke_map("GET", "/open/renew/getCompanyRenewStatus", "renew.status", req)
|