@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,578 @@
1
+ import { getSharedBoardUartManager } from './manager.js';
2
+ import { DefaultTimeouts, error, ErrorCodes } from '../core/errors.js';
3
+ import { addSessionConsumer, closeAllSessionHardwarePreserveState, forceCloseAllSessions, listBoardUartSessions, listExistingBoardUartSessions, readFromSession, reopenSessionHardwareByIds, removeSessionConsumer, setBoardUartHardwareSuspended, writeToSession, } from './sessions.js';
4
+ const HOST_LEASE_IDLE_MS = 30000;
5
+ const HOST_LEASE_HEARTBEAT_TIMEOUT_MS = 15000;
6
+ const HOST_LEASE_SWEEP_INTERVAL_MS = 1000;
7
+ const MAX_HOST_LEASE_BUFFER_BYTES = 1024 * 1024;
8
+ export class BoardUartResourceManager {
9
+ static getInstance() {
10
+ if (!BoardUartResourceManager.instance) {
11
+ BoardUartResourceManager.instance = new BoardUartResourceManager();
12
+ }
13
+ return BoardUartResourceManager.instance;
14
+ }
15
+ constructor() {
16
+ this.firmwareClosedSessionIds = null;
17
+ this.attached = new Map();
18
+ this.seq = 0;
19
+ this.hostLeases = new Map();
20
+ this.hostHeartbeats = new Map();
21
+ this.hostLeaseSeq = 0;
22
+ this.hostLeaseSweepTimer = null;
23
+ this.suspended = false;
24
+ this.suspendOwner = null;
25
+ this.suspendedSinceMs = null;
26
+ this.maxSuspendMs = null;
27
+ this.autoResumed = false;
28
+ this.suspendTimer = null;
29
+ this.hostLeaseSweepTimer = setInterval(() => {
30
+ try {
31
+ this.sweepHostLeases();
32
+ }
33
+ catch {
34
+ // ignore sweep errors
35
+ }
36
+ }, HOST_LEASE_SWEEP_INTERVAL_MS);
37
+ // Do not keep the process alive only for this timer.
38
+ this.hostLeaseSweepTimer.unref?.();
39
+ }
40
+ updateHostHeartbeat(hostInstanceId) {
41
+ const id = (hostInstanceId || '').trim();
42
+ if (!id)
43
+ return;
44
+ const now = Date.now();
45
+ this.hostHeartbeats.set(id, now);
46
+ const lease = this.hostLeases.get(id);
47
+ if (lease) {
48
+ lease.lastHeartbeatAtMs = now;
49
+ }
50
+ }
51
+ normalizeHostInstanceId(hostInstanceId) {
52
+ const id = (hostInstanceId || '').trim();
53
+ if (!id) {
54
+ throw error(ErrorCodes.EL_INVALID_PARAMS, 'missing hostInstanceId (metadata: x-embedlink-host-instance-id)');
55
+ }
56
+ return id;
57
+ }
58
+ enqueueHostLeaseData(lease, chunk) {
59
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
60
+ lease.buffer.push(buf);
61
+ lease.bufferBytes += buf.length;
62
+ while (lease.bufferBytes > MAX_HOST_LEASE_BUFFER_BYTES && lease.buffer.length > 0) {
63
+ const old = lease.buffer.shift();
64
+ lease.bufferBytes -= old.length;
65
+ }
66
+ const waiters = Array.from(lease.waiters);
67
+ for (const fn of waiters) {
68
+ try {
69
+ fn();
70
+ }
71
+ catch {
72
+ // ignore waiter errors
73
+ }
74
+ }
75
+ }
76
+ detachHostLease(hostInstanceId) {
77
+ const rec = this.hostLeases.get(hostInstanceId);
78
+ if (!rec)
79
+ return;
80
+ this.hostLeases.delete(hostInstanceId);
81
+ try {
82
+ removeSessionConsumer(rec.sessionId, rec.consumerId);
83
+ }
84
+ catch {
85
+ // ignore detach errors
86
+ }
87
+ }
88
+ sweepHostLeases() {
89
+ const now = Date.now();
90
+ for (const [hostInstanceId, lease] of this.hostLeases.entries()) {
91
+ if (lease.inFlight > 0)
92
+ continue;
93
+ const idleMs = now - lease.lastIoAtMs;
94
+ const heartbeatMs = now - lease.lastHeartbeatAtMs;
95
+ const hasHeartbeat = this.hostHeartbeats.has(hostInstanceId);
96
+ const heartbeatExpired = hasHeartbeat && heartbeatMs > HOST_LEASE_HEARTBEAT_TIMEOUT_MS;
97
+ if (idleMs > HOST_LEASE_IDLE_MS || heartbeatExpired) {
98
+ this.detachHostLease(hostInstanceId);
99
+ }
100
+ }
101
+ }
102
+ async touchHostIo(params) {
103
+ const id = this.normalizeHostInstanceId(params.hostInstanceId);
104
+ const existing = this.hostLeases.get(id);
105
+ if (existing) {
106
+ existing.lastIoAtMs = Date.now();
107
+ const hb = this.hostHeartbeats.get(id);
108
+ if (hb)
109
+ existing.lastHeartbeatAtMs = hb;
110
+ return existing;
111
+ }
112
+ const consumerId = `mcp-${Date.now()}-${++this.hostLeaseSeq}`;
113
+ const now = Date.now();
114
+ const lease = {
115
+ hostInstanceId: id,
116
+ id: `mcp-host:${id}`,
117
+ source: `mcp-host ${id}`,
118
+ consumerId,
119
+ sessionId: params.sessionId || '',
120
+ port: params.port || '',
121
+ baud: params.baud || 0,
122
+ attachedAt: now,
123
+ lastIoAtMs: now,
124
+ lastHeartbeatAtMs: this.hostHeartbeats.get(id) || now,
125
+ reading: false,
126
+ inFlight: 0,
127
+ buffer: [],
128
+ bufferBytes: 0,
129
+ waiters: new Set(),
130
+ };
131
+ const session = await addSessionConsumer({
132
+ consumerId,
133
+ sessionId: params.sessionId,
134
+ port: params.port,
135
+ baud: params.baud,
136
+ onData: (chunk) => this.enqueueHostLeaseData(lease, chunk),
137
+ });
138
+ lease.sessionId = session.id;
139
+ lease.port = session.port;
140
+ lease.baud = session.baud;
141
+ this.hostLeases.set(id, lease);
142
+ return lease;
143
+ }
144
+ async readForHost(params) {
145
+ if (this.suspended) {
146
+ const owner = this.suspendOwner || 'unknown';
147
+ throw error(ErrorCodes.EL_BOARD_UART_SUSPENDED, `board uart resource is suspended by ${owner}`);
148
+ }
149
+ const lease = await this.touchHostIo({
150
+ hostInstanceId: params.hostInstanceId,
151
+ sessionId: params.sessionId,
152
+ });
153
+ if (lease.reading) {
154
+ throw error(ErrorCodes.EL_BOARD_UART_READ_IN_PROGRESS, `board-uart lease is already reading: ${lease.hostInstanceId}`);
155
+ }
156
+ const maxBytes = params.maxBytes && params.maxBytes > 0 ? params.maxBytes : 8192;
157
+ const quietMs = params.quietMs && params.quietMs > 0 ? params.quietMs : 200;
158
+ const timeoutMs = params.timeoutMs && params.timeoutMs > 0 ? params.timeoutMs : DefaultTimeouts.boardUart.read;
159
+ lease.reading = true;
160
+ lease.inFlight += 1;
161
+ try {
162
+ const chunks = [];
163
+ let bytes = 0;
164
+ let lastActivity = Date.now();
165
+ const drainFromBuffer = () => {
166
+ while (lease.buffer.length && bytes < maxBytes) {
167
+ const chunk = lease.buffer.shift();
168
+ lease.bufferBytes -= chunk.length;
169
+ const remaining = maxBytes - bytes;
170
+ if (chunk.length <= remaining) {
171
+ chunks.push(chunk);
172
+ bytes += chunk.length;
173
+ }
174
+ else {
175
+ const part = chunk.subarray(0, remaining);
176
+ const rest = chunk.subarray(remaining);
177
+ chunks.push(part);
178
+ bytes += part.length;
179
+ lease.buffer.unshift(rest);
180
+ lease.bufferBytes += rest.length;
181
+ break;
182
+ }
183
+ }
184
+ };
185
+ drainFromBuffer();
186
+ return await new Promise((resolve) => {
187
+ let finished = false;
188
+ const finish = (timedOut) => {
189
+ if (finished)
190
+ return;
191
+ finished = true;
192
+ clearTimeout(timeoutTimer);
193
+ clearInterval(quietTimer);
194
+ lease.waiters.delete(onNewData);
195
+ drainFromBuffer();
196
+ const remains = lease.bufferBytes;
197
+ resolve({ data: Buffer.concat(chunks, bytes), bytes, timedOut, remains });
198
+ };
199
+ const onNewData = () => {
200
+ lastActivity = Date.now();
201
+ drainFromBuffer();
202
+ if (bytes >= maxBytes) {
203
+ finish(false);
204
+ }
205
+ };
206
+ lease.waiters.add(onNewData);
207
+ const quietTimer = setInterval(() => {
208
+ const now = Date.now();
209
+ if (now - lastActivity >= quietMs) {
210
+ finish(false);
211
+ }
212
+ }, quietMs);
213
+ const timeoutTimer = setTimeout(() => {
214
+ finish(true);
215
+ }, timeoutMs);
216
+ if (bytes >= maxBytes) {
217
+ finish(false);
218
+ }
219
+ });
220
+ }
221
+ finally {
222
+ lease.inFlight -= 1;
223
+ lease.reading = false;
224
+ }
225
+ }
226
+ /**
227
+ * 通过 session/port/baud 将监听器附加到现有会话模型上。
228
+ * 当前实现基于 board_uart/sessions.ts,作为轻量封装,后续可演进为独立资源管理器。
229
+ */
230
+ async attachResource(params) {
231
+ if (this.suspended) {
232
+ const owner = this.suspendOwner || 'unknown';
233
+ throw error(ErrorCodes.EL_BOARD_UART_SUSPENDED, `board uart resource is suspended by ${owner}`);
234
+ }
235
+ const consumerId = `att-${Date.now()}-${++this.seq}`;
236
+ const { listener, source } = params;
237
+ const session = await addSessionConsumer({
238
+ consumerId,
239
+ sessionId: params.sessionId,
240
+ port: params.port,
241
+ baud: params.baud,
242
+ onData: listener.onData,
243
+ onError: listener.onError,
244
+ });
245
+ const id = `att-${session.id}-${consumerId}`;
246
+ const rec = {
247
+ id,
248
+ source,
249
+ sessionId: session.id,
250
+ port: session.port,
251
+ baud: session.baud,
252
+ attachedAt: Date.now(),
253
+ consumerId,
254
+ };
255
+ this.attached.set(id, rec);
256
+ return {
257
+ id: rec.id,
258
+ source: rec.source,
259
+ sessionId: rec.sessionId,
260
+ port: rec.port,
261
+ baud: rec.baud,
262
+ attachedAt: rec.attachedAt,
263
+ };
264
+ }
265
+ detachResource(attachId) {
266
+ const rec = this.attached.get(attachId);
267
+ if (!rec)
268
+ return;
269
+ this.attached.delete(attachId);
270
+ removeSessionConsumer(rec.sessionId, rec.consumerId);
271
+ }
272
+ async writeCommand(params) {
273
+ if (this.suspended) {
274
+ const owner = this.suspendOwner || 'unknown';
275
+ throw error(ErrorCodes.EL_BOARD_UART_SUSPENDED, `board uart resource is suspended by ${owner}`);
276
+ }
277
+ const startTime = Date.now();
278
+ const cmdStr = params.command.endsWith('\n') ? params.command : params.command + '\n';
279
+ const cmdBuffer = Buffer.from(cmdStr);
280
+ const timeoutMs = params.timeoutMs && params.timeoutMs > 0 ? params.timeoutMs : 5000;
281
+ const activeGap = Math.min(Math.max(Math.floor(timeoutMs * 0.1), 100), 1000);
282
+ const maxOutputBytes = 16384;
283
+ // 无 waitFor 时直接写入并返回 done
284
+ if (!params.waitFor) {
285
+ await this.write({ data: cmdBuffer, sessionId: params.sessionId });
286
+ return {
287
+ status: 'done',
288
+ output: '',
289
+ nextStep: '',
290
+ elapsedMs: Date.now() - startTime,
291
+ lastOutputGapMs: 0,
292
+ remains: 0, // 写入操作不涉及读取,剩余数据为0
293
+ };
294
+ }
295
+ let targetSessionId = params.sessionId;
296
+ if (!targetSessionId) {
297
+ const sessions = await listBoardUartSessions();
298
+ const def = sessions.find((s) => s.isDefault);
299
+ if (def)
300
+ targetSessionId = def.id;
301
+ else if (sessions.length > 0)
302
+ targetSessionId = sessions[0].id;
303
+ else {
304
+ throw new Error('No active board uart session to wait for response.');
305
+ }
306
+ }
307
+ const consumerId = `cmd-${Date.now()}-${Math.random().toString(36).slice(2)}`;
308
+ let lastOutputAt = Date.now();
309
+ let isDone = false;
310
+ let timeoutTimer;
311
+ const outputBuffers = [];
312
+ let totalBytes = 0;
313
+ const waitPromise = new Promise((resolve, reject) => {
314
+ const cleanup = () => {
315
+ if (isDone)
316
+ return;
317
+ isDone = true;
318
+ if (timeoutTimer)
319
+ clearTimeout(timeoutTimer);
320
+ removeSessionConsumer(targetSessionId, consumerId);
321
+ };
322
+ const finalize = (payload) => {
323
+ cleanup();
324
+ const gap = typeof payload.lastOutputGapMs === 'number'
325
+ ? payload.lastOutputGapMs
326
+ : Date.now() - lastOutputAt;
327
+ // 计算剩余数据:buffer 中还未被读取的数据 + 已读取但未返回的数据
328
+ const remains = payload.remains ?? (totalBytes > maxOutputBytes ? totalBytes - maxOutputBytes : 0);
329
+ resolve({ ...payload, lastOutputGapMs: gap, remains });
330
+ };
331
+ const onData = (chunk) => {
332
+ if (isDone)
333
+ return;
334
+ lastOutputAt = Date.now();
335
+ outputBuffers.push(chunk);
336
+ totalBytes += chunk.length;
337
+ const chunkStr = chunk.toString();
338
+ const outputStr = Buffer.concat(outputBuffers, Math.min(totalBytes, maxOutputBytes)).toString();
339
+ if (outputStr.includes(params.waitFor)) {
340
+ finalize({ status: 'done', output: outputStr, nextStep: '' });
341
+ return;
342
+ }
343
+ if (totalBytes > maxOutputBytes) {
344
+ const truncatedStr = Buffer.concat(outputBuffers, maxOutputBytes).toString();
345
+ finalize({
346
+ status: 'truncated',
347
+ output: truncatedStr,
348
+ nextStep: '输出超8KB,建议用 read 获取后续,或发送 signal 中断',
349
+ });
350
+ }
351
+ };
352
+ const onError = (err) => {
353
+ if (isDone)
354
+ return;
355
+ cleanup();
356
+ reject(err);
357
+ };
358
+ addSessionConsumer({
359
+ consumerId,
360
+ sessionId: targetSessionId,
361
+ onData,
362
+ onError,
363
+ })
364
+ .then(() => {
365
+ timeoutTimer = setTimeout(() => {
366
+ if (isDone)
367
+ return;
368
+ const gap = Date.now() - lastOutputAt;
369
+ const status = gap < activeGap ? 'busy' : 'running';
370
+ const nextStep = status === 'busy'
371
+ ? '持续输出,若需结束请发送 signal(如 Ctrl+C)'
372
+ : '未匹配,回车探测/延长等待,或发送 signal 中断';
373
+ const outputStr = Buffer.concat(outputBuffers, Math.min(totalBytes, maxOutputBytes)).toString();
374
+ finalize({ status, output: outputStr, nextStep, lastOutputGapMs: gap });
375
+ }, timeoutMs);
376
+ })
377
+ .catch((err) => {
378
+ reject(err);
379
+ });
380
+ });
381
+ await this.write({ data: cmdBuffer, sessionId: targetSessionId });
382
+ try {
383
+ const result = await waitPromise;
384
+ return {
385
+ status: result.status,
386
+ output: result.output,
387
+ nextStep: result.nextStep,
388
+ elapsedMs: Date.now() - startTime,
389
+ lastOutputGapMs: result.lastOutputGapMs,
390
+ remains: result.remains,
391
+ };
392
+ }
393
+ catch (e) {
394
+ return {
395
+ status: 'error',
396
+ output: '',
397
+ nextStep: `会话异常:${e?.message || String(e)}`,
398
+ elapsedMs: Date.now() - startTime,
399
+ lastOutputGapMs: Date.now() - lastOutputAt,
400
+ remains: 0, // 错误情况下,剩余数据设为0
401
+ };
402
+ }
403
+ }
404
+ async write(params) {
405
+ if (this.suspended) {
406
+ const owner = this.suspendOwner || 'unknown';
407
+ throw error(ErrorCodes.EL_BOARD_UART_SUSPENDED, `board uart resource is suspended by ${owner}`);
408
+ }
409
+ return writeToSession(params.sessionId, params.data);
410
+ }
411
+ async read(params) {
412
+ if (this.suspended) {
413
+ const owner = this.suspendOwner || 'unknown';
414
+ throw error(ErrorCodes.EL_BOARD_UART_SUSPENDED, `board uart resource is suspended by ${owner}`);
415
+ }
416
+ return readFromSession(params);
417
+ }
418
+ async getStatus() {
419
+ const mgr = getSharedBoardUartManager();
420
+ const hw = await mgr.status();
421
+ const sessions = listExistingBoardUartSessions();
422
+ let status = 'disconnected';
423
+ if (hw.disabled || !hw.hasModule || !hw.ports.length) {
424
+ status = 'disconnected';
425
+ }
426
+ else {
427
+ // 当前实现无法精确区分“被其他程序占用”场景,统一视为 connected;
428
+ // 具体错误通过读写时的错误码暴露。
429
+ status = 'connected';
430
+ }
431
+ const firstSession = sessions[0];
432
+ const attachedTools = [
433
+ ...Array.from(this.attached.values()).map((rec) => ({
434
+ id: rec.id,
435
+ source: rec.source,
436
+ sessionId: rec.sessionId,
437
+ port: rec.port,
438
+ baud: rec.baud,
439
+ attachedAt: rec.attachedAt,
440
+ })),
441
+ ...Array.from(this.hostLeases.values()).map((rec) => ({
442
+ id: rec.id,
443
+ source: rec.source,
444
+ sessionId: rec.sessionId,
445
+ port: rec.port,
446
+ baud: rec.baud,
447
+ attachedAt: rec.attachedAt,
448
+ })),
449
+ ];
450
+ return {
451
+ status,
452
+ disabled: hw.disabled,
453
+ hasModule: hw.hasModule,
454
+ ports: hw.ports,
455
+ port: firstSession?.port ?? null,
456
+ baud: firstSession?.baud ?? null,
457
+ sessions,
458
+ attachedTools,
459
+ suspended: this.suspended,
460
+ suspendOwner: this.suspendOwner,
461
+ suspendedSinceMs: this.suspendedSinceMs,
462
+ maxSuspendMs: this.maxSuspendMs,
463
+ autoResumed: this.autoResumed,
464
+ };
465
+ }
466
+ async forceCloseHardware() {
467
+ await forceCloseAllSessions();
468
+ this.attached.clear();
469
+ this.hostLeases.clear();
470
+ }
471
+ async setHardwareDisabled(disabled) {
472
+ const mgr = getSharedBoardUartManager();
473
+ mgr.setRuntimeDisabled(disabled);
474
+ if (disabled) {
475
+ await forceCloseAllSessions();
476
+ this.attached.clear();
477
+ this.hostLeases.clear();
478
+ }
479
+ }
480
+ async suspend(params) {
481
+ const owner = (params.owner || '').trim();
482
+ const maxSuspendMs = typeof params.maxSuspendMs === 'number' && params.maxSuspendMs > 0
483
+ ? params.maxSuspendMs
484
+ : null;
485
+ if (!owner) {
486
+ throw error(ErrorCodes.EL_INVALID_PARAMS, 'BoardUartResourceManager.suspend: owner is required');
487
+ }
488
+ if (this.suspended && this.suspendOwner && this.suspendOwner !== owner) {
489
+ throw error(ErrorCodes.EL_INVALID_PARAMS, `board uart already suspended by ${this.suspendOwner}`);
490
+ }
491
+ this.suspended = true;
492
+ this.suspendOwner = owner;
493
+ this.suspendedSinceMs = Date.now();
494
+ this.maxSuspendMs = maxSuspendMs;
495
+ this.autoResumed = false;
496
+ if (this.suspendTimer) {
497
+ clearTimeout(this.suspendTimer);
498
+ this.suspendTimer = null;
499
+ }
500
+ // 关闭所有当前会话底层串口,释放物理资源
501
+ await forceCloseAllSessions();
502
+ this.attached.clear();
503
+ this.hostLeases.clear();
504
+ if (maxSuspendMs && maxSuspendMs > 0) {
505
+ this.suspendTimer = setTimeout(() => {
506
+ if (!this.suspended)
507
+ return;
508
+ if (this.suspendOwner !== owner)
509
+ return;
510
+ this.autoResumed = true;
511
+ // 自动恢复时忽略错误,避免定时器导致进程崩溃
512
+ this.resume(owner).catch(() => { });
513
+ }, maxSuspendMs);
514
+ }
515
+ }
516
+ async resume(owner) {
517
+ if (!this.suspended)
518
+ return;
519
+ if (owner && this.suspendOwner && this.suspendOwner !== owner) {
520
+ throw error(ErrorCodes.EL_INVALID_PARAMS, `board uart suspended by ${this.suspendOwner}, cannot resume as ${owner}`);
521
+ }
522
+ const sessionIds = this.firmwareClosedSessionIds;
523
+ this.firmwareClosedSessionIds = null;
524
+ // firmware suspend: keep session/attach/lease, only cut hardware; resume should restore hardware.
525
+ // even if reopen fails, we must unfreeze the system and surface errors via subsequent I/O.
526
+ if (sessionIds && sessionIds.length) {
527
+ setBoardUartHardwareSuspended(null);
528
+ await reopenSessionHardwareByIds(sessionIds);
529
+ }
530
+ else {
531
+ // always clear the hardware-level guard when resuming.
532
+ setBoardUartHardwareSuspended(null);
533
+ }
534
+ this.suspended = false;
535
+ this.suspendOwner = null;
536
+ this.suspendedSinceMs = null;
537
+ this.maxSuspendMs = null;
538
+ if (this.suspendTimer) {
539
+ clearTimeout(this.suspendTimer);
540
+ this.suspendTimer = null;
541
+ }
542
+ }
543
+ async suspendForFirmware(maxSuspendMs) {
544
+ const owner = 'firmware.burn_recover';
545
+ const ms = typeof maxSuspendMs === 'number' && maxSuspendMs > 0 ? maxSuspendMs : null;
546
+ if (this.suspended && this.suspendOwner && this.suspendOwner !== owner) {
547
+ throw error(ErrorCodes.EL_INVALID_PARAMS, `board uart already suspended by ${this.suspendOwner}`);
548
+ }
549
+ this.suspended = true;
550
+ this.suspendOwner = owner;
551
+ this.suspendedSinceMs = Date.now();
552
+ this.maxSuspendMs = ms;
553
+ this.autoResumed = false;
554
+ setBoardUartHardwareSuspended(owner);
555
+ if (this.suspendTimer) {
556
+ clearTimeout(this.suspendTimer);
557
+ this.suspendTimer = null;
558
+ }
559
+ this.firmwareClosedSessionIds = await closeAllSessionHardwarePreserveState();
560
+ if (ms && ms > 0) {
561
+ this.suspendTimer = setTimeout(() => {
562
+ if (!this.suspended)
563
+ return;
564
+ if (this.suspendOwner !== owner)
565
+ return;
566
+ this.autoResumed = true;
567
+ this.resume(owner).catch(() => { });
568
+ }, ms);
569
+ }
570
+ }
571
+ async resumeFromFirmware() {
572
+ await this.resume('firmware.burn_recover');
573
+ }
574
+ }
575
+ BoardUartResourceManager.instance = null;
576
+ export function getBoardUartResourceManager() {
577
+ return BoardUartResourceManager.getInstance();
578
+ }