@wdywdy/electric-open-platform 0.0.0-stage → 1.0.1
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.
- package/LICENSE +21 -0
- package/README.md +561 -2
- package/dist/cjs/client.js +173 -0
- package/dist/cjs/client.js.map +1 -0
- package/dist/cjs/core/api.js +518 -0
- package/dist/cjs/core/api.js.map +1 -0
- package/dist/cjs/core/auth.js +293 -0
- package/dist/cjs/core/auth.js.map +1 -0
- package/dist/cjs/core/crypto.js +160 -0
- package/dist/cjs/core/crypto.js.map +1 -0
- package/dist/cjs/core/errors.js +114 -0
- package/dist/cjs/core/errors.js.map +1 -0
- package/dist/cjs/core/http.js +70 -0
- package/dist/cjs/core/http.js.map +1 -0
- package/dist/cjs/core/models.js +105 -0
- package/dist/cjs/core/models.js.map +1 -0
- package/dist/cjs/core/push.js +79 -0
- package/dist/cjs/core/push.js.map +1 -0
- package/dist/cjs/core/router.js +124 -0
- package/dist/cjs/core/router.js.map +1 -0
- package/dist/cjs/core/runtime.js +37 -0
- package/dist/cjs/core/runtime.js.map +1 -0
- package/dist/cjs/core/sign.js +75 -0
- package/dist/cjs/core/sign.js.map +1 -0
- package/dist/cjs/core/storage.js +152 -0
- package/dist/cjs/core/storage.js.map +1 -0
- package/dist/cjs/core/transport-fetch.js +57 -0
- package/dist/cjs/core/transport-fetch.js.map +1 -0
- package/dist/cjs/core/transport.js +41 -0
- package/dist/cjs/core/transport.js.map +1 -0
- package/dist/cjs/core/types.js +5 -0
- package/dist/cjs/core/types.js.map +1 -0
- package/dist/cjs/index.js +111 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/package.json +3 -0
- package/dist/esm/client.d.ts +73 -0
- package/dist/esm/client.js +169 -0
- package/dist/esm/client.js.map +1 -0
- package/dist/esm/core/api.d.ts +377 -0
- package/dist/esm/core/api.js +506 -0
- package/dist/esm/core/api.js.map +1 -0
- package/dist/esm/core/auth.d.ts +69 -0
- package/dist/esm/core/auth.js +289 -0
- package/dist/esm/core/auth.js.map +1 -0
- package/dist/esm/core/crypto.d.ts +24 -0
- package/dist/esm/core/crypto.js +154 -0
- package/dist/esm/core/crypto.js.map +1 -0
- package/dist/esm/core/errors.d.ts +86 -0
- package/dist/esm/core/errors.js +108 -0
- package/dist/esm/core/errors.js.map +1 -0
- package/dist/esm/core/http.d.ts +24 -0
- package/dist/esm/core/http.js +56 -0
- package/dist/esm/core/http.js.map +1 -0
- package/dist/esm/core/models.d.ts +441 -0
- package/dist/esm/core/models.js +102 -0
- package/dist/esm/core/models.js.map +1 -0
- package/dist/esm/core/push.d.ts +54 -0
- package/dist/esm/core/push.js +74 -0
- package/dist/esm/core/push.js.map +1 -0
- package/dist/esm/core/router.d.ts +44 -0
- package/dist/esm/core/router.js +120 -0
- package/dist/esm/core/router.js.map +1 -0
- package/dist/esm/core/runtime.d.ts +23 -0
- package/dist/esm/core/runtime.js +33 -0
- package/dist/esm/core/runtime.js.map +1 -0
- package/dist/esm/core/sign.d.ts +54 -0
- package/dist/esm/core/sign.js +67 -0
- package/dist/esm/core/sign.js.map +1 -0
- package/dist/esm/core/storage.d.ts +73 -0
- package/dist/esm/core/storage.js +141 -0
- package/dist/esm/core/storage.js.map +1 -0
- package/dist/esm/core/transport-fetch.d.ts +2 -0
- package/dist/esm/core/transport-fetch.js +54 -0
- package/dist/esm/core/transport-fetch.js.map +1 -0
- package/dist/esm/core/transport.d.ts +23 -0
- package/dist/esm/core/transport.js +36 -0
- package/dist/esm/core/transport.js.map +1 -0
- package/dist/esm/core/types.d.ts +76 -0
- package/dist/esm/core/types.js +2 -0
- package/dist/esm/core/types.js.map +1 -0
- package/dist/esm/index.d.ts +37 -0
- package/dist/esm/index.js +47 -0
- package/dist/esm/index.js.map +1 -0
- package/package.json +63 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ACME 毅丰电器
|
|
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.
|
package/README.md
CHANGED
|
@@ -1,3 +1,562 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @wdywdy/electric-open-platform
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
面向 **智慧式用电数据开放平台**(Mtsci Open Platform,`open.snd02.com`)的纯 TypeScript SDK。
|
|
4
|
+
|
|
5
|
+
**零运行时依赖、零原生工程** —— 不依赖任何宿主框架,`npm install` 之后即可在
|
|
6
|
+
Node 后端、Electron、浏览器(配代理)、任意前端框架里直接 `import`。
|
|
7
|
+
|
|
8
|
+
- **OAuth2 全流程**:headless 登录(`authverify2.as`)与重定向登录两种授权方式,
|
|
9
|
+
`accessToken` / `refreshToken` 自动维护与提前续期,并发请求只刷新一次。
|
|
10
|
+
- **签名自动完成**:按平台规范对参数名排序、拼接参数值、追加 `appSecret` 做 MD5,
|
|
11
|
+
业务代码无需关心。
|
|
12
|
+
- **类型完整**:全部枚举、字段(含 `aTUp` / `mxgl` 这类线上原始字段名)与 `.d.ts` 声明。
|
|
13
|
+
- **错误可编排**:统一 `ElectricOpenPlatformError`,携带平台返回码,可判断「设备离线」
|
|
14
|
+
「token 过期」「接口未开通」等。
|
|
15
|
+
- **可插拔**:网络传输(`Transport`)与 token 存储(`KeyValueStore`)都是接口,
|
|
16
|
+
需要走原生网络栈、自建网关、Redis、文件时注入自己的实现即可。
|
|
17
|
+
- **已对真实平台联调验证**:见 [验证结果](#已验证行为)。
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## 安装
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install @wdywdy/electric-open-platform
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
已发布到 npm:<https://www.npmjs.com/package/@wdywdy/electric-open-platform>
|
|
28
|
+
|
|
29
|
+
零依赖、无 peer 依赖,也不需要任何平台工程初始化步骤。要求 Node ≥ 18(需要全局 `fetch`),
|
|
30
|
+
或任何支持 ES2020 的浏览器/打包器。
|
|
31
|
+
|
|
32
|
+
## 快速开始
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import { ElectricOpenPlatform } from '@wdywdy/electric-open-platform';
|
|
36
|
+
|
|
37
|
+
// 1) 配置(appSecret 不要写进源码,见「安全」)
|
|
38
|
+
await ElectricOpenPlatform.configure({
|
|
39
|
+
appKey: 'O000093290',
|
|
40
|
+
appSecret: process.env.EOP_APP_SECRET!,
|
|
41
|
+
redirectUri: 'http://yourserverurl/receive',
|
|
42
|
+
projectCode: 'P00000104893',
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// 2) 登录(换取 accessToken,有效期 3600s,之后自动续期)
|
|
46
|
+
await ElectricOpenPlatform.login('yfdq01', '******');
|
|
47
|
+
|
|
48
|
+
// 3) 调用接口
|
|
49
|
+
const project = await ElectricOpenPlatform.project.getInfo();
|
|
50
|
+
const boxes = await ElectricOpenPlatform.box.listOnline();
|
|
51
|
+
const live = await ElectricOpenPlatform.channel.realtimeChannels({ mac: boxes.map((b) => b.mac) });
|
|
52
|
+
|
|
53
|
+
// 4) 控制(合闸/分闸,会真实作用于现场设备)
|
|
54
|
+
await ElectricOpenPlatform.control.switchCircuit({
|
|
55
|
+
mac: boxes[0].mac,
|
|
56
|
+
action: 'close', // 'open' 合闸 | 'close' 分闸
|
|
57
|
+
addr: 1,
|
|
58
|
+
skipWhenAbnormalLock: true,
|
|
59
|
+
});
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## 在任何环境里使用
|
|
65
|
+
|
|
66
|
+
包本身只做「签名 + 认证 + 解析」,网络出口默认是标准 `fetch`。**唯一需要留意的坑是 CORS**:
|
|
67
|
+
平台不返回 CORS 头,浏览器的同源策略会拦掉直连请求。
|
|
68
|
+
|
|
69
|
+
| 环境 | 能否直连 | 做法 |
|
|
70
|
+
| --- | --- | --- |
|
|
71
|
+
| Node 后端 / 定时任务 / 数据中台 | ✅ | 直接用,默认 `fetch` |
|
|
72
|
+
| Electron 主进程 | ✅ | 直接用 |
|
|
73
|
+
| Electron 渲染进程 | ⚠️ | 走 IPC 到主进程,或在主进程建一个 transport |
|
|
74
|
+
| 浏览器单页应用 | ❌ | `baseUrl` 指向你自己的反向代理 / 后端中转 |
|
|
75
|
+
| 有原生网络能力的容器(App 壳、小程序等) | ⚠️ | 注入一个走原生网络的 `transport`(10 行) |
|
|
76
|
+
|
|
77
|
+
### 浏览器:用反向代理(推荐)
|
|
78
|
+
|
|
79
|
+
```nginx
|
|
80
|
+
# nginx:把 /eop/ 转发到平台,浏览器只请求自己的域名
|
|
81
|
+
location /eop/ {
|
|
82
|
+
proxy_pass https://open.snd02.com/;
|
|
83
|
+
proxy_set_header Host open.snd02.com;
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
await ElectricOpenPlatform.configure({
|
|
89
|
+
// ...
|
|
90
|
+
baseUrl: 'https://your-site.com/eop', // 只改这一行
|
|
91
|
+
});
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
> `redirectUri` 参与签名,必须与平台登记的地址完全一致 —— **不要**因为走代理就顺手改它。
|
|
95
|
+
|
|
96
|
+
### 注入自己的传输实现
|
|
97
|
+
|
|
98
|
+
`Transport` 只有两个成员:`name` 和 `postForm()`。任何能发 POST 的通道都可以接进来。
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
import type { Transport } from '@wdywdy/electric-open-platform';
|
|
102
|
+
|
|
103
|
+
// 例:在 Electron 主进程里用 Node 的 http 模块(绕过渲染进程的 CORS)
|
|
104
|
+
const viaMainProcess: Transport = {
|
|
105
|
+
name: 'electron-ipc',
|
|
106
|
+
async postForm(req) {
|
|
107
|
+
return globalThis.electronBridge.request({
|
|
108
|
+
url: req.url,
|
|
109
|
+
body: req.body,
|
|
110
|
+
headers: req.headers,
|
|
111
|
+
timeoutMs: req.timeoutMs,
|
|
112
|
+
});
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
await ElectricOpenPlatform.configure({ ...cfg, transport: viaMainProcess });
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
如果宿主的网络能力来自原生桥(而不是浏览器 `fetch`),同样只需要包一层 `postForm()`:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
import { parseBody, type Transport } from '@wdywdy/electric-open-platform';
|
|
123
|
+
|
|
124
|
+
const viaNative: Transport = {
|
|
125
|
+
name: 'native-http',
|
|
126
|
+
async postForm(req) {
|
|
127
|
+
// 由你的原生桥返回 { status, body };不受 WebView 同源策略限制
|
|
128
|
+
const res = await nativeBridge.httpRequest(req.url, req.body, req.headers);
|
|
129
|
+
return { status: res.status, data: parseBody(res.body), raw: res.body };
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
await ElectricOpenPlatform.configure({ ...cfg, transport: viaNative });
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### 注入自己的存储实现
|
|
137
|
+
|
|
138
|
+
默认 `auto` 是 `localStorage → 内存`。想持久化到文件、Redis、数据库,或容器提供的原生偏好:
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
import { readFile, writeFile, rm } from 'node:fs/promises';
|
|
142
|
+
import type { KeyValueStore } from '@wdywdy/electric-open-platform';
|
|
143
|
+
|
|
144
|
+
const fileStore: KeyValueStore = {
|
|
145
|
+
name: 'file',
|
|
146
|
+
async get(key) {
|
|
147
|
+
try { return await readFile(`./.eop/${key}`, 'utf8'); } catch { return null; }
|
|
148
|
+
},
|
|
149
|
+
async set(key, value) {
|
|
150
|
+
await import('node:fs/promises').then((fs) => fs.mkdir('./.eop', { recursive: true }));
|
|
151
|
+
await writeFile(`./.eop/${key}`, value, 'utf8');
|
|
152
|
+
},
|
|
153
|
+
async remove(key) {
|
|
154
|
+
await rm(`./.eop/${key}`, { force: true });
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
await ElectricOpenPlatform.configure({ ...cfg, store: fileStore });
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`store` 的读写失败不会让进程崩溃:读失败当作「没有 token」,写失败只影响本次持久化
|
|
162
|
+
(内存里的 token 仍然可用)。
|
|
163
|
+
|
|
164
|
+
### 包里带的两个现成后端
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
import { createMemoryStore, createLocalStorageStore } from '@wdywdy/electric-open-platform';
|
|
168
|
+
|
|
169
|
+
createMemoryStore(); // 进程内 Map,适合测试/一次性脚本
|
|
170
|
+
createLocalStorageStore(); // 探活失败(隐私模式、SSR)时返回 null
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## 网络传输与诊断
|
|
176
|
+
|
|
177
|
+
传输实现的选择顺序**只有三条**,不存在「按宿主猜」:
|
|
178
|
+
|
|
179
|
+
| 优先级 | 来源 | 说明 |
|
|
180
|
+
| --- | --- | --- |
|
|
181
|
+
| 1 | `configure({ transport })` | 最高,已知宿主时推荐显式指定 |
|
|
182
|
+
| 2 | `configure()` 之前 `setTransport()` | 调用方提前注入,不会被覆盖 |
|
|
183
|
+
| 3 | 内置 `fetch` | 默认实现 |
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
import { currentTransportName, detectRuntime, hasGlobalFetch } from '@wdywdy/electric-open-platform';
|
|
187
|
+
|
|
188
|
+
currentTransportName(); // 'fetch' / 你注入的名字
|
|
189
|
+
detectRuntime(); // 'node' | 'browser' | 'unknown'(仅诊断)
|
|
190
|
+
hasGlobalFetch(); // 当前运行时有没有全局 fetch
|
|
191
|
+
ElectricOpenPlatform.describe();
|
|
192
|
+
// → { configured, appKey, appSecret:'****xxxx', redirectUri, baseUrl, projectCode,
|
|
193
|
+
// storage:'auto', storageBackend:'memory', runtime:'node', transport:'fetch', token:{…} }
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
`describe()` 里的密钥已脱敏,可以直接打印到诊断页。
|
|
197
|
+
|
|
198
|
+
## 认证
|
|
199
|
+
|
|
200
|
+
### 方式一:headless 登录(推荐给服务端 / 可信客户端)
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
const result = await ElectricOpenPlatform.login(username, password);
|
|
204
|
+
// → { success, code, expiresIn, expiresAt }
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
内部依次调用 `POST /oauth/authverify2.as`(取 code)与 `POST /oauth/token.as`(换 token)。
|
|
208
|
+
|
|
209
|
+
### 方式二:浏览器重定向登录
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
// 打开该 URL 让用户登录,平台会带回 ?code=
|
|
213
|
+
const url = ElectricOpenPlatform.getAuthorizeUrl({ state: 'csrf-1' });
|
|
214
|
+
|
|
215
|
+
// 你的 redirectUri 收到回调后
|
|
216
|
+
await ElectricOpenPlatform.handleRedirect('http://yourserverurl/receive?code=xxxx');
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
### token 生命周期
|
|
220
|
+
|
|
221
|
+
| 事项 | 行为 |
|
|
222
|
+
| --- | --- |
|
|
223
|
+
| 有效期 | 平台返回 `expiresIn`(当前 3600s) |
|
|
224
|
+
| 提前续期 | 默认提前 60s 用 `refreshToken` 刷新(`tokenRefreshSkewMs` 可调) |
|
|
225
|
+
| 接口返回 `code:"2"` | 自动刷新一次并重放原请求,对调用方透明 |
|
|
226
|
+
| refresh 也失效 | 若配置了账号密码则自动重登;否则抛 `E_NOT_CONFIGURED` |
|
|
227
|
+
| 并发 | 同一时刻只有一个刷新请求在飞,其余等待复用 |
|
|
228
|
+
| 持久化 | `store`(自定义)→ `localStorage` → 内存 |
|
|
229
|
+
|
|
230
|
+
## API 一览
|
|
231
|
+
|
|
232
|
+
所有分组都挂在 `ElectricOpenPlatform` 上。
|
|
233
|
+
|
|
234
|
+
### `project`
|
|
235
|
+
| 方法 | 平台接口 |
|
|
236
|
+
| --- | --- |
|
|
237
|
+
| `getInfo()` | `GET_PROJECT_INFO` |
|
|
238
|
+
| `modifyInfo(fields)` | `MODIFY_PROJECT_INFO` |
|
|
239
|
+
| `dayStats({year,month,day?})` | `GET_PROJECT_DAY_STATS` |
|
|
240
|
+
|
|
241
|
+
### `box`
|
|
242
|
+
| 方法 | 平台接口 |
|
|
243
|
+
| --- | --- |
|
|
244
|
+
| `list({equipmentType?,build?,unit?,room?})` | `GET_BOXES` |
|
|
245
|
+
| `listOnline()` | `GET_BOXES` + 本地过滤 `online === 1` |
|
|
246
|
+
| `get(mac)` | `GET_BOX` |
|
|
247
|
+
| `positionTree()` | `GET_BOX_POSITION_TREE` |
|
|
248
|
+
| `properties({mac,nodeType?,addr?})` | `GET_BOX_PROPERTIES` |
|
|
249
|
+
| `modifyProperties({mac,params})` | `MODIFY_BOX_PROPERTIES` |
|
|
250
|
+
| `modifyInfo({mac,...fields})` | `MODIFY_BOX_INFO` |
|
|
251
|
+
| `setRealtimeRate({mac,enable,rate})` | `SET_BOX_REALTIME_RATE` |
|
|
252
|
+
| `onlineHistory({mac,startTime,endTime})` | `GET_BOX_ONLINE_HISTORY` |
|
|
253
|
+
|
|
254
|
+
### `channel`
|
|
255
|
+
| 方法 | 平台接口 |
|
|
256
|
+
| --- | --- |
|
|
257
|
+
| `realtime({mac})` | `GET_BOX_CHANNELS_REALTIME`(≤20 台,自动归一化返回结构) |
|
|
258
|
+
| `realtimeChannels({mac})` | 同上,拍平为带 `mac` 的线路数组 |
|
|
259
|
+
| `list({mac\|param})` | `GET_BOX_CHANNELS` |
|
|
260
|
+
| `channelsOc({mac})` | `GET_BOX_CHANNELS_OC` |
|
|
261
|
+
| `history({mac,date,addr?})` | `GET_BOX_CHANNELS_REALTIME_HISTORY`(**仅按天**,`date` 格式 `yyyy-MM-dd`) |
|
|
262
|
+
|
|
263
|
+
### `control`
|
|
264
|
+
| 方法 | 命令 |
|
|
265
|
+
| --- | --- |
|
|
266
|
+
| `switchCircuit({mac,action,addr})` | `OCSWITCH` |
|
|
267
|
+
| `batchSwitch({items,action})` | `BATCH_BOX_CONTROL` |
|
|
268
|
+
| `updateCircuit({mac,addr,title,maxPowerW})` | `SWITCHSET` |
|
|
269
|
+
| `remoteLock({mac,addr,locked})` | `REMOTE_SWITCH_LOCK` |
|
|
270
|
+
| `unlockAbnormal({mac,addr})` | `ABNORMAL_SWITCH_UNLOCK` |
|
|
271
|
+
| `setVisibility` / `setControl` | `SETVISIBILITY` / `SETCONTROL` |
|
|
272
|
+
| `setAutoLeakage({enable,day,hour,minute})` | `SETAUTOLEAK` |
|
|
273
|
+
| `getTimers({mac})` / `getLeakageCheck({mac})` | `GET_CFG_TIMER` / `GET_CFG_LKICHK` |
|
|
274
|
+
| `control({mac,cmd,options})` | `PUT_BOX_CONTROL`(任意命令,类型安全) |
|
|
275
|
+
|
|
276
|
+
### `alarm`
|
|
277
|
+
| 方法 | 说明 |
|
|
278
|
+
| --- | --- |
|
|
279
|
+
| `list({start,end,mac?,page?})` | `GET_BOX_ALARM`,返回 `{total,page,totalPage,datas}` |
|
|
280
|
+
| `listAll(opts)` | 自动翻页 |
|
|
281
|
+
| `byType({...,typeNumbers})` | 本地按告警类型过滤(配合 `AlarmType` 枚举) |
|
|
282
|
+
| `maintenance({alarmId})` | `GET_ALARM_MAINTENANCE`(支持数组,最多 20 个) |
|
|
283
|
+
| `urged({mac,alarmId})` | `ALARM_URGED`(`mac` 为**必填**) |
|
|
284
|
+
| `boxMaintenanceHistory({mac?,start,end})` | `GET_BOX_MAINTENANCE_HISTORY`(`start`/`end` 必填,`pageSize` ≤100) |
|
|
285
|
+
|
|
286
|
+
### `stats`
|
|
287
|
+
`day()` / `hour()` / `month()` 对应 **`GET_BOXES_DAY_STATS`** / `_HOUR_` / `_MON_STATS`,
|
|
288
|
+
另有 `totalElectricity(rows)` 帮你从 `addr=0` 那一行取设备总电量。
|
|
289
|
+
|
|
290
|
+
> ⚠️ 方法名是 `GET_BOXES_*`(BOX + **ES**),不是 `GET_BOXS_*`。
|
|
291
|
+
> 少一个 E 时平台返回「接口名称(X)无效!」,很容易被误判成「接口未开通」。已修正并加回归测试。
|
|
292
|
+
|
|
293
|
+
### `thirdparty` — 第三方设备
|
|
294
|
+
烟感 / 燃气 / 水表 / 温湿度 / 光照等(`equipmentType ≠ 1/2`)走独立接口:
|
|
295
|
+
|
|
296
|
+
| 方法 | 平台接口 |
|
|
297
|
+
| --- | --- |
|
|
298
|
+
| `status({mac,equipmentType,dataType?,startDate?,endDate?})` | `GET_THR_DEVICE_STATUS` |
|
|
299
|
+
| `statusParsed(...)` | 同上,并把 `data` 的 JSON 字符串解析成对象 |
|
|
300
|
+
| `listDevices({equipmentType})` | `GET_BOXES` 按类型过滤 |
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
import { ThirdPartyEquipmentType, MeterQueryType } from '@wdywdy/electric-open-platform';
|
|
304
|
+
|
|
305
|
+
// 感烟
|
|
306
|
+
const rows = await ElectricOpenPlatform.thirdparty.statusParsed({
|
|
307
|
+
mac: '866971033551412',
|
|
308
|
+
equipmentType: ThirdPartyEquipmentType.SmokeForSafe, // 6
|
|
309
|
+
});
|
|
310
|
+
// rows[0] = { devType:'FS3031', msgType:'BatVol', data:{ BatLow:0, Voltage:'2.78' }, mac:'…' }
|
|
311
|
+
|
|
312
|
+
// 智能水表必须指定 dataType
|
|
313
|
+
await ElectricOpenPlatform.thirdparty.status({
|
|
314
|
+
mac: 'W1',
|
|
315
|
+
equipmentType: ThirdPartyEquipmentType.SmartWaterMeter, // 16
|
|
316
|
+
dataType: MeterQueryType.Range,
|
|
317
|
+
startDate: '2026-11-01',
|
|
318
|
+
endDate: '2026-11-02',
|
|
319
|
+
});
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
平台约束(SDK 已内置校验):
|
|
323
|
+
- `equipmentType` 传 1(微断)或 2(电气火灾)会被拒绝 → 改用 `channel.realtime()`
|
|
324
|
+
- 智能水表(16) 必须带 `dataType`;`dataType=1` 时必须带 `startDate`/`endDate`
|
|
325
|
+
|
|
326
|
+
### `scene` / `push`
|
|
327
|
+
`scene.list()` / `scene.execute()`;`push.query/subscribe/unsubscribe/failedCheck/failedReset`,
|
|
328
|
+
并导出 `verifyPushKey()` 供你的后端校验平台推送的 `key`。
|
|
329
|
+
|
|
330
|
+
### 逃生舱
|
|
331
|
+
```ts
|
|
332
|
+
// 平台新增接口、SDK 还没包时直接用:
|
|
333
|
+
const data = await ElectricOpenPlatform.invoke<MyType>('NEW_METHOD', { foo: 'bar' });
|
|
334
|
+
```
|
|
335
|
+
同样自动签名、自动续期、自动解包。
|
|
336
|
+
|
|
337
|
+
## 错误处理
|
|
338
|
+
|
|
339
|
+
```ts
|
|
340
|
+
import { ElectricOpenPlatformError, PlatformCodes, TransportCodes } from '@wdywdy/electric-open-platform';
|
|
341
|
+
|
|
342
|
+
try {
|
|
343
|
+
await ElectricOpenPlatform.control.switchCircuit({ mac, action: 'open', addr: 1 });
|
|
344
|
+
} catch (err) {
|
|
345
|
+
if (err instanceof ElectricOpenPlatformError) {
|
|
346
|
+
switch (err.code) {
|
|
347
|
+
case PlatformCodes.BOX_OFFLINE: // '1001'
|
|
348
|
+
return showToast('设备不在线');
|
|
349
|
+
case PlatformCodes.REMOTE_CONTROL_FORBIDDEN: // '1004'
|
|
350
|
+
return showToast('该线路不允许远程控制');
|
|
351
|
+
case TransportCodes.NOT_PROVISIONED:
|
|
352
|
+
return showToast('该接口未开通,请联系平台商务');
|
|
353
|
+
case TransportCodes.NETWORK:
|
|
354
|
+
return showToast('网络异常,请稍后重试');
|
|
355
|
+
default:
|
|
356
|
+
return showToast(err.message); // 已含平台中文提示
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
throw err;
|
|
360
|
+
}
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
`err` 上还有 `isTokenExpired`、`isRetryable`、`method`、`details`、`toJSON()`。
|
|
364
|
+
|
|
365
|
+
## 安全
|
|
366
|
+
|
|
367
|
+
1. **`appSecret` 只应存在于服务端**。它参与 MD5 签名,放进浏览器/前端包等于公开。
|
|
368
|
+
推荐架构:前端 → 你自己的后端(持有 secret,用本 SDK 签名并转发)→ 平台。
|
|
369
|
+
2. **token 存储**:默认 `localStorage`(浏览器)或内存(Node)。需要更强保护时传自定义
|
|
370
|
+
`store`(如加密文件、系统密钥链、Redis)。`store` 只负责存取字符串,加密策略由你决定。
|
|
371
|
+
3. **控制类接口**(`PUT_BOX_CONTROL` 等)会真实操作现场设备,务必加二次确认。
|
|
372
|
+
4. SDK **从不**把 `accessToken` / `appSecret` 写进日志;`logger` 回调里已做脱敏。
|
|
373
|
+
|
|
374
|
+
## 已验证行为
|
|
375
|
+
|
|
376
|
+
以下结论来自对真实平台(`projectCode=P00000104893`)的联调,不是纸面推导:
|
|
377
|
+
|
|
378
|
+
| 接口 | 结果 |
|
|
379
|
+
| --- | --- |
|
|
380
|
+
| `GET_PROJECT_INFO` | ✅ 佛山市毅丰电器实业有限公司(AUDITED) |
|
|
381
|
+
| `GET_PROJECT_DAY_STATS` | ✅ |
|
|
382
|
+
| `GET_BOXES` | ✅ 11 台设备,在线 2 台 |
|
|
383
|
+
| `GET_BOX_POSITION_TREE` | ✅ 2 个顶层层级 |
|
|
384
|
+
| `GET_BOX_CHANNELS_REALTIME` | ✅ → 线路实时电压/电流/温度/漏电 |
|
|
385
|
+
| `GET_BOX_CHANNELS` / `_OC` | ✅ 28 个有效节点 |
|
|
386
|
+
| `GET_BOX_CHANNELS_REALTIME_HISTORY` | ✅ 88 条(参数是 `date`,**不是**区间) |
|
|
387
|
+
| `GET_BOX_ALARM` | ✅ 近 24h 数十条告警 |
|
|
388
|
+
| `GET_BOX_PROPERTIES` | ✅(**必须**带 `nodeType=COMM`) |
|
|
389
|
+
| `GET_BOX_ONLINE_HISTORY` | ✅(参数是 `startTime`/`endTime`,**不是** `start`/`end`) |
|
|
390
|
+
| `GET_BOX_MAINTENANCE_HISTORY` | ✅(`start`/`end` 必填) |
|
|
391
|
+
| `GET_CFG_TIMER` / `GET_CFG_LKICHK` | ✅ |
|
|
392
|
+
| `SCENE_SERVICE` / `SUBSCRIBE_PUSH_SERVICE` | ✅ |
|
|
393
|
+
| `GET_THR_DEVICE_STATUS` | ✅(按 `equipmentType` 调用,本项目无第三方设备故返回空) |
|
|
394
|
+
| `GET_BOXES_DAY_STATS` / `_HOUR_STATS` / `_MON_STATS` | ✅ 均返回真实数据 |
|
|
395
|
+
| `oauth/refresh.as` | ✅ 刷新后再次调用正常 |
|
|
396
|
+
|
|
397
|
+
**当前 appKey 唯一未开通的接口:`GET_BOX`**。已用对照实验确认(`GET_BOXES` 同一签名算法成功、
|
|
398
|
+
`GET_BOX` 换三组参数均失败、故意破坏签名会得到同样文案),属于接口级未授权,
|
|
399
|
+
不是 SDK 实现或签名问题。需要时请联系平台商务开通。
|
|
400
|
+
|
|
401
|
+
### 接口命名陷阱(务必注意)
|
|
402
|
+
|
|
403
|
+
| 现象 | 真实原因 |
|
|
404
|
+
| --- | --- |
|
|
405
|
+
| `接口名称(X)无效!` | 方法名写错 **或** 该 appKey 未开通 |
|
|
406
|
+
| `无效的接口方法参数签名!` | 既可能是签名错,**也可能是接口未开通**——平台复用同一文案 |
|
|
407
|
+
|
|
408
|
+
典型踩坑:把 `GET_BOXES_DAY_STATS` 写成 `GET_BOXS_DAY_STATS`(少一个 `E`),
|
|
409
|
+
返回 `接口名称(GET_BOXS_DAY_STATS)无效!`,极易误判为“未开通”而放弃接入。
|
|
410
|
+
SDK 已按文档修正,并有回归测试锁定三个统计接口的方法名。
|
|
411
|
+
|
|
412
|
+
复现验证:
|
|
413
|
+
|
|
414
|
+
```bash
|
|
415
|
+
npm run build
|
|
416
|
+
# 先设置 EOP_* 环境变量,见 scripts/verify-live.mjs 顶部注释
|
|
417
|
+
node scripts/verify-live.mjs # 端到端 16 项检查
|
|
418
|
+
node scripts/verify-all-apis.mjs # 全量接口逐个调用(20/21 通过,GET_BOX 未开通)
|
|
419
|
+
node scripts/diff-api-coverage.cjs # 文档 vs 实现 覆盖率对照(应为「未封装: 0 个」)
|
|
420
|
+
node scripts/diagnose-getbox.mjs # 对照实验:证明 GET_BOX 是未开通而非签名错
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
## 本地调试
|
|
424
|
+
|
|
425
|
+
```bash
|
|
426
|
+
npm install
|
|
427
|
+
npm test # 99 个单测:MD5 向量、签名样例、token 生命周期、接口参数、
|
|
428
|
+
# 传输/存储注入优先级,以及「不得出现平台耦合」的防回潮守卫
|
|
429
|
+
npm run build # 产出 dist/esm(ESM)+ dist/cjs(CJS)+ 完整 .d.ts
|
|
430
|
+
npm run demo # 打开 http://localhost:5178 调试台
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
> 浏览器里直接跑会被 **CORS** 拦截(平台不返回 CORS 头)。调试台用来看 UI 与错误路径;
|
|
434
|
+
> 真实请求请配反向代理,或在 Node 里跑。
|
|
435
|
+
|
|
436
|
+
## 接入示例
|
|
437
|
+
|
|
438
|
+
### Node 后端(最贴合本包的用法)
|
|
439
|
+
|
|
440
|
+
```ts
|
|
441
|
+
// server.mjs —— Node ≥ 18,无需任何额外依赖
|
|
442
|
+
import { ElectricOpenPlatform } from '@wdywdy/electric-open-platform';
|
|
443
|
+
|
|
444
|
+
await ElectricOpenPlatform.configure({
|
|
445
|
+
appKey: process.env.EOP_APP_KEY!,
|
|
446
|
+
appSecret: process.env.EOP_APP_SECRET!,
|
|
447
|
+
redirectUri: process.env.EOP_REDIRECT_URI!,
|
|
448
|
+
projectCode: process.env.EOP_PROJECT_CODE,
|
|
449
|
+
username: process.env.EOP_USERNAME,
|
|
450
|
+
password: process.env.EOP_PASSWORD,
|
|
451
|
+
storage: 'memory',
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
export async function dashboard() {
|
|
455
|
+
const boxes = await ElectricOpenPlatform.box.listOnline();
|
|
456
|
+
const live = await ElectricOpenPlatform.channel.realtimeChannels({ mac: boxes.map((b) => b.mac) });
|
|
457
|
+
return { boxes, live };
|
|
458
|
+
}
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
### Ionic / Angular / React / Vue
|
|
462
|
+
|
|
463
|
+
```ts
|
|
464
|
+
// Angular service 示例;React/Vue 把 Injectable 换成 hook / composable 即可
|
|
465
|
+
import { Injectable } from '@angular/core';
|
|
466
|
+
import { ElectricOpenPlatform } from '@wdywdy/electric-open-platform';
|
|
467
|
+
|
|
468
|
+
@Injectable({ providedIn: 'root' })
|
|
469
|
+
export class PowerApiService {
|
|
470
|
+
private ready?: Promise<void>;
|
|
471
|
+
|
|
472
|
+
private ensure(): Promise<void> {
|
|
473
|
+
// configure() 幂等,但只需执行一次
|
|
474
|
+
this.ready ??= ElectricOpenPlatform.configure({
|
|
475
|
+
appKey: environment.eopAppKey,
|
|
476
|
+
appSecret: environment.eopAppSecret,
|
|
477
|
+
redirectUri: environment.eopRedirectUri,
|
|
478
|
+
projectCode: environment.eopProjectCode,
|
|
479
|
+
baseUrl: environment.eopBaseUrl, // 指向自己的反向代理
|
|
480
|
+
});
|
|
481
|
+
return this.ready;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async dashboard(): Promise<{ boxes: unknown; live: unknown }> {
|
|
485
|
+
await this.ensure();
|
|
486
|
+
const boxes = await ElectricOpenPlatform.box.listOnline();
|
|
487
|
+
const live = await ElectricOpenPlatform.channel.realtimeChannels({ mac: boxes.map((b) => b.mac) });
|
|
488
|
+
return { boxes, live };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** 60s 自刷新(token 由 SDK 维护,无需自己管过期) */
|
|
492
|
+
startPolling(onData: (data: unknown) => void): () => void {
|
|
493
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
494
|
+
const tick = () => this.dashboard().then(onData).catch(console.error);
|
|
495
|
+
tick();
|
|
496
|
+
timer = setInterval(tick, 60_000);
|
|
497
|
+
return () => clearInterval(timer);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
组件销毁时记得 `ElectricOpenPlatform.dispose()` 释放续期定时器(或直接调 `logout()`)。
|
|
503
|
+
|
|
504
|
+
## 发布到 npm
|
|
505
|
+
|
|
506
|
+
包名 **`@wdywdy/electric-open-platform`**(`@wdywdy` 是 npm 个人账号 `wdywdy` 的用户名 scope,
|
|
507
|
+
无需建组织即可发布)。只发布**一个**包,版本以 `package.json` 为准:
|
|
508
|
+
|
|
509
|
+
> ℹ️ **1.0.0 已于 2026-11-04 成功发布**(用勾选了 *Bypass 2FA* 的 **Granular Access Token**,
|
|
510
|
+
> 权限 Read and write + All packages)。
|
|
511
|
+
> 该账号开了 2FA,日后发布同样需要「动态码」或这种 token,否则会返回
|
|
512
|
+
> `E403 Two-factor authentication ... is required to publish packages`。
|
|
513
|
+
>
|
|
514
|
+
> ⚠️ 注意:**只读 token 不行** —— 它连 `npm access list packages` 都会 403,
|
|
515
|
+
> publish 同样失败,必须先确认 token 具备写权限。
|
|
516
|
+
|
|
517
|
+
```bash
|
|
518
|
+
# 首次发布
|
|
519
|
+
npm login # 或 npm adduser
|
|
520
|
+
npm whoami # 应打印 wdywdy
|
|
521
|
+
node scripts/check-versions.mjs # 版本 / exports / 零依赖自检
|
|
522
|
+
npm publish # publishConfig.access=public,无需再传 --access
|
|
523
|
+
# 账号开了 2FA 的话:npm publish --otp=六位动态码
|
|
524
|
+
|
|
525
|
+
npm view @wdywdy/electric-open-platform version # 验证
|
|
526
|
+
|
|
527
|
+
# 之后每次发版(推荐:打 tag 自动发)
|
|
528
|
+
node scripts/check-versions.mjs 1.0.1
|
|
529
|
+
git commit -am "chore(release): v1.0.1"
|
|
530
|
+
git tag v1.0.1 && git push && git push --tags
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
工作流 `.github/workflows/publish-npm.yml` 会依次:装依赖 → 跑单测 → 重建产物并校验产物已提交
|
|
534
|
+
→ 校验版本与零依赖 → **grep 断言产物里没有平台耦合残留** → 打包自检 → 发布
|
|
535
|
+
(已发布过的版本自动跳过,便于重跑)。
|
|
536
|
+
|
|
537
|
+
**CI 自动发布需要配认证**,二选一:
|
|
538
|
+
|
|
539
|
+
1. **Trusted Publishing(推荐,无需存 token)**:npm 包页面 → Settings → Trusted Publisher →
|
|
540
|
+
GitHub 仓库(`lanyangayang/electric-open-platform`)与 workflow 文件名
|
|
541
|
+
`publish-npm.yml`(workflow 里已有 `id-token: write` 与 `--provenance`)。
|
|
542
|
+
注意:包必须先手动发布过一次才能绑定。
|
|
543
|
+
2. **Access Token**:仓库 Settings → Secrets and variables → Actions → `NPM_TOKEN`,
|
|
544
|
+
值取 npmjs.com → Access Tokens 生成的 **Granular token**(限制为只读+发布、限定此包)。
|
|
545
|
+
npm 正在收紧「绕过 2FA 的 token」,长期建议走方案 1。
|
|
546
|
+
|
|
547
|
+
## 平台接口速查
|
|
548
|
+
|
|
549
|
+
| 项 | 值 |
|
|
550
|
+
| --- | --- |
|
|
551
|
+
| 基础地址 | `https://open.snd02.com` |
|
|
552
|
+
| 取 code | `POST /oauth/authverify2.as` |
|
|
553
|
+
| 取 token | `POST /oauth/token.as` |
|
|
554
|
+
| 刷新 token | `POST /oauth/refresh.as` |
|
|
555
|
+
| 业务接口 | `POST /invoke/router.as`(`application/x-www-form-urlencoded`,UTF-8) |
|
|
556
|
+
| 公共参数 | `client_id`(=appKey)、`method`、`access_token`、`timestamp`(`yyyyMMddHHmmss` 本地时间)、`sign` |
|
|
557
|
+
| `client_secret` | `MD5(appKey + grant_type + redirect_uri + code\|refreshToken + appSecret)` |
|
|
558
|
+
| 签名 | 除 `sign` 外所有参数按**参数名升序**拼接**参数值** + `appSecret`,再 MD5 |
|
|
559
|
+
|
|
560
|
+
## License
|
|
561
|
+
|
|
562
|
+
MIT
|