agentworkshop 0.7.1 → 0.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.output/nitro.json +1 -1
- package/.output/public/_nuxt/{Dgp2wK2U.js → BkKYf8xY.js} +2 -2
- package/.output/public/_nuxt/BuKI8z-5.js +1 -0
- package/.output/public/_nuxt/builds/latest.json +1 -1
- package/.output/public/_nuxt/builds/meta/c6c01cdd-762e-4fb8-86e7-753c5185b856.json +1 -0
- package/.output/public/_nuxt/daq.BXsnevQv.css +1 -0
- package/.output/server/chunks/_/nitro.mjs +726 -688
- package/.output/server/chunks/build/{daq-D_W59QOe.mjs → daq-CmeM4u5u.mjs} +106 -101
- package/.output/server/chunks/build/daq-CmeM4u5u.mjs.map +1 -0
- package/.output/server/chunks/routes/api/plugins/_name/_...path_.mjs +8 -2
- package/.output/server/chunks/routes/api/plugins/_name/_...path_.mjs.map +1 -1
- package/.output/server/chunks/routes/renderer.mjs +1 -1
- package/.output/server/chunks/virtual/entry.mjs +1 -1
- package/.output/server/chunks/virtual/precomputed.mjs +1 -1
- package/.output/server/package.json +1 -1
- package/app/pages/daq/index.vue +2 -0
- package/config.yml +1 -1
- package/package.json +1 -1
- package/scripts/_dbg-plc-agent-loop.ts +102 -0
- package/scripts/_dbg-plc-e2e.mjs +171 -0
- package/scripts/_lib-plc-sim-child.mjs +28 -0
- package/scripts/dev-plc-simulator.mjs +139 -0
- package/server/api/plugins/[name]/[...path].ts +10 -1
- package/server/data/daqs.json +137 -17
- package/server/data/dcw-lines.json +28 -0
- package/server/data/dcw-products.json +28 -0
- package/server/data/dcw-recipes.json +112 -0
- package/server/data/dcw-rollback.json +100 -1
- package/server/data/dcw-runs.json +108 -72
- package/server/data/dcw-writes.json +216 -0
- package/server/data/dcws.json +125 -98
- package/server/data/line-runs.json +40 -0
- package/server/services/workshop/daq/daq-node.ts +5 -0
- package/server/services/workshop/daq/daq-runtime.ts +6 -2
- package/server/services/workshop/daq/drivers.ts +41 -4
- package/server/services/workshop/dcw/dcw-runtime.ts +5 -0
- package/server/services/workshop/dcw/drivers.ts +10 -10
- package/shared/daq-protocol.ts +2 -0
- package/.output/public/_nuxt/DjqcbFBE.js +0 -1
- package/.output/public/_nuxt/builds/meta/0f65fbec-c4c0-45de-9c50-bb41ada375e5.json +0 -1
- package/.output/public/_nuxt/daq.m1dxZaD-.css +0 -1
- package/.output/server/chunks/build/daq-D_W59QOe.mjs.map +0 -1
package/app/pages/daq/index.vue
CHANGED
|
@@ -354,6 +354,8 @@ const rowLabel: Record<RowState, string> = {
|
|
|
354
354
|
function statePillOf(n: DaqNodeView): { key: RowState, label: string, tip: string } {
|
|
355
355
|
const s = rowStateOf(n)
|
|
356
356
|
if (s === 'offline') {
|
|
357
|
+
// 采样失败优先透出分类错误(连接/超时/PLC 异常 + 处理提示);无错误才给通用提示
|
|
358
|
+
if (n.lastError) return { key: s, label: rowLabel.offline, tip: n.lastError }
|
|
357
359
|
const tip = !daq.controller.running
|
|
358
360
|
? tt('daq.k1gateoff140')
|
|
359
361
|
: tt('daq.k1staled129', { p0: Math.round(Math.max((n.intervalMs ?? daq.controller.defaultIntervalMs) * 4, 12_000) / 1000) })
|
package/config.yml
CHANGED
package/package.json
CHANGED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent × 数采 × 数控 全闭环真机验证(进程内平台 + 真实 Modbus 接线):
|
|
3
|
+
* 完整产线运营流(建线/产品/配方,开跑后配方参数经真实 Modbus 写入)。
|
|
4
|
+
* Agent 工具闭环:dcw_read 取证,dcw_control 调温,数采 PV 跟随收敛,daq_query 取证序列。
|
|
5
|
+
* 数据目录 AW_HOME 隔离;PLC 模拟器为本脚本子进程(15041)。
|
|
6
|
+
* 运行: npx tsx --tsconfig .nuxt/tsconfig.server.json scripts/_dbg-plc-agent-loop.ts
|
|
7
|
+
*/
|
|
8
|
+
import { mkdtempSync } from 'node:fs'
|
|
9
|
+
import { tmpdir } from 'node:os'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
import { startPlcSim } from './_lib-plc-sim-child.mjs'
|
|
12
|
+
|
|
13
|
+
const awHome = mkdtempSync(join(tmpdir(), 'aw-plc-loop-'))
|
|
14
|
+
process.env.AW_MODE = 'home'
|
|
15
|
+
process.env.AW_HOME = awHome
|
|
16
|
+
process.env.NO_PROXY = '127.0.0.1,localhost'
|
|
17
|
+
|
|
18
|
+
let failures = 0
|
|
19
|
+
const check = (name: string, ok: boolean, detail = ''): void => {
|
|
20
|
+
console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`)
|
|
21
|
+
if (!ok) failures += 1
|
|
22
|
+
}
|
|
23
|
+
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))
|
|
24
|
+
|
|
25
|
+
// ===== 启动 PLC 模拟器(子进程,固定端口) =====
|
|
26
|
+
const PLC_PORT = 15041
|
|
27
|
+
const sim = startPlcSim(PLC_PORT)
|
|
28
|
+
const simReady = await sim.ready
|
|
29
|
+
check('PLC 模拟器就绪(15041)', simReady)
|
|
30
|
+
|
|
31
|
+
// ===== 平台装配(隔离配置根;真实产线运营流) =====
|
|
32
|
+
const { getDcwController } = await import('../server/services/workshop/dcw/dcw-controller')
|
|
33
|
+
const { getDaqController } = await import('../server/services/workshop/daq/daq-controller')
|
|
34
|
+
const { bindDaqHost } = await import('../server/services/workshop/daq/host-bindings')
|
|
35
|
+
const { getAgentNodeBindingRepo } = await import('../server/services/workshop/agents/node-bindings.repo')
|
|
36
|
+
|
|
37
|
+
// 数采网关的宿主端口(WS 广播 + 产线运行门控)由 REST 路由绑定;harness 无路由,手动绑定
|
|
38
|
+
bindDaqHost(() => {})
|
|
39
|
+
|
|
40
|
+
const AGENT = 'agent-plc-loop'
|
|
41
|
+
const dcw = getDcwController()
|
|
42
|
+
const daq = getDaqController()
|
|
43
|
+
|
|
44
|
+
const line = dcw.createLine({ name: 'PLC 闭环验证线' })
|
|
45
|
+
const product = dcw.createProduct({ name: '闭环验证产品', lineId: line.id })
|
|
46
|
+
const mbSp = { host: '127.0.0.1', port: PLC_PORT, unitId: 1, register: 40021, dataType: 'float32', byteOrder: 'big' }
|
|
47
|
+
const mbPv = { host: '127.0.0.1', port: PLC_PORT, unitId: 1, register: 40001, registerType: 'holding', dataType: 'float32', byteOrder: 'big' }
|
|
48
|
+
const spNode = dcw.create({ templateRef: 'dcw-temp-sp', name: '烘箱温度设定', driver: 'modbus-tcp', driverConfig: mbSp, min: 0, max: 300, lineId: line.id })
|
|
49
|
+
const pvNode = daq.create({ templateRef: 'daq-temp-tc', name: '烘箱温度PV', driver: 'modbus-tcp', driverConfig: mbPv, lineId: line.id })
|
|
50
|
+
const recipe = dcw.createRecipe({ productId: product.id, name: 'A-闭环工艺', params: [{ nodeId: spNode.id, value: 160, min: 100, max: 200 }], daqWindows: [{ nodeId: pvNode.id, min: 0, max: 400 }] })
|
|
51
|
+
const repo = getAgentNodeBindingRepo()
|
|
52
|
+
repo.bind(AGENT, spNode.id, 'dcw', 'auto')
|
|
53
|
+
repo.bind(AGENT, pvNode.id, 'daq', 'auto')
|
|
54
|
+
console.log(`line=${line.id} sp=${spNode.id} pv=${pvNode.id}\n`)
|
|
55
|
+
|
|
56
|
+
// ===== ① 开跑:配方参数 160 经真实 Modbus 写入 =====
|
|
57
|
+
const run = await dcw.lineStart(line.id, recipe.id)
|
|
58
|
+
const spResult = run.results.find(r => r.nodeId === spNode.id)
|
|
59
|
+
check('①开跑下发配方参数(160 → 真实 PLC,回读一致)', spResult?.ok === true, spResult?.message.slice(0, 80))
|
|
60
|
+
|
|
61
|
+
// ===== ② Agent 闭环:观察 PV 收敛 → 调温 → 复核 =====
|
|
62
|
+
const { OmpRpcAgentImpl } = await import('../server/services/workshop/agents/omp-agent')
|
|
63
|
+
const impl = new OmpRpcAgentImpl({ agentId: AGENT, name: '闭环调控员', role: 'worker', channelId: 'ch-plc-loop' })
|
|
64
|
+
;(impl as unknown as { workspace: unknown }).workspace = { listAgents: async () => [] }
|
|
65
|
+
const handle = (impl as unknown as Record<string, (req: unknown) => Promise<{ text: string, isError?: boolean }>>).handleHostTool.bind(impl)
|
|
66
|
+
|
|
67
|
+
const pvOf = (): number | null => daq.byId(pvNode.id)?.value ?? null
|
|
68
|
+
let pv = pvOf()
|
|
69
|
+
const t0 = Date.now()
|
|
70
|
+
while ((pv == null || pv < 120) && Date.now() - t0 < 90_000) {
|
|
71
|
+
await sleep(3000)
|
|
72
|
+
pv = pvOf()
|
|
73
|
+
}
|
|
74
|
+
check('②开跑后 PV 向 160 收敛(越过 120℃)', pv != null && pv > 120, `PV=${pv} 耗时=${Math.round((Date.now() - t0) / 1000)}s`)
|
|
75
|
+
|
|
76
|
+
const r1 = await handle({ toolName: 'dcw_read', arguments: { node_id: spNode.id } })
|
|
77
|
+
check('③dcw_read 取证:ACT ≈ 配方设定 160', !r1.isError && /160/.test(r1.text) && /一致/.test(r1.text), r1.text.replace(/\n/g, ' ').slice(0, 110))
|
|
78
|
+
|
|
79
|
+
const w = await handle({ toolName: 'dcw_control', arguments: { node_id: spNode.id, value: 180, hypothesis: '提温 20℃ 预期 PV 跟随上升,60s 后复测' } })
|
|
80
|
+
check('④Agent 联锁内调温 180(开优化记录)', !w.isError && /下发成功/.test(w.text), w.text.replace(/\n/g, ' ').slice(0, 110))
|
|
81
|
+
|
|
82
|
+
let pv2: number | null = null
|
|
83
|
+
const t1 = Date.now()
|
|
84
|
+
while (Date.now() - t1 < 120_000) {
|
|
85
|
+
await sleep(4000)
|
|
86
|
+
pv2 = pvOf()
|
|
87
|
+
if (pv2 != null && pv2 >= 172) break
|
|
88
|
+
}
|
|
89
|
+
check('⑤调温后 PV 跟随收敛 ≥172℃(真实闭环)', pv2 != null && pv2 >= 172, `PV=${pv2} 耗时=${Math.round((Date.now() - t1) / 1000)}s`)
|
|
90
|
+
|
|
91
|
+
const r2 = await handle({ toolName: 'dcw_read', arguments: { node_id: spNode.id } })
|
|
92
|
+
check('⑥dcw_read 复核:ACT=180 与 SET 一致', !r2.isError && /180/.test(r2.text) && /一致/.test(r2.text), r2.text.replace(/\n/g, ' ').slice(0, 110))
|
|
93
|
+
|
|
94
|
+
const q = await handle({ toolName: 'daq_query', arguments: { node_id: pvNode.id, last_minutes: 5 } })
|
|
95
|
+
check('⑦daq_query 取证序列含 PV 数据', !q.isError && /(avg|min|max|样本|latest|最近)/i.test(q.text), q.text.replace(/\n/g, ' ').slice(0, 110))
|
|
96
|
+
|
|
97
|
+
const deny = await handle({ toolName: 'dcw_control', arguments: { node_id: spNode.id, value: 350 } })
|
|
98
|
+
check('⑧越量程调温被拒(350 ∉ [0,300])', deny.isError === true && /越出|量程/.test(deny.text))
|
|
99
|
+
|
|
100
|
+
sim.kill()
|
|
101
|
+
console.log(failures === 0 ? '\nPLC-AGENT-LOOP ALL PASS' : `\nPLC-AGENT-LOOP FAILED(${failures})`)
|
|
102
|
+
process.exit(failures === 0 ? 0 : 1)
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PLC 真实场景端到端(数采 × 数控闭环;HTTP 面,打真实 dev server):
|
|
3
|
+
* 自管 PLC 工艺模拟器(dev-plc-simulator,15040)→
|
|
4
|
+
* ①数采节点(温度PV 40001)挂运行产线 → 采样流入(基线≈环境温度)
|
|
5
|
+
* ②数控节点(温度SP 40021)REST 下发 170℃ → PLC 写入 + 同址回读一致
|
|
6
|
+
* ③闭环收敛:数采 PV 跟随爬升并稳态落在 SP 容差内(真实 Modbus 双向走线)
|
|
7
|
+
* ④数控周期读 ACT 收敛 = SET;写历史/运维留痕可查
|
|
8
|
+
* 运行: node scripts/_dbg-plc-e2e.mjs(CLEANUP=1 清理测试节点)
|
|
9
|
+
*/
|
|
10
|
+
import { spawn } from 'node:child_process'
|
|
11
|
+
|
|
12
|
+
const ROOT = process.env.E2E_ROOT ?? 'http://127.0.0.1:3000'
|
|
13
|
+
const CLEANUP = process.env.CLEANUP === '1'
|
|
14
|
+
const PORT = Number(process.env.PLC_PORT ?? 15040)
|
|
15
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
|
16
|
+
let failed = 0
|
|
17
|
+
const check = (name, cond, detail = '') => { console.log(` ${cond ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`); if (!cond) failed++ }
|
|
18
|
+
|
|
19
|
+
// ===== 启动 PLC 模拟器(子进程;结束即杀) =====
|
|
20
|
+
const sim = spawn(process.execPath, ['scripts/dev-plc-simulator.mjs', '--port', String(PORT)], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
|
|
21
|
+
let simLog = ''
|
|
22
|
+
sim.stdout.on('data', d => { simLog += String(d) })
|
|
23
|
+
sim.stderr.on('data', d => { simLog += String(d) })
|
|
24
|
+
await new Promise((resolve) => {
|
|
25
|
+
const t = setTimeout(resolve, 8000)
|
|
26
|
+
sim.stdout.on('data', (d) => { if (String(d).includes('就绪')) { clearTimeout(t); resolve() } })
|
|
27
|
+
})
|
|
28
|
+
check('PLC 模拟器就绪(15040)', simLog.includes('就绪'), simLog.split('\n')[0])
|
|
29
|
+
|
|
30
|
+
process.on('exit', () => { try { sim.kill() } catch {} })
|
|
31
|
+
|
|
32
|
+
// ===== 登录 + 运行产线 =====
|
|
33
|
+
const login = await fetch(`${ROOT}/api/users/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email: 'zhangwei@awshop.io', password: 'Awshop@123' }) }).then(r => r.json())
|
|
34
|
+
const H = { authorization: `Bearer ${login.data.token}`, 'content-type': 'application/json' }
|
|
35
|
+
const j = async (u, m = 'GET', b, attempt = 0) => {
|
|
36
|
+
try {
|
|
37
|
+
return await fetch(ROOT + u, { method: m, headers: H, body: b ? JSON.stringify(b) : undefined, signal: AbortSignal.timeout(30_000) }).then(r => r.json())
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
if (attempt >= 2) throw err
|
|
41
|
+
await sleep(1500)
|
|
42
|
+
return j(u, m, b, attempt + 1)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const dcwData = (await j('/api/workshop/dcw')).data
|
|
47
|
+
const running = dcwData.lineStates?.find(s => s.active)
|
|
48
|
+
if (!running) {
|
|
49
|
+
const cand = dcwData.lines.map(l => ({ line: l, recipe: dcwData.recipes.find(r => r.lineId === l.id) })).find(x => x.recipe)
|
|
50
|
+
await j(`/api/workshop/dcw/lines/${cand.line.id}/start`, 'POST', { recipeId: cand.recipe.id })
|
|
51
|
+
check('运行产线(自动开跑)', true, cand.line.name)
|
|
52
|
+
}
|
|
53
|
+
else check('运行产线在跑', true, `${running.recipeName} @ line ${running.lineId}`)
|
|
54
|
+
const lineId = running?.lineId ?? (await j('/api/workshop/dcw')).data.lineStates.find(s => s.active)?.lineId
|
|
55
|
+
|
|
56
|
+
// ===== 幂等清理 + 建节点 =====
|
|
57
|
+
const extraNodes = []
|
|
58
|
+
for (const n of (await j('/api/workshop/daq')).data.nodes.filter(n => n.name?.startsWith('PLC闭环·'))) await j(`/api/workshop/daq/${n.id}`, 'DELETE')
|
|
59
|
+
for (const n of (await j('/api/workshop/dcw')).data.nodes.filter(n => n.name?.startsWith('PLC闭环·'))) await j(`/api/workshop/dcw/${n.id}`, 'DELETE')
|
|
60
|
+
|
|
61
|
+
const mb = { host: '127.0.0.1', port: PORT, unitId: 1 }
|
|
62
|
+
const daqNode = (await j('/api/workshop/daq', 'POST', {
|
|
63
|
+
templateRef: 'daq-temp-tc', name: 'PLC闭环·温度PV', driver: 'modbus-tcp', lineId,
|
|
64
|
+
driverConfig: { ...mb, register: 40001, registerType: 'holding', dataType: 'float32', byteOrder: 'big' },
|
|
65
|
+
})).data.node
|
|
66
|
+
const dcwNode = (await j('/api/workshop/dcw', 'POST', {
|
|
67
|
+
templateRef: 'dcw-temp-sp', name: 'PLC闭环·温度SP', driver: 'modbus-tcp', lineId, min: 0, max: 300, readIntervalMs: 1000,
|
|
68
|
+
driverConfig: { ...mb, register: 40021, dataType: 'float32', byteOrder: 'big' },
|
|
69
|
+
})).data.node
|
|
70
|
+
check('①数采 PV 节点 + 数控 SP 节点建立', !!daqNode?.id && !!dcwNode?.id, `${daqNode?.id} / ${dcwNode?.id}`)
|
|
71
|
+
|
|
72
|
+
const daqValue = async () => ((await j('/api/workshop/daq')).data.nodes.find(n => n.id === daqNode.id))?.value ?? null
|
|
73
|
+
const dcwView = async () => (await j('/api/workshop/dcw')).data.nodes.find(n => n.id === dcwNode.id)
|
|
74
|
+
const write = (id, value) => j(`/api/workshop/dcw/${id}/write`, 'POST', { value }).then((r) => {
|
|
75
|
+
if (!r.data?.outcome) console.log(' [debug] write envelope:', JSON.stringify(r).slice(0, 200))
|
|
76
|
+
return r.data?.outcome ?? { ok: false, message: r.message ?? 'unknown' }
|
|
77
|
+
})
|
|
78
|
+
const read = id => j(`/api/workshop/dcw/${id}/read`, 'POST', {}).then(r => r.data?.read ?? { ok: false, value: null, message: r.message ?? 'unknown' })
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
// ===== ① 基线:SP=0 → 工艺处于冷却态(PV 远低于目标,且采样在流入) =====
|
|
82
|
+
let baseline = null
|
|
83
|
+
let prev = null
|
|
84
|
+
let flowing = false
|
|
85
|
+
for (let i = 0; i < 25; i++) {
|
|
86
|
+
await sleep(2000)
|
|
87
|
+
baseline = await daqValue().catch(() => null)
|
|
88
|
+
if (baseline != null && prev != null && baseline !== prev) flowing = true
|
|
89
|
+
prev = baseline
|
|
90
|
+
if (baseline != null && flowing) break
|
|
91
|
+
}
|
|
92
|
+
check('②基线:冷却态 PV<35℃ 且采样持续流入', baseline != null && baseline < 35 && flowing, `PV=${baseline}`)
|
|
93
|
+
|
|
94
|
+
// ===== ② 数控下发 170(真实 Modbus 写 + 同址回读) =====
|
|
95
|
+
const wResp = await j(`/api/workshop/dcw/${dcwNode.id}/write`, 'POST', { value: 170 })
|
|
96
|
+
if (!wResp.data?.outcome) console.log(' [debug] write envelope:', JSON.stringify(wResp).slice(0, 220))
|
|
97
|
+
const w = wResp.data.outcome
|
|
98
|
+
check('③数控下发 170℃(写+回读一致)', w.ok === true, w.message.slice(0, 70))
|
|
99
|
+
|
|
100
|
+
// ===== ③ 闭环收敛:PV 爬升并稳态(斜率 5℃/s + τ 8s → 90s 内收敛) =====
|
|
101
|
+
const t0 = Date.now()
|
|
102
|
+
let crossed = false
|
|
103
|
+
let settled = null
|
|
104
|
+
while (Date.now() - t0 < 150_000) {
|
|
105
|
+
await sleep(3000)
|
|
106
|
+
const v = await daqValue()
|
|
107
|
+
if (v != null && v > 150 && !crossed) crossed = true
|
|
108
|
+
if (crossed && Math.abs(v - 170) <= 4) { settled = v; break }
|
|
109
|
+
}
|
|
110
|
+
check('④闭环收敛:PV 越过 150℃(跟随 SP)', crossed)
|
|
111
|
+
check('④PV 稳态落在 170±4℃(真实工艺闭环)', settled != null, `settled=${settled} 耗时=${Math.round((Date.now() - t0) / 1000)}s`)
|
|
112
|
+
|
|
113
|
+
// ===== ④ 数控周期读 ACT 收敛;写历史留痕 =====
|
|
114
|
+
const v = await dcwView()
|
|
115
|
+
check('⑤数控周期读 ACT ≈ SET(170±2)', v.readValue != null && Math.abs(v.readValue - 170) <= 2 && v.lastReadAt != null, `ACT=${v.readValue} SET=${v.value}`)
|
|
116
|
+
const writes = (await j(`/api/workshop/dcw/${dcwNode.id}/param-ledger`)).data?.ledger
|
|
117
|
+
check('⑤下发留痕可查(param-ledger/写历史)', !!writes)
|
|
118
|
+
|
|
119
|
+
// ===== ⑥ 标定换算节点(scale=10):物理量 = 10 × PLC 值 =====
|
|
120
|
+
const cal = (await j('/api/workshop/dcw', 'POST', {
|
|
121
|
+
templateRef: 'dcw-temp-sp', name: 'PLC闭环·标定×10', driver: 'modbus-tcp', readIntervalMs: 0,
|
|
122
|
+
min: 0, max: 3000, transform: { kind: 'linear', scale: 10, offset: 0 },
|
|
123
|
+
driverConfig: { host: '127.0.0.1', port: PORT, unitId: 1, register: 40023, dataType: 'float32', byteOrder: 'big' },
|
|
124
|
+
})).data.node
|
|
125
|
+
extraNodes.push(cal.id)
|
|
126
|
+
const cw = await write(cal.id, 160)
|
|
127
|
+
check('⑥标定节点:物理 160 → PLC 16 写入+回读一致', cw.ok === true, cw.message.slice(0, 80))
|
|
128
|
+
const cr = await read(cal.id)
|
|
129
|
+
check('⑥标定节点:读回 PLC 16 → 解码 160 工程量', cr.ok && Math.abs(cr.value - 160) < 0.01, `value=${cr.value} raw=${cr.raw}`)
|
|
130
|
+
|
|
131
|
+
// ===== ⑦ 错误反馈:连接失败分类文案 =====
|
|
132
|
+
const dead = (await j('/api/workshop/dcw', 'POST', {
|
|
133
|
+
templateRef: 'dcw-temp-sp', name: 'PLC闭环·故障注入', driver: 'modbus-tcp', readIntervalMs: 0, min: 0, max: 300,
|
|
134
|
+
driverConfig: { host: '127.0.0.1', port: 15199, unitId: 1, register: 40021, dataType: 'float32', byteOrder: 'big' },
|
|
135
|
+
})).data.node
|
|
136
|
+
extraNodes.push(dead.id)
|
|
137
|
+
const t = await j(`/api/workshop/dcw/${dead.id}/test`, 'POST').then(r => r.data.test)
|
|
138
|
+
check('⑦连接失败:测试连接给出可操作诊断', t.ok === false && /(无法建立连接|连接失败|端口)/.test(t.message), t.message.slice(0, 90))
|
|
139
|
+
const dw = await write(dead.id, 170)
|
|
140
|
+
check('⑦下发失败:连接类错误文案 + 处理提示', dw.ok === false && /(无法建立连接|超时|通信|连接)/.test(dw.message), dw.message.slice(0, 90))
|
|
141
|
+
const dr = await read(dead.id)
|
|
142
|
+
check('⑦读取失败:连接类错误文案', dr.ok === false && /(无法建立连接|超时|读取失败)/.test(dr.message), dr.message.slice(0, 90))
|
|
143
|
+
|
|
144
|
+
// DAQ 侧采样失败:lastError 分类文案落到节点视图(API 可查)
|
|
145
|
+
const deadDaq = (await j('/api/workshop/daq', 'POST', {
|
|
146
|
+
templateRef: 'daq-temp-tc', name: 'PLC闭环·故障DAQ', driver: 'modbus-tcp', lineId,
|
|
147
|
+
driverConfig: { host: '127.0.0.1', port: 15199, unitId: 1, register: 40001, registerType: 'holding', dataType: 'float32', byteOrder: 'big' },
|
|
148
|
+
})).data.node
|
|
149
|
+
await sleep(4000)
|
|
150
|
+
const deadView = (await j('/api/workshop/daq')).data.nodes.find(n => n.id === deadDaq.id)
|
|
151
|
+
check('⑦DAQ 采样失败:offline + 分类错误透出', deadView.state === 'offline' && !!deadView.lastError && /(无法建立连接|超时|连接)/.test(deadView.lastError), (deadView.lastError ?? '').slice(0, 80))
|
|
152
|
+
|
|
153
|
+
// ===== ⑧ 越量程:400 + 量程详情(安全联锁语义) =====
|
|
154
|
+
const range = await j(`/api/workshop/dcw/${dcwNode.id}/write`, 'POST', { value: 999 })
|
|
155
|
+
check('⑧越量程下发:400 + 量程详情', range.code === 'VALIDATION_ERROR' && /量程/.test(range.message), range.message.slice(0, 90))
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
if (CLEANUP) {
|
|
159
|
+
for (const id of extraNodes) await j(`/api/workshop/dcw/${id}`, 'DELETE').catch(() => {})
|
|
160
|
+
await j(`/api/workshop/daq/${daqNode.id}`, 'DELETE').catch(() => {})
|
|
161
|
+
await j(`/api/workshop/dcw/${dcwNode.id}`, 'DELETE').catch(() => {})
|
|
162
|
+
const deadDaqId = (await j('/api/workshop/daq')).data.nodes.find(n => n.name === 'PLC闭环·故障DAQ')?.id
|
|
163
|
+
if (deadDaqId) await j(`/api/workshop/daq/${deadDaqId}`, 'DELETE').catch(() => {})
|
|
164
|
+
console.log('(cleanup:测试节点已删)')
|
|
165
|
+
}
|
|
166
|
+
else console.log(`(节点保留:${daqNode.id} / ${dcwNode.id} / +${extraNodes.length};CLEANUP=1 可清理)`)
|
|
167
|
+
sim.kill()
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
console.log(failed === 0 ? '\nPLC-E2E ALL PASS' : `\nPLC-E2E FAILED(${failed})`)
|
|
171
|
+
process.exit(failed === 0 ? 0 : 1)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PLC 模拟器子进程启动助手(供验证脚本复用)。
|
|
3
|
+
* 启动 scripts/dev-plc-simulator.mjs 并等待「就绪」;返回句柄与 kill()。
|
|
4
|
+
*/
|
|
5
|
+
import { spawn } from 'node:child_process'
|
|
6
|
+
|
|
7
|
+
export function startPlcSim(port) {
|
|
8
|
+
const sim = spawn(process.execPath, ['scripts/dev-plc-simulator.mjs', '--port', String(port)], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
|
|
9
|
+
const ready = new Promise((resolve) => {
|
|
10
|
+
const t = setTimeout(() => resolve(false), 8000)
|
|
11
|
+
sim.stdout.on('data', (d) => {
|
|
12
|
+
if (String(d).includes('就绪')) {
|
|
13
|
+
clearTimeout(t)
|
|
14
|
+
resolve(true)
|
|
15
|
+
}
|
|
16
|
+
})
|
|
17
|
+
sim.stderr.on('data', () => {})
|
|
18
|
+
})
|
|
19
|
+
return {
|
|
20
|
+
ready,
|
|
21
|
+
kill: () => {
|
|
22
|
+
try {
|
|
23
|
+
sim.kill()
|
|
24
|
+
}
|
|
25
|
+
catch { /* 已退出 */ }
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PLC 工艺过程模拟器 —— 真实场景接入用(Modbus TCP 从站,modbus-serial ServerTCP)。
|
|
3
|
+
*
|
|
4
|
+
* 仿真对象:涂布产线烘干单元。与「只摆寄存器」的演示从站不同,本模拟器带工艺动力学:
|
|
5
|
+
* - 过程值 PV 对设定值 SP 呈一阶惯性响应(PV' = PV + (SP−PV)·(1−e^(−dt/τ)) + 噪声)
|
|
6
|
+
* - SP 变更按斜率限制爬升(驱动/加热器执行器速率)——真实 PLC 变频器语义
|
|
7
|
+
* - 停机(线圈0=false)时 PV 自由衰减;环境温度慢漂移
|
|
8
|
+
* - 状态字 40011(uint16):bit0 运行 / bit1 加热中 / bit2 报警(温度偏差 >12℃)
|
|
9
|
+
*
|
|
10
|
+
* 寄存器布局(float32 大端,4xxxx 保持寄存器,协议偏移 = 地址 − 40001):
|
|
11
|
+
* 40001 温度PV 40003 速度PV 40005 张力PV 40011 状态字(uint16) 40031 环境温度
|
|
12
|
+
* 40021 温度SP 40023 速度SP 40025 张力SP(可写)
|
|
13
|
+
* 线圈: 0=运行使能(on) 1=加热使能(on)
|
|
14
|
+
*
|
|
15
|
+
* 设计:工艺状态 = JS 数值(每 tick 刷新进字数组);Modbus 写只改 SP 字并解包回状态。
|
|
16
|
+
* 运行: node scripts/dev-plc-simulator.mjs [--port 15040]
|
|
17
|
+
*/
|
|
18
|
+
import ModbusRTU from 'modbus-serial'
|
|
19
|
+
|
|
20
|
+
const args = process.argv.slice(2)
|
|
21
|
+
const portIx = args.indexOf('--port')
|
|
22
|
+
const PORT = portIx >= 0 ? Number(args[portIx + 1]) : 15040
|
|
23
|
+
|
|
24
|
+
// ---- 字镜像(协议偏移 → uint16;与 dev-modbus-simulator 同构的数组载体) ----
|
|
25
|
+
const HOLDING = new Array(100).fill(0)
|
|
26
|
+
const COILS = [true, true] // 0=运行使能 1=加热使能
|
|
27
|
+
|
|
28
|
+
// ---- 工艺回路(τ 一阶时间常数;rate 执行器斜率/秒;sigma 过程噪声) ----
|
|
29
|
+
const CIRCUITS = [
|
|
30
|
+
{ name: '温度', pvOff: 0, spOff: 20, pv: 0, sp: 0, ramped: 0, tau: 8, rate: 5, sigma: 0.25 },
|
|
31
|
+
{ name: '速度', pvOff: 2, spOff: 22, pv: 0, sp: 0, ramped: 0, tau: 4, rate: 8, sigma: 0.6 },
|
|
32
|
+
{ name: '张力', pvOff: 4, spOff: 24, pv: 0, sp: 0, ramped: 0, tau: 6, rate: 3, sigma: 0.15 },
|
|
33
|
+
]
|
|
34
|
+
const DT_MS = 500
|
|
35
|
+
const AMBIENT = 24 + Math.random() * 3
|
|
36
|
+
CIRCUITS[0].pv = AMBIENT
|
|
37
|
+
|
|
38
|
+
const wordsOf = (v) => {
|
|
39
|
+
const b = Buffer.alloc(4)
|
|
40
|
+
b.writeFloatBE(Number.isFinite(v) ? v : 0, 0)
|
|
41
|
+
return [b.readUInt16BE(0), b.readUInt16BE(2)]
|
|
42
|
+
}
|
|
43
|
+
const valOf = (off) => {
|
|
44
|
+
const b = Buffer.alloc(4)
|
|
45
|
+
b.writeUInt16BE(HOLDING[off] ?? 0, 0)
|
|
46
|
+
b.writeUInt16BE(HOLDING[off + 1] ?? 0, 2)
|
|
47
|
+
return b.readFloatBE(0)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 初始化:SP=0;温度 PV 从环境温度起步
|
|
51
|
+
for (const c of CIRCUITS) {
|
|
52
|
+
const [hi, lo] = wordsOf(c.sp)
|
|
53
|
+
HOLDING[c.spOff] = hi
|
|
54
|
+
HOLDING[c.spOff + 1] = lo
|
|
55
|
+
}
|
|
56
|
+
{
|
|
57
|
+
const [hi, lo] = wordsOf(CIRCUITS[0].pv)
|
|
58
|
+
HOLDING[0] = hi
|
|
59
|
+
HOLDING[1] = lo
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const ramped = new Map(CIRCUITS.map(c => [c.name, 0]))
|
|
63
|
+
let ambient = AMBIENT
|
|
64
|
+
let tickN = 0
|
|
65
|
+
let statusWord = 1
|
|
66
|
+
|
|
67
|
+
function step() {
|
|
68
|
+
const dt = DT_MS / 1000
|
|
69
|
+
tickN++
|
|
70
|
+
ambient = AMBIENT + Math.sin(tickN / 600) * 1.2
|
|
71
|
+
const run = COILS[0]
|
|
72
|
+
const heat = COILS[1]
|
|
73
|
+
for (const c of CIRCUITS) {
|
|
74
|
+
// 执行器斜率限制(设定值爬坡)
|
|
75
|
+
const target = run ? c.sp : 0
|
|
76
|
+
const r = ramped.get(c.name)
|
|
77
|
+
if (Math.abs(target - r) <= c.rate * dt) ramped.set(c.name, target)
|
|
78
|
+
else ramped.set(c.name, r + Math.sign(target - r) * c.rate * dt)
|
|
79
|
+
// 加热线圈断开 → 温度回路失去能量,目标回落环境温度
|
|
80
|
+
const eff = c.name === '温度' && !heat ? ambient : ramped.get(c.name)
|
|
81
|
+
// 一阶惯性 + 过程噪声
|
|
82
|
+
const gain = 1 - Math.exp(-dt / c.tau)
|
|
83
|
+
const noise = (Math.random() - 0.5) * 2 * c.sigma * Math.sqrt(dt * 2)
|
|
84
|
+
c.pv = Math.max(-50, c.pv + (eff - c.pv) * gain + noise)
|
|
85
|
+
const [hi, lo] = wordsOf(c.pv)
|
|
86
|
+
HOLDING[c.pvOff] = hi
|
|
87
|
+
HOLDING[c.pvOff + 1] = lo
|
|
88
|
+
}
|
|
89
|
+
const t = CIRCUITS[0]
|
|
90
|
+
statusWord = (run ? 1 : 0) | (heat && t.sp - t.pv > 0.5 ? 2 : 0) | (run && Math.abs(t.sp - t.pv) > 12 ? 4 : 0)
|
|
91
|
+
HOLDING[40011 - 40001] = statusWord
|
|
92
|
+
const [ah, al] = wordsOf(ambient)
|
|
93
|
+
HOLDING[40031 - 40001] = ah
|
|
94
|
+
HOLDING[40031 - 40001 + 1] = al
|
|
95
|
+
}
|
|
96
|
+
setInterval(step, DT_MS)
|
|
97
|
+
|
|
98
|
+
// ---- SP 写入 → 解包回工艺状态(下次 step 以新 SP 爬坡) ----
|
|
99
|
+
const CIRCUIT_SP = new Map([[20, CIRCUITS[0]], [22, CIRCUITS[1]], [24, CIRCUITS[2]]])
|
|
100
|
+
function resyncPair(off) {
|
|
101
|
+
const c = CIRCUIT_SP.get(off)
|
|
102
|
+
if (c) {
|
|
103
|
+
c.sp = valOf(off)
|
|
104
|
+
console.log(`[plc] WRITE ${c.name}SP ${40001 + off} ← ${c.sp.toFixed(3)}`)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---- Modbus 从站载体(与 dev-modbus-simulator 同构) ----
|
|
109
|
+
const vector = {
|
|
110
|
+
getHoldingRegister: addr => HOLDING[addr] ?? 0,
|
|
111
|
+
getMultipleHoldingRegisters: (addr, length) => HOLDING.slice(addr, addr + length),
|
|
112
|
+
setRegister: (addr, value) => {
|
|
113
|
+
console.log(`[plc:dbg] setRegister addr=${addr} value=${value} → HOLDING[${addr}]=${value & 0xFFFF}`)
|
|
114
|
+
HOLDING[addr] = value & 0xFFFF
|
|
115
|
+
resyncPair(addr)
|
|
116
|
+
resyncPair(addr - 1) // 本字可能是某 SP 对的低位字
|
|
117
|
+
},
|
|
118
|
+
setMultipleRegisters: (addr, values) => {
|
|
119
|
+
for (let i = 0; i < values.length; i++) HOLDING[addr + i] = values[i] & 0xFFFF
|
|
120
|
+
resyncPair(addr)
|
|
121
|
+
},
|
|
122
|
+
readCoils: () => COILS.slice(),
|
|
123
|
+
readDiscreteInputs: () => new Array(16).fill(false),
|
|
124
|
+
readInputRegisters: (addr, length) => HOLDING.slice(addr, addr + length),
|
|
125
|
+
writeCoil: (addr, value) => { COILS[addr] = value },
|
|
126
|
+
writeMultipleCoils: () => {},
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const server = new ModbusRTU.ServerTCP(vector, { host: '0.0.0.0', port: PORT, debug: false })
|
|
130
|
+
server.on('initialized', () => {
|
|
131
|
+
console.log(`[plc] 烘干单元模拟器就绪 pid=${process.pid} 0.0.0.0:${PORT} unitId=1`)
|
|
132
|
+
console.log(`[plc] 回路: ${CIRCUITS.map(c => `${c.name}(PV 4${40001 + c.pvOff}/SP 4${40021 + c.spOff - 20},τ=${c.tau}s,rate=${c.rate}/s)`).join(' | ')}`)
|
|
133
|
+
console.log(`[plc] 环境温度 ${AMBIENT.toFixed(1)}℃ · 线圈 0=运行(on) 1=加热(on)`)
|
|
134
|
+
})
|
|
135
|
+
server.on('socket', () => {})
|
|
136
|
+
process.on('SIGINT', () => {
|
|
137
|
+
server.close(() => process.exit(0))
|
|
138
|
+
setTimeout(() => process.exit(0), 500)
|
|
139
|
+
})
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* /api/plugins/:name/** —— 插件自注册 API 的转发层(exact-match)。
|
|
3
3
|
* 插件经 ctx.route(method, path, handler) 注册;handler(event) 返回值由 nitro 序列化。
|
|
4
4
|
* 鉴权由插件自行处理(v1 不强制;可经 resolveUser 复用业务鉴权)。
|
|
5
|
+
* 错误隔离:插件 handler 抛错 → 结构化日志(带插件归属)+ 干净 500 信封,不裸传堆栈。
|
|
5
6
|
*/
|
|
6
7
|
import { defineEventHandler, createError, readBody } from 'h3'
|
|
7
8
|
import { getPluginHost } from '@/server/services/workshop/plugins/host.mjs'
|
|
@@ -18,5 +19,13 @@ export default defineEventHandler(async (event) => {
|
|
|
18
19
|
// 预读 body 挂到 event(插件 handler 无 h3 导入能力,经 event.awBody 消费)
|
|
19
20
|
const awBody = await readBody(event).catch(() => undefined)
|
|
20
21
|
;(event as Record<string, unknown>).awBody = awBody
|
|
21
|
-
|
|
22
|
+
try {
|
|
23
|
+
return await handler(event)
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
// 问题日志:插件归属 + 路由 + 原始原因(运维按 name 即可定位到具体插件目录)
|
|
27
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
28
|
+
host.logger?.error?.(`插件路由处理失败 [${name}] ${event.method} /api/plugins/${name}${path}: ${reason}`)
|
|
29
|
+
throw createError({ statusCode: 500, statusMessage: `插件路由处理失败(${name}): ${reason}` })
|
|
30
|
+
}
|
|
22
31
|
})
|