dsh-plc-decode 0.2.1 → 0.2.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.
@@ -0,0 +1,299 @@
1
+ /**
2
+ * S7-200 / S7-200 SMART 语句表(STL)→ 文字梯形图 + 机械讲解(纯函数,零依赖)
3
+ *
4
+ * 认得的写法:
5
+ * 触点 LD/LDN、A/AN、O/ON、NOT
6
+ * 逻辑堆栈 ALD、OLD、LPS、LRD、LPP
7
+ * 边沿 EU(画成 ┤P├)、ED(画成 ┤N├)
8
+ * 比较触点 西门子写法 LDD=/AD<>/OW>… 与梯形图写法 ==I/<D/>=R… 都认
9
+ * 输出 =(线圈)、S/R(置位复位,可带个数)、CALL(子程序与库块)
10
+ * 指令盒 MOV_、TON/TOF、CTU/CTD、INC/DEC、算术、移位… 一律按"盒+引脚"画
11
+ *
12
+ * 风格与 ladder 一致:触点与导线在左、盒画方框、引脚列两侧、值写在导线上。
13
+ * 认不出的写法不猜:按盒原样画,并在讲解里标"未识别"。
14
+ */
15
+
16
+ import { dw } from './ladder-render.mjs'
17
+
18
+ // ───────────────────────── 解析 ─────────────────────────
19
+
20
+ /** STL 文本 → 网络数组 [{ no, title, comments[], instrs[] }] */
21
+ export function parseStl(text) {
22
+ const nets = []
23
+ let cur = null
24
+ const open = (no, title = '') => { cur = { no, title, comments: [], instrs: [] }; nets.push(cur); return cur }
25
+ for (const raw0 of String(text ?? '').replace(/\r/g, '').split('\n')) {
26
+ const raw = raw0.trim()
27
+ if (!raw) continue
28
+ const nh = raw.match(/^(?:NETWORK|网络)\s*(\d+)\s*(.*)$/i)
29
+ if (nh) { open(Number(nh[1]), nh[2].trim()); continue }
30
+ if (raw.startsWith('//')) { (cur ?? open(nets.length + 1)).comments.push(raw.replace(/^\/\/+\s?/, '')); continue }
31
+ const body = raw.replace(/^[A-Za-z_][\w.]*:\s*/, '') // 去掉行标号
32
+ const mm = body.match(/^(\S+)\s*(.*)$/)
33
+ if (!mm) continue
34
+ const m = mm[1].toUpperCase()
35
+ const rest = mm[2].split(';')[0].trim() // 去掉行末注释
36
+ const ops = rest ? rest.split(',').map((s) => s.trim()).filter((s) => s !== '') : []
37
+ ;(cur ?? open(nets.length + 1)).instrs.push({ m, ops, raw })
38
+ }
39
+ return nets
40
+ }
41
+
42
+ // ───────────────────────── 指令表 ─────────────────────────
43
+
44
+ const CONTACT = { LD: 'no', LDN: 'nc', A: 'no', AN: 'nc', O: 'no', ON: 'nc' }
45
+
46
+ /** 比较触点的两种写法:LDD= / AD<> / OW> 与 ==I / <D / >=R */
47
+ function matchCompare(m) {
48
+ let x = m.match(/^(LD|A|O)?(==|<>|>=|<=|>|<)([BWDRIL]?)$/)
49
+ if (x) return { pre: x[1] ?? '', op: x[2], type: x[3] ?? '' }
50
+ x = m.match(/^(LD|A|O)([BWDRIL])(=|<>|>=|<=|>|<)$/)
51
+ if (x) return { pre: x[1], op: x[3], type: x[2] }
52
+ return null
53
+ }
54
+
55
+ /** 常见指令盒的引脚名;认不出就用 IN1/IN2/…/OUT 的中性名 */
56
+ const BOX_PINS = {
57
+ MOV_B: ['IN', 'OUT'], MOV_W: ['IN', 'OUT'], MOV_DW: ['IN', 'OUT'], MOV_R: ['IN', 'OUT'],
58
+ TON: ['IN', 'PT'], TOF: ['IN', 'PT'], TONR: ['IN', 'PT'],
59
+ CTU: ['CU', 'PV'], CTD: ['CD', 'PV'], CTUD: ['CU', 'PV'],
60
+ INC_B: ['IN', 'OUT'], INC_W: ['IN', 'OUT'], INC_DW: ['IN', 'OUT'],
61
+ DEC_B: ['IN', 'OUT'], DEC_W: ['IN', 'OUT'], DEC_DW: ['IN', 'OUT'],
62
+ '+I': ['IN1', 'OUT'], '-I': ['IN1', 'OUT'], '*I': ['IN1', 'OUT'], '/I': ['IN1', 'OUT'],
63
+ '+D': ['IN1', 'OUT'], '-D': ['IN1', 'OUT'], '*D': ['IN1', 'OUT'], '/D': ['IN1', 'OUT'],
64
+ '+R': ['IN1', 'OUT'], '-R': ['IN1', 'OUT'], '*R': ['IN1', 'OUT'], '/R': ['IN1', 'OUT'],
65
+ SHL_B: ['IN', 'N', 'OUT'], SHL_W: ['IN', 'N', 'OUT'], SHL_DW: ['IN', 'N', 'OUT'],
66
+ SHR_B: ['IN', 'N', 'OUT'], SHR_W: ['IN', 'N', 'OUT'], SHR_DW: ['IN', 'N', 'OUT'],
67
+ WAND_W: ['IN1', 'IN2', 'OUT'], WOR_W: ['IN1', 'IN2', 'OUT'], WXOR_W: ['IN1', 'IN2', 'OUT'],
68
+ B_I: ['IN', 'OUT'], I_B: ['IN', 'OUT'], I_DI: ['IN', 'OUT'], DI_I: ['IN', 'OUT'],
69
+ }
70
+ /** 末位引脚是"输出"(画在盒右侧)的指令 */
71
+ const RIGHT_PIN = /^(MOV_|INC_|DEC_|SHL_|SHR_|WAND_|WOR_|WXOR_|B_I|I_B|I_DI|DI_I|[+\-*/][IDRBW])/
72
+
73
+ // ───────────────────────── 建逻辑树 ─────────────────────────
74
+
75
+ const contactNode = (addr, nc) => ({ t: 'contact', label: addr ?? '', sym: nc ? '┤/├' : '┤ ├' })
76
+
77
+ /** 一条网络的指令流 → 梯级数组 [{ logic, out }] */
78
+ export function buildRungs(net) {
79
+ const stack = []
80
+ const rungs = []
81
+ const top = () => stack[stack.length - 1]
82
+ const setTop = (v) => { stack[stack.length - 1] = v }
83
+ const both = (fn) => { const b = stack.pop(); const a = stack.pop(); stack.push(fn(a, b)) }
84
+
85
+ for (const { m, ops } of net.instrs ?? []) {
86
+ if (m in CONTACT) {
87
+ if (m === 'LD' || m === 'LDN') stack.push(contactNode(ops[0], m === 'LDN'))
88
+ else if (m === 'A' || m === 'AN') setTop({ t: 'and', a: top(), b: contactNode(ops[0], m === 'AN') })
89
+ else setTop({ t: 'or', kids: [top(), contactNode(ops[0], m === 'ON')] })
90
+ continue
91
+ }
92
+ if (m === 'ALD') { both((a, b) => ({ t: 'and', a, b })); continue }
93
+ if (m === 'OLD') { both((a, b) => ({ t: 'or', kids: [a, b] })); continue }
94
+ if (m === 'LPS') { stack.push(top()); continue }
95
+ if (m === 'LRD') { setTop(stack[stack.length - 2] ?? top()); continue }
96
+ if (m === 'LPP') { stack.pop(); continue }
97
+ if (m === 'EU' || m === 'ED') { setTop({ t: 'edge', kind: m === 'EU' ? 'P' : 'N', a: top() }); continue }
98
+ if (m === 'NOT') { setTop({ t: 'not', a: top() }); continue }
99
+
100
+ const cmp = matchCompare(m)
101
+ if (cmp) {
102
+ const op = cmp.op === '=' ? '==' : cmp.op
103
+ const node = { t: 'contact', label: ops[0] ?? '', sym: `┤${op}${cmp.type} ${ops[1] ?? ''}├`, cmpOp: cmp.op, cmpTo: ops[1] ?? '' }
104
+ if (cmp.pre === 'LD' || !stack.length) stack.push(node)
105
+ else if (cmp.pre === 'O') setTop({ t: 'or', kids: [top(), node] })
106
+ else setTop({ t: 'and', a: top(), b: node })
107
+ continue
108
+ }
109
+
110
+ const logic = top() ?? null
111
+ if (m === '=') rungs.push({ logic, out: { kind: 'coil', addr: ops[0] ?? '' } })
112
+ else if (m === 'S' || m === 'R') rungs.push({ logic, out: { kind: m === 'S' ? 'set' : 'reset', addr: ops[0] ?? '', n: ops[1] } })
113
+ else if (m === 'CALL') rungs.push({ logic, out: { kind: 'call', name: ops[0] ?? '', args: ops.slice(1) } })
114
+ else rungs.push({ logic, out: { kind: 'box', m, ops } })
115
+ }
116
+ return rungs
117
+ }
118
+
119
+ // ───────────────────────── 画:逻辑树 → 多行块 ─────────────────────────
120
+
121
+ function rNode(n) {
122
+ if (!n) return { lines: [''], mid: 0, w: 0 }
123
+ if (n.t === 'contact') { const s = `${n.label} ${n.sym}`.trim(); return { lines: [s], mid: 0, w: dw(s) } }
124
+ if (n.t === 'not') {
125
+ const A = rNode(n.a); const tag = ' [NOT]'
126
+ const lines = A.lines.slice(); lines[A.mid] += tag
127
+ return { lines, mid: A.mid, w: A.w + dw(tag) }
128
+ }
129
+ if (n.t === 'edge') {
130
+ const A = rNode(n.a); const add = `──${n.kind === 'P' ? '┤P├' : '┤N├'}`
131
+ const lines = A.lines.slice(); lines[A.mid] += add
132
+ return { lines, mid: A.mid, w: A.w + dw(add) }
133
+ }
134
+ if (n.t === 'and') {
135
+ const A = rNode(n.a), B = rNode(n.b)
136
+ const mid = Math.max(A.mid, B.mid)
137
+ const offA = mid - A.mid, offB = mid - B.mid
138
+ const h = Math.max(A.lines.length + offA, B.lines.length + offB)
139
+ const lines = []
140
+ for (let i = 0; i < h; i++) {
141
+ const ia = i - offA, ib = i - offB
142
+ const la = ia >= 0 && ia < A.lines.length ? A.lines[ia] : ' '.repeat(A.w)
143
+ const lb = ib >= 0 && ib < B.lines.length ? B.lines[ib] : ' '.repeat(B.w)
144
+ lines.push(la + (i === mid ? '──' : ' ') + lb)
145
+ }
146
+ return { lines, mid, w: A.w + 2 + B.w }
147
+ }
148
+ if (n.t === 'or') {
149
+ const kids = (n.kids ?? []).map(rNode)
150
+ const w = Math.max(0, ...kids.map((k) => k.w))
151
+ const rows = []
152
+ const outRows = []
153
+ const branchRows = []
154
+ for (const k of kids) for (let i = 0; i < k.lines.length; i++) branchRows.push(k.lines[i])
155
+ const jAt = Math.floor(branchRows.length / 2)
156
+ branchRows.forEach((text, idx) => {
157
+ outRows.push({ kind: 'branch', text })
158
+ if (idx === jAt - 1 && jAt < branchRows.length) outRows.push({ kind: 'junction' })
159
+ })
160
+ if (!outRows.some((r) => r.kind === 'junction')) outRows.splice(Math.max(1, Math.ceil(outRows.length / 2)), 0, { kind: 'junction' })
161
+ for (const [i, r] of outRows.entries()) {
162
+ const first = i === 0, last = i === outRows.length - 1
163
+ const lc = first ? '┌' : last ? '└' : r.kind === 'junction' ? '┤' : '├'
164
+ const rc = first ? '┐' : last ? '┘' : r.kind === 'junction' ? '├' : '┤'
165
+ if (r.kind === 'junction') rows.push(lc + ' '.repeat(w + 2) + rc)
166
+ else rows.push(lc + '─' + r.text + ' '.repeat(Math.max(0, w - dw(r.text))) + '─' + rc)
167
+ }
168
+ const mid = outRows.findIndex((r) => r.kind === 'junction')
169
+ return { lines: rows, mid: mid < 0 ? Math.floor(rows.length / 2) : mid, w: w + 4 }
170
+ }
171
+ return { lines: ['?'], mid: 0, w: 1 }
172
+ }
173
+
174
+ // ───────────────────────── 画:输出(线圈 / 指令盒) ─────────────────────────
175
+
176
+ const MIN_VAL_W = 8
177
+ const HAS_ENO = /^(MOV_|INC_|DEC_|SHL_|SHR_|WAND_|WOR_|WXOR_|B_I|I_B|I_DI|DI_I|[+\-*/][IDRBW])/
178
+ const INST_BOX = /^(TON|TOF|TONR|CTU|CTD|CTUD)$/
179
+
180
+ /** 指令盒:左引脚、右引脚(有则画在下边一行);值写在导线上;EN 行留给进入的导线 */
181
+ function boxNode(out) {
182
+ let rows, name
183
+ if (out.kind === 'call') {
184
+ name = out.name
185
+ rows = [{ pinL: 'EN', pinR: '', val: null }, ...out.args.map((a, i) => ({ pinL: `P${i + 1}`, pinR: '', val: a }))]
186
+ } else if (INST_BOX.test(out.m)) { // 定时器/计数器:编号进盒名,不算引脚
187
+ name = `${out.m} ${out.ops[0] ?? ''}`
188
+ rows = [
189
+ { pinL: out.m.startsWith('CT') ? 'CU' : 'IN', pinR: '', val: null },
190
+ { pinL: out.m.startsWith('CT') ? 'PV' : 'PT', pinR: '', val: out.ops[1] ?? null },
191
+ ]
192
+ } else {
193
+ name = out.m
194
+ const pins = BOX_PINS[out.m] ?? out.ops.map((_, i) => (RIGHT_PIN.test(out.m) && i === out.ops.length - 1 ? 'OUT' : `IN${i + 1}`))
195
+ const rightOn = RIGHT_PIN.test(out.m) && pins.length >= 2
196
+ rows = [{ pinL: 'EN', pinR: HAS_ENO.test(out.m) ? 'ENO' : '', val: null }]
197
+ pins.forEach((p, i) => {
198
+ if (rightOn && i === pins.length - 1) rows.push({ pinL: '', pinR: p, val: null, valRight: out.ops[i] ?? null })
199
+ else rows.push({ pinL: p, pinR: '', val: out.ops[i] ?? null })
200
+ })
201
+ }
202
+ const vw = Math.max(MIN_VAL_W, ...rows.filter((r) => !r.valRight).map((r) => dw(r.val ?? '')))
203
+ const inside = Math.max(18, ...rows.map((r) => 2 + dw(r.pinL) + dw(r.pinR)))
204
+ const mkLine = (row) => {
205
+ const leftArea = row.valRight ? ' '.repeat(vw) : (row.val ? String(row.val).padEnd(vw) : '─'.repeat(vw))
206
+ const l = row.pinL ? ' ' + row.pinL : ''
207
+ const r = row.pinR ? row.pinR + ' ' : ''
208
+ const inner = l + ' '.repeat(Math.max(1, inside - dw(l) - dw(r))) + r
209
+ return leftArea + '──────│' + inner + '│' + (row.valRight ? '── ' + row.valRight : '')
210
+ }
211
+ const lines = [' '.repeat(vw + 6) + '┌' + '─'.repeat(inside) + '┐', mkLine(rows[0])]
212
+ const midRow = 1
213
+ lines.push(' '.repeat(vw + 6) + '│' + (' ' + name).padEnd(inside) + '│')
214
+ for (const r of rows.slice(1)) lines.push(mkLine(r))
215
+ lines.push(' '.repeat(vw + 6) + '└' + '─'.repeat(inside) + '┘')
216
+ return { lines, mid: midRow, w: dw(lines[1]) }
217
+ }
218
+
219
+ function outNode(out) {
220
+ if (!out) return { lines: [''], mid: 0, w: 0 }
221
+ if (out.kind === 'coil') { const s = `──( ${out.addr} )`; return { lines: [s], mid: 0, w: dw(s) } }
222
+ if (out.kind === 'set' || out.kind === 'reset') {
223
+ const s = `──( ${out.kind === 'set' ? 'S' : 'R'} ${out.addr}${out.n ? ', ' + out.n : ''} )`
224
+ return { lines: [s], mid: 0, w: dw(s) }
225
+ }
226
+ return boxNode(out)
227
+ }
228
+
229
+ // ───────────────────────── 讲解(机械) ─────────────────────────
230
+
231
+ function describeLogic(n) {
232
+ if (!n) return ''
233
+ if (n.t === 'contact') {
234
+ if (n.cmpOp) {
235
+ const w = { '==': '等于', '<>': '不等于', '>': '大于', '<': '小于', '>=': '不小于', '<=': '不大于' }[n.cmpOp] ?? n.cmpOp
236
+ return `${n.label} ${w} ${n.cmpTo}`
237
+ }
238
+ return `${n.label}${n.sym.includes('/') ? ' 断开' : ' 接通'}`
239
+ }
240
+ if (n.t === 'not') return `非(${describeLogic(n.a)})`
241
+ if (n.t === 'edge') return `${describeLogic(n.a)},取${n.kind === 'P' ? '上升' : '下降'}沿`
242
+ if (n.t === 'and') return `${describeLogic(n.a)} 且 ${describeLogic(n.b)}`
243
+ if (n.t === 'or') return `(${(n.kids ?? []).map((k) => (k?.t === 'and' || k?.t === 'or' ? `(${describeLogic(k)})` : describeLogic(k))).join(' 或 ')})`
244
+ return '?'
245
+ }
246
+
247
+ function describeOut(out) {
248
+ if (!out) return '(无输出)'
249
+ if (out.kind === 'coil') return `输出 ${out.addr} 得电`
250
+ if (out.kind === 'set') return `置位 ${out.addr}${out.n ? `(含 ${out.n} 位)` : ''}`
251
+ if (out.kind === 'reset') return `复位 ${out.addr}${out.n ? `(含 ${out.n} 位)` : ''}`
252
+ if (out.kind === 'call') return `调用 ${out.name}${out.args.length ? `(参数 ${out.args.join('、')})` : ''}`
253
+ const v = out.ops ?? []
254
+ if (/^MOV_(B|W|DW|R)$/.test(out.m)) return `把 ${v[0] ?? '?'} 送进 ${v[1] ?? '?'}`
255
+ if (/^(TON|TOF|TONR)$/.test(out.m)) return `${v[0] ?? '?'} 计时,PT=${v[1] ?? '?'}`
256
+ if (/^(CTU|CTD|CTUD)$/.test(out.m)) return `${v[0] ?? '?'} 计数,PV=${v[1] ?? '?'}`
257
+ if (/^(INC|DEC)_/.test(out.m)) return `${v[0] ?? '?'} ${out.m.startsWith('INC') ? '加 1' : '减 1'}${v[1] ? ` → ${v[1]}` : ''}`
258
+ return `${out.m} ${v.join(', ')}(未识别写法,按盒原样画)`
259
+ }
260
+
261
+ export function explainRung(rung) {
262
+ const cond = describeLogic(rung.logic)
263
+ return cond ? `当 ${cond} 时,${describeOut(rung.out)}。` : `${describeOut(rung.out)}。`
264
+ }
265
+
266
+ // ───────────────────────── 总入口 ─────────────────────────
267
+
268
+ function ladderLines(rung) {
269
+ const L = rNode(rung.logic), O = outNode(rung.out)
270
+ const mid = Math.max(L.mid, O.mid)
271
+ const h = Math.max(L.lines.length + (mid - L.mid), O.lines.length + (mid - O.mid))
272
+ const out = []
273
+ for (let i = 0; i < h; i++) {
274
+ const ia = i - (mid - L.mid), ib = i - (mid - O.mid)
275
+ const la = ia >= 0 && ia < L.lines.length ? L.lines[ia] : ' '.repeat(L.w)
276
+ const lb = ib >= 0 && ib < O.lines.length ? O.lines[ib] : ' '.repeat(O.w)
277
+ out.push(' ' + la + (i === mid ? '──' : ' ') + lb)
278
+ }
279
+ return out
280
+ }
281
+
282
+ /** 语句表 → 文字梯形图(+ 机械讲解)。opts.summary === false 时不出讲解行。 */
283
+ export function stlToLadder(text, opts = {}) {
284
+ const nets = parseStl(text)
285
+ const chunks = []
286
+ if (opts.title) chunks.push(opts.title)
287
+ for (const net of nets) {
288
+ const body = [`网络 ${net.no}${net.title ? ' ── ' + net.title : ''}`]
289
+ for (const c of net.comments) body.push(` // ${c}`)
290
+ const rungs = buildRungs(net)
291
+ if (!rungs.length) { body.push(' (该网络没有可识别的输出指令)'); chunks.push(body.join('\n')); continue }
292
+ for (const rung of rungs) {
293
+ body.push(...ladderLines(rung).map((l) => l.replace(/\s+$/, '')))
294
+ if (opts.summary !== false) body.push(` 讲解:${explainRung(rung)}`)
295
+ }
296
+ chunks.push(body.join('\n'))
297
+ }
298
+ return chunks.join('\n\n') + '\n'
299
+ }
package/hmi.js CHANGED
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * hmi-decode —— 触摸屏(HMI)工程入口
3
3
  *
