@sstar/embedlink_agent 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.
Files changed (53) hide show
  1. package/README.md +107 -0
  2. package/dist/.platform +1 -0
  3. package/dist/board/docs.js +59 -0
  4. package/dist/board/notes.js +11 -0
  5. package/dist/board_uart/history.js +81 -0
  6. package/dist/board_uart/index.js +66 -0
  7. package/dist/board_uart/manager.js +313 -0
  8. package/dist/board_uart/resource.js +578 -0
  9. package/dist/board_uart/sessions.js +559 -0
  10. package/dist/config/index.js +341 -0
  11. package/dist/core/activity.js +7 -0
  12. package/dist/core/errors.js +45 -0
  13. package/dist/core/log_stream.js +26 -0
  14. package/dist/files/__tests__/files_manager.test.js +209 -0
  15. package/dist/files/artifact_manager.js +68 -0
  16. package/dist/files/file_operation_logger.js +271 -0
  17. package/dist/files/files_manager.js +511 -0
  18. package/dist/files/index.js +87 -0
  19. package/dist/files/types.js +5 -0
  20. package/dist/firmware/burn_recover.js +733 -0
  21. package/dist/firmware/prepare_images.js +184 -0
  22. package/dist/firmware/user_guide.js +43 -0
  23. package/dist/index.js +449 -0
  24. package/dist/logger.js +245 -0
  25. package/dist/macro/index.js +241 -0
  26. package/dist/macro/runner.js +168 -0
  27. package/dist/nfs/index.js +105 -0
  28. package/dist/plugins/loader.js +30 -0
  29. package/dist/proto/agent.proto +473 -0
  30. package/dist/resources/docs/board-interaction.md +115 -0
  31. package/dist/resources/docs/firmware-upgrade.md +404 -0
  32. package/dist/resources/docs/nfs-mount-guide.md +78 -0
  33. package/dist/resources/docs/tftp-transfer-guide.md +81 -0
  34. package/dist/secrets/index.js +9 -0
  35. package/dist/server/grpc.js +1069 -0
  36. package/dist/server/web.js +2284 -0
  37. package/dist/ssh/adapter.js +126 -0
  38. package/dist/ssh/candidates.js +85 -0
  39. package/dist/ssh/index.js +3 -0
  40. package/dist/ssh/paircheck.js +35 -0
  41. package/dist/ssh/tunnel.js +111 -0
  42. package/dist/tftp/client.js +345 -0
  43. package/dist/tftp/index.js +284 -0
  44. package/dist/tftp/server.js +731 -0
  45. package/dist/uboot/index.js +45 -0
  46. package/dist/ui/assets/index-BlnLVmbt.js +374 -0
  47. package/dist/ui/assets/index-xMbarYXA.css +32 -0
  48. package/dist/ui/index.html +21 -0
  49. package/dist/utils/network.js +150 -0
  50. package/dist/utils/platform.js +83 -0
  51. package/dist/utils/port-check.js +153 -0
  52. package/dist/utils/user-prompt.js +139 -0
  53. package/package.json +64 -0
