agentworkshop 0.7.40 → 0.7.41

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 (116) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/_nuxt/{CzOCSGLe.js → BN16aRdQ.js} +1 -1
  3. package/.output/public/_nuxt/BWVhZntO.js +1 -0
  4. package/.output/public/_nuxt/{Dihnimlk.js → C0ZrS1KL.js} +1 -1
  5. package/.output/public/_nuxt/{Dr9v6C0f.js → C4s_niy-.js} +2 -2
  6. package/.output/public/_nuxt/D6mbFA-a.js +1 -0
  7. package/.output/public/_nuxt/{DcsCcxp4.js → D98NH8kG.js} +1 -1
  8. package/.output/public/_nuxt/{C_iYD-EB.js → DGbd-UiG.js} +1 -1
  9. package/.output/public/_nuxt/{DCn7QZ5N.js → Dap5PmTL.js} +1 -1
  10. package/.output/public/_nuxt/DvLxILQP.js +1 -0
  11. package/.output/public/_nuxt/{DZshyNLa.js → IREZ1eC2.js} +1 -1
  12. package/.output/public/_nuxt/_id_.D9ymtHB5.css +1 -0
  13. package/.output/public/_nuxt/_id_.jf-_8E_c.css +1 -0
  14. package/.output/public/_nuxt/builds/latest.json +1 -1
  15. package/.output/public/_nuxt/builds/meta/78a295bb-2072-48a4-8b46-6d545fdae743.json +1 -0
  16. package/.output/public/_nuxt/ca4TJ3Op.js +1 -0
  17. package/.output/public/_nuxt/daq.DjSgY94l.css +1 -0
  18. package/.output/public/_nuxt/{BP-NyKVG.js → eqq3wWQF.js} +1 -1
  19. package/.output/server/chunks/_/nitro.mjs +886 -673
  20. package/.output/server/chunks/_/nitro.mjs.map +1 -1
  21. package/.output/server/chunks/build/{TownView-BuLAsmpR.mjs → TownView--otsnr_7.mjs} +4 -4
  22. package/.output/server/chunks/build/{TownView-BuLAsmpR.mjs.map → TownView--otsnr_7.mjs.map} +1 -1
  23. package/.output/server/chunks/build/{_id_-BU1wfOID.mjs → _id_-Brv-JSvr.mjs} +141 -140
  24. package/.output/server/chunks/build/_id_-Brv-JSvr.mjs.map +1 -0
  25. package/.output/server/chunks/build/{_id_-BYsEua57.mjs → _id_-D9pl1OZy.mjs} +37 -36
  26. package/.output/server/chunks/build/_id_-D9pl1OZy.mjs.map +1 -0
  27. package/.output/server/chunks/build/{_wsId_-BxlDcJHg.mjs → _wsId_-BASfDf0s.mjs} +4 -4
  28. package/.output/server/chunks/build/{_wsId_-BxlDcJHg.mjs.map → _wsId_-BASfDf0s.mjs.map} +1 -1
  29. package/.output/server/chunks/build/{daq-cgyjKmJu.mjs → daq-DkMvD8sg.mjs} +93 -92
  30. package/.output/server/chunks/build/daq-DkMvD8sg.mjs.map +1 -0
  31. package/.output/server/chunks/build/{dcw-CoTxBlAZ.mjs → dcw-C1jgVJkO.mjs} +3 -3
  32. package/.output/server/chunks/build/{dcw-CoTxBlAZ.mjs.map → dcw-C1jgVJkO.mjs.map} +1 -1
  33. package/.output/server/chunks/build/{logs-BSOeZwwI.mjs → logs--dByNyhw.mjs} +2 -2
  34. package/.output/server/chunks/build/{logs-BSOeZwwI.mjs.map → logs--dByNyhw.mjs.map} +1 -1
  35. package/.output/server/chunks/build/{pages-DFxjn9Xa.mjs → pages-B0bUWnC6.mjs} +3 -3
  36. package/.output/server/chunks/build/{pages-DFxjn9Xa.mjs.map → pages-B0bUWnC6.mjs.map} +1 -1
  37. package/.output/server/chunks/build/{town-qMPql9Se.mjs → town-CoQdblbZ.mjs} +4 -4
  38. package/.output/server/chunks/build/{town-qMPql9Se.mjs.map → town-CoQdblbZ.mjs.map} +1 -1
  39. package/.output/server/chunks/build/{useDaqStream-CIBaiGAl.mjs → useDaqStream-DfCFpb3p.mjs} +9 -3
  40. package/.output/server/chunks/build/useDaqStream-DfCFpb3p.mjs.map +1 -0
  41. package/.output/server/chunks/build/{useDcwStream-CBbiYnWe.mjs → useDcwStream-UPiX-3iO.mjs} +11 -2
  42. package/.output/server/chunks/build/useDcwStream-UPiX-3iO.mjs.map +1 -0
  43. package/.output/server/chunks/routes/api/workshop/channel-templates/_id/instantiate.post.mjs +1 -1
  44. package/.output/server/chunks/routes/api/workshop/channels.post.mjs +3 -3
  45. package/.output/server/chunks/routes/api/workshop/dcw/index5.post.mjs +1 -1
  46. package/.output/server/chunks/routes/api/workshop/dcw/lines/_id/start.post.mjs +1 -1
  47. package/.output/server/chunks/routes/api/workshop/dcw/lines/_id/stop.post.mjs +1 -1
  48. package/.output/server/chunks/routes/api/workshop/dcw/params/_id_.delete.mjs +1 -1
  49. package/.output/server/chunks/routes/api/workshop/dcw/params/_id_.patch.mjs +1 -1
  50. package/.output/server/chunks/routes/api/workshop/dcw/templates/_key_.delete.mjs +1 -1
  51. package/.output/server/chunks/routes/api/workshop/dcw/templates/_key_.patch.mjs +1 -1
  52. package/.output/server/chunks/routes/api/workshop/harnesses/_id/providers.get.mjs +1 -1
  53. package/.output/server/chunks/routes/api/workshop/harnesses.get.mjs +1 -1
  54. package/.output/server/chunks/routes/api/workshop/hitl/pending.get.mjs +1 -1
  55. package/.output/server/chunks/routes/api/workshop/hitl/respond.post.mjs +1 -1
  56. package/.output/server/chunks/routes/api/workshop/index.put.mjs +1 -1
  57. package/.output/server/chunks/routes/api/workshop/index5.get.mjs +3 -1
  58. package/.output/server/chunks/routes/api/workshop/index5.get.mjs.map +1 -1
  59. package/.output/server/chunks/routes/api/workshop/index6.get.mjs +3 -1
  60. package/.output/server/chunks/routes/api/workshop/index6.get.mjs.map +1 -1
  61. package/.output/server/chunks/routes/api/workshop/plugins/_name/disable.post.mjs +1 -1
  62. package/.output/server/chunks/routes/api/workshop/plugins/_name/enable.post.mjs +1 -1
  63. package/.output/server/chunks/routes/api/workshop/scene/layouts/_channelId_.delete.mjs +1 -1
  64. package/.output/server/chunks/routes/api/workshop/scene/layouts/_channelId_.put.mjs +1 -1
  65. package/.output/server/chunks/routes/api/workshop/workspaces/_id/channel-templates/_tplId_.post.mjs +1 -1
  66. package/.output/server/chunks/routes/renderer.mjs +2 -2
  67. package/.output/server/chunks/virtual/entry.mjs +9 -9
  68. package/.output/server/chunks/virtual/precomputed.mjs +1 -1
  69. package/.output/server/package.json +1 -1
  70. package/README-zh.md +5 -5
  71. package/README.md +6 -6
  72. package/app/composables/workshop/useDaqStream.ts +7 -4
  73. package/app/composables/workshop/useDcwStream.ts +8 -2
  74. package/app/pages/daq/[id].vue +9 -5
  75. package/app/pages/daq/index.vue +13 -10
  76. package/app/pages/dcw/[id].vue +8 -4
  77. package/docs/cli.en.md +2 -2
  78. package/docs/cli.md +2 -2
  79. package/docs/plugins.en.md +33 -1
  80. package/docs/plugins.md +29 -1
  81. package/docs/sdk.en.md +1 -1
  82. package/docs/sdk.md +1 -1
  83. package/package.json +3 -2
  84. package/scripts/_loop-biax-campaign.mjs +124 -0
  85. package/scripts/test-serial-bridge.mjs +418 -0
  86. package/scripts/ui/e2e-interactive.mjs +328 -0
  87. package/scripts/ui/seed-industrial-demo.mjs +171 -0
  88. package/server/api/workshop/daq/index.get.ts +3 -1
  89. package/server/api/workshop/dcw/index.get.ts +3 -0
  90. package/server/plugins/aw-plugins.ts +9 -7
  91. package/server/plugins-builtin/serial-bridge/client.mjs +210 -0
  92. package/server/plugins-builtin/serial-bridge/drivers.mjs +409 -0
  93. package/server/plugins-builtin/serial-bridge/i18n.json +62 -0
  94. package/server/plugins-builtin/serial-bridge/index.mjs +247 -0
  95. package/server/plugins-builtin/serial-bridge/lib.mjs +208 -0
  96. package/server/plugins-builtin/serial-bridge/rtu.mjs +168 -0
  97. package/server/services/workshop/daq/daq-controller.ts +7 -2
  98. package/server/services/workshop/daq/drivers.ts +78 -3
  99. package/server/services/workshop/dcw/dcw-controller.ts +5 -1
  100. package/server/services/workshop/dcw/drivers.ts +85 -2
  101. package/server/services/workshop/dcw/plugin-bridge.ts +45 -0
  102. package/server/services/workshop/plugins/host.mjs +30 -0
  103. package/shared/daq-protocol.ts +15 -0
  104. package/.output/public/_nuxt/BOt2SP4z.js +0 -1
  105. package/.output/public/_nuxt/ClILjvWH.js +0 -1
  106. package/.output/public/_nuxt/D8qvkfyY.js +0 -1
  107. package/.output/public/_nuxt/Du9ZJtnj.js +0 -1
  108. package/.output/public/_nuxt/_id_.CM4bIgKY.css +0 -1
  109. package/.output/public/_nuxt/_id_.bNV69Dtf.css +0 -1
  110. package/.output/public/_nuxt/builds/meta/f4707884-7cf4-4a31-80d2-e3d72b73745f.json +0 -1
  111. package/.output/public/_nuxt/daq.B0D3dPAc.css +0 -1
  112. package/.output/server/chunks/build/_id_-BU1wfOID.mjs.map +0 -1
  113. package/.output/server/chunks/build/_id_-BYsEua57.mjs.map +0 -1
  114. package/.output/server/chunks/build/daq-cgyjKmJu.mjs.map +0 -1
  115. package/.output/server/chunks/build/useDaqStream-CIBaiGAl.mjs.map +0 -1
  116. package/.output/server/chunks/build/useDcwStream-CBbiYnWe.mjs.map +0 -1