4
- * 覆盖两类屏,跟 PLC 侧分开(PLC 工程走 plc-decode):
4
+ * 覆盖三类屏,跟 PLC 侧分开(PLC 工程走 plc-decode):
5
5
  * ① 西门子 Smart Line(WinCC flexible SMART)工程 `.hmismart`
6
6
  * —— 解码出画面/变量/连接清单;新旧两份出差异报告(起始画面/画面表/画面名/配套文件)。
7
7
  * ② 繁易 FStudio / Flexem 工程 `.fsprj`
8
8
  * —— 扫元件文本(按语种分槽)+出可导入文本库的多语言表。
9
+ * ③ 威纶通 EasyBuilder Pro 工程 `.emtp` / 编译产物 `.exob`
10
+ * —— .emtp 加密读不动,改读同目录编译产物 .exob:出画面、文字对象、报警事件与文字库条目。
9
11
  *
10
12
  * 只读源工程;产物按各自约定落 「<工作区>\解码归档\<工程名>\HMI解码\」或「<工程目录>\多语言产出\」。
11
13
  */
@@ -21,8 +23,8 @@ const TIMEOUT_MS = 300_000
21
23
  /** 本插件自带资源目录(hmismart 解码器 / hmismart 差异 / FStudio 脚本) */
22
24
  const ASSETS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'assets')