@@ -0,0 +1,733 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { spawn } from 'node:child_process';
4
+ import { createRequire } from 'node:module';
5
+ import { error, ErrorCodes } from '../core/errors.js';
6
+ import { getBoardUartResourceManager } from '../board_uart/resource.js';
7
+ import { getAgentLogger, LogLevel } from '../logger.js';
8
+ const require = createRequire(import.meta.url);
9
+ function tailUtf8(text, maxBytes) {
10
+ if (!text)
11
+ return { tail: '', truncated: false };
12
+ const buf = Buffer.from(text, 'utf8');
13
+ if (buf.length <= maxBytes) {
14
+ return { tail: text, truncated: false };
15
+ }
16
+ const slice = buf.subarray(buf.length - maxBytes);
17
+ return { tail: slice.toString('utf8'), truncated: true };
18
+ }
19
+ function buildCombinedLog(stdout, stderr) {
20
+ return stdout + (stderr ? `\n[STDERR]\n${stderr}` : '');
21
+ }
22
+ /**
23
+ * 清理FlashTool日志,仅移除ANSI颜色控制符等,保留所有原始内容和格式
24
+ */
25
+ function cleanFlashToolLog(rawLog) {
26
+ if (!rawLog)
27
+ return '';
28
+ // 移除ANSI转义序列(颜色、光标控制等)
29
+ const ansiRegex = /\x1b\[[0-9;?]*[A-Za-z]/g;
30
+ let cleaned = rawLog.replace(ansiRegex, '');
31
+ // 移除其他常见的控制序列(但保留\t\n\r等格式控制符)
32
+ const controlRegex = /\x0f|\x1b[=>]|\x1b\(B|\x1b\)0/g;
33
+ cleaned = cleaned.replace(controlRegex, '');
34
+ return cleaned;
35
+ }
36
+ /**
37
+ * 解析和验证 Windows FlashTool 参数
38
+ * @param args 参数数组
39
+ * @param imagesRootAbs 镜像根目录绝对路径
40
+ * @returns 解析后的参数对象
41
+ */
42
+ function parseAndValidateWindowsFlashToolArgs(args, imagesRootAbs) {
43
+ const params = {};
44
+ const errors = [];
45
+ // 检查必选参数
46
+ const hasSpinor = args.includes('-spinor');
47
+ const hasSpinand = args.includes('-spinand');
48
+ if (!hasSpinor && !hasSpinand) {
49
+ errors.push('缺少 Flash 类型参数:必须指定 -spinor 或 -spinand');
50
+ }
51
+ else if (hasSpinor && hasSpinand) {
52
+ errors.push('Flash 类型冲突:不能同时指定 -spinor 和 -spinand');
53
+ }
54
+ else {
55
+ params.flashType = hasSpinor ? 'spinor' : 'spinand';
56
+ }
57
+ // 解析起始地址
58
+ const startAddrIndex = args.findIndex((arg, index) => arg === '-s' && index + 1 < args.length);
59
+ if (startAddrIndex === -1) {
60
+ errors.push('缺少起始地址参数:必须使用 -s {hex_address} 指定烧录地址');
61
+ }
62
+ else {
63
+ const address = args[startAddrIndex + 1];
64
+ if (!isValidHexAddress(address)) {
65
+ errors.push(`无效的起始地址格式:${address},必须是有效的十六进制地址(如 0x140000)`);
66
+ }
67
+ else {
68
+ params.startAddress = address;
69
+ }
70
+ }
71
+ // 解析文件路径
72
+ const fileIndex = args.findIndex((arg, index) => arg === '-f' && index + 1 < args.length);
73
+ if (fileIndex === -1) {
74
+ errors.push('缺少文件路径参数:必须使用 -f {file_path} 指定烧录文件');
75
+ }
76
+ else {
77
+ const filePath = args[fileIndex + 1];
78
+ params.filePath = filePath;
79
+ }
80
+ // 检查是否有 run 参数
81
+ params.run = args.includes('-run');
82
+ if (!params.run) {
83
+ errors.push('缺少执行标志:必须包含 -run 参数来执行烧录');
84
+ }
85
+ // 解析可选参数:校验标志
86
+ const verifyIndex = args.findIndex((arg, index) => arg === '-v' && index + 1 < args.length);
87
+ if (verifyIndex !== -1) {
88
+ const verifyValue = args[verifyIndex + 1];
89
+ if (verifyValue !== '0' && verifyValue !== '1') {
90
+ errors.push(`无效的校验标志:${verifyValue},必须是 0(不校验)或 1(校验)`);
91
+ }
92
+ else {
93
+ params.verify = verifyValue === '0' ? 0 : 1;
94
+ }
95
+ }
96
+ // 解析可选参数:擦除类型
97
+ const eraseIndex = args.findIndex((arg, index) => arg === '-e' && index + 1 < args.length);
98
+ if (eraseIndex !== -1) {
99
+ const eraseValue = args[eraseIndex + 1];
100
+ if (eraseValue !== '0' && eraseValue !== '1') {
101
+ errors.push(`无效的擦除类型:${eraseValue},必须是 0(擦除整个flash)或 1(仅擦除file area)`);
102
+ }
103
+ else {
104
+ params.eraseType = eraseValue === '0' ? 0 : 1;
105
+ }
106
+ }
107
+ // 如果有错误,抛出异常
108
+ if (errors.length > 0) {
109
+ const errorMessage = errors.join('; ');
110
+ throw error(ErrorCodes.EL_INVALID_PARAMS, `Windows FlashTool 参数验证失败:${errorMessage}`, {
111
+ details: { errors, params },
112
+ });
113
+ }
114
+ return params;
115
+ }
116
+ /**
117
+ * 验证十六进制地址格式
118
+ * @param address 地址字符串
119
+ * @returns 是否为有效的十六进制地址
120
+ */
121
+ function isValidHexAddress(address) {
122
+ if (!address || typeof address !== 'string') {
123
+ return false;
124
+ }
125
+ // 支持格式:0x140000 或 140000
126
+ const hexPattern = /^(0x)?[0-9a-fA-F]+$/;
127
+ if (!hexPattern.test(address)) {
128
+ return false;
129
+ }
130
+ // 检查是否为合理的地址范围(不能太大)
131
+ const numericValue = parseInt(address.replace(/^0x/, ''), 16);
132
+ return numericValue >= 0 && numericValue <= 0xffffffff;
133
+ }
134
+ /**
135
+ * 解析和验证 Linux ISP Tool 参数
136
+ * @param args 参数数组
137
+ * @param imagesRootAbs 镜像根目录绝对路径
138
+ * @returns 解析后的参数对象
139
+ */
140
+ function parseAndValidateLinuxIspArgs(args, imagesRootAbs) {
141
+ const params = {};
142
+ const errors = [];
143
+ // 解析Flash类型
144
+ const flashTypeIndex = args.findIndex((arg, index) => arg === '-t' && index + 1 < args.length);
145
+ if (flashTypeIndex !== -1) {
146
+ const flashTypeValue = args[flashTypeIndex + 1];
147
+ if (flashTypeValue === '0' || flashTypeValue === '1') {
148
+ params.flashType = flashTypeValue === '0' ? 0 : 1;
149
+ }
150
+ else {
151
+ errors.push(`无效的Flash类型:${flashTypeValue},必须是 0(SPINOR)或 1(SPINAND)`);
152
+ }
153
+ }
154
+ // 解析Debug Board ID
155
+ const debugBoardIndex = args.findIndex((arg, index) => arg === '-i' && index + 1 < args.length);
156
+ if (debugBoardIndex !== -1) {
157
+ params.debugBoardId = args[debugBoardIndex + 1];
158
+ }
159
+ // 解析文件路径和地址对 (-a address, -p file)
160
+ const addressFilePairs = [];
161
+ for (let i = 0; i < args.length; i++) {
162
+ if (args[i] === '-a' && i + 1 < args.length) {
163
+ const address = args[i + 1];
164
+ if (!isValidHexAddress(address)) {
165
+ errors.push(`无效的地址格式:${address},必须是有效的十六进制地址(如 0x140000)`);
166
+ }
167
+ else {
168
+ if (addressFilePairs.length === 0) {
169
+ addressFilePairs.push({ address });
170
+ }
171
+ else {
172
+ const lastPair = addressFilePairs[addressFilePairs.length - 1];
173
+ if (!lastPair.address) {
174
+ lastPair.address = address;
175
+ }
176
+ else {
177
+ addressFilePairs.push({ address });
178
+ }
179
+ }
180
+ }
181
+ i++; // 跳过地址值
182
+ }
183
+ else if (args[i] === '-p' && i + 1 < args.length) {
184
+ const file = args[i + 1];
185
+ if (addressFilePairs.length === 0) {
186
+ addressFilePairs.push({ file });
187
+ }
188
+ else {
189
+ const lastPair = addressFilePairs[addressFilePairs.length - 1];
190
+ if (!lastPair.file) {
191
+ lastPair.file = file;
192
+ }
193
+ else {
194
+ addressFilePairs.push({ file });
195
+ }
196
+ }
197
+ i++; // 跳过文件路径
198
+ }
199
+ }
200
+ // 检查是否有有效的地址文件对
201
+ if (addressFilePairs.length > 0) {
202
+ const firstValidPair = addressFilePairs.find((pair) => pair.address && pair.file);
203
+ if (firstValidPair) {
204
+ params.address = firstValidPair.address;
205
+ params.filePath = firstValidPair.file;
206
+ }
207
+ }
208
+ // 解析可选参数:擦除模式
209
+ const eraseModeIndex = args.findIndex((arg, index) => arg === '-e' && index + 1 < args.length);
210
+ if (eraseModeIndex !== -1) {
211
+ const eraseModeValue = args[eraseModeIndex + 1];
212
+ if (eraseModeValue === '0' || eraseModeValue === '1') {
213
+ params.eraseMode = eraseModeValue === '0' ? 0 : 1;
214
+ }
215
+ else {
216
+ errors.push(`无效的擦除模式:${eraseModeValue},必须是 0(擦除指定地址)或 1(擦除全部)`);
217
+ }
218
+ }
219
+ // 解析可选参数:坏块检查
220
+ const badBlockIndex = args.findIndex((arg, index) => arg === '-b' && index + 1 < args.length);
221
+ if (badBlockIndex !== -1) {
222
+ const badBlockValue = args[badBlockIndex + 1];
223
+ if (badBlockValue === '1' || badBlockValue.toLowerCase() === 'true') {
224
+ params.badBlockCheck = true;
225
+ }
226
+ }
227
+ // 如果有错误,抛出异常
228
+ if (errors.length > 0) {
229
+ const errorMessage = errors.join('; ');
230
+ throw error(ErrorCodes.EL_INVALID_PARAMS, `Linux ISP Tool 参数验证失败:${errorMessage}`, {
231
+ details: { errors, params },
232
+ });
233
+ }
234
+ return params;
235
+ }
236
+ /**
237
+ * 验证 Windows FlashTool 参数并检查文件存在性
238
+ * @param args 参数数组
239
+ * @param imagesRootAbs 镜像根目录绝对路径
240
+ * @returns 验证结果对象
241
+ */
242
+ async function validateWindowsFlashToolParams(args, imagesRootAbs) {
243
+ // 检查是否看起来像 Windows FlashTool 参数
244
+ const hasFlashTypeFlags = args.some((arg) => arg === '-spinor' || arg === '-spinand');
245
+ const hasWindowsSpecificFlags = args.some((arg) => ['-run', '-s', '-f', '-v', '-e'].includes(arg));
246
+ // 如果不是 Windows FlashTool 参数,跳过检查
247
+ if (!hasFlashTypeFlags && !hasWindowsSpecificFlags) {
248
+ return { isValid: true };
249
+ }
250
+ try {
251
+ // 解析和验证参数
252
+ const params = parseAndValidateWindowsFlashToolArgs(args, imagesRootAbs);
253
+ // 检查文件存在性
254
+ if (params.filePath) {
255
+ let fullPath;
256
+ // 如果是绝对路径,直接使用
257
+ if (path.isAbsolute(params.filePath)) {
258
+ fullPath = params.filePath;
259
+ }
260
+ else {
261
+ // 相对路径需要与 imagesRootAbs 组合
262
+ fullPath = path.join(imagesRootAbs, params.filePath);
263
+ }
264
+ try {
265
+ const fileStat = await fs.stat(fullPath);
266
+ if (!fileStat.isFile()) {
267
+ return {
268
+ isValid: false,
269
+ errorMessage: `FlashTool 文件路径不是常规文件:${params.filePath}`,
270
+ details: { parsedPath: fullPath, args },
271
+ };
272
+ }
273
+ // 检查文件大小是否合理(0 < size < 1GB)
274
+ if (fileStat.size === 0) {
275
+ return {
276
+ isValid: false,
277
+ errorMessage: `FlashTool 文件为空:${params.filePath}`,
278
+ details: { parsedPath: fullPath, fileSize: fileStat.size, args },
279
+ };
280
+ }
281
+ const maxSize = 1024 * 1024 * 1024; // 1GB
282
+ if (fileStat.size > maxSize) {
283
+ return {
284
+ isValid: false,
285
+ errorMessage: `FlashTool 文件过大:${params.filePath} (${fileStat.size} bytes > ${maxSize} bytes)`,
286
+ details: { parsedPath: fullPath, fileSize: fileStat.size, maxSize, args },
287
+ };
288
+ }
289
+ }
290
+ catch (e) {
291
+ if (e.code === 'ENOENT') {
292
+ return {
293
+ isValid: false,
294
+ errorMessage: `FlashTool 文件不存在:${params.filePath}`,
295
+ details: { parsedPath: fullPath, args, originalError: e.message },
296
+ };
297
+ }
298
+ if (e.code === 'EACCES') {
299
+ return {
300
+ isValid: false,
301
+ errorMessage: `FlashTool 文件无访问权限:${params.filePath}`,
302
+ details: { parsedPath: fullPath, args, originalError: e.message },
303
+ };
304
+ }
305
+ // 其他文件系统错误
306
+ return {
307
+ isValid: false,
308
+ errorMessage: `FlashTool 文件检查失败:${params.filePath} (${e.code || 'UNKNOWN'}: ${e.message || String(e)})`,
309
+ details: { parsedPath: fullPath, args, originalError: e.message, errorCode: e.code },
310
+ };
311
+ }
312
+ }
313
+ return { isValid: true, details: { parsedParams: params } };
314
+ }
315
+ catch (e) {
316
+ // 参数解析或格式验证失败
317
+ return {
318
+ isValid: false,
319
+ errorMessage: e.message || 'Windows FlashTool 参数验证失败',
320
+ details: { args, originalError: e },
321
+ };
322
+ }
323
+ }
324
+ /**
325
+ * 验证 Linux ISP Tool 参数并检查文件存在性
326
+ * @param args 参数数组
327
+ * @param imagesRootAbs 镜像根目录绝对路径
328
+ * @returns 验证结果对象
329
+ */
330
+ async function validateLinuxIspParams(args, imagesRootAbs) {
331
+ // 检查是否看起来像 Linux ISP Tool 参数
332
+ const hasLinuxFlags = args.some((arg) => ['-t', '-i', '-a', '-p', '-e', '-b'].includes(arg));
333
+ // 如果不是 Linux ISP Tool 参数,跳过检查
334
+ if (!hasLinuxFlags) {
335
+ return { isValid: true };
336
+ }
337
+ try {
338
+ // 解析和验证参数
339
+ const params = parseAndValidateLinuxIspArgs(args, imagesRootAbs);
340
+ // 检查文件存在性(Linux ISP Tool 可以同时有多个文件,检查所有)
341
+ const filesToCheck = [];
342
+ // 收集所有 -p 参数指定的文件
343
+ for (let i = 0; i < args.length; i++) {
344
+ if (args[i] === '-p' && i + 1 < args.length) {
345
+ filesToCheck.push(args[i + 1]);
346
+ i++; // 跳过文件路径
347
+ }
348
+ }
349
+ // 检查所有文件
350
+ for (const filePath of filesToCheck) {
351
+ let fullPath;
352
+ // 如果是绝对路径,直接使用
353
+ if (path.isAbsolute(filePath)) {
354
+ fullPath = filePath;
355
+ }
356
+ else {
357
+ // 相对路径需要与 imagesRootAbs 组合
358
+ fullPath = path.join(imagesRootAbs, filePath);
359
+ }
360
+ try {
361
+ const fileStat = await fs.stat(fullPath);
362
+ if (!fileStat.isFile()) {
363
+ return {
364
+ isValid: false,
365
+ errorMessage: `Linux ISP Tool 文件路径不是常规文件:${filePath}`,
366
+ details: { parsedPath: fullPath, args, allFiles: filesToCheck },
367
+ };
368
+ }
369
+ // 检查文件大小是否合理(0 < size < 1GB)
370
+ if (fileStat.size === 0) {
371
+ return {
372
+ isValid: false,
373
+ errorMessage: `Linux ISP Tool 文件为空:${filePath}`,
374
+ details: {
375
+ parsedPath: fullPath,
376
+ fileSize: fileStat.size,
377
+ args,
378
+ allFiles: filesToCheck,
379
+ },
380
+ };
381
+ }
382
+ const maxSize = 1024 * 1024 * 1024; // 1GB
383
+ if (fileStat.size > maxSize) {
384
+ return {
385
+ isValid: false,
386
+ errorMessage: `Linux ISP Tool 文件过大:${filePath} (${fileStat.size} bytes > ${maxSize} bytes)`,
387
+ details: {
388
+ parsedPath: fullPath,
389
+ fileSize: fileStat.size,
390
+ maxSize,
391
+ args,
392
+ allFiles: filesToCheck,
393
+ },
394
+ };
395
+ }
396
+ }
397
+ catch (e) {
398
+ if (e.code === 'ENOENT') {
399
+ return {
400
+ isValid: false,
401
+ errorMessage: `Linux ISP Tool 文件不存在:${filePath}`,
402
+ details: {
403
+ parsedPath: fullPath,
404
+ args,
405
+ allFiles: filesToCheck,
406
+ originalError: e.message,
407
+ },
408
+ };
409
+ }
410
+ if (e.code === 'EACCES') {
411
+ return {
412
+ isValid: false,
413
+ errorMessage: `Linux ISP Tool 文件无访问权限:${filePath}`,
414
+ details: {
415
+ parsedPath: fullPath,
416
+ args,
417
+ allFiles: filesToCheck,
418
+ originalError: e.message,
419
+ },
420
+ };
421
+ }
422
+ // 其他文件系统错误
423
+ return {
424
+ isValid: false,
425
+ errorMessage: `Linux ISP Tool 文件检查失败:${filePath} (${e.code || 'UNKNOWN'}: ${e.message || String(e)})`,
426
+ details: {
427
+ parsedPath: fullPath,
428
+ args,
429
+ allFiles: filesToCheck,
430
+ originalError: e.message,
431
+ errorCode: e.code,
432
+ },
433
+ };
434
+ }
435
+ }
436
+ return { isValid: true, details: { parsedParams: params, checkedFiles: filesToCheck } };
437
+ }
438
+ catch (e) {
439
+ // 参数解析或格式验证失败
440
+ return {
441
+ isValid: false,
442
+ errorMessage: e.message || 'Linux ISP Tool 参数验证失败',
443
+ details: { args, originalError: e },
444
+ };
445
+ }
446
+ }
447
+ export async function firmwareBurnRecover(params) {
448
+ try {
449
+ const { cfg } = params;
450
+ const imagesRoot = params.imagesRoot && params.imagesRoot.length > 0 ? params.imagesRoot : 'images_agent';
451
+ const imagesRootAbs = path.join(cfg.tftp.dir, imagesRoot);
452
+ let st;
453
+ try {
454
+ st = await fs.stat(imagesRootAbs);
455
+ }
456
+ catch (e) {
457
+ throw error(ErrorCodes.EL_INVALID_PARAMS, `firmware.burn_recover: imagesRoot directory not found: ${imagesRootAbs}`, { cause: e });
458
+ }
459
+ if (!st.isDirectory()) {
460
+ throw error(ErrorCodes.EL_INVALID_PARAMS, `firmware.burn_recover: imagesRoot must be a directory, got ${imagesRootAbs}`);
461
+ }
462
+ const rawArgs = Array.isArray(params.args) ? params.args.slice() : [];
463
+ if (!rawArgs.length) {
464
+ throw error(ErrorCodes.EL_INVALID_PARAMS, 'firmware.burn_recover: args must be a non-empty string array');
465
+ }
466
+ // 将 AI 传来的参数视为 POSIX 风格,尽量转换为本平台可用形式,并做简单的文件存在性检查
467
+ const normalizedArgs = [];
468
+ for (const arg of rawArgs) {
469
+ if (typeof arg !== 'string') {
470
+ throw error(ErrorCodes.EL_INVALID_PARAMS, 'firmware.burn_recover: every arg must be a string');
471
+ }
472
+ // 保留原字符串,但将内部的 / 替换为当前平台的分隔符,便于本地工具识别
473
+ const converted = path.sep === '/' ? arg : arg.replace(/\//g, path.sep).replace(/\\/g, path.sep);
474
+ normalizedArgs.push(converted);
475
+ }
476
+ // ISP Tool 参数验证 - 先尝试 Linux ISP Tool,再尝试 Windows FlashTool
477
+ let validationResult = await validateLinuxIspParams(normalizedArgs, imagesRootAbs);
478
+ if (validationResult.isValid) {
479
+ // 如果 Linux 验证通过,不再尝试 Windows 验证
480
+ }
481
+ else {
482
+ // 如果不是 Linux ISP Tool 参数,尝试 Windows FlashTool 验证
483
+ validationResult = await validateWindowsFlashToolParams(normalizedArgs, imagesRootAbs);
484
+ }
485
+ if (!validationResult.isValid) {
486
+ // 返回验证失败的结果给AI,而不是抛出异常
487
+ return {
488
+ ok: false,
489
+ exitCode: -1,
490
+ details: `参数验证失败: ${validationResult.errorMessage}`,
491
+ logTail: validationResult.errorMessage || '参数验证失败',
492
+ };
493
+ }
494
+ // 尝试对"看起来像相对路径"的参数做存在性检查(不以 - 开头且不含 :)
495
+ for (const arg of rawArgs) {
496
+ if (!arg || arg.startsWith('-') || arg.includes(':'))
497
+ continue;
498
+ // 将传入的 POSIX 风格路径归一化后再组合工作目录
499
+ const relPosix = arg.replace(/\\/g, '/');
500
+ const candidate = path.join(imagesRootAbs, relPosix);
501
+ try {
502
+ const s = await fs.stat(candidate);
503
+ if (!s.isFile()) {
504
+ // 不是 regular file 就当作普通参数忽略
505
+ // eslint-disable-next-line no-continue
506
+ continue;
507
+ }
508
+ }
509
+ catch {
510
+ // 不强制当作错误,避免误伤类似 boardId/serial 等值
511
+ // 将缺失信息留给 FlashTool 工具自身报错
512
+ }
513
+ }
514
+ // NOTE: 串口资源释放与挂起机制在 board_uart.resource 中统一处理;
515
+ // 这里仅专注于调用 FlashTool 工具并收集日志。
516
+ const timeoutMs = typeof params.timeoutMs === 'number' && params.timeoutMs > 0
517
+ ? params.timeoutMs
518
+ : cfg.timeouts.flash.write;
519
+ const ispExe = (cfg.firmware?.flashtool?.binaryPath || '').trim();
520
+ if (!ispExe) {
521
+ throw error(ErrorCodes.EL_INVALID_PARAMS, 'firmware.burn_recover: FlashTool未配置。请在Agent配置文件中设置 firmware.flashtool.binaryPath');
522
+ }
523
+ // 检查文件是否存在
524
+ try {
525
+ const stExe = await fs.stat(ispExe);
526
+ const isFile = typeof stExe.isFile === 'function' ? stExe.isFile() : !stExe.isDirectory();
527
+ if (!isFile) {
528
+ // 区分文件不存在和路径指向目录的情况
529
+ if (stExe.isDirectory()) {
530
+ throw error(ErrorCodes.EL_INVALID_PARAMS, `firmware.burn_recover: FlashTool路径指向目录而非可执行文件: ${ispExe}`);
531
+ }
532
+ else {
533
+ throw error(ErrorCodes.EL_INVALID_PARAMS, `firmware.burn_recover: FlashTool不是常规文件: ${ispExe}`);
534
+ }
535
+ }
536
+ // 检查文件权限
537
+ const hasExecutePermission = (stExe.mode & 0o111) !== 0;
538
+ const hasReadPermission = (stExe.mode & 0o444) !== 0;
539
+ if (!hasReadPermission) {
540
+ throw error(ErrorCodes.EL_INVALID_PARAMS, `firmware.burn_recover: FlashTool文件无读取权限: ${ispExe} (当前权限: ${(stExe.mode & 0o777).toString(8)})`);
541
+ }
542
+ if (!hasExecutePermission && process.platform !== 'win32') {
543
+ throw error(ErrorCodes.EL_INVALID_PARAMS, `firmware.burn_recover: FlashTool文件无执行权限: ${ispExe} (当前权限: ${(stExe.mode & 0o777).toString(8)}, 运行: chmod +x "${ispExe}")`);
544
+ }
545
+ }
546
+ catch (e) {
547
+ if (e && e.code === ErrorCodes.EL_INVALID_PARAMS) {
548
+ throw e; // 重新抛出我们自己的结构化错误
549
+ }
550
+ if (e.code === 'ENOENT') {
551
+ throw error(ErrorCodes.EL_INVALID_PARAMS, `firmware.burn_recover: FlashTool文件不存在: ${ispExe}`);
552
+ }
553
+ if (e.code === 'EACCES') {
554
+ throw error(ErrorCodes.EL_INVALID_PARAMS, `firmware.burn_recover: FlashTool文件权限不足: ${ispExe} (请检查文件读取和执行权限)`);
555
+ }
556
+ if (e.code === 'EPERM') {
557
+ throw error(ErrorCodes.EL_INVALID_PARAMS, `firmware.burn_recover: FlashTool文件访问被拒绝: ${ispExe} (可能需要管理员权限)`);
558
+ }
559
+ // 其他文件系统错误
560
+ throw error(ErrorCodes.EL_INVALID_PARAMS, `firmware.burn_recover: FlashTool文件检查失败: ${ispExe} (${e.code || 'UNKNOWN'}: ${e.message || String(e)})`);
561
+ }
562
+ const logger = getAgentLogger();
563
+ const mgr = getBoardUartResourceManager();
564
+ let exitCode = -1;
565
+ let log = '';
566
+ try {
567
+ await mgr.suspendForFirmware(timeoutMs + 30000);
568
+ logger.write(LogLevel.INFO, 'firmware.burn_recover.start', {
569
+ imagesRoot,
570
+ exe: ispExe,
571
+ args: normalizedArgs,
572
+ timeoutMs,
573
+ });
574
+ const { exitCode: ec, stdout: out, stderr: err, } = await new Promise((resolve) => {
575
+ const spawnWithPipes = () => {
576
+ const child = spawn(ispExe, normalizedArgs, {
577
+ cwd: imagesRootAbs,
578
+ stdio: ['ignore', 'pipe', 'pipe'],
579
+ });
580
+ let stdout = '';
581
+ let stderr = '';
582
+ child.stdout?.on('data', (buf) => {
583
+ stdout += buf.toString('utf8');
584
+ });
585
+ child.stderr?.on('data', (buf) => {
586
+ stderr += buf.toString('utf8');
587
+ });
588
+ let finished = false;
589
+ const effectiveTimeout = timeoutMs > 0 ? timeoutMs : 0;
590
+ const timer = effectiveTimeout > 0
591
+ ? setTimeout(() => {
592
+ if (finished)
593
+ return;
594
+ finished = true;
595
+ child.kill('SIGKILL');
596
+ resolve({ exitCode: -1, stdout: `${stdout}\n[FlashTool TIMEOUT]\n`, stderr });
597
+ }, effectiveTimeout)
598
+ : null;
599
+ child.on('error', (e) => {
600
+ if (finished)
601
+ return;
602
+ finished = true;
603
+ if (timer)
604
+ clearTimeout(timer);
605
+ const msg = e?.message || String(e);
606
+ resolve({ exitCode: -1, stdout: `${stdout}\n[FlashTool ERROR] ${msg}\n`, stderr });
607
+ });
608
+ child.on('close', (code) => {
609
+ if (finished)
610
+ return;
611
+ finished = true;
612
+ if (timer)
613
+ clearTimeout(timer);
614
+ const exit = typeof code === 'number' ? code : -1;
615
+ resolve({ exitCode: exit, stdout, stderr });
616
+ });
617
+ };
618
+ // Windows 下某些 FlashTool 工具会直接写 Console(绕过 stdout/stderr pipe),
619
+ // 因此必须使用 ConPTY 捕获控制台输出。
620
+ // 约束:@homebridge/node-pty-prebuilt-multiarch 在 Windows 下是必需依赖;不可用时必须显式失败,避免 stdout/stderr 为空误导上层。
621
+ if (process.platform === 'win32') {
622
+ try {
623
+ // 使用 require 来避免 TypeScript 编译时的模块解析错误
624
+ const nodePty = require('@homebridge/node-pty-prebuilt-multiarch');
625
+ const ptySpawn = nodePty?.spawn;
626
+ if (typeof ptySpawn !== 'function') {
627
+ resolve({
628
+ exitCode: -1,
629
+ stdout: '',
630
+ stderr: '[FlashTool ERROR] @homebridge/node-pty-prebuilt-multiarch.spawn is not available (required for Windows console capture)',
631
+ });
632
+ return;
633
+ }
634
+ let stdout = '';
635
+ const stderr = '';
636
+ const term = ptySpawn(ispExe, normalizedArgs, {
637
+ cwd: imagesRootAbs,
638
+ name: 'xterm-256color',
639
+ cols: 120,
640
+ rows: 40,
641
+ env: process.env,
642
+ encoding: 'utf8',
643
+ useConpty: true,
644
+ });
645
+ let finished = false;
646
+ const effectiveTimeout = timeoutMs > 0 ? timeoutMs : 0;
647
+ const timer = effectiveTimeout > 0
648
+ ? setTimeout(() => {
649
+ if (finished)
650
+ return;
651
+ finished = true;
652
+ try {
653
+ term.kill();
654
+ }
655
+ catch {
656
+ // ignore
657
+ }
658
+ resolve({
659
+ exitCode: -1,
660
+ stdout: `${stdout}\n[FlashTool TIMEOUT]\n`,
661
+ stderr,
662
+ });
663
+ }, effectiveTimeout)
664
+ : null;
665
+ term.onData((data) => {
666
+ stdout += data;
667
+ });
668
+ term.onExit(({ exitCode: code }) => {
669
+ if (finished)
670
+ return;
671
+ finished = true;
672
+ if (timer)
673
+ clearTimeout(timer);
674
+ const exit = typeof code === 'number' ? code : -1;
675
+ resolve({ exitCode: exit, stdout, stderr });
676
+ });
677
+ }
678
+ catch (e) {
679
+ const msg = e?.message || String(e);
680
+ resolve({
681
+ exitCode: -1,
682
+ stdout: '',
683
+ stderr: `[FlashTool ERROR] @homebridge/node-pty-prebuilt-multiarch is required on Windows to capture FlashTool console output: ${msg}`,
684
+ });
685
+ }
686
+ return;
687
+ }
688
+ spawnWithPipes();
689
+ });
690
+ exitCode = ec;
691
+ // stdout/stderr:为上层语义判定提供尽可能完整的证据;超大输出时截断
692
+ const outTail = tailUtf8(out, 8192);
693
+ const errTail = tailUtf8(err, 8192);
694
+ // logTail 只用于人类快速查看,不需要保存完整输出,避免额外内存膨胀
695
+ const combinedLog = buildCombinedLog(outTail.tail, errTail.tail);
696
+ // 清理日志,移除控制符和无关内容
697
+ log = cleanFlashToolLog(combinedLog);
698
+ logger.write(LogLevel.INFO, 'firmware.burn_recover.done', {
699
+ imagesRoot,
700
+ exe: ispExe,
701
+ args: normalizedArgs,
702
+ timeoutMs,
703
+ exitCode,
704
+ });
705
+ }
706
+ finally {
707
+ try {
708
+ await mgr.resumeFromFirmware();
709
+ }
710
+ catch {
711
+ // ignore resume errors here; 状态会通过 BoardUartStatus 反映
712
+ }
713
+ }
714
+ const { tail } = tailUtf8(log, 8192);
715
+ const ok = exitCode === 0;
716
+ return {
717
+ ok,
718
+ exitCode,
719
+ logTail: tail,
720
+ details: `FlashTool run over, you should check the log get ValidationResult.`,
721
+ };
722
+ }
723
+ catch (e) {
724
+ // 捕获所有异常,确保不会导致程序崩溃
725
+ const errorMessage = e?.message || String(e);
726
+ return {
727
+ ok: false,
728
+ exitCode: -1,
729
+ details: `烧录过程中发生异常: ${errorMessage}`,
730
+ logTail: `ERROR: ${errorMessage}`,
731
+ };
732
+ }
733
+ }