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,247 @@
1
+ /**
2
+ * serial-bridge —— 串口通信协议插件(内置;随服务启动期装载)。
3
+ *
4
+ * 能力:
5
+ * 1. 数采读驱动(kind='serial'):Modbus RTU(RS-485/232,FC03/FC04)与 ASCII 行协议
6
+ * (每行一个数值或 JSON)两种模式,经 ctx.daq.registerDriver 注入 —— 前端数采中心
7
+ * 「添加节点」即出现「串口 Serial」协议与动态参数表单;
8
+ * 2. 写控驱动(kind='serial'):Modbus RTU 写保持寄存器(FC06/FC16)+ 同址回读校验,
9
+ * ASCII 行模板下行(无回读),经 ctx.dcw.registerWriteDriver 注入;
10
+ * 3. 后端 API(/api/plugins/serial-bridge/*):health(链路健康)/ ports(系统串口枚举)/
11
+ * probe(无落库连接探针,支持 ASCII 发行探测);
12
+ * 4. Agent 工具 serial_ports:让 Agent 能枚举本机串口并诊断链路;
13
+ * 5. 前端面板(plugins.page 插槽):串口包可用性、系统端口表、探针表单、链路状态。
14
+ *
15
+ * 运行时依赖:serialport(optionalDependencies;缺失时 available=false 如实降级,
16
+ * 主服务与其余插件不受影响)。RTU 帧机为插件自研(rtu.mjs,单 serialport 原生栈)——
17
+ * 不用 modbus-serial:其包装层与 nitro dev worker isolate 组合会触发 V8 HandleScope
18
+ * 原生崩溃(实测稳定复现),纯 serialport 打开同口正常。
19
+ * 插件契约见 docs/plugins.md;驱动工厂见 drivers.mjs(可注入 opener,单测免硬件)。
20
+ */
21
+ import { createRequire } from 'node:module'
22
+ import { createSerialDrivers, SERIAL_READ_FIELDS } from './drivers.mjs'
23
+ import { classifySerialError, normalizeSerialConfig } from './lib.mjs'
24
+ import { createRtuClient } from './rtu.mjs'
25
+
26
+ const require = createRequire(import.meta.url)
27
+
28
+ /** serialport 可用性探测(包缺失/原生二进制不匹配 → null,驱动如实「未安装」) */
29
+ function loadSerialPort() {
30
+ try {
31
+ const mod = require('serialport')
32
+ const SerialPort = mod.SerialPort ?? mod.default?.SerialPort
33
+ return SerialPort ? { SerialPort } : null
34
+ }
35
+ catch {
36
+ return null
37
+ }
38
+ }
39
+
40
+ /** 带硬超时的等待(建连/开串口防悬挂) */
41
+ function withTimeout(p, ms, message) {
42
+ return new Promise((resolve, reject) => {
43
+ const t = setTimeout(() => reject(new Error(message)), ms)
44
+ Promise.resolve(p).then(
45
+ (v) => {
46
+ clearTimeout(t)
47
+ resolve(v)
48
+ },
49
+ (e) => {
50
+ clearTimeout(t)
51
+ reject(e)
52
+ },
53
+ )
54
+ })
55
+ }
56
+
57
+ export default {
58
+ name: 'serial-bridge',
59
+ version: '1.0.0',
60
+ description: '串口通信协议桥:Modbus RTU(RS-485/232)与 ASCII 行协议读驱动、带回读校验的写驱动、串口枚举/探针 API 与前端面板。',
61
+ auth: 'user',
62
+ client: './client.mjs',
63
+ setup(ctx) {
64
+ // ---- 运行时依赖探测(缺失不阻断装载,驱动 available=false 如实降级) ----
65
+ const sp = loadSerialPort()
66
+ if (!sp) ctx.logger.warn('serialport 包不可用(可选依赖未安装或原生二进制不匹配):串口驱动将显示「未安装」。npm i serialport 后重启即恢复。')
67
+
68
+ // ---- 协议栈实现(注入驱动工厂) ----
69
+
70
+ /** Modbus RTU over 串口:自研事务客户端(单 serialport 原生栈;见 rtu.mjs 头注) */
71
+ async function openRtu(cfg) {
72
+ if (!sp) throw new Error('serialport 包不可用(npm i serialport 后重启)')
73
+ const client = createRtuClient(sp.SerialPort, cfg)
74
+ await client.open()
75
+ return client
76
+ }
77
+
78
+ /** ASCII 行协议:serialport + 行分割;缓存最新行(保鲜窗在驱动层判定) */
79
+ async function openLine(cfg) {
80
+ if (!sp) throw new Error('serialport 包不可用(npm i serialport 后重启)')
81
+ const port = new sp.SerialPort({
82
+ path: cfg.path,
83
+ baudRate: cfg.baudRate,
84
+ dataBits: cfg.dataBits,
85
+ stopBits: cfg.stopBits,
86
+ parity: cfg.parity,
87
+ autoOpen: true,
88
+ })
89
+ await withTimeout(
90
+ new Promise((resolve, reject) => {
91
+ port.once('open', resolve)
92
+ port.once('error', reject)
93
+ }),
94
+ cfg.timeoutMs,
95
+ `串口打开超时(${cfg.timeoutMs}ms): ${cfg.path}@${cfg.baudRate}`,
96
+ )
97
+ const state = { latest: { text: '', at: 0 }, alive: true }
98
+ // 行分割:优先 readline 解析器;不可用时按 buffer 手工切行(等价语义)
99
+ let buffer = ''
100
+ const feed = (chunk) => {
101
+ buffer += chunk.toString('utf8')
102
+ let i
103
+ while ((i = buffer.indexOf('\n')) >= 0) {
104
+ const line = buffer.slice(0, i).replace(/\r$/, '')
105
+ buffer = buffer.slice(i + 1)
106
+ if (line.trim()) state.latest = { text: line, at: Date.now() }
107
+ }
108
+ }
109
+ port.on('data', feed)
110
+ const markDead = () => void (state.alive = false)
111
+ port.on('error', markDead)
112
+ port.on('close', markDead)
113
+ return {
114
+ alive: () => state.alive,
115
+ latest: () => state.latest,
116
+ writeLine: line => new Promise((resolve, reject) => {
117
+ port.write(`${line.endsWith('\n') ? line : `${line}\n`}`, (err) => {
118
+ if (err) reject(err)
119
+ else resolve()
120
+ })
121
+ }),
122
+ close: () => new Promise((resolve) => {
123
+ if (!port.isOpen) return resolve()
124
+ port.close(() => resolve())
125
+ }),
126
+ }
127
+ }
128
+
129
+ const factory = createSerialDrivers({
130
+ openRtu,
131
+ openLine,
132
+ isAvailable: () => Boolean(sp),
133
+ log: ctx.logger,
134
+ })
135
+
136
+ // ---- 驱动注入(数采读 + 写控;插件系统核心链路) ----
137
+ ctx.daq.registerDriver(factory.readDriver)
138
+ ctx.dcw.registerWriteDriver(factory.writeDriver)
139
+ ctx.onDispose(() => factory.closeAll())
140
+
141
+ // ---- 后端 API(/api/plugins/serial-bridge/*) ----
142
+
143
+ // 系统串口枚举(路径/厂商/序列号/友好名;供前端探针表单与运维排障)
144
+ ctx.route('GET', '/ports', async () => {
145
+ if (!sp) return { success: false, error: 'serialport 包不可用(npm i serialport 后重启)', ports: [] }
146
+ try {
147
+ const list = await sp.SerialPort.list()
148
+ return {
149
+ success: true,
150
+ ports: list.map(p => ({
151
+ path: p.path,
152
+ manufacturer: p.manufacturer ?? null,
153
+ serialNumber: p.serialNumber ?? null,
154
+ vendorId: p.vendorId ?? null,
155
+ productId: p.productId ?? null,
156
+ friendlyName: p.friendlyName ?? p.pnpId ?? null,
157
+ })),
158
+ }
159
+ }
160
+ catch (err) {
161
+ return { success: false, error: classifySerialError(err), ports: [] }
162
+ }
163
+ })
164
+
165
+ // 链路健康(协议栈可用性 + 打开的串口连接池快照;路由与 Agent 工具共用)
166
+ async function healthPayload() {
167
+ const nodes = await ctx.daq.nodes().catch(() => [])
168
+ const serialNodes = (Array.isArray(nodes) ? nodes : []).filter(n => n?.driver === 'serial')
169
+ return {
170
+ plugin: ctx.name,
171
+ available: Boolean(sp),
172
+ packages: { serialport: Boolean(sp) },
173
+ links: factory.poolSnapshot(),
174
+ /** 使用串口驱动的数采节点(读侧;写控节点见 dcw) */
175
+ readNodes: serialNodes.map(n => ({ id: n.id, name: n.name, lineId: n.lineId ?? null, path: n.driverConfig?.path ?? null, enabled: n.enabled !== false })),
176
+ }
177
+ }
178
+
179
+ ctx.route('GET', '/health', () => healthPayload())
180
+
181
+ // 连接探针(不落库):按给定参数开串口读一次;ascii 模式可选先行下发探测行。
182
+ // 复用驱动工厂的连接池与超时语义 —— probe 通过 = 该参数创建节点后必可采。
183
+ ctx.route('POST', '/probe', async (event) => {
184
+ const b = event?.awBody ?? {}
185
+ const driverConfig = {
186
+ mode: b.mode,
187
+ path: b.path,
188
+ baudRate: b.baudRate,
189
+ parity: b.parity,
190
+ dataBits: b.dataBits,
191
+ stopBits: b.stopBits,
192
+ unitId: b.unitId,
193
+ register: b.register,
194
+ registerType: b.registerType,
195
+ dataType: b.dataType,
196
+ byteOrder: b.byteOrder,
197
+ jsonPath: b.jsonPath,
198
+ timeoutMs: b.timeoutMs,
199
+ }
200
+ const { cfg, errors } = normalizeSerialConfig(driverConfig)
201
+ if (errors.length) return { success: false, error: errors.join(';') }
202
+ const t0 = Date.now()
203
+ try {
204
+ // ascii 探测行:先发一条(如「?」问询帧),给主动上报型设备一个触发
205
+ if (cfg.mode === 'ascii-line' && b.sendLine) {
206
+ const conn = await factory.getConn(cfg)
207
+ await conn.line.writeLine(String(b.sendLine))
208
+ }
209
+ const r = await factory.readDriver.test(driverConfig)
210
+ return { success: true, ...r, latencyMs: r.latencyMs ?? Date.now() - t0 }
211
+ }
212
+ catch (err) {
213
+ return { success: false, ok: false, message: classifySerialError(err), latencyMs: Date.now() - t0 }
214
+ }
215
+ })
216
+
217
+ // ---- Agent 工具:串口枚举 + 链路诊断 ----
218
+ ctx.omp.registerTool({
219
+ name: 'serial_ports',
220
+ label: '串口诊断',
221
+ description: '枚举本机串口端口(路径/厂商)与 serial-bridge 插件的串口链路健康(打开的连接/错误计数/串口数采节点)。串口设备接入与排障时使用。',
222
+ parameters: { type: 'object', properties: {} },
223
+ roles: ['lead', 'worker'],
224
+ handler: async () => {
225
+ try {
226
+ const d = await healthPayload()
227
+ const lines = [
228
+ `可用性: ${d.available === true ? 'serialport 就绪(RTU 帧机内建)' : `不可用(${JSON.stringify(d.packages ?? {})})`}`,
229
+ `串口数采节点: ${(d.readNodes ?? []).length} 个${(d.readNodes ?? []).length ? `(${d.readNodes.map(n => `${n.name || n.id}@${n.path || '?'}${n.enabled ? '' : '[停用]'}`).join(', ')})` : ''}`,
230
+ `打开的链路: ${(d.links ?? []).length} 条`,
231
+ ]
232
+ for (const l of d.links ?? []) lines.push(` - ${l.key}(pending=${l.pending}, errors=${l.errors}, 最近使用 ${l.lastUsedAt ? new Date(l.lastUsedAt).toISOString() : '-'})`)
233
+ if (sp) {
234
+ const ports = await sp.SerialPort.list()
235
+ lines.push(`系统串口: ${ports.length ? ports.map(p => p.path).join(', ') : '(无)'}`)
236
+ }
237
+ return { text: lines.join('\n') }
238
+ }
239
+ catch (err) {
240
+ return { text: `serial_ports 异常: ${err?.message ?? err}`, isError: true }
241
+ }
242
+ },
243
+ })
244
+
245
+ ctx.logger.info(`serial-bridge 就绪:驱动 serial(读 ${SERIAL_READ_FIELDS.length} 参数)+ 写控 serial + 路由 health/ports/probe + 工具 serial_ports;serialport ${sp ? '可用' : '未安装(驱动显示未安装)'}`)
246
+ },
247
+ }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * serial-bridge —— 纯协议层(零 I/O,可独立单测)。
3
+ *
4
+ * 串口链路上的两种报文语义:
5
+ * - modbus-rtu:Modbus RTU over RS-485/232(CRC16 帧;寄存器读 FC03/FC04,写 FC06/FC16);
6
+ * 帧的收发与超时由 modbus-serial 承担,本层提供 CRC/编解码与寄存器↔工程量换算,
7
+ * 与内置 modbus-tcp / dcw 写驱动逐字节兼容(big/little/wordSwap 同一字节序约定)。
8
+ * - ascii-line:设备每行输出一个数值(裸数字或 JSON,如 `{"temp":25.3}`);按 jsonPath 取值。
9
+ *
10
+ * 本文件不 import 任何宿主模块 —— 测试脚本直接以 ESM 引入即可。
11
+ */
12
+
13
+ // ============================================================
14
+ // 配置规范化(driverConfig → 强类型连接参数)
15
+ // ============================================================
16
+
17
+ const MODES = new Set(['modbus-rtu', 'ascii-line'])
18
+ const PARITIES = new Set(['none', 'even', 'odd'])
19
+ const DATA_TYPES = new Set(['int16', 'uint16', 'int32', 'uint32', 'float32'])
20
+ const BYTE_ORDERS = new Set(['big', 'little', 'wordSwap'])
21
+ export const WORDS_OF = { int16: 1, uint16: 1, int32: 2, uint32: 2, float32: 2 }
22
+
23
+ /**
24
+ * 规范化并校验串口驱动配置;返回 { cfg, errors }。
25
+ * errors 非空时 cfg 仍给出(缺省补齐),调用方决定是否以 errors 拒绝。
26
+ */
27
+ export function normalizeSerialConfig(raw = {}) {
28
+ const errors = []
29
+ const s = v => String(v ?? '').trim()
30
+ const cfg = {
31
+ mode: MODES.has(s(raw.mode)) ? s(raw.mode) : 'modbus-rtu',
32
+ path: s(raw.path),
33
+ baudRate: Number(raw.baudRate) > 0 ? Number(raw.baudRate) : 9600,
34
+ dataBits: [7, 8].includes(Number(raw.dataBits)) ? Number(raw.dataBits) : 8,
35
+ stopBits: [1, 2].includes(Number(raw.stopBits)) ? Number(raw.stopBits) : 1,
36
+ parity: PARITIES.has(s(raw.parity)) ? s(raw.parity) : 'none',
37
+ unitId: Number(raw.unitId) > 0 ? Math.min(247, Math.floor(Number(raw.unitId))) : 1,
38
+ register: Number.isFinite(Number(raw.register)) ? Number(raw.register) : 0,
39
+ registerType: s(raw.registerType) === 'input' ? 'input' : 'holding',
40
+ dataType: DATA_TYPES.has(s(raw.dataType)) ? s(raw.dataType) : 'float32',
41
+ byteOrder: BYTE_ORDERS.has(s(raw.byteOrder)) ? s(raw.byteOrder) : 'big',
42
+ scale: Number.isFinite(Number(raw.scale)) && Number(raw.scale) !== 0 ? Number(raw.scale) : 1,
43
+ jsonPath: s(raw.jsonPath),
44
+ /** ascii-line 写模板({v} 替换为数值;行尾 \n 自动补) */
45
+ writeTemplate: s(raw.writeTemplate),
46
+ /** ascii-line 数据保鲜窗(超过视为停发,采样返回 null 跳帧,不重复消费陈旧值) */
47
+ staleMs: Number(raw.staleMs) > 0 ? Number(raw.staleMs) : 30_000,
48
+ /** 单次串口事务超时(建连/响应共用;半开链路防悬挂) */
49
+ timeoutMs: Number(raw.timeoutMs) > 0 ? Math.min(10_000, Number(raw.timeoutMs)) : 3000,
50
+ }
51
+ if (!cfg.path) errors.push('缺少串口路径 path(Windows 如 COM3,Linux 如 /dev/ttyUSB0)')
52
+ if (!MODES.has(s(raw.mode)) && s(raw.mode)) errors.push(`mode 非法: ${s(raw.mode)}(仅 modbus-rtu / ascii-line)`)
53
+ if (cfg.mode === 'modbus-rtu' && raw.register == null) errors.push('modbus-rtu 模式缺少寄存器地址 register(如 40001)')
54
+ return { cfg, errors }
55
+ }
56
+
57
+ /** 4xxxx 保持寄存器 → 协议偏移(40001 → 0);3xxxx 输入寄存器同理(与内置 modbus 驱动同规约) */
58
+ export function registerOffset(addr, area) {
59
+ if (area === 'input') return addr >= 30001 ? addr - 30001 : addr
60
+ return addr >= 40001 ? addr - 40001 : addr
61
+ }
62
+
63
+ // ============================================================
64
+ // Modbus RTU 帧(CRC16;诊断/探针与单测用 —— 常规收发走 modbus-serial)
65
+ // ============================================================
66
+
67
+ /** Modbus CRC16(多项式 0xA001 反射;返回 16 位无符号) */
68
+ export function crc16Modbus(buf) {
69
+ let crc = 0xFFFF
70
+ for (const byte of buf) {
71
+ crc ^= byte & 0xFF
72
+ for (let i = 0; i < 8; i++) {
73
+ if (crc & 1) crc = (crc >> 1) ^ 0xA001
74
+ else crc >>= 1
75
+ }
76
+ }
77
+ return crc & 0xFFFF
78
+ }
79
+
80
+ /** 构造 RTU 请求帧:addr + func + payload + CRC(lo hi) */
81
+ export function buildRtuFrame(addr, func, payload = []) {
82
+ const body = Buffer.from([addr & 0xFF, func & 0xFF, ...payload])
83
+ const crc = crc16Modbus(body)
84
+ return Buffer.concat([body, Buffer.from([crc & 0xFF, (crc >> 8) & 0xFF])])
85
+ }
86
+
87
+ /**
88
+ * 解析 RTU 响应帧(不含地址校验——单从站总线由调用方关注):
89
+ * 返回 { addr, func, data:Buffer } | { error:'crc'|'short' }
90
+ */
91
+ export function parseRtuFrame(buf) {
92
+ if (!buf || buf.length < 4) return { error: 'short' }
93
+ const crc = buf.readUInt16LE(buf.length - 2)
94
+ if (crc !== crc16Modbus(buf.subarray(0, buf.length - 2))) return { error: 'crc' }
95
+ return { addr: buf[0], func: buf[1] & 0x7F, data: buf.subarray(2, buf.length - 2) }
96
+ }
97
+
98
+ // ============================================================
99
+ // 寄存器字 ↔ 数值(与内置数采 decodeRegisters / 写控 encodeWords 逐字节一致)
100
+ // ============================================================
101
+
102
+ const hi = x => (x >> 8) & 0xFF
103
+ const lo = x => x & 0xFF
104
+
105
+ /** 寄存器字序 → 数值(1~2 word;big=ABCD / little=DCBA / wordSwap=CDAB) */
106
+ export function decodeRegisters(data, dataType, byteOrder) {
107
+ if (dataType === 'int16' || dataType === 'uint16') {
108
+ const raw = data[0] ?? 0
109
+ return dataType === 'int16' ? (raw << 16) >> 16 : raw
110
+ }
111
+ let b
112
+ if (byteOrder === 'little') b = [hi(data[1]), lo(data[1]), hi(data[0]), lo(data[0])].reverse()
113
+ else if (byteOrder === 'wordSwap') b = [hi(data[1]), lo(data[1]), hi(data[0]), lo(data[0])]
114
+ else b = [hi(data[0]), lo(data[0]), hi(data[1]), lo(data[1])]
115
+ const buf = Buffer.from(b)
116
+ if (dataType === 'float32') return buf.readFloatBE(0)
117
+ if (dataType === 'uint32') return buf.readUInt32BE(0)
118
+ return buf.readInt32BE(0)
119
+ }
120
+
121
+ /** 数值 → 寄存器字序(decodeRegisters 的逆;写驱动 FC06/FC16 用) */
122
+ export function encodeWords(raw, dataType, byteOrder) {
123
+ if (dataType === 'int16' || dataType === 'uint16') return [raw & 0xFFFF]
124
+ const buf = Buffer.alloc(4)
125
+ if (dataType === 'float32') buf.writeFloatBE(raw, 0)
126
+ else if (dataType === 'uint32') buf.writeUInt32BE(raw, 0)
127
+ else buf.writeInt32BE(raw, 0)
128
+ const B = [buf[0], buf[1], buf[2], buf[3]]
129
+ const word = (a, b) => ((a & 0xFF) << 8) | (b & 0xFF)
130
+ if (byteOrder === 'little') return [word(B[1], B[0]), word(B[3], B[2])]
131
+ if (byteOrder === 'wordSwap') return [word(B[2], B[3]), word(B[0], B[1])]
132
+ return [word(B[0], B[1]), word(B[2], B[3])]
133
+ }
134
+
135
+ // ============================================================
136
+ // 工程量 ↔ 原始值(写控线性映射;与 dcw/drivers.ts 同语义)
137
+ // ============================================================
138
+
139
+ const num = (v) => {
140
+ const n = Number(v)
141
+ return v !== '' && v != null && Number.isFinite(n) ? n : undefined
142
+ }
143
+
144
+ /** 工程量 → 原始值(未提供 rawMin/rawMax 时直传) */
145
+ export function engToRaw(eng, cfg, domain = { min: 0, max: 100 }) {
146
+ const rawMin = num(cfg.rawMin)
147
+ const rawMax = num(cfg.rawMax)
148
+ if (rawMin === undefined || rawMax === undefined || rawMax === rawMin) return eng
149
+ const engMin = num(cfg.engMin) ?? domain.min
150
+ const engMax = num(cfg.engMax) ?? domain.max
151
+ if (engMax === engMin) return rawMin
152
+ return rawMin + ((eng - engMin) / (engMax - engMin)) * (rawMax - rawMin)
153
+ }
154
+
155
+ /** 原始值 → 工程量(engToRaw 的逆;回读换算用) */
156
+ export function rawToEng(raw, cfg, domain = { min: 0, max: 100 }) {
157
+ const rawMin = num(cfg.rawMin)
158
+ const rawMax = num(cfg.rawMax)
159
+ if (rawMin === undefined || rawMax === undefined || rawMax === rawMin) return raw
160
+ const engMin = num(cfg.engMin) ?? domain.min
161
+ const engMax = num(cfg.engMax) ?? domain.max
162
+ if (rawMax === rawMin) return engMin
163
+ return engMin + ((raw - rawMin) / (rawMax - rawMin)) * (engMax - engMin)
164
+ }
165
+
166
+ // ============================================================
167
+ // ASCII 行取值(裸数字直取;JSON 按 jsonPath 点分路径,含数组下标)
168
+ // ============================================================
169
+
170
+ /** 从一行报文提取数值;无法提取返回 null(不抛,采样循环靠 null 跳帧) */
171
+ export function extractLineValue(text, jsonPath) {
172
+ const trimmed = String(text ?? '').trim()
173
+ if (!trimmed) return null
174
+ const direct = Number(trimmed)
175
+ if (Number.isFinite(direct)) return direct
176
+ let parsed
177
+ try {
178
+ parsed = JSON.parse(trimmed)
179
+ }
180
+ catch {
181
+ return null
182
+ }
183
+ const target = jsonPath
184
+ ? String(jsonPath).split('.').reduce((acc, k) => (acc == null ? undefined : acc[k]), parsed)
185
+ : parsed
186
+ const n = typeof target === 'number' ? target : Number(target)
187
+ return Number.isFinite(n) ? n : null
188
+ }
189
+
190
+ // ============================================================
191
+ // 通信故障分类(与内置 classifyCommError 同风格;串口特化)
192
+ // ============================================================
193
+
194
+ export function classifySerialError(err) {
195
+ const raw = err instanceof Error ? err.message : String(err)
196
+ const m = raw.toLowerCase()
197
+ if (/no such file|device not found|file not found|access denied|拒绝访问|找不到/.test(m))
198
+ return `串口不存在或被占用——检查设备插入/驱动与路径拼写,关闭占用该口的程序(${raw})`
199
+ if (/timeout|timed out/.test(m))
200
+ return `串口响应超时——检查波特率/校验位/从站地址与接线(${raw})`
201
+ if (/crc/.test(m))
202
+ return `CRC 校验失败——线路干扰或波特率/校验位不匹配(${raw})`
203
+ if (/illegal|exception|function code/.test(m))
204
+ return `设备拒绝该操作——功能码或寄存器地址不被支持(${raw})`
205
+ if (/port is not open|closing|closed/.test(m))
206
+ return `串口已断开——将自动重建,若持续失败请检查线缆(${raw})`
207
+ return raw
208
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * serial-bridge —— Modbus RTU 事务客户端(直接架在 serialport 之上)。
3
+ *
4
+ * 为什么不用 modbus-serial 的 connectRTUBuffered:其包装层与 nitro dev 的 worker
5
+ * isolate 组合下触发 V8 HandleScope 原生崩溃(实测稳定复现,纯 serialport 打开同口
6
+ * 正常)—— 详见 docs/plugins.md「已知边界」。本类用 lib.mjs 的 CRC16/帧编解码自行
7
+ * 实现 FC03/FC04/FC06/FC16:连接级队列(drivers.mjs withConn)保证同链路同时只有
8
+ * 一个事务,故「请求 → 等响应帧」的匹配无需复杂多路复用。
9
+ *
10
+ * 帧形态(RTU):
11
+ * 请求 [addr, func, ...payload, crcLo, crcHi]
12
+ * 读响应 addr, func, byteCount, data…, crc(8+2×words 字节)
13
+ * 写响应 FC06 回显 8 字节;FC16 回 [addr, func, offsetHi, offsetLo, countHi, countLo, crc…]
14
+ * 异常 addr, func|0x80, exceptionCode, crc(5 字节)
15
+ */
16
+ import { buildRtuFrame, crc16Modbus } from './lib.mjs'
17
+
18
+ const hi8 = x => (x >> 8) & 0xFF
19
+ const lo8 = x => x & 0xFF
20
+
21
+ /**
22
+ * @param {Function} SerialPort serialport 构造器(注入,便于测试)
23
+ * @param {{ path, baudRate, dataBits, stopBits, parity, unitId, timeoutMs }} cfg
24
+ */
25
+ export function createRtuClient(SerialPort, cfg) {
26
+ let port = null
27
+ let buffer = Buffer.alloc(0)
28
+ /** 当前在飞事务的收帧器(连接级队列保证至多一个) */
29
+ let waiter = null
30
+ let dead = false
31
+
32
+ /** 响应帧期望总长(-1 = 还差 byteCount 字节没法判) */
33
+ function frameLength(func) {
34
+ if ((func & 0x7F) === 3 || (func & 0x7F) === 4) return -1
35
+ if ((func & 0x7F) === 6 || (func & 0x7F) === 16) return 8
36
+ return 0
37
+ }
38
+
39
+ function feed(chunk) {
40
+ buffer = Buffer.concat([buffer, chunk])
41
+ if (!waiter) {
42
+ buffer = Buffer.alloc(0)
43
+ return
44
+ }
45
+ const min = 4
46
+ if (buffer.length < min) return
47
+ const func = buffer[1]
48
+ let need
49
+ if (func & 0x80) need = 5
50
+ else {
51
+ const fixed = frameLength(func)
52
+ need = fixed >= 0 ? fixed : (buffer.length >= 3 ? 5 + buffer[2] : -1)
53
+ }
54
+ if (need < 0 || buffer.length < need) return
55
+ const frame = buffer.subarray(0, need)
56
+ buffer = Buffer.from(buffer.subarray(need))
57
+ const w = waiter
58
+ waiter = null
59
+ const crc = frame.readUInt16LE(frame.length - 2)
60
+ if (crc !== crc16Modbus(frame.subarray(0, frame.length - 2))) {
61
+ w.reject(new Error('CRC 校验失败(CRC mismatch)'))
62
+ return
63
+ }
64
+ if (frame[0] !== cfg.unitId) {
65
+ w.reject(new Error(`响应从站地址不匹配(期望 ${cfg.unitId},收到 ${frame[0]})`))
66
+ return
67
+ }
68
+ if (frame[1] & 0x80) {
69
+ w.reject(new Error(`Modbus 异常响应 exception code=${frame[2]}(功能码 ${frame[1] & 0x7F})`))
70
+ return
71
+ }
72
+ w.resolve(frame)
73
+ }
74
+
75
+ /** 单事务:写请求帧 → 等匹配响应帧(硬超时;连接级队列保证同时至多一个) */
76
+ function transact(func, payload) {
77
+ if (dead || !port || !port.isOpen) return Promise.reject(new Error('Port is not open'))
78
+ return new Promise((resolveP, rejectP) => {
79
+ const frame = buildRtuFrame(cfg.unitId, func, payload)
80
+ const timer = setTimeout(() => {
81
+ waiter = null
82
+ rejectP(new Error(`Modbus 响应超时(${cfg.timeoutMs}ms)`))
83
+ }, cfg.timeoutMs)
84
+ waiter = {
85
+ resolve: (f) => {
86
+ clearTimeout(timer)
87
+ resolveP(f)
88
+ },
89
+ reject: (e) => {
90
+ clearTimeout(timer)
91
+ rejectP(e)
92
+ },
93
+ }
94
+ buffer = Buffer.alloc(0)
95
+ port.write(frame, (err) => {
96
+ if (err) {
97
+ clearTimeout(timer)
98
+ waiter = null
99
+ rejectP(err)
100
+ }
101
+ })
102
+ })
103
+ }
104
+
105
+ function wordsFrom(frame) {
106
+ const byteCount = frame[2]
107
+ const out = []
108
+ for (let i = 0; i < byteCount / 2; i++) out.push(frame.readUInt16BE(3 + i * 2))
109
+ return out
110
+ }
111
+
112
+ return {
113
+ /** 打开串口(autoOpen;open/error 事件二选一收敛,硬超时由 cfg.timeoutMs 保护) */
114
+ open() {
115
+ return new Promise((resolveP, rejectP) => {
116
+ port = new SerialPort({
117
+ path: cfg.path,
118
+ baudRate: cfg.baudRate,
119
+ dataBits: cfg.dataBits,
120
+ stopBits: cfg.stopBits,
121
+ parity: cfg.parity,
122
+ autoOpen: true,
123
+ })
124
+ const timer = setTimeout(() => rejectP(new Error(`串口打开超时(${cfg.timeoutMs}ms): ${cfg.path}@${cfg.baudRate}`)), cfg.timeoutMs)
125
+ port.once('open', () => {
126
+ clearTimeout(timer)
127
+ resolveP()
128
+ })
129
+ port.once('error', (err) => {
130
+ clearTimeout(timer)
131
+ rejectP(err)
132
+ })
133
+ port.on('data', feed)
134
+ const markDead = () => void (dead = true)
135
+ port.on('error', markDead)
136
+ port.on('close', markDead)
137
+ })
138
+ },
139
+ /** modbus-serial 兼容面(drivers.mjs 零改动):isOpen、setID、读写方法、close */
140
+ get isOpen() { return Boolean(port && port.isOpen && !dead) },
141
+ setID() { /* 单连接绑定单从站:unitId 在 cfg 固定 */ },
142
+ on() { /* 兼容位;端口错误已在内部标记 dead */ },
143
+ async readHoldingRegisters(offset, length) {
144
+ const f = await transact(3, [hi8(offset), lo8(offset), hi8(length), lo8(length)])
145
+ return { data: wordsFrom(f) }
146
+ },
147
+ async readInputRegisters(offset, length) {
148
+ const f = await transact(4, [hi8(offset), lo8(offset), hi8(length), lo8(length)])
149
+ return { data: wordsFrom(f) }
150
+ },
151
+ async writeRegister(offset, value) {
152
+ await transact(6, [hi8(offset), lo8(offset), hi8(value), lo8(value)])
153
+ return { address: offset, value }
154
+ },
155
+ async writeRegisters(offset, words) {
156
+ const byteCount = words.length * 2
157
+ const payload = [hi8(offset), lo8(offset), hi8(words.length), lo8(words.length), byteCount, ...words.flatMap(w => [hi8(w), lo8(w)])]
158
+ await transact(16, payload)
159
+ return { address: offset, length: words.length }
160
+ },
161
+ close() {
162
+ return new Promise((resolveP) => {
163
+ if (!port || !port.isOpen) return resolveP()
164
+ port.close(() => resolveP())
165
+ })
166
+ },
167
+ }
168
+ }
@@ -27,7 +27,7 @@ import { daqRuntimeSettings } from '../settings'
27
27
  import { getSystemConfigService } from '../../system-config'
28
28
  import { daqKeyFromRef, normalizeDataTransform, normalizeSignalKind, DAQ_DRIVERS, type AepDaqControllerState, type AepDaqFrame, type AepDaqReading, type AepDaqNodeChange, type DaqDriverKind, type DaqNodeView, type DataTransform, type DriverTestResult } from '../../../../shared/daq-protocol'
29
29
  import { AppError, ErrorCodes } from '../../../utils/errors'
30
- import { normalizeDriverKind, resolveDaqDriver, probeDriverAvailability, listPluginDrivers, type DaqFrameSample } from './drivers'
30
+ import { normalizeDriverKind, resolveDaqDriver, probeDriverAvailability, listPluginDrivers, driverCatalog, type DaqFrameSample } from './drivers'
31
31
  import { findDaqTemplate } from './daq-templates'
32
32
  import { DaqNode } from './daq-node'
33
33
  import { DaqNodeRuntime, type DaqRuntimeHost } from './daq-runtime'
@@ -1216,11 +1216,16 @@ class DaqController {
1216
1216
  return this.testDriver(node.driver, node.driverConfig)
1217
1217
  }
1218
1218
 
1219
- /** 驱动可用性(meta;包缺失 → planned 提示而非硬失败) */
1219
+ /** 驱动可用性(meta;包缺失 → planned 提示而非硬失败;含插件驱动) */
1220
1220
  async driverAvailability(): Promise<Record<string, boolean>> {
1221
1221
  return probeDriverAvailability()
1222
1222
  }
1223
1223
 
1224
+ /** 驱动目录(内置 + 插件自描述合并;前端「添加节点」下拉/动态参数表单数据源) */
1225
+ async driverCatalog(): Promise<ReturnType<typeof driverCatalog>> {
1226
+ return driverCatalog()
1227
+ }
1228
+
1224
1229
  // ---------- 存量迁移(device-twins kind=daq → DaqNode 幂等供给) ----------
1225
1230
 
1226
1231
  /**