23
25
  const SCRIPT_FSTUDIO = join(ASSETS_DIR, 'fstudio_i18n.py')
24
- const SCRIPT_WINCC = join(ASSETS_DIR, 'wincc_i18n.py')
25
26
  const SCRIPT_FSTUDIO_TABLES = join(ASSETS_DIR, 'fstudio-tables.py')
27
+ const SCRIPT_EBPRO_DECODE = join(ASSETS_DIR, 'ebpro-decode.mjs')
26
28
 
27
29
  // ───────────────────────── 通用工具 ─────────────────────────
28
30
 
@@ -105,7 +107,7 @@ async function runNode(ctx, script, argv, workdir, signal, exec) {
105
107
 
106
108
  /** 跑 FStudio 的 Python 脚本(WinError 5 读锁重试一次)。 */
107
109
  async function runPy(ctx, argv, workdir, signal, exec) {
108
- const command = `& ${PY} ${psq(SCRIPT_FSTUDIO)} ${argv.map(psq).join(' ')}`
110
+ const command = `$env:PYTHONIOENCODING='utf-8'; & ${PY} ${psq(SCRIPT_FSTUDIO)} ${argv.map(psq).join(' ')}`
109
111
  let r = await runShell(ctx, command, workdir, signal, exec)
110
112
  if (r.exitCode !== 0 && /PermissionError|WinError 5|OSError/.test(`${r.err} ${r.out}`)) {
111
113
  r = await runShell(ctx, command, workdir, signal, exec)
@@ -115,17 +117,6 @@ async function runPy(ctx, argv, workdir, signal, exec) {
115
117
  }
116
118
 
117
119
  /** 跑本插件 assets 里的任意 Python 脚本(WinError 5 读锁重试一次)。 */
118
- async function runPyScript(ctx, script, argv, workdir, signal, exec) {
119
- if (!existsSync(script)) throw new Error(`插件资源缺失: ${script}`)
120
- const command = `& ${PY} ${psq(script)} ${argv.map(psq).join(' ')}`
121
- let r = await runShell(ctx, command, workdir, signal, exec)
122
- if (r.exitCode !== 0 && /PermissionError|WinError 5|OSError/.test(`${r.err} ${r.out}`)) {
123
- r = await runShell(ctx, command, workdir, signal, exec)
124
- }
125
- if (r.exitCode !== 0) throw new Error(`python 退出码 ${r.exitCode}\n${r.err || r.out || '(无输出)'}`)
126
- return r.out
127
- }
128
-
129
120
  // ───────────────────────── 识别 ─────────────────────────
130
121
 
131
122
  /**
@@ -143,6 +134,8 @@ function identify(file) {
143
134
  // .mcp/.mcgs = MCGS / LSTP Studio(Microsoft Access 数据库,表里就是画面/变量/报警)
144
135
  if (low.endsWith('.nbp')) return { kind: 'nb', abs }
145
136
  if (low.endsWith('.mcp') || low.endsWith('.mcgs') || low.endsWith('.mcp.mcgs')) return { kind: 'mcgs', abs }
137
+ // 威纶通 EasyBuilder Pro:.emtp 工程本体加密读不动,解码取同工程编译产物 .exob
138
+ if (low.endsWith('.emtp') || low.endsWith('.exob')) return { kind: 'ebpro', abs }
146
139
  if (existsSync(join(abs, 'HMI', 'Frame'))) return { kind: 'fstudio', project: abs }
147
140
  const head = readFileSync(abs).subarray(0, 4)
148
141
  if (head.length === 4 && head[0] === 0x01 && head[1] === 0x0F && head[2] === 0x00 && head[3] === 0x00) {
@@ -150,7 +143,7 @@ function identify(file) {
150
143
  }
151
144
  throw new Error(
152
145
  `不是可识别的触摸屏工程(前 4 字节: ${[...head].map(b => b.toString(16).padStart(2, '0')).join(' ')}): ${file}\n`
153
- + '支持两类: .hmismart(西门子 WinCC flexible SMART 触摸屏工程)/ .fsprj 或含 HMI\\Frame 的 FStudio 工程目录。\n'
146
+ + '支持: .hmismart(西门子 WinCC flexible SMART)/ .fsprj 或含 HMI\\Frame 的 FStudio 工程目录 / .nbp(欧姆龙 NB)/ .mcp·.mcgs(MCGS)/ .emtp·.exob(威纶通 EasyBuilder Pro)。\n'
154
147
  + '提示: PLC 工程(.project / .dvp / .smart / .smartlib)请用 plc-decode。'
155
148
  )
156
149
  }
@@ -216,7 +209,7 @@ async function diffHmismart(ctx, oldAbs, newAbs, outDirArg, agent, signal) {
216
209
  /** 跑任意本插件自带的 Python 脚本(argv 走 shell 引号)。 */
217
210
  async function runPyFile(ctx, script, argv, workdir, signal, exec) {
218
211
  if (!existsSync(script)) throw new Error(`插件资源缺失: ${script}`)
219
- const { out, err, exitCode } = await runShell(ctx, `& ${PY} ${psq(script)} ${argv.map(psq).join(' ')}`, workdir, signal, exec)
212
+ const { out, err, exitCode } = await runShell(ctx, `$env:PYTHONIOENCODING='utf-8'; & ${PY} ${psq(script)} ${argv.map(psq).join(' ')}`, workdir, signal, exec)
220
213
  if (exitCode !== 0) throw new Error(`脚本退出码 ${exitCode}\n${err || out || '(无输出)'}`)
221
214
  return out
222
215
  }
@@ -239,35 +232,20 @@ async function scanFstudio(ctx, fileOrDir, outDirArg, exec) {
239
232
  return `扫描完成:\n${out}\n明细 JSON: ${jsonPath}`
240
233
  }
241
234
 
242
- async function makeFstudio(ctx, fileOrDir, outDirArg, dict, exec) {
243
- const proj = requireFstudio(fileOrDir)
244
- const outDir = outDirArg ? resolve(outDirArg) : join(proj, '多语言产出')
245
- mkdirSync(outDir, { recursive: true })
246
- const argv = ['make', proj, outDir]
247
- if (dict) argv.push(resolve(dict))
248
- const out = await runPy(ctx, argv, sessionBase(exec?.agent), exec?.signal, exec)
249
- return `FStudio 多语言产出:\n${out}\n`
250
- + `对照表: ${join(outDir, '元件多语言对照表.xlsx')}\n`
251
- + `导入表: ${join(outDir, '文本库导入表.xlsx')}(FStudio 文本库 → 导入)\n`
252
- + '下一步:把导入表导进文本库,再逐元件勾「使用文本库」指到条目。'
253
- }
235
+ // ───────────────────────── 威纶通 EasyBuilder Pro .emtp / .exob ─────────────────────────
254
236
 
255
237
  /**
256
- * 已导出的文本库表换语种列:第三语种(如韩语)整列换掉,其余列原样。
257
- * 词典给第三语种译文(json 的 "pt"/"ko",或 py 的 ELEMENT_PT/ELEMENT_KO)。
238
+ * EBpro 工程解码:读同工程编译产物 .exob(.emtp 本体加密读不动),出画面/文字对象/报警事件/文字库。
239
+ * 默认产物落「<工作区>\解码归档\<工程名>\HMI解码\」。只读源文件。
258
240
  */
259
- async function retagFstudio(ctx, src, outArg, dict, lang, minStatus, exec) {
260
- const abs = resolve(src)
261
- if (!existsSync(abs)) throw new Error(`源表不存在: ${src}`)
262
- if (!dict) throw new Error('参数 dict 必填:给第三语种词典(.json 的 {"pt": {…}} 或 .py 的 ELEMENT_PT = {…})')
263
- const dpath = resolve(dict)
264
- if (!existsSync(dpath)) throw new Error(`词典不存在: ${dpath}`)
265
- const out = outArg ? resolve(outArg) : join(dirname(abs), `文本库导入表_${lang || 'third'}.xlsx`)
266
- const argv = ['retag', abs, out, dpath]
267
- if (lang) argv.push(lang)
268
- if (Number.isFinite(minStatus) && minStatus >= 2) argv.push(String(Math.trunc(minStatus)))
269
- const log = await runPy(ctx, argv, sessionBase(exec?.agent), exec?.signal, exec)
270
- return `${log}\n导入: FStudio → 文本库 → 导入 → 选这份表(列名已按目标语种 LCID 写好)。`
241
+ async function decodeEbpro(ctx, file, outDirArg, exobArg, exec) {
242
+ const abs = resolve(file)
243
+ const target = outDirArg ? resolve(outDirArg) : join(defaultArchiveDir(abs, sessionBase(exec?.agent)), 'HMI解码')
244
+ mkdirSync(target, { recursive: true })
245
+ const argv = [abs, target]
246
+ if (exobArg) argv.push('--exob', resolve(exobArg))
247
+ const out = await runNode(ctx, SCRIPT_EBPRO_DECODE, argv, dirname(target), exec?.signal, exec)
248
+ return `${out}\n产物目录: ${target}\n先读 HMI解码报告.md;原始表在 10-画面.tsv / 11-文字对象.tsv / 12-报警事件.tsv / 13-文字库.tsv。`
271
249
  }
272
250
 
273
251
  // ───────────────────────── 注册面 ─────────────────────────
@@ -278,16 +256,6 @@ const SKILLS = [
278
256
  description: '触摸屏工程读取工作流:.hmismart 全解(画面/变量/连接)与新旧差异、.srt 项目文本旁路读法、FStudio 多语言入口。接触摸屏(HMI)工程任务时加载。',
279
257
  file: 'hmi-decode-workflow.md',
280
258
  },
281
- {
282
- name: 'fstudio-i18n-workflow',
283
- description: 'FStudio/Flexem 触摸屏多语言翻译的正确工作流:三步(扫元件文本→出对照表→出可导入表)与踩坑清单(列名逐字符一致、元件文字写死不走文本库、原工程英文槽常是错填)。接多语言/翻译类触摸屏任务时加载。',
284
- file: 'fstudio-i18n-workflow.md',
285
- },
286
- {
287
- name: 'wincc-text-i18n-workflow',
288
- description: '西门子 WinCC flexible/SMART 项目文本(Screens/Others/Messages 三件套 xls)翻译流程:格式契约(第 1 列是匹配键、换行是真换行、$_ignore 不许动)、分型判据、错译串位的识别、一源一译、术语表与出 .xls 的写法。接「触摸屏项目文本翻译/英文没填/导回英文」类任务时加载。',
289
- file: 'wincc-text-i18n-workflow.md',
290
- },
291
259
  ]
292
260
 
293
261
  const TEXT_OUTPUT = {
@@ -304,11 +272,11 @@ export function registerHmi(ctx) {
304
272
  // ── 工具一:触摸屏工程统一入口(.hmismart 解码 / FStudio 文本扫描)──
305
273
  ctx.tools.register({
306
274
  name: 'hmi-decode',
307
- description: '触摸屏/组态(HMI)工程统一入口:.hmismart(西门子 WinCC flexible SMART)→ 画面/变量/连接;.fsprj 或含 HMI\\Frame 的 FStudio 工程目录 → 扫元件文本;.nbp(欧姆龙 NB-Designer,XML)→ 画面/控件/地址;.mcp / .mcgs(MCGS·LSTP Studio,Access 库)→ 画面/变量/报警/策略。用户提供触摸屏或组态工程文件、要求读取/理解/核对其内容时调用。只读,不改动工程文件。PLC 工程请用 plc-decode。',
275
+ description: '触摸屏/组态(HMI)工程统一入口:.hmismart(西门子 WinCC flexible SMART)→ 画面/变量/连接;.fsprj 或含 HMI\\Frame 的 FStudio 工程目录 → 扫元件文本;.nbp(欧姆龙 NB-Designer,XML)→ 画面/控件/地址;.mcp / .mcgs(MCGS·LSTP Studio,Access 库)→ 画面/变量/报警/策略;.emtp / .exob(威纶通 EasyBuilder Pro)→ 画面/文字对象/报警事件/文字库(读编译产物)。用户提供触摸屏或组态工程文件、要求读取/理解/核对其内容时调用。只读,不改动工程文件。PLC 工程请用 plc-decode。',
308
276
  parameters: {
309
277
  type: 'object',
310
278
  properties: {
311
- file: { type: 'string', description: '触摸屏工程:.hmismart 路径,或 .fsprj / FStudio 工程目录(含 HMI\\Frame 的那一层)。' },
279
+ file: { type: 'string', description: '触摸屏工程:.hmismart,或 .fsprj / FStudio 工程目录(含 HMI\\Frame 的那一层),或 .nbp / .mcp / .mcgs / .emtp / .exob。' },
312
280
  outDir: { type: 'string', description: '可选:输出目录;缺省 .hmismart 写工作区「解码归档\\<工程文件名>\\HMI解码\\」,FStudio 写 <工程目录>\\多语言产出\\。' },
313
281
  },
314
282
  required: ['file'],
@@ -335,11 +303,15 @@ export function registerHmi(ctx) {
335
303
  text: `已解码完成(类型: ${kind === 'mcgs' ? 'mcgs / MCGS·LSTP 组态工程(Access 库)' : 'nb / NB-Designer 工程(XML)'}):\n${out}\n产物目录: ${target}`,
336
304
  }
337
305
  }
306
+ if (kind === 'ebpro') {
307
+ const text = await decodeEbpro(ctx, abs, outDir, undefined, exec)
308
+ return { ok: true, text: `已解码完成(类型: ebpro / 威纶通 EasyBuilder Pro):\n${text}` }
309
+ }
338
310
  const tables = await decodeFstudio(ctx, project, outDir, exec)
339
311
  const scan = await scanFstudio(ctx, project, undefined, exec)
340
312
  return {
341
313
  ok: true,
342
- text: `FStudio 工程(类型: fstudio / Flexem FStudio):\n${tables.text}\n产物目录: ${tables.target}\n${scan}\n下一步: 要出对照表与可导入文本库的表,用工具 fstudio-i18n。`,
314
+ text: `FStudio 工程(类型: fstudio / Flexem FStudio):\n${tables.text}\n产物目录: ${tables.target}\n${scan}\n下一步: 要看各语种元件文本明细,用 hmi-decode 对同一工程的扫描输出。`,
343
315
  }
344
316
  },
345
317
  })
@@ -372,15 +344,16 @@ export function registerHmi(ctx) {
372
344
  },
373
345
  })
374
346
 
375
- // ── 工具三:FStudio 只扫元件文本,先看清要翻什么 ──
347
+ // ── 工具七:威纶通 EasyBuilder Pro 工程解码(读编译产物 .exob)──
376
348
  ctx.tools.register({
377
- name: 'fstudio-scan-texts',
378
- description: '扫描 FStudio/Flexem 工程里全部静态文本元件的文字(按语种分槽):每个元件的各语种槽内容、以及文本库现状,出明细 JSON。用于「先看清有哪些文字要翻、哪些没进库」。只读。',
349
+ name: 'ebpro-decode',
350
+ description: '威纶通 EasyBuilder Pro(EBpro)工程解码:.emtp 工程本体压缩+加密读不动,改读同工程编译产物 .exob,解出画面清单(按大号标签对象推定)、每个文字对象(所属画面/类型码/语言槽/文字/文字度量/偏移)、报警事件文本、文字库条目;产物为「HMI解码报告.md + 00-格式说明.md + 10-画面.tsv / 11-文字对象.tsv / 12-报警事件.tsv / 13-文字库.tsv + 08-统计.json」。用户给 .emtp / .exob 要求「解码 / 读画面 / 看有哪些画面和文字」时调用。只读源文件。',
379
351
  parameters: {
380
352
  type: 'object',
381
353
  properties: {
382
- file: { type: 'string', description: 'FStudio 工程:.fsprj 路径或工程目录。' },
383
- outDir: { type: 'string', description: '可选:输出目录;缺省写 <工程目录>\\多语言产出\\。' },
354
+ file: { type: 'string', description: 'EBpro 工程 .emtp 路径 / 编译产物 .exob 路径 / 工程目录(给 .emtp 或目录时自动找同名 .exob)。' },
355
+ outDir: { type: 'string', description: '可选:输出目录;缺省写工作区「解码归档\\<工程文件名>\\HMI解码\\」。' },
356
+ exob: { type: 'string', description: '可选:显式指定编译产物 .exob(与 file 不同名时用)。' },
384
357
  },
385
358
  required: ['file'],
386
359
  additionalProperties: false,
@@ -390,109 +363,9 @@ export function registerHmi(ctx) {
390
363
  const file = typeof args.file === 'string' ? args.file.trim() : ''
391
364
  if (!file) throw new Error('参数 file 不能为空')
392
365
  if (!existsSync(resolve(file))) throw new Error(`路径不存在: ${file}`)
393
- const outDir = typeof args.outDir === 'string' && args.outDir.trim() ? resolve(args.outDir.trim()) : undefined
394
- return { ok: true, text: await scanFstudio(ctx, file, outDir, exec) }
395
- },
396
- })
397
-
398
- // ── 工具四:FStudio 多语言一步到位,出对照表 + 可导入表 ──
399
- ctx.tools.register({
400
- name: 'fstudio-i18n',
401
- description: 'FStudio/Flexem 触摸屏多语言:扫工程里全部静态文本元件(按语种分槽的中/英/韩等文字),出多语言对照表与可直接导入 FStudio 文本库的表(列名与软件导出的 TextLib 表一致)。用户说「触摸屏多语言/翻译」「文本库没翻译」「画面文字切语种不变」「元件文本入库」时调用。只读工程,产物写输出目录。',
402
- parameters: {
403
- type: 'object',
404
- properties: {
405
- file: { type: 'string', description: 'FStudio 工程:.fsprj 路径,或工程目录(含 HMI\\Frame 的那一层)。' },
406
- outDir: { type: 'string', description: '可选:输出目录;缺省写 <工程目录>\\多语言产出\\。' },
407
- dict: { type: 'string', description: '可选:词典文件(.py 含 ELEMENT_EN/LONG_EN、ELEMENT_PT/LONG_PT 或 ELEMENT_KO/LONG_KO,或 .json 平表 / {"en":{…},"pt":{…}}),给中文配译文。' },
408
- },
409
- required: ['file'],
410
- additionalProperties: false,
411
- },
412
- output: TEXT_OUTPUT,
413
- async execute(args, exec) {
414
- const file = typeof args.file === 'string' ? args.file.trim() : ''
415
- if (!file) throw new Error('参数 file 不能为空')
416
- if (!existsSync(resolve(file))) throw new Error(`路径不存在: ${file}`)
417
- const outDir = typeof args.outDir === 'string' && args.outDir.trim() ? resolve(args.outDir.trim()) : undefined
418
- const dict = typeof args.dict === 'string' && args.dict.trim() ? args.dict.trim() : ''
419
- return { ok: true, text: await makeFstudio(ctx, file, outDir, dict, exec) }
420
- },
421
- })
422
-
423
- // ── 工具五:已导出的文本库表换语种列(第三语种整列换掉)──
424
- ctx.tools.register({
425
- name: 'fstudio-retag',
426
- description: '把 FStudio 已导出的文本库表(TextLib 表:Guid/Name/Status/各语种列)整列换语种:第三语种列(如韩语)换成词典给的目标语种(巴葡 pt / 葡葡 pt-pt / 俄 ru / 西 es …),中英文列、行序、Guid、Status 原样保留,出可直接导回文本库的表。用户说「这个导出的文本库表把韩语去掉换成葡萄牙语」「文本库表换语种/加语种」时调用。只读源表。',
427
- parameters: {
428
- type: 'object',
429
- properties: {
430
- src: { type: 'string', description: 'FStudio 导出的文本库表:.xls 或 .xlsx(含 Guid/Name/Status 列)。' },
431
- dict: { type: 'string', description: '词典:.json({"pt": {"中文":"译文"}} 或平表 {"中文":"译文"})或 .py(ELEMENT_PT = {…})。' },
432
- out: { type: 'string', description: '可选:输出 xlsx 路径;缺省写源表同目录「文本库导入表_<语种>.xlsx」。' },
433
- lang: { type: 'string', description: '可选:语种键 pt(巴西葡语)/pt-pt(葡萄牙葡语)/ko/ru/es/vi/th/ar;缺省按词典里有的语种自动取。' },
434
- minStatus: { type: 'number', description: '可选:把状态数不足的条目补到这个状态数(如 2)。新增状态行 Guid 留空、Status 递增、文字与首行相同——屏上开关/指示灯是双状态元件,库里条目只有一个状态时状态 1 会显示空白。' },
435
- },
436
- required: ['src', 'dict'],
437
- additionalProperties: false,
438
- },
439
- output: TEXT_OUTPUT,
440
- async execute(args, exec) {
441
- const src = typeof args.src === 'string' ? args.src.trim() : ''
442
- if (!src) throw new Error('参数 src 不能为空')
443
- const dict = typeof args.dict === 'string' ? args.dict.trim() : ''
444
- const out = typeof args.out === 'string' && args.out.trim() ? args.out.trim() : ''
445
- const lang = typeof args.lang === 'string' && args.lang.trim() ? args.lang.trim() : ''
446
- const minStatus = typeof args.minStatus === 'number' ? args.minStatus : 0
447
- return { ok: true, text: await retagFstudio(ctx, src, out, dict, lang, minStatus, exec) }
448
- },
449
- })
450
-
451
- // ── 工具六:WinCC flexible 项目文本导出表多语言(Screens/Others/Messages 三件套)──
452
- ctx.tools.register({
453
- name: 'wincc-text-i18n',
454
- description: 'WinCC flexible/SMART 项目文本导出表(Screens.xls / Others.xls / Messages.xls 三件套)多语言处理:scan 分型出待译清单(未译/空译/$_ignore/非中文同形,并标出一源多译)、apply 按「中文原文→译文」映射回写目标语言列并出同扩展名 .xls、check 自检(键列未动、无未译、无残留 $_ignore、无串位、无一源多译)。用户说「触摸屏翻译/项目文本翻译/英文没填/导回英文」时调用。产物写回源表同路径同名覆盖(改前备份原件)。',
455
- parameters: {
456
- type: 'object',
457
- properties: {
458
- action: { type: 'string', enum: ['scan', 'apply', 'check'], description: 'scan=分型出清单;apply=按映射回写;check=自检产物。' },
459
- files: { type: 'array', items: { type: 'string' }, description: 'scan/apply 用:项目文本导出表路径(.xls 或 .xlsx),可多个。' },
460
- dict: { type: 'string', description: 'apply 用:映射文件(.tsv 两列「源文本<TAB>译文」,或 .json 平表);换行在映射里写 \\n。' },
461
- outdir: { type: 'string', description: '可选:输出目录;缺省写会话工作区「翻译_项目文本\\」。' },
462
- src: { type: 'string', description: 'check 用:原始导出表路径。' },
463
- out: { type: 'string', description: 'check 用:回写后的产物路径。' },
464
- },
465
- required: ['action'],
466
- additionalProperties: false,
467
- },
468
- output: TEXT_OUTPUT,
469
- async execute(args, exec) {
470
- const action = String(args.action || 'scan').trim()
471
- const files = Array.isArray(args.files) ? args.files.filter(x => typeof x === 'string' && x.trim()).map(x => x.trim()) : []
472
- const base = sessionBase(exec?.agent)
473
- const outdir = typeof args.outdir === 'string' && args.outdir.trim()
474
- ? resolve(args.outdir.trim())
475
- : join(base, '翻译_项目文本')
476
- if (action === 'scan' || action === 'apply') {
477
- if (files.length === 0) throw new Error('scan/apply 需要 files(项目文本导出表路径)')
478
- for (const f of files) if (!existsSync(resolve(f))) throw new Error(`文件不存在: ${f}`)
479
- if (action === 'scan') {
480
- return { ok: true, text: await runPyScript(ctx, SCRIPT_WINCC, ['scan', '--files', ...files.map(f => resolve(f)), '--outdir', outdir], base, exec?.signal, exec) }
481
- }
482
- const dict = typeof args.dict === 'string' && args.dict.trim() ? resolve(args.dict.trim()) : ''
483
- if (!dict) throw new Error('apply 需要 dict(映射文件 .tsv/.json)')
484
- if (!existsSync(dict)) throw new Error(`映射文件不存在: ${dict}`)
485
- return { ok: true, text: await runPyScript(ctx, SCRIPT_WINCC, ['apply', '--files', ...files.map(f => resolve(f)), '--dict', dict, '--outdir', outdir], base, exec?.signal, exec) }
486
- }
487
- if (action === 'check') {
488
- const src = typeof args.src === 'string' && args.src.trim() ? resolve(args.src.trim()) : ''
489
- const out = typeof args.out === 'string' && args.out.trim() ? resolve(args.out.trim()) : ''
490
- if (!src || !out) throw new Error('check 需要 src 与 out')
491
- if (!existsSync(src)) throw new Error(`文件不存在: ${src}`)
492
- if (!existsSync(out)) throw new Error(`文件不存在: ${out}`)
493
- return { ok: true, text: await runPyScript(ctx, SCRIPT_WINCC, ['check', '--src', src, '--out', out, '--outdir', outdir], base, exec?.signal, exec) }
494
- }
495
- throw new Error(`未知 action: ${action}(可用 scan / apply / check)`)
366
+ const outDir = typeof args.outDir === 'string' && args.outDir.trim() ? args.outDir.trim() : ''
367
+ const exob = typeof args.exob === 'string' && args.exob.trim() ? args.exob.trim() : ''
368
+ return { ok: true, text: await decodeEbpro(ctx, file, outDir, exob, exec) }
496
369
  },
497
370
  })
498
371
 
@@ -540,26 +413,6 @@ export function registerHmi(ctx) {
540
413
  },
541
414
  })
542
415
 
543
- // ── 技能(两篇资产)──
544
- ctx.commands.register({
545
- name: 'fstudio-retag',
546
- description: '把 FStudio 导出的文本库表整列换语种(如去掉韩语换成葡萄牙语),可顺带补足状态行',
547
- input: { hint: '<导出的TextLib表> <词典.json> [输出.xlsx] [语种键 pt/pt-pt/ko/ru] [最少状态数 如2]' },
548
- handler: async ({ rawInput, agent }) => {
549
- try {
550
- const parts = splitArgs(rawInput)
551
- if (parts.length < 2) {
552
- return ({ kind: 'error', text: '用法: /fstudio-retag <导出的TextLib表.xls> <词典.json> [输出.xlsx] [语种键] [最少状态数](带空格路径请加引号)' })
553
- }
554
- const minStatus = parts[4] && /^\d+$/.test(parts[4]) ? Number(parts[4]) : 0
555
- const text = await retagFstudio(ctx, parts[0], parts[2], parts[1], parts[3], minStatus, { agent })
556
- return { kind: 'success', text }
557
- } catch (error) {
558
- return ({ kind: 'error', text: error instanceof Error ? error.message : String(error) })
559
- }
560
- },
561
- })
562
-
563
416
  ctx.skills.registerProvider(() => ({
564
417
  name: 'hmi-decode',
565
418
  list: () => Promise.resolve(SKILLS.map(s => ({