@@ -0,0 +1,124 @@
1
+ /**
2
+ * 双拉任务确定性 campaign —— R11 Major-1 的实验落地:
3
+ * 固定模拟器状态(plantReset warm)+ 固定 commit + 固定配方,连续执行 N=30 次
4
+ * AgentTeam 双拉闭环任务,记录每次入带结果,给出入带率的 Wilson 95% CI。
5
+ * 预注册判据:入带 = |最终厚度 − 25.0| ≤ 0.7 µm;每次写后等待 ≥12s 运输滞后。
6
+ * 运行: AW_BASE=http://127.0.0.1:3005 NO_PROXY=127.0.0.1,localhost node scripts/_loop-biax-campaign.mjs [N]
7
+ */
8
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
9
+ import { join, resolve, dirname } from 'node:path'
10
+ import { fileURLToPath, pathToFileURL } from 'node:url'
11
+
12
+ const HERE = dirname(fileURLToPath(import.meta.url))
13
+ const REPO = resolve(HERE, '..')
14
+ const N = Number(process.argv[2] ?? 30)
15
+ const BASE = process.env.AW_BASE ?? 'http://127.0.0.1:3005'
16
+ const OUT = join(REPO, 'bench', 'results', 'biax-campaign')
17
+ const SIM = process.env.E2E_SIM_API ?? 'http://127.0.0.1:4010'
18
+ const fetchRetry = async (url, opts = {}, tries = 8) => {
19
+ let lastErr
20
+ for (let i = 1; i <= tries; i++) {
21
+ try { return await fetch(url, { ...opts, signal: AbortSignal.timeout(20000) }) }
22
+ catch (e) { lastErr = e; await new Promise(r => setTimeout(r, Math.min(500 * 2 ** (i - 1), 6000))) }
23
+ }
24
+ throw lastErr
25
+ }
26
+
27
+ mkdirSync(OUT, { recursive: true })
28
+
29
+ const BS = String.fromCharCode(92)
30
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms))
31
+
32
+ const login = await (await fetch(BASE + '/api/users/login', {
33
+ method: 'POST', headers: { 'content-type': 'application/json' },
34
+ body: JSON.stringify({ email: 'admin@awshop.local', password: 'admin123' }),
35
+ })).json()
36
+ const H = { authorization: `Bearer ${login.data.token}`, 'content-type': 'application/json' }
37
+ const api = {
38
+ call: async (method, u, b) => (await fetch(BASE + u, { method, headers: H, body: b ? JSON.stringify(b) : undefined })).json(),
39
+ }
40
+
41
+ const { ensureSimulator, simUp, simNodes, simExport, plantReset } = await import(pathToFileURL(join(REPO, 'bench', 'lib', 'sim.mjs')).href)
42
+ const { provisionBiaxLine, runBiaxMission, ensureBiaxLine } = await import(pathToFileURL(join(REPO, 'bench', 'lib', 'biax.mjs')).href)
43
+ const { makeApi } = await import(pathToFileURL(join(REPO, 'bench', 'lib', 'util.mjs')).href)
44
+
45
+ if (!(await simUp())) {
46
+ console.error('模拟器不可达:请先启动 plc-node-simulator (:4010)')
47
+ process.exit(1)
48
+ }
49
+ await ensureSimulator().catch(() => {})
50
+
51
+ const sfx = 'camp' + Date.now().toString(36).slice(-4)
52
+ // 平台夹具:channel + mock lead/worker(任务板剧本)+ opencode 绑定执行体
53
+ const ch = await api.call('POST', '/api/workshop/channels', { name: `biax-campaign-${sfx}`, leadAgent: { name: `clead-${sfx}`, harness: 'mock', config: { delayMs: 40 } } })
54
+ const channelId = ch.data?.channelId ?? ch.data?.channel?.id ?? ch.data?.id
55
+ const worker = await api.call('POST', '/api/workshop/agents', { name: `cworker-${sfx}`, harness: 'mock', config: { delayMs: 60 } })
56
+ await api.call('POST', `/api/workshop/channels/${channelId}/agents`, { agentId: worker.data?.id, role: 'worker' })
57
+ const agent = await api.call('POST', '/api/workshop/agents', { name: `cexec-${sfx}`, harness: 'opencode', config: {} })
58
+ const joinResp = await api.call('POST', `/api/workshop/channels/${channelId}/agents`, { agentId: agent.data?.id, role: 'worker' })
59
+ const instId = joinResp.data?.id ?? joinResp.data?.agentId
60
+ if (!instId) { console.error('执行体创建失败'); process.exit(1) }
61
+
62
+ const runs = []
63
+ const stateFile = join(OUT, 'campaign-state.json')
64
+ const save = () => writeFileSync(stateFile, JSON.stringify({ sfx, startedAt: new Date().toISOString(), runs }, null, 1))
65
+
66
+ console.log(`campaign start: N=${N} base=${BASE} sim=:4010`)
67
+ for (let k = 1; k <= N; k++) {
68
+ const t0 = Date.now()
69
+ // ① 固定模拟器状态(确定性根修:零随机漂移 + warm 标称)
70
+ await plantReset({ warm: true, disturbances: { heaterDecay: 1, feedDriftPerMin: 0 } })
71
+ await sleep(1500)
72
+ // ②.5 SP 归一化:崩塌任务会把 SP 写到量程外(实测 pump-sp=0 残留),起跑前统一设回
73
+ // 量程内 35% 位(确定性起点;任务只写铸速/快辊/轨宽三旋钮,其余 SP 仅作配方合法值)
74
+ {
75
+ const nodes = (await simNodes()).filter(n => n.id.startsWith('biax-'))
76
+ for (const dev of nodes) {
77
+ for (const sig of dev.signals ?? []) {
78
+ if (!/SP/i.test(sig.id) && !/SP/i.test(sig.name ?? '')) continue
79
+ const lo = sig.min ?? 0, hi = sig.max ?? 100
80
+ const v = Number(sig.value)
81
+ if (!Number.isFinite(v) || v < lo + 0.05 * (hi - lo)) {
82
+ const target = +(lo + 0.35 * (hi - lo)).toFixed(sig.decimals ?? 1)
83
+ await fetchRetry(SIM + '/api/nodes/' + dev.id + '/signals/' + encodeURIComponent(sig.id) + '/manual', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value: target }) }).catch(() => {})
84
+ }
85
+ }
86
+ }
87
+ await sleep(1200)
88
+ }
89
+
90
+ // ② 每轮独立布线(探测补建幂等;节点随 purge 回收)
91
+ const ensured = await ensureBiaxLine()
92
+ if (!ensured?.verified) throw new Error('ensureBiaxLine 失败')
93
+ const provisioned = await provisionBiaxLine(api, { sfx: sfx + k })
94
+ const line = provisioned
95
+ // ③ 绑定执行体到三个执行节点的 DCW
96
+ for (const nodeId of Object.values(line.dcw)) {
97
+ await api.call('POST', '/api/workshop/agent-tools/bindings', { agentId: instId, nodeId, kind: 'dcw', mode: 'auto' })
98
+ }
99
+ await sleep(2000)
100
+ // ④ 任务(含治理写与判定)
101
+ const mission = await runBiaxMission(api, { instId, line, simDevices: (await simNodes()).filter(n => n.id.startsWith('biax')), sfx: sfx + k, maxRounds: 6 })
102
+ if (k === 1 || !mission.attained) console.log('DIAG provErrors:', JSON.stringify(provisioned.errors ?? []), '| dcw:', Object.keys(line.dcw ?? {}).length, '| daq:', Object.keys(line.daq ?? {}).length, '| ids:', JSON.stringify(line.ids ?? {}))
103
+ const run = {
104
+ k, attained: mission.attained, thickness: mission.thickness,
105
+ writes: mission.writes, knobs: mission.distinctNodes.size, wallS: Math.round((Date.now() - t0) / 1000),
106
+ }
107
+ runs.push(run)
108
+ save()
109
+ console.log(`[${k}/${N}] attained=${run.attained} h=${run.thickness?.toFixed(2)}µm writes=${run.writes} wall=${run.wallS}s`)
110
+ // ⑤ 收尾:停线防批次泄漏
111
+ await api.call('POST', `/api/workshop/dcw/lines/${line.ids.line}/stop`, {}).catch(() => {})
112
+ await api.call('DELETE', `/api/workshop/dcw/lines/${line.ids.line}?purge=1`, {}).catch(() => {})
113
+ await sleep(2000)
114
+ }
115
+
116
+ const ok = runs.filter(r => r.attained).length
117
+ const n = runs.length
118
+ const p = ok / n
119
+ const z = 1.96
120
+ const ci = n ? [p - z * Math.sqrt((p * (1 - p)) / n), p + z * Math.sqrt((p * (1 - p)) / n)] : [0, 0]
121
+ const summary = { n, ok, rate: +p.toFixed(3), wilson95: ci.map(c => +c.toFixed(3)), runs }
122
+ writeFileSync(join(OUT, 'campaign-summary.json'), JSON.stringify(summary, null, 1))
123
+ console.log(`\n结果: ${ok}/${n} 入带,rate=${(p * 100).toFixed(1)}%,Wilson95=[${ci[0].toFixed(3)},${ci[1].toFixed(3)}]`)
124
+ console.log(`产物: ${OUT}`)
@@ -0,0 +1,418 @@
1
+ /**
2
+ * 回归测试 —— serial-bridge 串口协议插件(纯协议层 + RTU 事务客户端 + 假端口驱动契约 + 插件入口契约)。
3
+ * 运行: node scripts/test-serial-bridge.mjs
4
+ * 退出码: 0 全通过 / 1 有失败
5
+ *
6
+ * 无需真实串口硬件:RTU 客户端(rtu.mjs)以假 SerialPort(内存从站应答)驱动,
7
+ * 驱动工厂(drivers.mjs)以注入 opener 构建,ASCII 行用假行端口;插件入口用桩 ctx
8
+ * 验证注册契约与 API 路由。
9
+ */
10
+ import { Buffer } from 'node:buffer'
11
+ import {
12
+ crc16Modbus,
13
+ buildRtuFrame,
14
+ parseRtuFrame,
15
+ decodeRegisters,
16
+ encodeWords,
17
+ engToRaw,
18
+ rawToEng,
19
+ extractLineValue,
20
+ normalizeSerialConfig,
21
+ registerOffset,
22
+ } from '../server/plugins-builtin/serial-bridge/lib.mjs'
23
+ import { createSerialDrivers } from '../server/plugins-builtin/serial-bridge/drivers.mjs'
24
+ import { createRtuClient } from '../server/plugins-builtin/serial-bridge/rtu.mjs'
25
+ import serialBridge from '../server/plugins-builtin/serial-bridge/index.mjs'
26
+
27
+ let pass = 0
28
+ let fail = 0
29
+ const check = (name, ok, detail = '') => {
30
+ if (ok) {
31
+ pass++
32
+ console.log(` PASS ${name}`)
33
+ return
34
+ }
35
+ fail++
36
+ console.log(` FAIL ${name}${detail ? ' — ' + detail : ''}`)
37
+ }
38
+ const approx = (a, b, eps = 1e-4) => Math.abs(a - b) <= eps
39
+
40
+ console.log('\n━━━ 1. 纯协议层(lib.mjs) ━━━')
41
+ {
42
+ // Modbus CRC16 已知向量:01 03 00 00 00 02 → C4 0B(Modbus 官方工具可复算)
43
+ const crc = crc16Modbus(Buffer.from([0x01, 0x03, 0x00, 0x00, 0x00, 0x02]))
44
+ check('CRC16 已知向量 01 03 00 00 00 02 → 0xC40B', crc === 0x0BC4, `got 0x${crc.toString(16)}`)
45
+
46
+ const frame = buildRtuFrame(1, 3, [0x00, 0x00, 0x00, 0x02])
47
+ check('RTU 帧构造(CRC lo/hi 序)', frame.length === 8 && frame[6] === 0xC4 && frame[7] === 0x0B, frame.toString('hex'))
48
+
49
+ const parsed = parseRtuFrame(frame)
50
+ check('RTU 帧解析(addr/func/data)', parsed.addr === 1 && parsed.func === 3 && parsed.data.length === 4)
51
+ const bad = Buffer.from(frame)
52
+ bad[2] ^= 0xFF
53
+ check('RTU 帧篡改 → crc 错误', parseRtuFrame(bad).error === 'crc')
54
+ check('RTU 短帧 → short', parseRtuFrame(Buffer.from([1, 2])).error === 'short')
55
+
56
+ // 寄存器编解码:float32 123.456 全字节序 roundtrip(int16 零点用带符号验证)
57
+ for (const byteOrder of ['big', 'little', 'wordSwap']) {
58
+ const words = encodeWords(123.456, 'float32', byteOrder)
59
+ const back = decodeRegisters(words, 'float32', byteOrder)
60
+ check(`float32 ${byteOrder} 编解码互逆`, approx(words.length === 2 ? back : back, 123.456), `got ${back}`)
61
+ }
62
+ const i16 = decodeRegisters([0xFFFE], 'int16', 'big')
63
+ check('int16 带符号解码 0xFFFE → -2', i16 === -2, `got ${i16}`)
64
+ const u32 = decodeRegisters(encodeWords(3000000123, 'uint32', 'big'), 'uint32', 'big')
65
+ check('uint32 3000000123 roundtrip', u32 === 3000000123, `got ${u32}`)
66
+
67
+ // 与内置 modbus 驱动同规约:40001 → 偏移 0,30001 → 0
68
+ check('registerOffset 40001→0 / 30001→0', registerOffset(40001, 'holding') === 0 && registerOffset(30001, 'input') === 0)
69
+
70
+ // 工程量↔原始值线性映射(0~27648 ↔ 0~100)
71
+ const cfg = { rawMin: 0, rawMax: 27648, engMin: 0, engMax: 100 }
72
+ const raw = engToRaw(50, cfg)
73
+ check('engToRaw 50 → 13824', approx(raw, 13824, 0.01), `got ${raw}`)
74
+ check('rawToEng 互逆', approx(rawToEng(raw, cfg), 50, 1e-6))
75
+ check('未配置量程直传', engToRaw(7.5, {}) === 7.5)
76
+
77
+ // ASCII 行取值
78
+ check('裸数字行', extractLineValue('25.3\n', '') === 25.3)
79
+ check('JSON 无路径 → null(需配置 jsonPath,取值路径语义显式)', extractLineValue('{"temp": 21.5}', '') === null)
80
+ check('JSON jsonPath', extractLineValue('{"data":{"temp":21.5}}', 'data.temp') === 21.5)
81
+ check('JSON 数组下标路径', extractLineValue('{"arr":[1,2,9.5]}', 'arr.2') === 9.5)
82
+ check('垃圾行 → null(跳帧)', extractLineValue('ERROR OVERHEAT', '') === null)
83
+ check('空行 → null', extractLineValue(' \n', '') === null)
84
+
85
+ // 配置规范化
86
+ const n1 = normalizeSerialConfig({ path: 'COM3', register: 40001 })
87
+ check('缺省补齐(mode/baud/type/byteOrder)', n1.errors.length === 0 && n1.cfg.mode === 'modbus-rtu' && n1.cfg.baudRate === 9600 && n1.cfg.dataType === 'float32' && n1.cfg.byteOrder === 'big')
88
+ const n2 = normalizeSerialConfig({ mode: 'ascii-line' })
89
+ check('ascii-line 不要求 register,仍要求 path', n2.errors.length === 1 && /path/.test(n2.errors[0]))
90
+ const n3 = normalizeSerialConfig({ path: 'COM3', register: 1, mode: 'nope' })
91
+ check('非法 mode 报错且回退 modbus-rtu', n3.cfg.mode === 'modbus-rtu' && n3.errors.some(e => /mode/.test(e)))
92
+ const n4 = normalizeSerialConfig({ path: 'COM3', register: 40001, scale: 0 })
93
+ check('scale=0 拒绝(除零保护)', n4.cfg.scale === 1)
94
+ }
95
+
96
+ console.log('\n━━━ 2. RTU 事务客户端(rtu.mjs,假 SerialPort 从站) ━━━')
97
+ {
98
+ /** 内存 RTU 从站:以 EventEmitter 风格的假 SerialPort,写请求即回应答帧 */
99
+ function makeFakeSerialSlave({ unitId = 7, holding = new Map(), corrupt = false, silent = false, badAddr = false } = {}) {
100
+ const listeners = { data: [], error: [], close: [], open: [] }
101
+ const port = {
102
+ isOpen: false,
103
+ on(ev, fn) { listeners[ev]?.push(fn) },
104
+ once(ev, fn) { listeners[ev]?.push(fn) },
105
+ write(buf, cb) {
106
+ setImmediate(() => cb?.())
107
+ if (silent) return
108
+ const req = Buffer.from(buf)
109
+ const func = req[1]
110
+ const respAddr = badAddr ? unitId + 1 : unitId
111
+ let resp
112
+ if (func === 3 || func === 4) {
113
+ const offset = req.readUInt16BE(2)
114
+ const count = req.readUInt16BE(4)
115
+ const data = Buffer.alloc(count * 2)
116
+ for (let i = 0; i < count; i++) data.writeUInt16BE(holding.get(offset + i) ?? 0, i * 2)
117
+ resp = Buffer.concat([Buffer.from([respAddr, func, count * 2]), data])
118
+ }
119
+ else if (func === 6) {
120
+ resp = Buffer.from(req.subarray(0, 6))
121
+ }
122
+ else if (func === 16) {
123
+ resp = Buffer.from([respAddr, func, req[2], req[3], req[4], req[5]])
124
+ }
125
+ else {
126
+ resp = Buffer.from([respAddr, func | 0x80, 1])
127
+ }
128
+ if (corrupt) resp[resp.length - 1] ^= 0xFF
129
+ const c = Buffer.alloc(2)
130
+ c.writeUInt16LE(crc16Modbus(resp))
131
+ const frame = Buffer.concat([resp, c])
132
+ if (corrupt) frame[frame.length - 1] ^= 0xFF
133
+ setImmediate(() => listeners.data.forEach(fn => fn(frame)))
134
+ },
135
+ close(cb) {
136
+ port.isOpen = false
137
+ listeners.close.forEach(fn => fn())
138
+ setImmediate(() => cb?.())
139
+ },
140
+ }
141
+ // autoOpen 语义:构造即异步发 open 事件(与真实 serialport 一致)
142
+ setImmediate(() => {
143
+ port.isOpen = true
144
+ listeners.open.forEach(fn => fn())
145
+ })
146
+ return port
147
+ }
148
+
149
+ const regs = new Map([[0, 0x41D4], [1, 0x0000]]) // float32 big 26.5
150
+ /** rtu.mjs 需要的是「类」(open() 内 new SerialPort(opts));构造器返回实例对象 */
151
+ const mkClass = (opts = {}) => function FakeSerialPortClass() {
152
+ return makeFakeSerialSlave({ holding: regs, ...opts })
153
+ }
154
+ const mk = (opts = {}) => createRtuClient(mkClass(opts), { path: 'FAKE', baudRate: 9600, dataBits: 8, stopBits: 1, parity: 'none', unitId: 7, timeoutMs: 400 })
155
+
156
+ const client = mk()
157
+ await client.open()
158
+ check('RTU open 后 isOpen', client.isOpen === true)
159
+
160
+ const r1 = await client.readHoldingRegisters(0, 2)
161
+ check('RTU FC03 读保持寄存器(float32 字序)', r1.data.length === 2 && r1.data[0] === 0x41D4 && r1.data[1] === 0x0000, JSON.stringify(r1))
162
+
163
+ const w1 = await client.writeRegister(5, 0xABCD)
164
+ check('RTU FC06 写单寄存器(回显)', w1.value === 0xABCD, JSON.stringify(w1))
165
+
166
+ const w2 = await client.writeRegisters(10, [1, 2, 3])
167
+ check('RTU FC16 写多寄存器', w2.length === 3, JSON.stringify(w2))
168
+ await client.close()
169
+ check('RTU close 后 isOpen=false', client.isOpen === false)
170
+
171
+ // 异常帧:专用假从站对一切请求回 func|0x80 + code=2(带合法 CRC)
172
+ function ExceptionPortClass() {
173
+ const __l = { data: [], error: [], close: [], open: [] }
174
+ const port = {
175
+ isOpen: false,
176
+ on(ev, fn) { __l[ev].push(fn) },
177
+ once(ev, fn) { __l[ev].push(fn) },
178
+ write(buf, cb) {
179
+ setImmediate(() => cb?.())
180
+ const body = Buffer.from([7, Buffer.from(buf)[1] | 0x80, 2])
181
+ const c = Buffer.alloc(2)
182
+ c.writeUInt16LE(crc16Modbus(body))
183
+ setImmediate(() => __l.data.forEach(fn => fn(Buffer.concat([body, c]))))
184
+ },
185
+ close(cb) { setImmediate(() => cb?.()) },
186
+ }
187
+ setImmediate(() => {
188
+ port.isOpen = true
189
+ __l.open.forEach(fn => fn())
190
+ })
191
+ return port
192
+ }
193
+ const exReal = createRtuClient(ExceptionPortClass, { path: 'FAKE', baudRate: 9600, dataBits: 8, stopBits: 1, parity: 'none', unitId: 7, timeoutMs: 400 })
194
+ await exReal.open()
195
+ let exErr = null
196
+ await exReal.readHoldingRegisters(0, 1).catch(e => (exErr = e.message))
197
+ check('RTU 异常帧 → exception code 错误', exErr?.includes('exception code=2'), exErr)
198
+
199
+ const crcClient = mk({ corrupt: true })
200
+ await crcClient.open()
201
+ let crcErr = null
202
+ await crcClient.readHoldingRegisters(0, 1).catch(e => (crcErr = e.message))
203
+ check('RTU CRC 篡改 → CRC 错误', crcErr?.includes('CRC'), crcErr)
204
+
205
+ const addrClient = mk({ badAddr: true })
206
+ await addrClient.open()
207
+ let addrErr = null
208
+ await addrClient.readHoldingRegisters(0, 1).catch(e => (addrErr = e.message))
209
+ check('RTU 从站地址不匹配 → 报错', addrErr?.includes('从站地址不匹配'), addrErr)
210
+
211
+ const silentClient = mk({ silent: true })
212
+ await silentClient.open()
213
+ const t0 = Date.now()
214
+ let timeoutErr = null
215
+ await silentClient.readHoldingRegisters(0, 1).catch(e => (timeoutErr = e.message))
216
+ check('RTU 无应答 → 硬超时(250ms)', timeoutErr?.includes('超时') && Date.now() - t0 < 1500, timeoutErr)
217
+ }
218
+
219
+ console.log('\n━━━ 3. 驱动契约(假端口,免硬件) ━━━')
220
+ {
221
+ // 内存 Modbus 从站:保持寄存器阵列 + 写镜像
222
+ function makeFakeRtu() {
223
+ const holding = new Map()
224
+ const input = new Map()
225
+ const client = {
226
+ isOpen: true,
227
+ closed: false,
228
+ setID() {},
229
+ on() {},
230
+ async readHoldingRegisters(offset, length) {
231
+ const out = []
232
+ for (let i = 0; i < length; i++) out.push(holding.get(offset + i) ?? 0)
233
+ return { data: out }
234
+ },
235
+ async readInputRegisters(offset, length) {
236
+ const out = []
237
+ for (let i = 0; i < length; i++) out.push(input.get(offset + i) ?? 0)
238
+ return { data: out }
239
+ },
240
+ async writeRegister(offset, value) { holding.set(offset, value & 0xFFFF) },
241
+ async writeRegisters(offset, words) { words.forEach((w, i) => holding.set(offset + i, w & 0xFFFF)) },
242
+ async close() {
243
+ client.closed = true
244
+ client.isOpen = false
245
+ },
246
+ }
247
+ client.__holding = holding
248
+ client.__input = input
249
+ return client
250
+ }
251
+
252
+ // float32 26.5(0x41D40000)按 big 端写入保持寄存器 0
253
+ const seedWords = (v) => {
254
+ const b = Buffer.alloc(4)
255
+ b.writeFloatBE(v, 0)
256
+ return [(b[0] << 8) | b[1], (b[2] << 8) | b[3]]
257
+ }
258
+
259
+ const fake = makeFakeRtu()
260
+ const w = seedWords(26.5)
261
+ fake.__holding.set(0, w[0])
262
+ fake.__holding.set(1, w[1])
263
+
264
+ const linePort = {
265
+ aliveRet: true,
266
+ alive() { return this.aliveRet },
267
+ latestRet: { text: '25.3', at: Date.now() },
268
+ latest() { return this.latestRet },
269
+ written: [],
270
+ async writeLine(line) { this.written.push(line) },
271
+ async close() {},
272
+ }
273
+
274
+ const factory = createSerialDrivers({
275
+ openRtu: async () => fake,
276
+ openLine: async () => linePort,
277
+ isAvailable: () => true,
278
+ log: { warn: () => {} },
279
+ })
280
+
281
+ const cfgRtu = { mode: 'modbus-rtu', path: 'COM9', baudRate: 19200, register: 40001, dataType: 'float32', byteOrder: 'big', scale: 1 }
282
+ const v1 = await factory.readDriver.sample({ driverConfig: cfgRtu })
283
+ check('sample: RTU float32 读数', approx(v1, 26.5), `got ${v1}`)
284
+
285
+ const cfgRtuScaled = { ...cfgRtu, scale: 0.1 }
286
+ const v2 = await factory.readDriver.sample({ driverConfig: cfgRtuScaled })
287
+ check('sample: 缩放系数生效(26.5×0.1)', approx(v2, 2.65), `got ${v2}`)
288
+
289
+ const cfgInput = { ...cfgRtu, registerType: 'input', register: 30001 }
290
+ fake.__input.set(0, 100)
291
+ const v3 = await factory.readDriver.sample({ driverConfig: { ...cfgInput, dataType: 'uint16' } })
292
+ check('sample: 输入寄存器(3x)FC04', v3 === 100, `got ${v3}`)
293
+
294
+ const cfgAscii = { mode: 'ascii-line', path: 'COM10', baudRate: 9600 }
295
+ const v4 = await factory.readDriver.sample({ driverConfig: cfgAscii })
296
+ check('sample: ASCII 行取值', v4 === 25.3, `got ${v4}`)
297
+
298
+ linePort.latestRet = { text: '{"data":{"t":-3.5}}', at: Date.now() }
299
+ const v5 = await factory.readDriver.sample({ driverConfig: { ...cfgAscii, jsonPath: 'data.t' } })
300
+ check('sample: ASCII JSON 路径 + 负值', v5 === -3.5, `got ${v5}`)
301
+
302
+ linePort.latestRet = { text: '99', at: Date.now() - 60_000 }
303
+ const v6 = await factory.readDriver.sample({ driverConfig: { ...cfgAscii, staleMs: 30000 } })
304
+ check('sample: 保鲜窗外 → null 跳帧', v6 === null, `got ${v6}`)
305
+
306
+ // 写 + 回读校验(0~27648 ↔ 0~100 映射)
307
+ const cfgWrite = { mode: 'modbus-rtu', path: 'COM9', baudRate: 19200, register: 40001, dataType: 'uint16', byteOrder: 'big', rawMin: 0, rawMax: 27648, engMin: 0, engMax: 100 }
308
+ const wr1 = await factory.writeDriver.write({ eng: 50, tolerance: 0.5, domain: { min: 0, max: 100 }, driverConfig: cfgWrite })
309
+ check('write: 写+回读一致 ACK', wr1.ok === true && approx(wr1.readback, 50, 0.5), JSON.stringify(wr1))
310
+
311
+ // 从站写入失真(写入值 ≠ 回读值,如从站寄存器故障)→ 回读不一致 → 拒绝 ACK
312
+ const corruptRtu = makeFakeRtu()
313
+ corruptRtu.writeRegister = async function (offset, value) {
314
+ this.__holding.set(offset, (value + 1000) & 0xFFFF)
315
+ }
316
+ corruptRtu.writeRegisters = async function (offset, words) {
317
+ words.forEach((w, i) => this.__holding.set(offset + i, (w + 1000) & 0xFFFF))
318
+ }
319
+ const corruptFactory = createSerialDrivers({
320
+ openRtu: async () => corruptRtu,
321
+ openLine: async () => linePort,
322
+ isAvailable: () => true,
323
+ log: { warn: () => {} },
324
+ })
325
+ const wr2 = await corruptFactory.writeDriver.write({ eng: 50, tolerance: 0.5, domain: { min: 0, max: 100 }, driverConfig: cfgWrite })
326
+ check('write: 回读不一致 → 拒绝', wr2.ok === false && /不一致/.test(wr2.message), JSON.stringify(wr2))
327
+ await corruptFactory.closeAll()
328
+
329
+ // ASCII 写模板下行
330
+ const wr3 = await factory.writeDriver.write({ eng: 42.5, tolerance: 0.5, domain: { min: 0, max: 100 }, driverConfig: { mode: 'ascii-line', path: 'COM10', writeTemplate: 'SET {v}' } })
331
+ check('write: ASCII 模板下行(无回读诚实标注)', wr3.ok === true && linePort.written[0] === 'SET 42.5' && wr3.readback === null, JSON.stringify({ wr3, written: linePort.written }))
332
+
333
+ // 连接测试
334
+ const t1 = await factory.readDriver.test(cfgRtu)
335
+ check('test: 通过并带回读数', t1.ok === true && t1.sampleValue != null, JSON.stringify(t1))
336
+ const t2 = await factory.readDriver.test({ mode: 'modbus-rtu', path: '' })
337
+ check('test: 缺 path 给可读错误', t2.ok === false && /path/.test(t2.message))
338
+ const t3 = await factory.writeDriver.test(cfgWrite)
339
+ check('test: 写驱动测试不触发真实写', t3.ok === true, JSON.stringify(t3))
340
+
341
+ // meta 自描述(前端动态表单数据源)
342
+ check('读驱动 meta.configFields 非空', Array.isArray(factory.readDriver.meta?.configFields) && factory.readDriver.meta.configFields.length >= 10)
343
+ check('写驱动 meta.configFields 含量程映射', factory.writeDriver.meta.configFields.some(f => f.key === 'rawMax'))
344
+
345
+ // 串行队列:前一事务阻塞时后续排队(契约:同一物理链路不并发)
346
+ let busy = 0
347
+ let maxBusy = 0
348
+ const slowFactory = createSerialDrivers({
349
+ openRtu: async () => ({
350
+ isOpen: true,
351
+ setID() {},
352
+ on() {},
353
+ async readHoldingRegisters() {
354
+ busy++
355
+ maxBusy = Math.max(maxBusy, busy)
356
+ await new Promise(r => setTimeout(r, 20))
357
+ busy--
358
+ return { data: [1, 2] }
359
+ },
360
+ async close() {},
361
+ }),
362
+ openLine: async () => linePort,
363
+ isAvailable: () => true,
364
+ log: { warn: () => {} },
365
+ })
366
+ const cfgSlow = { mode: 'modbus-rtu', path: 'COMX', register: 40001 }
367
+ await Promise.all([
368
+ slowFactory.readDriver.sample({ driverConfig: cfgSlow }),
369
+ slowFactory.readDriver.sample({ driverConfig: cfgSlow }),
370
+ slowFactory.readDriver.sample({ driverConfig: cfgSlow }),
371
+ ])
372
+ check('同链路事务串行化(无并发读)', maxBusy === 1, `maxBusy=${maxBusy}`)
373
+
374
+ await factory.closeAll()
375
+ check('closeAll 关闭串口连接', fake.closed === true)
376
+ }
377
+
378
+ console.log('\n━━━ 3. 插件入口契约(index.mjs + 桩 ctx) ━━━')
379
+ {
380
+ const registered = { daqDrivers: [], dcwDrivers: [], routes: new Map(), tools: [], disposed: [] }
381
+ const ctx = {
382
+ name: 'serial-bridge',
383
+ logger: { info: () => {}, warn: m => console.warn(' [log]', m), error: () => {} },
384
+ daq: {
385
+ registerDriver: d => registered.daqDrivers.push(d),
386
+ nodes: async () => [{ id: 'n1', name: '炉温', driver: 'serial', enabled: true, lineId: 'L1', driverConfig: { path: 'COM3' } }],
387
+ },
388
+ dcw: { registerWriteDriver: d => registered.dcwDrivers.push(d) },
389
+ route: (method, path, handler) => registered.routes.set(`${method} ${path}`, handler),
390
+ omp: { registerTool: t => registered.tools.push(t) },
391
+ onDispose: fn => registered.disposed.push(fn),
392
+ http: {},
393
+ }
394
+ await serialBridge.setup(ctx)
395
+
396
+ check('数采读驱动已注册(kind=serial)', registered.daqDrivers.length === 1 && registered.daqDrivers[0].kind === 'serial')
397
+ check('写控驱动已注册(kind=serial)', registered.dcwDrivers.length === 1 && registered.dcwDrivers[0].kind === 'serial')
398
+ check('驱动 available 探测可用(serialport 已安装)', await registered.daqDrivers[0].available() === true)
399
+ check('API 路由已注册(health/ports/probe)', ['GET /health', 'GET /ports', 'POST /probe'].every(k => registered.routes.has(k)))
400
+ check('omp 工具已注册(serial_ports)', registered.tools.length === 1 && registered.tools[0].name === 'serial_ports')
401
+ check('dispose 已登记(串口回收)', registered.disposed.length >= 1)
402
+
403
+ const health = await registered.routes.get('GET /health')()
404
+ check('GET /health:可用性 + 串口节点识别', health.available === true && health.readNodes.length === 1 && health.readNodes[0].path === 'COM3', JSON.stringify(health))
405
+ const ports = await registered.routes.get('GET /ports')()
406
+ check('GET /ports:系统串口枚举(免硬件)', ports.success === true && Array.isArray(ports.ports), JSON.stringify(ports).slice(0, 120))
407
+ const probe1 = await registered.routes.get('POST /probe')({ awBody: { mode: 'modbus-rtu' } })
408
+ check('POST /probe:缺参数给可读错误', probe1.success === false && /path/i.test(probe1.error))
409
+ const probe2 = await registered.routes.get('POST /probe')({ awBody: { mode: 'ascii-line', path: 'COM_NOT_EXIST_42', timeoutMs: 800 } })
410
+ check('POST /probe:不存在的串口给可行动诊断', probe2.success === true && probe2.ok === false && /串口/.test(probe2.message), JSON.stringify(probe2))
411
+
412
+ const tool = registered.tools[0]
413
+ const r = await tool.handler({})
414
+ check('serial_ports 工具返回诊断文本', r.text.includes('可用性') && r.text.includes('炉温'), r.text)
415
+ }
416
+
417
+ console.log(`\n━━━ 结果: ${pass} 通过 / ${fail} 失败 ━━━`)
418
+ process.exit(fail ? 1 : 0)