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 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
+ }