leap-cn-mcp 0.1.0
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/README.md +276 -0
- package/bin/leap-cn-mcp.js +189 -0
- package/package.json +24 -0
- package/src/leapmotor-cn-sdk.js +900 -0
package/README.md
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
# Leapmotor CN SDK + CLI + HTML Demo
|
|
2
|
+
|
|
3
|
+
本目录包含零跑中国 App 逆向出的核心链路实现:
|
|
4
|
+
|
|
5
|
+
- 发送短信验证码
|
|
6
|
+
- 验证码登录
|
|
7
|
+
- 旧登录 token 换新网关 `accessToken/signKey`
|
|
8
|
+
- 查询车辆列表、车辆路由、车况、电池、里程、门锁、车窗、位置、空调状态
|
|
9
|
+
- 构造/发送控车命令和查询控车结果
|
|
10
|
+
|
|
11
|
+
实现分三层:
|
|
12
|
+
|
|
13
|
+
```text
|
|
14
|
+
src/leapmotor-cn-sdk.js SDK 核心,供 CLI、服务或后续 MCP 调用
|
|
15
|
+
bin/leap-cn.js CLI,默认 JSON 输出,适合 AI/脚本调用
|
|
16
|
+
server.js + public/ 原 HTML demo,保留给人工调试
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## CLI
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
cd /Users/qian/project/lingpao/demo
|
|
23
|
+
npm run cli -- session
|
|
24
|
+
npm run cli -- vehicles
|
|
25
|
+
npm run cli -- state
|
|
26
|
+
npm run cli -- mileage
|
|
27
|
+
npm run cli -- location
|
|
28
|
+
npm run cli -- overview
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
登录:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npm run cli -- send-sms --phone <手机号>
|
|
35
|
+
npm run cli -- login --phone <手机号> --code <验证码>
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
车辆选择:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npm run cli -- vehicles
|
|
42
|
+
npm run cli -- select --vin <VIN> --car-type S01
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
远控预览,不会触发真实车辆动作:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npm run cli -- control-preview --command lock
|
|
49
|
+
npm run cli -- control-preview --command window --value 0
|
|
50
|
+
npm run cli -- control-preview --command acOn
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
真实远控需要同时给出两个安全开关:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
npm run cli -- control-send --command lock --allow-write --confirm
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
如果命令需要操作密码,CLI 提供两个等价参数:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
npm run cli -- control-send --command unlock --pin <操作密码> --allow-write --confirm
|
|
63
|
+
npm run cli -- control-send --command unlock --operation-password <操作密码> --allow-write --confirm
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
查询控车结果:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
npm run cli -- control-query --msgID <msgID>
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
支持的远控命令可查看:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
npm run cli -- commands
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
常用命令名:
|
|
79
|
+
|
|
80
|
+
```text
|
|
81
|
+
lock, unlock, trunk, trunkOpen, trunkClose, frunkOpen, frunkClose,
|
|
82
|
+
horn, batteryPreheat, batteryPreheatOff,
|
|
83
|
+
window, windowClose, windowVent,
|
|
84
|
+
sunshadeOpen, sunshadeClose, sunroofOpen, sunroofClose,
|
|
85
|
+
ac, acOn, acOff, quickCool, quickHeat, windshieldDefrost, custom
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`custom` 示例:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
npm run cli -- control-preview \
|
|
92
|
+
--command custom \
|
|
93
|
+
--cmdid 170 \
|
|
94
|
+
--state-json '{"operate":"manual","temperature":"24","windlevel":"3","mode":"cold","circle":"in","wshld":"0","position":"all"}'
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## SDK
|
|
98
|
+
|
|
99
|
+
```js
|
|
100
|
+
import { LeapmotorChinaClient } from './src/leapmotor-cn-sdk.js';
|
|
101
|
+
|
|
102
|
+
const client = new LeapmotorChinaClient({
|
|
103
|
+
sessionFile: '/Users/qian/project/lingpao/demo/.leap-session.json'
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
await client.loadSession();
|
|
107
|
+
const vehicles = await client.listVehicles();
|
|
108
|
+
const status = await client.getVehicleState();
|
|
109
|
+
const preview = await client.previewControl({ command: 'lock' });
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
真实远控默认禁用。SDK 层需要:
|
|
113
|
+
|
|
114
|
+
```js
|
|
115
|
+
const client = new LeapmotorChinaClient({ allowWrite: true });
|
|
116
|
+
await client.sendControl({
|
|
117
|
+
command: 'unlock',
|
|
118
|
+
operationPassword: '<操作密码>',
|
|
119
|
+
confirm: true
|
|
120
|
+
});
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## MCP
|
|
124
|
+
|
|
125
|
+
给 AI 调用时,推荐使用 MCP stdio 服务:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
npx --yes --package leap-cn-mcp leap-cn-mcp
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
可执行文件:
|
|
132
|
+
|
|
133
|
+
```text
|
|
134
|
+
/Users/qian/project/lingpao/demo/bin/leap-cn-mcp.js
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
示例 MCP 配置:
|
|
138
|
+
|
|
139
|
+
```json
|
|
140
|
+
{
|
|
141
|
+
"mcpServers": {
|
|
142
|
+
"leapmotor-cn": {
|
|
143
|
+
"command": "npx",
|
|
144
|
+
"args": ["--yes", "--package", "leap-cn-mcp", "leap-cn-mcp"],
|
|
145
|
+
"env": {
|
|
146
|
+
"LEAP_SESSION_FILE": "/Users/qian/project/lingpao/demo/.leap-session.json"
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
MCP tools:
|
|
154
|
+
|
|
155
|
+
```text
|
|
156
|
+
leap_session
|
|
157
|
+
leap_send_sms
|
|
158
|
+
leap_login_sms
|
|
159
|
+
leap_vehicles
|
|
160
|
+
leap_select_vehicle
|
|
161
|
+
leap_route
|
|
162
|
+
leap_state
|
|
163
|
+
leap_mileage
|
|
164
|
+
leap_location
|
|
165
|
+
leap_overview
|
|
166
|
+
leap_commands
|
|
167
|
+
leap_control_preview
|
|
168
|
+
leap_control_send
|
|
169
|
+
leap_control_query
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
MCP 真实远控比 CLI 更保守,需要同时满足:
|
|
173
|
+
|
|
174
|
+
```text
|
|
175
|
+
1. 启动 MCP 服务时设置 LEAP_ALLOW_WRITE=1
|
|
176
|
+
2. tool 参数 allowWrite=true
|
|
177
|
+
3. tool 参数 confirm=true
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
不满足任一条件都会拒绝发送。`leap_control_preview` 永远只生成 dryRun 摘要。
|
|
181
|
+
|
|
182
|
+
## Web Demo
|
|
183
|
+
|
|
184
|
+
Web demo 现在也复用同一个 SDK 文件:
|
|
185
|
+
|
|
186
|
+
```text
|
|
187
|
+
server.js -> src/leapmotor-cn-sdk.js
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
页面里的操作密码输入框会透传为 SDK 的 `operationPassword`,由 SDK 加密成 `oppwd` 后发送。页面仍要求勾选确认后才会触发真实控车。
|
|
191
|
+
|
|
192
|
+
## 运行
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
cd /Users/qian/project/lingpao/demo
|
|
196
|
+
npm start
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
默认地址:
|
|
200
|
+
|
|
201
|
+
```text
|
|
202
|
+
http://localhost:8787
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
可选环境变量:
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
PORT=8787 LEAP_DEVICE_ID=<deviceId> LEAP_APP_VERSION=1.22.87 LEAP_SUB_VERSION=3.19.2-2 npm start
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
CLI 也支持:
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
LEAP_SESSION_FILE=/Users/qian/project/lingpao/demo/.leap-session.json npm run cli -- session
|
|
215
|
+
LEAP_ALLOW_WRITE=1 npm run cli -- control-send --command lock --confirm
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
`deviceId` 注意事项:
|
|
219
|
+
|
|
220
|
+
```text
|
|
221
|
+
LEAP_DEVICE_ID 只应在首次登录/新 session 时指定。
|
|
222
|
+
如果 session 文件已存在,SDK 会优先使用 session 内保存的 deviceId。
|
|
223
|
+
不要把已有 session 里的 deviceId 手工替换成新值。
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
实测把现有 session 的 `deviceId` 改成随机值后:
|
|
227
|
+
|
|
228
|
+
```text
|
|
229
|
+
车辆列表/实时状态:仍返回 code/result=0
|
|
230
|
+
旧 carownerservice 里程接口:返回 result=39 信息校验失败
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
远控也依赖旧签名链路,因此换 `deviceId` 后应重新短信登录,不要复用旧 session。
|
|
234
|
+
|
|
235
|
+
## 安全说明
|
|
236
|
+
|
|
237
|
+
- 短信验证码接口会在服务端按 App 规则将手机号 RSA 加密为 `phoneNo` 后发送,不落盘保存手机号。
|
|
238
|
+
- token、signKey、车辆选择会保存到本地 `.leap-session.json`,用于重启后自动恢复登录态。
|
|
239
|
+
- `.leap-session.json` 已加入 `.gitignore`,文件权限按 `600` 写入;不要提交或分享。
|
|
240
|
+
- 页面上的“重置会话”会同时清空内存并删除 `.leap-session.json`。
|
|
241
|
+
- 登录接口和调试输出不返回完整 token/signKey。
|
|
242
|
+
- CLI 的 `control-preview` 不会触发真实车辆动作。
|
|
243
|
+
- CLI 的 `control-send` 必须同时提供 `--allow-write` 和 `--confirm`。
|
|
244
|
+
- SDK 的 `sendControl()` 必须 `allowWrite=true` 且 `confirm=true`。
|
|
245
|
+
- Web demo 控车接口会触发真实车辆动作,页面要求勾选确认后才会发送。
|
|
246
|
+
- 操作密码如填写,会按 App 规则使用旧 token 加密成 `oppwd` 后发送。
|
|
247
|
+
|
|
248
|
+
## 验证结果
|
|
249
|
+
|
|
250
|
+
当前 session 下已用 CLI 验证:
|
|
251
|
+
|
|
252
|
+
```text
|
|
253
|
+
npm run cli -- vehicles -> code/result=0
|
|
254
|
+
npm run cli -- state -> code/result=0,summary 可读电池、门锁、车窗、空调、经纬度
|
|
255
|
+
npm run cli -- mileage -> code/result=0
|
|
256
|
+
npm run cli -- location -> code/result=0,当前响应 data=null;经纬度以 state summary 为准
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
真实远控没有执行。仅验证了:
|
|
260
|
+
|
|
261
|
+
```text
|
|
262
|
+
control-preview 只生成 dryRun 请求摘要
|
|
263
|
+
control-send 缺少 --allow-write 会拒绝
|
|
264
|
+
control-send 缺少 --confirm 会拒绝
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
## 主要源码
|
|
268
|
+
|
|
269
|
+
```text
|
|
270
|
+
src/leapmotor-cn-sdk.js
|
|
271
|
+
bin/leap-cn.js
|
|
272
|
+
server.js
|
|
273
|
+
public/index.html
|
|
274
|
+
public/app.js
|
|
275
|
+
public/styles.css
|
|
276
|
+
```
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import { createClientFromEnv, commandPresets } from '../src/leapmotor-cn-sdk.js';
|
|
4
|
+
|
|
5
|
+
const client = createClientFromEnv({
|
|
6
|
+
sessionFile: process.env.LEAP_SESSION_FILE || new URL('../.leap-session.json', import.meta.url).pathname,
|
|
7
|
+
allowWrite: process.env.LEAP_ALLOW_WRITE === '1'
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const tools = [
|
|
11
|
+
tool('leap_session', 'Read current Leapmotor session summary.', {}),
|
|
12
|
+
tool('leap_send_sms', 'Send SMS login code. Phone is RSA encrypted before sending.', {
|
|
13
|
+
phone: stringSchema('Phone number')
|
|
14
|
+
}, ['phone']),
|
|
15
|
+
tool('leap_login_sms', 'Verify SMS code and exchange gateway token.', {
|
|
16
|
+
phone: stringSchema('Phone number'),
|
|
17
|
+
smsCode: stringSchema('SMS verification code')
|
|
18
|
+
}, ['phone', 'smsCode']),
|
|
19
|
+
tool('leap_vehicles', 'List vehicles bound to the account.', {}),
|
|
20
|
+
tool('leap_select_vehicle', 'Select active vehicle for subsequent calls.', {
|
|
21
|
+
vin: stringSchema('Vehicle VIN'),
|
|
22
|
+
carType: stringSchema('Optional car type')
|
|
23
|
+
}, ['vin']),
|
|
24
|
+
tool('leap_route', 'Fetch selected vehicle route.', {}),
|
|
25
|
+
tool('leap_state', 'Fetch selected vehicle realtime state and normalized summary.', {}),
|
|
26
|
+
tool('leap_mileage', 'Fetch selected vehicle mileage and energy detail.', {}),
|
|
27
|
+
tool('leap_location', 'Fetch old chassis/location endpoint. Realtime state may have better coordinates.', {}),
|
|
28
|
+
tool('leap_overview', 'Fetch route, realtime state, mileage, and location together.', {}),
|
|
29
|
+
tool('leap_commands', 'List supported remote-control command presets.', {}),
|
|
30
|
+
tool('leap_control_preview', 'Build a remote-control request without sending it.', {
|
|
31
|
+
command: stringSchema('Command preset name'),
|
|
32
|
+
value: stringSchema('Value for value-based commands such as window'),
|
|
33
|
+
cmdid: stringSchema('Custom command id'),
|
|
34
|
+
stateJson: stringSchema('Custom state JSON'),
|
|
35
|
+
operationPassword: stringSchema('Operation password/PIN; encrypted into oppwd')
|
|
36
|
+
}, ['command']),
|
|
37
|
+
tool('leap_control_send', 'Send a real remote-control command. Requires LEAP_ALLOW_WRITE=1, allowWrite=true, and confirm=true.', {
|
|
38
|
+
command: stringSchema('Command preset name'),
|
|
39
|
+
value: stringSchema('Value for value-based commands such as window'),
|
|
40
|
+
cmdid: stringSchema('Custom command id'),
|
|
41
|
+
stateJson: stringSchema('Custom state JSON'),
|
|
42
|
+
operationPassword: stringSchema('Operation password/PIN; encrypted into oppwd'),
|
|
43
|
+
allowWrite: booleanSchema('Must be true'),
|
|
44
|
+
confirm: booleanSchema('Must be true')
|
|
45
|
+
}, ['command', 'allowWrite', 'confirm']),
|
|
46
|
+
tool('leap_control_query', 'Query remote-control result by msgID/eventId.', {
|
|
47
|
+
msgID: stringSchema('Control msgID/eventId')
|
|
48
|
+
}, ['msgID'])
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const toolMap = new Map(tools.map((item) => [item.name, item]));
|
|
52
|
+
|
|
53
|
+
await client.loadSession();
|
|
54
|
+
|
|
55
|
+
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
56
|
+
rl.on('line', async (line) => {
|
|
57
|
+
if (!line.trim()) return;
|
|
58
|
+
let request;
|
|
59
|
+
try {
|
|
60
|
+
request = JSON.parse(line);
|
|
61
|
+
const response = await handleRequest(request);
|
|
62
|
+
if (response) write(response);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
write({
|
|
65
|
+
jsonrpc: '2.0',
|
|
66
|
+
id: request?.id ?? null,
|
|
67
|
+
error: { code: -32000, message: error.message }
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
async function handleRequest(request) {
|
|
73
|
+
const { id, method, params = {} } = request;
|
|
74
|
+
if (method === 'initialize') {
|
|
75
|
+
return {
|
|
76
|
+
jsonrpc: '2.0',
|
|
77
|
+
id,
|
|
78
|
+
result: {
|
|
79
|
+
protocolVersion: params.protocolVersion || '2024-11-05',
|
|
80
|
+
capabilities: { tools: {} },
|
|
81
|
+
serverInfo: { name: 'leapmotor-cn-mcp', version: '0.1.0' }
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
if (method === 'notifications/initialized') return null;
|
|
86
|
+
if (method === 'tools/list') {
|
|
87
|
+
return { jsonrpc: '2.0', id, result: { tools } };
|
|
88
|
+
}
|
|
89
|
+
if (method === 'tools/call') {
|
|
90
|
+
const result = await callTool(params.name, params.arguments || {});
|
|
91
|
+
return {
|
|
92
|
+
jsonrpc: '2.0',
|
|
93
|
+
id,
|
|
94
|
+
result: {
|
|
95
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
jsonrpc: '2.0',
|
|
101
|
+
id,
|
|
102
|
+
error: { code: -32601, message: `Method not found: ${method}` }
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function callTool(name, args) {
|
|
107
|
+
if (!toolMap.has(name)) throw new Error(`Unknown tool: ${name}`);
|
|
108
|
+
switch (name) {
|
|
109
|
+
case 'leap_session':
|
|
110
|
+
return { session: client.sessionSummary(), auth: client.authDebugSummary() };
|
|
111
|
+
case 'leap_send_sms':
|
|
112
|
+
return { response: await client.sendSms(required(args.phone, 'phone')) };
|
|
113
|
+
case 'leap_login_sms':
|
|
114
|
+
return await client.loginWithSms({
|
|
115
|
+
phone: required(args.phone, 'phone'),
|
|
116
|
+
smsCode: required(args.smsCode, 'smsCode')
|
|
117
|
+
});
|
|
118
|
+
case 'leap_vehicles':
|
|
119
|
+
return await client.listVehicles();
|
|
120
|
+
case 'leap_select_vehicle':
|
|
121
|
+
return { session: await client.selectVehicle(required(args.vin, 'vin'), args.carType || '') };
|
|
122
|
+
case 'leap_route':
|
|
123
|
+
return await client.getVehicleRoute();
|
|
124
|
+
case 'leap_state':
|
|
125
|
+
return await client.getVehicleState();
|
|
126
|
+
case 'leap_mileage':
|
|
127
|
+
return await client.getMileageEnergy();
|
|
128
|
+
case 'leap_location':
|
|
129
|
+
return await client.getLocation();
|
|
130
|
+
case 'leap_overview':
|
|
131
|
+
return await client.getOverview();
|
|
132
|
+
case 'leap_commands':
|
|
133
|
+
return { commands: commandPresets };
|
|
134
|
+
case 'leap_control_preview':
|
|
135
|
+
return await client.previewControl(controlArgs(args));
|
|
136
|
+
case 'leap_control_send':
|
|
137
|
+
if (process.env.LEAP_ALLOW_WRITE !== '1') {
|
|
138
|
+
throw new Error('真实控车需要 MCP 服务启动时设置 LEAP_ALLOW_WRITE=1');
|
|
139
|
+
}
|
|
140
|
+
if (args.allowWrite !== true || args.confirm !== true) {
|
|
141
|
+
throw new Error('真实控车需要 allowWrite=true 且 confirm=true');
|
|
142
|
+
}
|
|
143
|
+
return await client.sendControl({ ...controlArgs(args), allowWrite: true, confirm: true });
|
|
144
|
+
case 'leap_control_query':
|
|
145
|
+
return await client.queryControlResult(required(args.msgID, 'msgID'));
|
|
146
|
+
default:
|
|
147
|
+
throw new Error(`Unhandled tool: ${name}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function controlArgs(args) {
|
|
152
|
+
return {
|
|
153
|
+
command: required(args.command, 'command'),
|
|
154
|
+
value: args.value,
|
|
155
|
+
cmdid: args.cmdid,
|
|
156
|
+
stateJson: args.stateJson,
|
|
157
|
+
operationPassword: args.operationPassword || ''
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function tool(name, description, properties, required = []) {
|
|
162
|
+
return {
|
|
163
|
+
name,
|
|
164
|
+
description,
|
|
165
|
+
inputSchema: {
|
|
166
|
+
type: 'object',
|
|
167
|
+
properties,
|
|
168
|
+
required,
|
|
169
|
+
additionalProperties: false
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function stringSchema(description) {
|
|
175
|
+
return { type: 'string', description };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function booleanSchema(description) {
|
|
179
|
+
return { type: 'boolean', description };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function required(value, name) {
|
|
183
|
+
if (value === undefined || value === null || value === '') throw new Error(`Missing required argument: ${name}`);
|
|
184
|
+
return value;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function write(message) {
|
|
188
|
+
process.stdout.write(`${JSON.stringify(message)}\n`);
|
|
189
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "leap-cn-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP stdio server for Leapmotor China app APIs.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"leap-cn-mcp": "bin/leap-cn-mcp.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"README.md",
|
|
11
|
+
"bin/leap-cn-mcp.js",
|
|
12
|
+
"src/leapmotor-cn-sdk.js"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"start": "node server.js",
|
|
16
|
+
"dev": "node --watch server.js",
|
|
17
|
+
"cli": "node bin/leap-cn.js",
|
|
18
|
+
"mcp": "node bin/leap-cn-mcp.js",
|
|
19
|
+
"check": "node --check src/leapmotor-cn-sdk.js && node --check bin/leap-cn.js && node --check bin/leap-cn-mcp.js && node --check server.js"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=20"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,900 @@
|
|
|
1
|
+
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_APP_VERSION = '1.22.87';
|
|
6
|
+
export const DEFAULT_SUB_VERSION = '3.19.2-2';
|
|
7
|
+
export const DEFAULT_SESSION_FILE = path.resolve(process.cwd(), '.leap-session.json');
|
|
8
|
+
|
|
9
|
+
const DEFAULT_PUBLIC_KEY =
|
|
10
|
+
'MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDHUIQKhkwNqJFTZPe98mC1lmpbY9r/+7PEWZg8ebqYXT3sumKRaQ0zcoTx42x0iybmCRXy4CcZrgGAbwKzwqwNw0rFquJ6c7mgQA6k3lZU3p96qBlzK7DSkoFR6mO9pjcd2hlJ8wH+IwI5b8IWWZhwVN/4cM7npG0S0zeRn3soEwIDAQAB';
|
|
11
|
+
|
|
12
|
+
const HOSTS = {
|
|
13
|
+
appUser: 'https://appuser.leapmotor.cn',
|
|
14
|
+
global: 'https://app-gw-global-master.leapmotor.com'
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const appointmentCmdIds = new Set(['161', '171', '361', '392']);
|
|
18
|
+
|
|
19
|
+
export const commandPresets = {
|
|
20
|
+
lock: { label: '上锁', cmdid: '110', state: { value: 'lock' }, acceptsOperationPassword: true },
|
|
21
|
+
unlock: { label: '解锁', cmdid: '110', state: { value: 'unlock' }, acceptsOperationPassword: true },
|
|
22
|
+
trunk: { label: '后备箱/尾门触发', cmdid: '130', state: { value: 'true' }, acceptsOperationPassword: true },
|
|
23
|
+
trunkOpen: { label: '后备箱/尾门打开', cmdid: '130', state: { value: 'true' }, acceptsOperationPassword: true },
|
|
24
|
+
trunkClose: { label: '后备箱/尾门关闭', cmdid: '130', state: { value: 'false' }, acceptsOperationPassword: true },
|
|
25
|
+
frunkOpen: { label: '前备箱打开', cmdid: '131', state: { value: '100' }, acceptsOperationPassword: true },
|
|
26
|
+
frunkClose: { label: '前备箱关闭', cmdid: '131', state: { value: '0' }, acceptsOperationPassword: true },
|
|
27
|
+
horn: { label: '鸣笛寻车', cmdid: '120', state: { value: 'true' }, acceptsOperationPassword: true },
|
|
28
|
+
batteryPreheat: { label: '电池/座舱预热开启', cmdid: '160', state: { value: 'ptcon' }, acceptsOperationPassword: true },
|
|
29
|
+
batteryPreheatOff: { label: '电池/座舱预热关闭', cmdid: '160', state: { value: 'ptcoff' }, acceptsOperationPassword: true },
|
|
30
|
+
window: { label: '车窗控制', cmdid: '230', stateFromValue: true, acceptsOperationPassword: true },
|
|
31
|
+
windowClose: { label: '车窗关闭', cmdid: '230', state: { value: '0' }, acceptsOperationPassword: true },
|
|
32
|
+
windowVent: { label: '车窗通风', cmdid: '230', state: { value: '2' }, acceptsOperationPassword: true },
|
|
33
|
+
sunshadeOpen: { label: '天幕/遮阳帘打开', cmdid: '240', state: { value: '10' }, acceptsOperationPassword: true },
|
|
34
|
+
sunshadeClose: { label: '天幕/遮阳帘关闭', cmdid: '240', state: { value: '0' }, acceptsOperationPassword: true },
|
|
35
|
+
sunroofOpen: { label: '天窗/车顶打开', cmdid: '300', state: { value: '1' }, acceptsOperationPassword: true },
|
|
36
|
+
sunroofClose: { label: '天窗/车顶关闭', cmdid: '300', state: { value: '0' }, acceptsOperationPassword: true },
|
|
37
|
+
ac: { label: '空调自定义', cmdid: '170', rawState: true, acceptsOperationPassword: true },
|
|
38
|
+
acOn: {
|
|
39
|
+
label: '空调开启',
|
|
40
|
+
cmdid: '170',
|
|
41
|
+
state: {
|
|
42
|
+
operate: 'manual',
|
|
43
|
+
temperature: '24',
|
|
44
|
+
windlevel: '3',
|
|
45
|
+
mode: 'cold',
|
|
46
|
+
circle: 'in',
|
|
47
|
+
wshld: '0',
|
|
48
|
+
position: 'all'
|
|
49
|
+
},
|
|
50
|
+
acceptsOperationPassword: true
|
|
51
|
+
},
|
|
52
|
+
acOff: {
|
|
53
|
+
label: '空调关闭',
|
|
54
|
+
cmdid: '170',
|
|
55
|
+
state: {
|
|
56
|
+
operate: 'off',
|
|
57
|
+
temperature: '24',
|
|
58
|
+
windlevel: '3',
|
|
59
|
+
mode: 'nohotcold',
|
|
60
|
+
circle: 'out',
|
|
61
|
+
wshld: '0',
|
|
62
|
+
position: 'all'
|
|
63
|
+
},
|
|
64
|
+
acceptsOperationPassword: true
|
|
65
|
+
},
|
|
66
|
+
quickCool: {
|
|
67
|
+
label: '极速降温',
|
|
68
|
+
cmdid: '170',
|
|
69
|
+
state: {
|
|
70
|
+
operate: 'manual',
|
|
71
|
+
temperature: '18',
|
|
72
|
+
windlevel: '7',
|
|
73
|
+
mode: 'cold',
|
|
74
|
+
circle: 'in',
|
|
75
|
+
wshld: '0',
|
|
76
|
+
position: 'all'
|
|
77
|
+
},
|
|
78
|
+
acceptsOperationPassword: true
|
|
79
|
+
},
|
|
80
|
+
quickHeat: {
|
|
81
|
+
label: '极速升温',
|
|
82
|
+
cmdid: '170',
|
|
83
|
+
state: {
|
|
84
|
+
operate: 'manual',
|
|
85
|
+
temperature: '32',
|
|
86
|
+
windlevel: '7',
|
|
87
|
+
mode: 'hot',
|
|
88
|
+
circle: 'in',
|
|
89
|
+
wshld: '0',
|
|
90
|
+
position: 'all'
|
|
91
|
+
},
|
|
92
|
+
acceptsOperationPassword: true
|
|
93
|
+
},
|
|
94
|
+
windshieldDefrost: {
|
|
95
|
+
label: '前挡除雾',
|
|
96
|
+
cmdid: '170',
|
|
97
|
+
state: {
|
|
98
|
+
operate: 'manual',
|
|
99
|
+
temperature: '32',
|
|
100
|
+
windlevel: '7',
|
|
101
|
+
mode: 'hot',
|
|
102
|
+
circle: 'in',
|
|
103
|
+
wshld: '1',
|
|
104
|
+
position: 'all'
|
|
105
|
+
},
|
|
106
|
+
acceptsOperationPassword: true
|
|
107
|
+
},
|
|
108
|
+
custom: { label: '自定义命令', custom: true, acceptsOperationPassword: true }
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export class LeapmotorChinaClient {
|
|
112
|
+
constructor(options = {}) {
|
|
113
|
+
this.sessionFile = options.sessionFile || DEFAULT_SESSION_FILE;
|
|
114
|
+
this.hosts = { ...HOSTS, ...(options.hosts || {}) };
|
|
115
|
+
this.allowWrite = Boolean(options.allowWrite);
|
|
116
|
+
this.state = {
|
|
117
|
+
deviceId: options.deviceId || crypto.randomUUID().replace(/-/g, ''),
|
|
118
|
+
appVersion: options.appVersion || DEFAULT_APP_VERSION,
|
|
119
|
+
subVersion: options.subVersion || DEFAULT_SUB_VERSION,
|
|
120
|
+
oldAuth: null,
|
|
121
|
+
newAuth: null,
|
|
122
|
+
vehicles: [],
|
|
123
|
+
selectedVin: '',
|
|
124
|
+
selectedCarType: '',
|
|
125
|
+
route: null
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async loadSession() {
|
|
130
|
+
try {
|
|
131
|
+
const saved = JSON.parse(await readFile(this.sessionFile, 'utf8'));
|
|
132
|
+
if (saved.deviceId) this.state.deviceId = saved.deviceId;
|
|
133
|
+
if (saved.appVersion) this.state.appVersion = saved.appVersion;
|
|
134
|
+
if (saved.subVersion) this.state.subVersion = saved.subVersion;
|
|
135
|
+
this.state.oldAuth = saved.oldAuth || null;
|
|
136
|
+
this.state.newAuth = saved.newAuth
|
|
137
|
+
? { ...saved.newAuth, signKey: Buffer.from(saved.newAuth.signKey || '', 'base64') }
|
|
138
|
+
: null;
|
|
139
|
+
this.state.vehicles = Array.isArray(saved.vehicles) ? saved.vehicles : [];
|
|
140
|
+
this.state.selectedVin = saved.selectedVin || '';
|
|
141
|
+
this.state.selectedCarType = saved.selectedCarType || '';
|
|
142
|
+
this.state.route = saved.route || null;
|
|
143
|
+
return { loaded: true, session: this.sessionSummary() };
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (error.code === 'ENOENT') return { loaded: false, session: this.sessionSummary() };
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async saveSession() {
|
|
151
|
+
await mkdir(path.dirname(this.sessionFile), { recursive: true });
|
|
152
|
+
await writeFile(this.sessionFile, JSON.stringify(this.serializeSession(), null, 2), { mode: 0o600 });
|
|
153
|
+
return this.sessionSummary();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async resetSession() {
|
|
157
|
+
this.state.oldAuth = null;
|
|
158
|
+
this.state.newAuth = null;
|
|
159
|
+
this.state.vehicles = [];
|
|
160
|
+
this.state.selectedVin = '';
|
|
161
|
+
this.state.selectedCarType = '';
|
|
162
|
+
this.state.route = null;
|
|
163
|
+
try {
|
|
164
|
+
await unlink(this.sessionFile);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if (error.code !== 'ENOENT') throw error;
|
|
167
|
+
}
|
|
168
|
+
return this.sessionSummary();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
serializeSession() {
|
|
172
|
+
return {
|
|
173
|
+
deviceId: this.state.deviceId,
|
|
174
|
+
appVersion: this.state.appVersion,
|
|
175
|
+
subVersion: this.state.subVersion,
|
|
176
|
+
oldAuth: this.state.oldAuth,
|
|
177
|
+
newAuth: this.state.newAuth
|
|
178
|
+
? { ...this.state.newAuth, signKey: this.state.newAuth.signKey.toString('base64') }
|
|
179
|
+
: null,
|
|
180
|
+
vehicles: this.state.vehicles,
|
|
181
|
+
selectedVin: this.state.selectedVin,
|
|
182
|
+
selectedCarType: this.state.selectedCarType,
|
|
183
|
+
route: this.state.route,
|
|
184
|
+
savedAt: new Date().toISOString()
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
sessionSummary() {
|
|
189
|
+
return {
|
|
190
|
+
deviceId: this.state.deviceId,
|
|
191
|
+
oldLogin: Boolean(this.state.oldAuth?.token),
|
|
192
|
+
newLogin: Boolean(this.state.newAuth?.accessToken),
|
|
193
|
+
accountIdMasked: maskValue(this.state.newAuth?.accountId || this.state.oldAuth?.accountId || ''),
|
|
194
|
+
selectedVin: this.state.selectedVin,
|
|
195
|
+
selectedVinMasked: maskValue(this.state.selectedVin),
|
|
196
|
+
selectedCarType: this.state.selectedCarType,
|
|
197
|
+
vehicleCount: this.state.vehicles.length,
|
|
198
|
+
appRegion: this.state.route?.appRegion || '',
|
|
199
|
+
appCenter: this.state.route?.appCenter || '',
|
|
200
|
+
sessionFile: this.sessionFile
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
authDebugSummary() {
|
|
205
|
+
const auth = this.state.newAuth;
|
|
206
|
+
return {
|
|
207
|
+
oldTokenPresent: Boolean(this.state.oldAuth?.token),
|
|
208
|
+
newTokenPresent: Boolean(auth?.accessToken),
|
|
209
|
+
oldAccountIdMasked: maskValue(this.state.oldAuth?.accountId || ''),
|
|
210
|
+
newAccountIdMasked: maskValue(auth?.accountId || ''),
|
|
211
|
+
gatewayAccountIdMasked: maskValue(auth?.gatewayAccountId || ''),
|
|
212
|
+
signKeyBytes: auth?.signKey?.length || 0,
|
|
213
|
+
signKeySha256Prefix: auth?.signKey ? sha256(auth.signKey).slice(0, 12) : '',
|
|
214
|
+
tokenPayload: jwtPayloadSummary(auth?.accessToken || '', this.state)
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async sendSms(phone) {
|
|
219
|
+
if (!phone) throw new Error('phone 不能为空');
|
|
220
|
+
const url = `${this.hosts.appUser}/app-user/applogin/compliance/sendmessagecode`;
|
|
221
|
+
return fetchJson(url, {
|
|
222
|
+
method: 'GET',
|
|
223
|
+
headers: this.oldAppHeaders(),
|
|
224
|
+
query: { phoneNo: phoneNoCiphertext(phone) }
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async loginWithSms(options = {}) {
|
|
229
|
+
if (!options.phone || !options.smsCode) throw new Error('phone 和 smsCode 不能为空');
|
|
230
|
+
const url = `${this.hosts.appUser}/app-user/applogin/check_login_with_phone`;
|
|
231
|
+
const params = {
|
|
232
|
+
phoneNoCiphertext: phoneNoCiphertext(options.phone),
|
|
233
|
+
smsCode: options.smsCode,
|
|
234
|
+
deviceID: this.state.deviceId,
|
|
235
|
+
smDeviceId: options.smDeviceId || this.state.deviceId,
|
|
236
|
+
os: 'android',
|
|
237
|
+
pageUrl: options.pageUrl || ''
|
|
238
|
+
};
|
|
239
|
+
for (const key of ['oaid', 'referrer', 'captchaOutput', 'genTime', 'lotNumber', 'passToken', 'requestId']) {
|
|
240
|
+
if (options[key]) params[key] = options[key];
|
|
241
|
+
}
|
|
242
|
+
if (options.captchaPhone) params.phone = options.captchaPhone;
|
|
243
|
+
|
|
244
|
+
const loginResponse = await fetchJson(url, {
|
|
245
|
+
method: 'POST',
|
|
246
|
+
headers: this.oldAppHeaders(),
|
|
247
|
+
query: params,
|
|
248
|
+
queryPost: true
|
|
249
|
+
});
|
|
250
|
+
this.state.oldAuth = extractOldAuth(loginResponse);
|
|
251
|
+
const exchangeResponse = await this.exchangeNewGateway();
|
|
252
|
+
await this.saveSession();
|
|
253
|
+
return {
|
|
254
|
+
login: sanitizeLoginResponse(loginResponse),
|
|
255
|
+
exchanged: Boolean(this.state.newAuth?.accessToken),
|
|
256
|
+
exchangeCode: exchangeResponse?.result ?? exchangeResponse?.code ?? null,
|
|
257
|
+
signKeyBytes: this.state.newAuth?.signKey?.length || 0,
|
|
258
|
+
session: this.sessionSummary()
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async exchangeNewGateway() {
|
|
263
|
+
this.requireOldAuth();
|
|
264
|
+
const url = `${this.hosts.global}/base/base-user/account/v1/login`;
|
|
265
|
+
const body = {
|
|
266
|
+
identifier: this.state.oldAuth.accountId,
|
|
267
|
+
identifierType: '1',
|
|
268
|
+
security: this.state.oldAuth.token
|
|
269
|
+
};
|
|
270
|
+
const response = await fetchJson(url, {
|
|
271
|
+
method: 'POST',
|
|
272
|
+
headers: this.newGatewayHeaders(body, false),
|
|
273
|
+
jsonBody: body
|
|
274
|
+
});
|
|
275
|
+
this.state.newAuth = extractNewAuth(response, this.state.oldAuth);
|
|
276
|
+
return response;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async refreshToken() {
|
|
280
|
+
this.requireNewAuth();
|
|
281
|
+
const url = `${this.hosts.global}/base/base-user/token/v1/refresh`;
|
|
282
|
+
const body = { refreshToken: this.state.newAuth.refreshToken };
|
|
283
|
+
const response = await fetchJson(url, {
|
|
284
|
+
method: 'POST',
|
|
285
|
+
headers: this.newGatewayHeaders(body, true),
|
|
286
|
+
jsonBody: body
|
|
287
|
+
});
|
|
288
|
+
this.state.newAuth = extractNewAuth(response, this.state.oldAuth);
|
|
289
|
+
await this.saveSession();
|
|
290
|
+
return { refreshed: true, session: this.sessionSummary() };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async listVehicles() {
|
|
294
|
+
this.requireNewAuth();
|
|
295
|
+
const url = `${this.hosts.global}/app/app-global-service/v1/vehicle/list`;
|
|
296
|
+
const response = await fetchJson(url, {
|
|
297
|
+
method: 'GET',
|
|
298
|
+
headers: this.newGatewayHeaders({}, true)
|
|
299
|
+
});
|
|
300
|
+
this.state.vehicles = vehicleListFromResponse(response);
|
|
301
|
+
if (!this.state.selectedVin && this.state.vehicles[0]?.vin) {
|
|
302
|
+
this.state.selectedVin = this.state.vehicles[0].vin;
|
|
303
|
+
this.state.selectedCarType = this.state.vehicles[0].carType || this.state.vehicles[0].cartype || '';
|
|
304
|
+
this.state.route = null;
|
|
305
|
+
}
|
|
306
|
+
await this.saveSession();
|
|
307
|
+
return { vehicles: this.state.vehicles, raw: response, session: this.sessionSummary() };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async selectVehicle(vin, carType = '') {
|
|
311
|
+
if (!vin) throw new Error('vin 不能为空');
|
|
312
|
+
this.state.selectedVin = String(vin);
|
|
313
|
+
const vehicle = this.state.vehicles.find((item) => item.vin === this.state.selectedVin);
|
|
314
|
+
this.state.selectedCarType = String(carType || vehicle?.carType || vehicle?.cartype || '');
|
|
315
|
+
this.state.route = null;
|
|
316
|
+
await this.saveSession();
|
|
317
|
+
return this.sessionSummary();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async getVehicleRoute() {
|
|
321
|
+
this.requireVin();
|
|
322
|
+
const url = `${this.hosts.global}/app/app-global-service/v1/vehicle/getCarRoute`;
|
|
323
|
+
const params = { vin: this.state.selectedVin };
|
|
324
|
+
const response = await fetchJson(url, {
|
|
325
|
+
method: 'GET',
|
|
326
|
+
headers: this.newGatewayHeaders(params, true),
|
|
327
|
+
query: params
|
|
328
|
+
});
|
|
329
|
+
this.state.route = routeFromResponse(response);
|
|
330
|
+
await this.saveSession();
|
|
331
|
+
return { route: this.state.route, raw: response, session: this.sessionSummary() };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async ensureRoute() {
|
|
335
|
+
this.requireVin();
|
|
336
|
+
if (this.state.route?.appRegion) return this.state.route;
|
|
337
|
+
await this.getVehicleRoute();
|
|
338
|
+
if (!this.state.route?.appRegion) throw new Error('车辆路由缺少 appRegion,无法继续');
|
|
339
|
+
return this.state.route;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async getVehicleState() {
|
|
343
|
+
const route = await this.ensureRoute();
|
|
344
|
+
const url = `${pickHost(route.appRegion)}/app/app-signal-service/signal/info/query`;
|
|
345
|
+
const body = { vin: this.state.selectedVin };
|
|
346
|
+
const raw = await fetchJson(url, {
|
|
347
|
+
method: 'POST',
|
|
348
|
+
headers: this.newGatewayHeaders(body, true),
|
|
349
|
+
jsonBody: body
|
|
350
|
+
});
|
|
351
|
+
return { raw, summary: vehicleStateSummary(raw), session: this.sessionSummary() };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async getMileageEnergy() {
|
|
355
|
+
this.requireVin();
|
|
356
|
+
const raw = await fetchFirstJson(await this.oldServiceCandidates(
|
|
357
|
+
'/carownerservice/v3/api/drivingrecord/mileage/energy/detail',
|
|
358
|
+
{ vin: this.state.selectedVin }
|
|
359
|
+
));
|
|
360
|
+
return { raw, session: this.sessionSummary() };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async getLocation() {
|
|
364
|
+
this.requireVin();
|
|
365
|
+
const raw = await fetchFirstJson(await this.oldServiceCandidates(
|
|
366
|
+
'/carownerservice/v3/api/chassis/query',
|
|
367
|
+
{ vin: this.state.selectedVin }
|
|
368
|
+
));
|
|
369
|
+
return { raw, session: this.sessionSummary() };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async getOverview() {
|
|
373
|
+
await this.ensureRoute();
|
|
374
|
+
const [route, carState, mileage, location] = await Promise.allSettled([
|
|
375
|
+
this.getVehicleRoute(),
|
|
376
|
+
this.getVehicleState(),
|
|
377
|
+
this.getMileageEnergy(),
|
|
378
|
+
this.getLocation()
|
|
379
|
+
]);
|
|
380
|
+
return { route, carState, mileage, location, session: this.sessionSummary() };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
buildCommand(input = {}) {
|
|
384
|
+
const preset = commandPresets[input.command || ''];
|
|
385
|
+
if (!preset) throw new Error(`未知控车命令: ${input.command}`);
|
|
386
|
+
let cmdid = preset.cmdid;
|
|
387
|
+
let stateJson;
|
|
388
|
+
|
|
389
|
+
if (preset.custom) {
|
|
390
|
+
cmdid = String(input.cmdid || '').trim();
|
|
391
|
+
if (!cmdid) throw new Error('自定义命令需要 cmdid');
|
|
392
|
+
stateJson = normalizeStateJson(input.stateJson);
|
|
393
|
+
} else if (preset.rawState) {
|
|
394
|
+
stateJson = normalizeStateJson(input.stateJson);
|
|
395
|
+
} else if (preset.stateFromValue) {
|
|
396
|
+
stateJson = JSON.stringify({ value: asString(input.value || '0') });
|
|
397
|
+
} else {
|
|
398
|
+
stateJson = JSON.stringify(preset.state);
|
|
399
|
+
}
|
|
400
|
+
return {
|
|
401
|
+
cmdid: String(cmdid),
|
|
402
|
+
state: stateJson,
|
|
403
|
+
label: preset.label,
|
|
404
|
+
acceptsOperationPassword: preset.acceptsOperationPassword === true
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async previewControl(input = {}) {
|
|
409
|
+
const route = await this.ensureRoute();
|
|
410
|
+
const command = this.buildCommand(input);
|
|
411
|
+
const params = {
|
|
412
|
+
cmdid: command.cmdid,
|
|
413
|
+
state: command.state,
|
|
414
|
+
carvin: this.state.selectedVin
|
|
415
|
+
};
|
|
416
|
+
if (input.operationPassword) params.oppwd = encryptOperationPassword(input.operationPassword, this.state.oldAuth);
|
|
417
|
+
const signed = this.oldSignedParams(params);
|
|
418
|
+
const appointment = appointmentCmdIds.has(command.cmdid);
|
|
419
|
+
const host = appointment ? pickHost(route.appCenter || route.appRegion) : pickHost(route.appRegion);
|
|
420
|
+
const url = appointment
|
|
421
|
+
? `${host}/carownerservice/v3/api/appremotectl/appointment`
|
|
422
|
+
: `${host}/app/app-control-service/v3/api/appremotectl`;
|
|
423
|
+
return {
|
|
424
|
+
dryRun: true,
|
|
425
|
+
appointment,
|
|
426
|
+
method: 'POST',
|
|
427
|
+
url,
|
|
428
|
+
command,
|
|
429
|
+
body: redactControlBody(signed),
|
|
430
|
+
session: this.sessionSummary()
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async sendControl(input = {}) {
|
|
435
|
+
if (!this.allowWrite && input.allowWrite !== true) {
|
|
436
|
+
throw new Error('真实控车默认禁用:需要 SDK allowWrite=true 或参数 allowWrite=true');
|
|
437
|
+
}
|
|
438
|
+
if (input.confirm !== true) throw new Error('真实控车需要 confirm=true');
|
|
439
|
+
const preview = await this.previewControl(input);
|
|
440
|
+
const raw = await fetchJson(preview.url, {
|
|
441
|
+
method: 'POST',
|
|
442
|
+
headers: this.oldAppHeaders(),
|
|
443
|
+
formBody: this.oldSignedParams({
|
|
444
|
+
cmdid: preview.command.cmdid,
|
|
445
|
+
state: preview.command.state,
|
|
446
|
+
carvin: this.state.selectedVin,
|
|
447
|
+
...(input.operationPassword
|
|
448
|
+
? { oppwd: encryptOperationPassword(input.operationPassword, this.state.oldAuth) }
|
|
449
|
+
: {})
|
|
450
|
+
})
|
|
451
|
+
});
|
|
452
|
+
return { dryRun: false, command: preview.command, raw, session: this.sessionSummary() };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async queryControlResult(msgID) {
|
|
456
|
+
if (!msgID) throw new Error('msgID 不能为空');
|
|
457
|
+
const route = await this.ensureRoute();
|
|
458
|
+
const url = `${pickHost(route.appRegion)}/app/app-control-service/v3/api/appremotectl/query`;
|
|
459
|
+
const raw = await fetchJson(url, {
|
|
460
|
+
method: 'GET',
|
|
461
|
+
headers: this.oldAppHeaders(),
|
|
462
|
+
query: this.oldSignedParams({ msgID })
|
|
463
|
+
});
|
|
464
|
+
return { raw, session: this.sessionSummary() };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async oldServiceCandidates(pathname, params) {
|
|
468
|
+
const route = await this.ensureRoute();
|
|
469
|
+
const hosts = [this.hosts.global, pickHost(route.appRegion), pickHost(route.appCenter)].filter(Boolean);
|
|
470
|
+
return [...new Set(hosts)].map((host) => ({
|
|
471
|
+
label: host,
|
|
472
|
+
url: `${host}${pathname}`,
|
|
473
|
+
options: {
|
|
474
|
+
method: 'GET',
|
|
475
|
+
headers: this.oldAppHeaders(),
|
|
476
|
+
query: this.oldSignedParams(params)
|
|
477
|
+
}
|
|
478
|
+
}));
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
oldAppHeaders() {
|
|
482
|
+
const headers = {
|
|
483
|
+
APPPlatform: 'Android',
|
|
484
|
+
APPVersion: this.state.appVersion,
|
|
485
|
+
APPImei: this.state.deviceId,
|
|
486
|
+
'C-VERSIONS': 'APP',
|
|
487
|
+
'XFX-CDN-VRS': 'v4'
|
|
488
|
+
};
|
|
489
|
+
if (this.state.oldAuth?.token) headers['XFX-CDN-CROSS-NODE'] = this.state.oldAuth.token;
|
|
490
|
+
return headers;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
newGatewayHeaders(params = {}, needLogin = true) {
|
|
494
|
+
const nonce = randomNonce();
|
|
495
|
+
const timestamp = String(Date.now());
|
|
496
|
+
const headers = {
|
|
497
|
+
source: 'leapmotor',
|
|
498
|
+
channel: '1',
|
|
499
|
+
acceptLanguage: 'zh-CN',
|
|
500
|
+
'x-region': 'CN',
|
|
501
|
+
'x-api-signature-version': '2.0',
|
|
502
|
+
digest: '',
|
|
503
|
+
version: this.state.appVersion,
|
|
504
|
+
deviceType: 'android',
|
|
505
|
+
nonce,
|
|
506
|
+
timestamp,
|
|
507
|
+
deviceId: this.state.deviceId,
|
|
508
|
+
userId: this.state.newAuth?.accountId || this.state.oldAuth?.accountId || '',
|
|
509
|
+
carvin: this.state.selectedVin || '',
|
|
510
|
+
cartype: this.state.selectedCarType || '',
|
|
511
|
+
'x-subversion': this.state.subVersion
|
|
512
|
+
};
|
|
513
|
+
const signHeaders = {
|
|
514
|
+
acceptLanguage: headers.acceptLanguage,
|
|
515
|
+
channel: headers.channel,
|
|
516
|
+
deviceId: headers.deviceId,
|
|
517
|
+
deviceType: headers.deviceType,
|
|
518
|
+
nonce: headers.nonce,
|
|
519
|
+
source: headers.source,
|
|
520
|
+
timestamp: headers.timestamp,
|
|
521
|
+
version: headers.version
|
|
522
|
+
};
|
|
523
|
+
const signBase = sortAndConcatValues(signHeaders, params);
|
|
524
|
+
if (needLogin) {
|
|
525
|
+
this.requireNewAuth();
|
|
526
|
+
headers.token = this.state.newAuth.accessToken;
|
|
527
|
+
headers.userId = this.state.newAuth.accountId || headers.userId;
|
|
528
|
+
headers.sign = crypto.createHmac('sha256', this.state.newAuth.signKey).update(signBase, 'utf8').digest('hex');
|
|
529
|
+
} else {
|
|
530
|
+
headers.sign = sha256(signBase);
|
|
531
|
+
}
|
|
532
|
+
return headers;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
oldSignedParams(params = {}) {
|
|
536
|
+
this.requireOldAuth();
|
|
537
|
+
const map = {
|
|
538
|
+
timespan: String(Date.now()),
|
|
539
|
+
nonce: randomNonce(),
|
|
540
|
+
deviceID: this.state.deviceId,
|
|
541
|
+
token: this.state.oldAuth.token,
|
|
542
|
+
...Object.fromEntries(Object.entries(params).map(([key, value]) => [key, asString(value)]))
|
|
543
|
+
};
|
|
544
|
+
const signStr = shortMd5(sortAndConcatValues(map));
|
|
545
|
+
delete map.token;
|
|
546
|
+
map.signStr = signStr;
|
|
547
|
+
return map;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
requireOldAuth() {
|
|
551
|
+
if (!this.state.oldAuth?.token || !this.state.oldAuth?.accountId) {
|
|
552
|
+
throw new Error('还没有完成验证码登录,缺少旧 token/accountId');
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
requireNewAuth() {
|
|
557
|
+
if (!this.state.newAuth?.accessToken || !this.state.newAuth?.signKey || this.state.newAuth.signKey.length === 0) {
|
|
558
|
+
throw new Error('还没有完成新网关 token 交换,缺少 accessToken/signKey');
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
requireVin() {
|
|
563
|
+
if (!this.state.selectedVin) throw new Error('还没有选择车辆 VIN');
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
export function createClientFromEnv(options = {}) {
|
|
568
|
+
return new LeapmotorChinaClient({
|
|
569
|
+
sessionFile: process.env.LEAP_SESSION_FILE || options.sessionFile || path.resolve(process.cwd(), '.leap-session.json'),
|
|
570
|
+
deviceId: process.env.LEAP_DEVICE_ID || options.deviceId,
|
|
571
|
+
appVersion: process.env.LEAP_APP_VERSION || options.appVersion,
|
|
572
|
+
subVersion: process.env.LEAP_SUB_VERSION || options.subVersion,
|
|
573
|
+
allowWrite: process.env.LEAP_ALLOW_WRITE === '1' || options.allowWrite
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
export function vehicleStateSummary(raw) {
|
|
578
|
+
const signalMap = extractSignalMap(raw);
|
|
579
|
+
const pick = (...keys) => {
|
|
580
|
+
for (const key of keys) {
|
|
581
|
+
if (signalMap?.[key] !== undefined && signalMap?.[key] !== null) return signalMap[key];
|
|
582
|
+
}
|
|
583
|
+
return null;
|
|
584
|
+
};
|
|
585
|
+
return {
|
|
586
|
+
battery: {
|
|
587
|
+
soc: pick('soc'),
|
|
588
|
+
expectedMileage: pick('expectedMileage'),
|
|
589
|
+
batteryCurrent: pick('batteryCurrent'),
|
|
590
|
+
batteryVoltage: pick('batteryVoltage'),
|
|
591
|
+
chargeState: pick('chargeState'),
|
|
592
|
+
chargeRemainTime: pick('chargeRemainTime'),
|
|
593
|
+
dcInputFastCharge: pick('dcInputFastCharge'),
|
|
594
|
+
chargesocSetting: pick('chargesocSetting')
|
|
595
|
+
},
|
|
596
|
+
locks: {
|
|
597
|
+
driverDoorLockStatus: pick('driverDoorLockStatus'),
|
|
598
|
+
bcmDoorCtrlAllow: pick('bcmDoorCtrlAllow')
|
|
599
|
+
},
|
|
600
|
+
doors: {
|
|
601
|
+
driverDoor: pick('lbcmDriverDoorStatus'),
|
|
602
|
+
passengerDoor: pick('rbcmDriverDoorStatus'),
|
|
603
|
+
leftRearDoor: pick('lbcmLeftRearDoorStatus'),
|
|
604
|
+
rightRearDoor: pick('rbcmRightRearDoorStatus'),
|
|
605
|
+
trunk: pick('bbcmBackDoorStatus')
|
|
606
|
+
},
|
|
607
|
+
windows: {
|
|
608
|
+
isSupportWindowsRemoteControl: pick('isSupportWindowsRemoteControl'),
|
|
609
|
+
leftFrontWindowPercent: pick('leftFrontWindowPercent'),
|
|
610
|
+
leftRearWindowPercent: pick('leftRearWindowPercent'),
|
|
611
|
+
rightFrontWindowPercent: pick('rightFrontWindowPercent'),
|
|
612
|
+
rightRearWindowPercent: pick('rightRearWindowPercent')
|
|
613
|
+
},
|
|
614
|
+
climate: {
|
|
615
|
+
acSwitch: pick('acSwitch'),
|
|
616
|
+
acSetting: pick('acSetting'),
|
|
617
|
+
acAirVolume: pick('acAirVolume'),
|
|
618
|
+
acAirVolumeSetting: pick('acAirVolumeSetting'),
|
|
619
|
+
acWindDirection: pick('acWindDirection'),
|
|
620
|
+
acCircleMode: pick('acCircleMode'),
|
|
621
|
+
acCoolingAndHeating: pick('acCoolingAndHeating'),
|
|
622
|
+
acTempMode: pick('acTempMode'),
|
|
623
|
+
indoorTemp: pick('indoorTemp'),
|
|
624
|
+
ptcState: pick('ptcState'),
|
|
625
|
+
ptcPowerSettingValue: pick('ptcPowerSettingValue'),
|
|
626
|
+
minSingleTemp: pick('minSingleTemp')
|
|
627
|
+
},
|
|
628
|
+
location: {
|
|
629
|
+
latitude: pick('latitude'),
|
|
630
|
+
longitude: pick('longitude'),
|
|
631
|
+
privacyGPS: pick('privacyGPS'),
|
|
632
|
+
privacyData: pick('privacyData')
|
|
633
|
+
}
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
export function extractSignalMap(raw) {
|
|
638
|
+
return findObject(raw, (obj) => obj.signalMap && typeof obj.signalMap === 'object')?.signalMap
|
|
639
|
+
|| raw?.data?.signalMap
|
|
640
|
+
|| raw?.signalMap
|
|
641
|
+
|| {};
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function randomNonce() {
|
|
645
|
+
return String(Math.floor(Math.random() * 10_000_000) + 10_000);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function asString(value) {
|
|
649
|
+
if (value === null || value === undefined) return '';
|
|
650
|
+
if (typeof value === 'string') return value;
|
|
651
|
+
return String(value);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function sortAndConcatValues(...maps) {
|
|
655
|
+
const merged = {};
|
|
656
|
+
for (const map of maps) {
|
|
657
|
+
if (!map) continue;
|
|
658
|
+
for (const [key, value] of Object.entries(map)) {
|
|
659
|
+
if (value !== null && value !== undefined) merged[key] = asString(value);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
return Object.keys(merged).sort().map((key) => merged[key]).join('');
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function shortMd5(input) {
|
|
666
|
+
return crypto.createHash('md5').update(input, 'utf8').digest('hex').slice(8, 24);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function sha256(input) {
|
|
670
|
+
return crypto.createHash('sha256').update(input, 'utf8').digest('hex');
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function base64UrlNoPadding(buffer) {
|
|
674
|
+
return Buffer.from(buffer).toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '');
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function base64UrlDecode(input) {
|
|
678
|
+
const normalized = input.replaceAll('-', '+').replaceAll('_', '/');
|
|
679
|
+
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
|
|
680
|
+
return Buffer.from(padded, 'base64');
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function phoneNoCiphertext(phone) {
|
|
684
|
+
const pem = ['-----BEGIN PUBLIC KEY-----', DEFAULT_PUBLIC_KEY, '-----END PUBLIC KEY-----'].join('\n');
|
|
685
|
+
const encrypted = crypto.publicEncrypt(
|
|
686
|
+
{ key: pem, padding: crypto.constants.RSA_PKCS1_PADDING },
|
|
687
|
+
Buffer.from(phone, 'utf8')
|
|
688
|
+
);
|
|
689
|
+
return base64UrlNoPadding(encrypted);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function deriveSignKey(accessToken, r2, r3) {
|
|
693
|
+
const parts = String(accessToken || '').split('.');
|
|
694
|
+
if (parts.length !== 3) throw new Error('accessToken 不是三段式 token,无法计算 signKey');
|
|
695
|
+
const tokenTail = base64UrlDecode(parts[2]);
|
|
696
|
+
const r2Buf = Buffer.from(r2 || '', 'base64');
|
|
697
|
+
const r3Buf = Buffer.from(r3 || '', 'base64');
|
|
698
|
+
const len = Math.min(tokenTail.length, r2Buf.length, r3Buf.length);
|
|
699
|
+
const out = Buffer.alloc(len);
|
|
700
|
+
for (let i = 0; i < len; i += 1) out[i] = tokenTail[i] ^ r2Buf[i] ^ r3Buf[i];
|
|
701
|
+
return out;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function jwtPayloadSummary(token, state) {
|
|
705
|
+
const parts = String(token || '').split('.');
|
|
706
|
+
if (parts.length !== 3) return { validJwtShape: false };
|
|
707
|
+
try {
|
|
708
|
+
const payload = JSON.parse(base64UrlDecode(parts[1]).toString('utf8'));
|
|
709
|
+
const userName = String(payload.user_name || payload.username || '');
|
|
710
|
+
const summary = {
|
|
711
|
+
validJwtShape: true,
|
|
712
|
+
keys: Object.keys(payload).sort(),
|
|
713
|
+
exp: payload.exp || null,
|
|
714
|
+
iat: payload.iat || null,
|
|
715
|
+
client_id: payload.client_id || '',
|
|
716
|
+
scope: payload.scope || '',
|
|
717
|
+
userNameContainsDeviceId: state.deviceId ? userName.includes(state.deviceId) : false,
|
|
718
|
+
userNameContainsOldAccountId: state.oldAuth?.accountId ? userName.includes(String(state.oldAuth.accountId)) : false,
|
|
719
|
+
userNameContainsNewAccountId: state.newAuth?.accountId ? userName.includes(String(state.newAuth.accountId)) : false
|
|
720
|
+
};
|
|
721
|
+
for (const key of ['accountId', 'account_id', 'sub', 'user_name', 'username']) {
|
|
722
|
+
if (payload[key] !== undefined && payload[key] !== null) summary[key] = maskValue(payload[key]);
|
|
723
|
+
}
|
|
724
|
+
return summary;
|
|
725
|
+
} catch {
|
|
726
|
+
return { validJwtShape: true, payloadParseError: true };
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
async function fetchJson(url, options = {}) {
|
|
731
|
+
const { method = 'GET', headers = {}, query, jsonBody, formBody, queryPost = false } = options;
|
|
732
|
+
const target = new URL(url);
|
|
733
|
+
if (query) {
|
|
734
|
+
for (const [key, value] of Object.entries(query)) {
|
|
735
|
+
if (value !== undefined && value !== null) target.searchParams.set(key, asString(value));
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
const requestHeaders = { ...headers };
|
|
739
|
+
let body;
|
|
740
|
+
if (jsonBody !== undefined) {
|
|
741
|
+
requestHeaders['content-type'] = 'application/json; charset=utf-8';
|
|
742
|
+
body = JSON.stringify(jsonBody);
|
|
743
|
+
} else if (formBody !== undefined) {
|
|
744
|
+
requestHeaders['content-type'] = 'application/x-www-form-urlencoded; charset=utf-8';
|
|
745
|
+
body = queryString(formBody);
|
|
746
|
+
} else if (queryPost) {
|
|
747
|
+
body = '';
|
|
748
|
+
}
|
|
749
|
+
const response = await fetch(target, { method, headers: requestHeaders, body });
|
|
750
|
+
const text = await response.text();
|
|
751
|
+
let data = null;
|
|
752
|
+
try {
|
|
753
|
+
data = text ? JSON.parse(text) : null;
|
|
754
|
+
} catch {
|
|
755
|
+
data = { raw: text };
|
|
756
|
+
}
|
|
757
|
+
if (!response.ok) {
|
|
758
|
+
const err = new Error(`HTTP ${response.status} ${response.statusText}`);
|
|
759
|
+
err.response = data;
|
|
760
|
+
throw err;
|
|
761
|
+
}
|
|
762
|
+
return data;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
async function fetchFirstJson(candidates) {
|
|
766
|
+
const errors = [];
|
|
767
|
+
for (const candidate of candidates) {
|
|
768
|
+
try {
|
|
769
|
+
return await fetchJson(candidate.url, candidate.options);
|
|
770
|
+
} catch (error) {
|
|
771
|
+
errors.push(`${candidate.label || candidate.url}: ${error.message}`);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
const err = new Error(errors.join(' | ') || '没有可尝试的请求地址');
|
|
775
|
+
err.attempts = errors;
|
|
776
|
+
throw err;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function queryString(params = {}) {
|
|
780
|
+
const search = new URLSearchParams();
|
|
781
|
+
for (const [key, value] of Object.entries(params)) {
|
|
782
|
+
if (value !== undefined && value !== null) search.set(key, asString(value));
|
|
783
|
+
}
|
|
784
|
+
return search.toString();
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function extractOldAuth(loginResponse) {
|
|
788
|
+
const direct = loginResponse?.data?.appLoginVO || loginResponse?.data?.appOneLoginVO;
|
|
789
|
+
const auth = direct || findObject(loginResponse, (obj) => obj.accountId && obj.token);
|
|
790
|
+
if (!auth?.accountId || !auth?.token) throw new Error('验证码登录响应中没有找到 accountId/token');
|
|
791
|
+
return {
|
|
792
|
+
accountId: String(auth.accountId),
|
|
793
|
+
token: String(auth.token),
|
|
794
|
+
refreshToken: auth.refreshToken ? String(auth.refreshToken) : '',
|
|
795
|
+
nickname: auth.nickname || '',
|
|
796
|
+
tokenExpired: auth.tokenExpired || ''
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function extractNewAuth(exchangeResponse, oldAuth) {
|
|
801
|
+
const data = exchangeResponse?.data || findObject(exchangeResponse, (obj) => obj.accessToken && obj.signParam);
|
|
802
|
+
if (!data?.accessToken || !data?.signParam?.r2 || !data?.signParam?.r3) {
|
|
803
|
+
throw new Error('新网关登录响应中没有找到 accessToken/signParam.r2/r3');
|
|
804
|
+
}
|
|
805
|
+
return {
|
|
806
|
+
accountId: String(oldAuth?.accountId || data.accountId || ''),
|
|
807
|
+
gatewayAccountId: data.accountId ? String(data.accountId) : '',
|
|
808
|
+
accessToken: String(data.accessToken),
|
|
809
|
+
refreshToken: data.refreshToken ? String(data.refreshToken) : '',
|
|
810
|
+
tokenExpireTime: data.tokenExpireTime || '',
|
|
811
|
+
nickname: data.nickname || '',
|
|
812
|
+
signParam: data.signParam,
|
|
813
|
+
encryptParam: data.encryptParam || null,
|
|
814
|
+
signKey: deriveSignKey(data.accessToken, data.signParam.r2, data.signParam.r3)
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function sanitizeLoginResponse(raw) {
|
|
819
|
+
return {
|
|
820
|
+
code: raw?.code ?? raw?.result ?? raw?.status ?? null,
|
|
821
|
+
success: raw?.success ?? raw?.result === 0,
|
|
822
|
+
msg: raw?.msg || raw?.message || '',
|
|
823
|
+
risk_type: raw?.data?.risk_type || raw?.risk_type || '',
|
|
824
|
+
requestId: raw?.data?.requestId || raw?.requestId || '',
|
|
825
|
+
hasAppLoginVO: Boolean(raw?.data?.appLoginVO || raw?.data?.appOneLoginVO)
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function vehicleListFromResponse(response) {
|
|
830
|
+
const vehicles = collectObjects(response, (obj) => typeof obj.vin === 'string' && obj.vin.length >= 8);
|
|
831
|
+
const byVin = new Map();
|
|
832
|
+
for (const vehicle of vehicles) {
|
|
833
|
+
if (!byVin.has(vehicle.vin)) byVin.set(vehicle.vin, vehicle);
|
|
834
|
+
}
|
|
835
|
+
return [...byVin.values()];
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function routeFromResponse(response) {
|
|
839
|
+
return response?.data || findObject(response, (obj) => obj.appRegion || obj.appCenter) || null;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function encryptOperationPassword(password, oldAuth) {
|
|
843
|
+
if (!password) return '';
|
|
844
|
+
if (!oldAuth?.token || oldAuth.token.length < 64) {
|
|
845
|
+
throw new Error('旧 token 长度不足,无法按 App 规则加密操作密码');
|
|
846
|
+
}
|
|
847
|
+
const key = Buffer.from(shortMd5(oldAuth.token.slice(0, 32)), 'utf8');
|
|
848
|
+
const iv = Buffer.from(shortMd5(oldAuth.token.slice(32, 64)), 'utf8');
|
|
849
|
+
const cipher = crypto.createCipheriv('aes-128-cbc', key, iv);
|
|
850
|
+
return Buffer.concat([cipher.update(password, 'utf8'), cipher.final()]).toString('base64');
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function normalizeStateJson(value) {
|
|
854
|
+
if (typeof value === 'object' && value !== null) return JSON.stringify(value);
|
|
855
|
+
const text = String(value || '').trim();
|
|
856
|
+
if (!text) throw new Error('state JSON 不能为空');
|
|
857
|
+
JSON.parse(text);
|
|
858
|
+
return text;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function redactControlBody(body) {
|
|
862
|
+
const out = { ...body };
|
|
863
|
+
if (out.oppwd) out.oppwd = `${String(out.oppwd).slice(0, 4)}...`;
|
|
864
|
+
if (out.signStr) out.signStr = `${String(out.signStr).slice(0, 6)}...`;
|
|
865
|
+
if (out.carvin) out.carvin = maskValue(out.carvin);
|
|
866
|
+
return out;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function pickHost(value) {
|
|
870
|
+
if (!value) return '';
|
|
871
|
+
return String(value).replace(/\/+$/g, '');
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function findObject(root, predicate, seen = new Set()) {
|
|
875
|
+
if (!root || typeof root !== 'object' || seen.has(root)) return null;
|
|
876
|
+
seen.add(root);
|
|
877
|
+
if (predicate(root)) return root;
|
|
878
|
+
const values = Array.isArray(root) ? root : Object.values(root);
|
|
879
|
+
for (const value of values) {
|
|
880
|
+
const found = findObject(value, predicate, seen);
|
|
881
|
+
if (found) return found;
|
|
882
|
+
}
|
|
883
|
+
return null;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function collectObjects(root, predicate, seen = new Set(), out = []) {
|
|
887
|
+
if (!root || typeof root !== 'object' || seen.has(root)) return out;
|
|
888
|
+
seen.add(root);
|
|
889
|
+
if (predicate(root)) out.push(root);
|
|
890
|
+
const values = Array.isArray(root) ? root : Object.values(root);
|
|
891
|
+
for (const value of values) collectObjects(value, predicate, seen, out);
|
|
892
|
+
return out;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function maskValue(value) {
|
|
896
|
+
const str = String(value || '');
|
|
897
|
+
if (!str) return '';
|
|
898
|
+
if (str.length <= 8) return `${str.slice(0, 2)}***`;
|
|
899
|
+
return `${str.slice(0, 4)}...${str.slice(-4)}`;
|
|
900
|
+
}
|