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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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
@@ -0,0 +1,6 @@
1
+ """Open-platform base URLs."""
2
+
3
+
4
+ class Endpoints:
5
+ TEST = "https://dev-api-open.wanzhuangkj.com"
6
+ PROD = "https://api-open.wanzhuangkj.com"
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)