dsh-plc-decode 0.2.0 → 0.2.2

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/index.js CHANGED
@@ -19,17 +19,60 @@
19
19
  import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs'
20
20
  import { createHash } from 'node:crypto'
21
21
  import { inflateSync } from 'node:zlib'
22
- import { tmpdir } from 'node:os'
22
+ import { homedir, tmpdir } from 'node:os'
23
23
  import { basename, dirname, extname, isAbsolute, join, resolve } from 'node:path'
24
24
  import { fileURLToPath } from 'node:url'
25
25
  import z from '@deepseek-ai/schemastery'
26
26
  import { writeSmartPack, writeSourcePack } from './assets/decode-pack.mjs'
27
27
  import { registerHmi } from './hmi.js'
28
+ import { apply as applyLadder, maybeLadderHint } from './ladder.js'
28
29
 
29
30
  export const name = 'dsh-plc-decode'
30
31
  export const inject = ['skills', 'tools', 'commands', 'shell', 'sandboxPolicy']
31
32
 
32
33
  /** 报错尾行:插件出问题时,使用者第一眼看的就是错误文本。 */
34
+
35
+ /* ---------- 校准表:解不出的字节/功能码 → 名字($DSH_HOME/plc-calibration.json) ---------- */
36
+ const CALIB_FILE = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'plc-calibration.json')
37
+ const CALIB_CMD = 'plc-calibrate'
38
+ function readCalib() { try { return JSON.parse(readFileSync(CALIB_FILE, 'utf8')) } catch { return {} } }
39
+ function writeCalib(obj) { try { mkdirSync(dirname(CALIB_FILE), { recursive: true }); writeFileSync(CALIB_FILE, JSON.stringify(obj, null, 2), 'utf8') } catch { /* 写不进去不影响解码 */ } }
40
+ function calibArgs() { try { if (!existsSync(CALIB_FILE)) writeCalib({}) } catch {} ; return existsSync(CALIB_FILE) ? ['--calib', CALIB_FILE] : [] }
41
+ /** 从报告里捞出「没解出来」的字节/功能码(DVP 未识别记录表 + 未定名功能码 ??XX)。 */
42
+ function collectUnknowns(text) {
43
+ const out = []
44
+ const seen = new Set()
45
+ for (const m of text.matchAll(/^\s*×(\d+)\s+([0-9a-f]{2}(?: [0-9a-f]{2})+)\s+首见/mg)) {
46
+ if (seen.has(m[2])) continue
47
+ seen.add(m[2]); out.push({ key: m[2], count: Number(m[1]) })
48
+ }
49
+ for (const m of text.matchAll(/\?\?([0-9A-F]{2})/g)) {
50
+ const k = 'cmd:' + m[1]
51
+ if (seen.has(k)) continue
52
+ seen.add(k); out.push({ key: k, count: 1 })
53
+ }
54
+ return out
55
+ }
56
+ /** 解码后追加「要照片 + 记校准」的提示,并把待补项写进校准表。 */
57
+ function calibrationHint(format, reportText, source) {
58
+ if (!reportText) return ''
59
+ const un = collectUnknowns(reportText)
60
+ if (!un.length) return ''
61
+ const calib = readCalib()
62
+ const known = calib[format] ?? {}
63
+ calib.__pending = calib.__pending ?? {}
64
+ calib.__pending[format] = calib.__pending[format] ?? {}
65
+ for (const u of un) if (!known[u.key]) calib.__pending[format][u.key] = { count: u.count, source: source ?? '', at: new Date().toISOString() }
66
+ writeCalib(calib)
67
+ const list = un.slice(0, 8).map((u) => ' ×' + u.count + ' ' + u.key).join('\n')
68
+ return '\n\n⚠ 有 ' + un.length + ' 类没解出来(已记进校准表待补):\n' + list
69
+ + '\n要补:在软件里对这几处截图(梯形图局部或指令表)发我,我按图对账。'
70
+ + '\n记法:/' + CALIB_CMD + ' <格式> "<字节>" "<指令名>" [备注] —— 记下后,下次同样的字节直接出名字。'
71
+ }
72
+ function reportHint(outd, format, src) {
73
+ try { const f = join(outd, '解码报告.md'); return existsSync(f) ? calibrationHint(format, readFileSync(f, 'utf8'), src) : '' } catch { return '' }
74
+ }
75
+
33
76
  const FEEDBACK_HINT = '遇到问题:加 QQ 号 2171960697,或开 Issue https://github.com/taoziliyouchong/dsh-plc-decode/issues'
34
77
 
35
78
  /** 插件配置(DSH 官方 Config 机制:cordis.patch.yml 里本插件行的 config 传进 apply)。
@@ -37,6 +80,8 @@ const FEEDBACK_HINT = '遇到问题:加 QQ 号 2171960697,或开 Issue https
37
80
  export const Config = z.object({
38
81
  archiveDir: z.string().default('解码归档')
39
82
  .description('解码产物归档根目录。相对路径按当前会话工作区解析,绝对路径原样使用。默认「解码归档」。'),
83
+ ladder: z.boolean().default(false)
84
+ .description('是否启用梯形图渲染(默认关闭)。开启后可用 ladder-auto / ladder-dvp / ladder-from-stl / ladder-render;也可用 /ladder on 随时切换。'),
40
85
  })
41
86
 
42
87
  /** 生效中的归档根目录;apply 时由 config.archiveDir 覆盖,未配置即默认值(与旧版行为一致)。 */
@@ -232,7 +277,7 @@ async function decodeCodesysProject(ctx, projPath, signal, exec, outDir, base) {
232
277
  const outd = outDir || defaultArchiveDir(abs, base)
233
278
  precreateOutDir(outd, kind)
234
279
  const result = kind === 'dvp'
235
- ? await runPy(ctx, 'dvp_decode.py', [abs, '-o', outd], dir, signal, exec)
280
+ ? await runPy(ctx, 'dvp_decode.py', [abs, '-o', outd, ...calibArgs()], dir, signal, exec)
236
281
  : await runPy(ctx, 'read_project.py', [abs, outd], dir, signal, exec)
237
282
  // 加密 .project(不是 zip,整文件高熵):read_project.py 报 BadZipFile。
238
283
  // 强硬解码通道:同目录常有 <工程>.precompilecache(zip),里面有全部标识符与中文名,
@@ -263,7 +308,7 @@ async function decodeCodesysProject(ctx, projPath, signal, exec, outDir, base) {
263
308
  async function decodeDvpText(ctx, dvpPath, signal, exec) {
264
309
  const abs = requireDvp(dvpPath, '需要一个 .dvp 工程文件路径(带空格请加引号)')
265
310
  const dir = dirname(abs)
266
- const result = await runPy(ctx, 'dvp_decode.py', [abs], dir, signal, exec)
311
+ const result = await runPy(ctx, 'dvp_decode.py', [abs, ...calibArgs()], dir, signal, exec)
267
312
  if (!result.ok) throw new Error(result.text)
268
313
  return { abs, dir, text: result.text }
269
314
  }
@@ -2616,6 +2661,9 @@ export function apply(ctx, config = {}) {
2616
2661
  // 触摸屏(HMI)工程解码:画面/控件/变量/地址,与 PLC 侧共用同一插件行(hmi.js)。
2617
2662
  registerHmi(ctx)
2618
2663
 
2664
+ // 梯形图渲染/自动成图(ladder.js):默认关闭,/ladder on 打开。
2665
+ applyLadder(ctx, cfg)
2666
+
2619
2667
  // ── 工具一:统一入口(四格式自动识别分发)──
2620
2668
  ctx.tools.register({
2621
2669
  name: 'plc-decode',
@@ -2651,7 +2699,7 @@ export function apply(ctx, config = {}) {
2651
2699
  if (kind === 'project' || kind === 'dvp') {
2652
2700
  const result = await decodeCodesysProject(ctx, abs, exec.signal, exec, outDir, sessionBase(exec?.agent))
2653
2701
  const tail = String(result.kind).includes('加密') ? '00-目录.md' : '00-索引.md'
2654
- return { ok: true, text: `已解码完成(类型: ${result.kind})\n输出目录: ${result.outd}\n${result.text}\n下一步: 先读 ${join(result.outd, tail)}` }
2702
+ return { ok: true, text: `已解码完成(类型: ${result.kind})\n输出目录: ${result.outd}\n${result.text}\n下一步: 先读 ${join(result.outd, tail)}` + maybeLadderHint() + reportHint(result.outd, result.kind === 'dvp' ? 'dvp' : 'codesys', abs) }
2655
2703
  }
2656
2704
  if (kind === 'hmismart') {
2657
2705
  throw new Error(
@@ -2662,7 +2710,7 @@ export function apply(ctx, config = {}) {
2662
2710
  )
2663
2711
  }
2664
2712
  const result = kind === 'smart' ? decodeSmartFile(abs, outDir, sessionBase(exec?.agent)) : decodeSmartLib(abs, outDir, sessionBase(exec?.agent))
2665
- return { ok: true, text: `已解码完成(类型: ${kind}):\n${result.proceed}` }
2713
+ return { ok: true, text: `已解码完成(类型: ${kind}):\n${result.proceed}` + maybeLadderHint() + reportHint(result.outdir ?? outDir ?? join(sessionBase(exec?.agent), '解码归档'), 'smart', abs) }
2666
2714
  },
2667
2715
  })
2668
2716
 
@@ -3234,11 +3282,7 @@ export function apply(ctx, config = {}) {
3234
3282
  `解析方式: ${isAbsolute(ARCHIVE_ROOT) ? '绝对路径,原样使用' : `相对路径,按会话工作区解析(本进程 cwd = ${process.cwd()})`}`,
3235
3283
  '默认落点: <归档根>\\<工程文件所在文件夹名>\\(同一文件夹里有多个工程文件时用 <文件夹名>_<文件名>)',
3236
3284
  '',
3237
- '改配置(改完重启 dsh 生效):在 DSH profile 的 cordis.patch.yml 里给本插件那行加 config:',
3238
- ' - id: dsh-plc-decode',
3239
- ' name: dsh-plc-decode',
3240
- ' config:',
3241
- ' archiveDir: D:/plc/解码归档',
3285
+ '改配置(改完重启 dsh 生效):在 DSH profile 的 cordis.patch.yml 里给本插件那行加 config,只写 archiveDir(归档根目录)。',
3242
3286
  '',
3243
3287
  '只改这一次、不写配置:解码时传 outDir 参数(工具参数或 /plc-decode 的第二个参数)。',
3244
3288
  ].join('\n'),
@@ -3432,6 +3476,23 @@ export function apply(ctx, config = {}) {
3432
3476
  }
3433
3477
  },
3434
3478
  })
3479
+
3480
+ ctx.commands.register({
3481
+ name: 'plc-calibrate',
3482
+ description: '记录解不出的字节/功能码对应的名字(校准表):解完后下次同样的字节直接出名字。参数:格式(dvp/smart/codesys)、字节或 cmd:XX、名字,可选备注。',
3483
+ input: { hint: '<格式> <字节或 cmd:XX> <名字> [备注]' },
3484
+ handler: async ({ rawInput }) => {
3485
+ const parts = splitArgs(rawInput)
3486
+ if (parts.length < 3) return { kind: 'error', text: '用法:/plc-calibrate <格式> <字节或 cmd:XX> <名字> [备注]' }
3487
+ const [format, key, name, ...note] = parts
3488
+ const calib = readCalib()
3489
+ calib[format] = calib[format] ?? {}
3490
+ calib[format][key] = { name, note: note.join(' '), at: new Date().toISOString() }
3491
+ if (calib.__pending && calib.__pending[format]) delete calib.__pending[format][key]
3492
+ writeCalib(calib)
3493
+ return { kind: 'success', text: '已记入校准表:' + format + ' ' + key + ' → ' + name + '(下次解码生效)' }
3494
+ },
3495
+ })
3435
3496
  }
3436
3497
 
3437
3498
  // 内部导出(诊断/CLI 验证用,不参与工具的 ctx 注册):与工具相同的解码入口
package/ladder.js ADDED
@@ -0,0 +1,381 @@
1
+ /**
2
+ * ladder —— 文字梯形图渲染 + 自动成图(模块,由 index.js 调用)
3
+ * 默认关闭;开:/ladder on(立即生效)或 profile 配置 ladder: true。
4
+ *
5
+ * 两件事:
6
+ * 1) ladder-render 给"网络描述"→ 软件观感的文字梯形图(方框+两侧引脚+导线值)
7
+ * 2) ladder-from-stl 给 S7-200/SMART 语句表 → 每个网络的文字梯形图 + 机械讲解
8
+ *
9
+ * 为什么要有它:2026-09-18 用户认可了这一版式,并要求"保证要输出这种质量"——
10
+ * 手敲空格会对不齐、换会话会换风格;把版式固化成代码后,样式不会再漂。
11
+ *
12
+ * 只读输入 + 可选写出文本文件;不改动任何工程文件。
13
+ */
14
+
15
+ import { readFileSync, writeFileSync, mkdtempSync } from 'node:fs'
16
+ import { dirname, join, resolve } from 'node:path'
17
+ import { homedir, tmpdir } from 'node:os'
18
+ import { fileURLToPath } from 'node:url'
19
+ import { BUNDLED_SKILL_RANK } from '@deepseek-ai/dsh-skill'
20
+ import { renderSpec, lintSpec } from './assets/ladder-render.mjs'
21
+ import { stlToLadder } from './assets/stl-to-ladder.mjs'
22
+ import { autoLadder } from './assets/smart-ladder-auto.mjs'
23
+ import { dvpLadder } from './assets/dvp-ladder.mjs'
24
+
25
+ /** 梯形图功能开关:默认关闭;状态存 $DSH_HOME/ladder-state.json,/ladder on|off 切换,立即生效。 */
26
+ const STATE_FILE = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'ladder-state.json')
27
+ const HINT_LIMIT = 3
28
+ const OFF_TEXT = '梯形图功能未开启。开启:/ladder on(立即生效),或在 profile 的 cordis.patch.yml 里给本插件行加 config ladder: true 后重启。'
29
+ let ladderOn = false
30
+
31
+ function readState() {
32
+ try { return JSON.parse(readFileSync(STATE_FILE, 'utf8')) } catch { return {} }
33
+ }
34
+ function patchState(patch) {
35
+ try { writeFileSync(STATE_FILE, JSON.stringify({ ...readState(), ...patch }), 'utf8') } catch { /* 状态写不进去不影响渲染 */ }
36
+ }
37
+ function setLadderOn(v) { ladderOn = Boolean(v); patchState({ enabled: ladderOn }) }
38
+ export function ladderEnabled() { return ladderOn }
39
+ /** 解码后提示(默认关闭时前三次提示「可以看梯形图」,之后不再提示)。 */
40
+ export function maybeLadderHint() {
41
+ if (ladderOn) return ''
42
+ const st = readState()
43
+ const n = Number(st.hints) || 0
44
+ if (n >= HINT_LIMIT) return ''
45
+ patchState({ hints: n + 1 })
46
+ return `\n\n提示:这个工程可以出梯形图(按块/网络画成文字图)。要开就说 /ladder on,开启后随时可看(第 ${n + 1}/${HINT_LIMIT} 次提示)。`
47
+ }
48
+
49
+ /* ---------- 台达:先跑解码器拿结构化指令表,再画图(数据源与报告同一份) ---------- */
50
+ const PY = 'python'
51
+ const DVP_DECODER = join(dirname(fileURLToPath(import.meta.url)), 'assets', 'codesys-tools', 'dvp_decode.py')
52
+ let CTX = null
53
+ function psq(v) { return `'${String(v).replace(/'/g, "''")}'` }
54
+ async function runPy(script, argv, workdir, exec) {
55
+ const policy = CTX?.sandboxPolicy && exec?.agent ? CTX.sandboxPolicy.resolve({ session: exec.agent.session }) : undefined
56
+ const spec = CTX.shell.resolve({
57
+ command: `$env:PYTHONIOENCODING='utf-8'; & ${PY} ${psq(script)} ${argv.map(psq).join(' ')}`,
58
+ workdir,
59
+ timeoutMs: 300000,
60
+ ...(policy ? { sandboxPolicy: policy } : {}),
61
+ ...(exec?.signal ? { signal: exec.signal } : {}),
62
+ })
63
+ const res = await (await CTX.shell.execute(spec)).result()
64
+ return { out: String(res.stdout?.text ?? '').trim(), err: String(res.stderr?.text ?? '').trim(), exitCode: res.exitCode }
65
+ }
66
+ async function decodeDvpToJson(file, exec) {
67
+ const abs = resolve(file)
68
+ const dir = mkdtempSync(join(tmpdir(), 'dvp-il-'))
69
+ const json = join(dir, 'insts.json')
70
+ const r = await runPy(DVP_DECODER, [abs, '--json', json], dirname(abs), exec)
71
+ if (r.exitCode !== 0) throw new Error(`DVP 解码失败(退出码 ${r.exitCode}):${r.err || r.out}`)
72
+ return json
73
+ }
74
+
75
+ /** 随包技能:文字梯形的排版规范与自动成图。 */
76
+ const SKILLS = [
77
+ { name: 'ladder-workflow', file: 'ladder-workflow.md', description: '文字梯形图排版与自动成图:STL/工程文件转图、按 spec 渲染、交付前自检' },
78
+ ]
79
+
80
+ const SPEC_HINT = 'spec = { title?, legend?, outFile?, networks: [ { name, note?, rows: [ { left?, pinL?, pinR?, out? } ], comments?, plain? } ] }'
81
+
82
+ /** 从 spec 渲染;顺带做结构自检(发现问题不改写,附在文本末尾便于当场改)。 */
83
+ function render(spec) {
84
+ const text = renderSpec(spec)
85
+ const issues = lintSpec(spec)
86
+ return issues.length ? `${text}\n(结构自检 ${issues.length} 条:${issues.join(';')})\n` : text
87
+ }
88
+
89
+ /** 注册工具、命令与内置技能(默认关闭;关着时工具只回一行开启提示)。 */
90
+ export function apply(ctx, config = {}) {
91
+ CTX = ctx
92
+ const st = readState()
93
+ ladderOn = typeof st.enabled === 'boolean' ? st.enabled : config.ladder === true
94
+ const regTool = (def) => ctx.tools.register({ ...def, execute: (args, exec) => (ladderOn ? def.execute(args, exec) : { ok: true, text: OFF_TEXT }) })
95
+ const regCmd = (def) => ctx.commands.register({ ...def, handler: async (e) => (ladderOn ? def.handler(e) : { kind: 'error', text: OFF_TEXT }) })
96
+
97
+ regTool({
98
+ name: 'ladder-render',
99
+ description: '把网络描述渲染成软件观感的文字梯形图(触点/导线在左、指令盒画方框、引脚列两侧、值写在导线上)。用于任何要交"文字梯形图"的场合:PLC 程序讲解、改动方案、对照说明。样式固定、对齐由本工具计算(中文只在标题与说明行),排版不会再漂。传 spec JSON:networks[].rows[] 的 left=方框左侧的导线与触点(如 "Alw~:SM0.0 ──┤ ├")、pinL/pinR=方框左右引脚名(如 EN/ENO)、out=方框右侧去向(如 VW198);comments[] 是网络下方的说明行。可选 outFile 直接写盘。',
100
+ parameters: {
101
+ type: 'object',
102
+ properties: {
103
+ title: { type: 'string', description: '整份输出的标题(可选)。' },
104
+ legend: { type: 'boolean', description: '是否在图例行(默认 true,用固定那行符号说明)。' },
105
+ outFile: { type: 'string', description: '可选:把渲染结果写入该文件(UTF-8)。' },
106
+ networks: {
107
+ type: 'array',
108
+ description: '网络数组,按数组顺序渲染。',
109
+ items: {
110
+ type: 'object',
111
+ properties: {
112
+ name: { type: 'string', description: '网络标题,如 "网络 6"。' },
113
+ note: { type: 'string', description: '标题后的补充,如 "MOV_W(每个扫描周期都执行)"。' },
114
+ rows: {
115
+ type: 'array',
116
+ description: '行数组;一行画一根导线。',
117
+ items: {
118
+ type: 'object',
119
+ properties: {
120
+ left: { type: 'string', description: '方框左侧的导线与元件。触点画成 ┤ ├(常开)、┤P├(上升沿)、┤==I +0├(比较);只写 ASCII 与制表符,不要混中文。' },
121
+ pinL: { type: 'string', description: '方框左侧引脚名(软件的英文名,如 EN、First、DataPtr)。' },
122
+ pinR: { type: 'string', description: '方框右侧引脚名(如 ENO、Done、Error)。' },
123
+ out: { type: 'string', description: '方框右侧导线的去向(如 VW198、M0.1);只写地址/符号,不写中文。' },
124
+ },
125
+ additionalProperties: false,
126
+ },
127
+ },
128
+ comments: { type: 'array', items: { type: 'string' }, description: '网络下方的说明行(中文写在这里)。' },
129
+ plain: { type: 'boolean', description: '该网络没有指令盒(例如直接以线圈结尾)时设 true:每行只画导线,不画方框。' },
130
+ },
131
+ required: ['name', 'rows'],
132
+ additionalProperties: false,
133
+ },
134
+ },
135
+ },
136
+ required: ['networks'],
137
+ additionalProperties: false,
138
+ },
139
+ output: {
140
+ schema: {
141
+ type: 'object',
142
+ properties: { ok: { type: 'boolean' }, text: { type: 'string' } },
143
+ required: ['ok', 'text'],
144
+ additionalProperties: false,
145
+ },
146
+ render: (_args, value) => [{ type: 'text', text: value.text }],
147
+ },
148
+ async execute(args) {
149
+ const text = render({ ...args })
150
+ const outFile = String(args.outFile ?? '').trim()
151
+ if (outFile) {
152
+ try { writeFileSync(resolve(outFile), text, 'utf8') } catch (e) {
153
+ throw new Error(`写入失败 ${outFile}: ${e instanceof Error ? e.message : String(e)}`)
154
+ }
155
+ return { ok: true, text: `${text}\n(已写入 ${resolve(outFile)})` }
156
+ }
157
+ return { ok: true, text }
158
+ },
159
+ })
160
+
161
+ regTool({
162
+ name: 'ladder-from-stl',
163
+ description: '把西门子 S7-200 / S7-200 SMART 语句表(STL)自动转成文字梯形图+机械讲解:认 LD/LDN、A/AN、O/ON、ALD/OLD/LPS/LRD/LPP、EU/ED、比较触点(LDD=/AD<>/OW> 与 ==I/<D 两种写法)、=、S/R、CALL、MOV_/TON/CTU/算术等指令盒。输出风格与 ladder-render 一致(触点与导线在左、盒画方框、引脚列两侧、值写在导线上),每个网络后附一句"当……时,……"的机械讲解(不编造:认不出的写法按盒原样画并标注)。传 stl=语句表正文,或 file=.awl/.txt 路径;可选 outFile 写盘、summary=false 关掉讲解行。',
164
+ parameters: {
165
+ type: 'object',
166
+ properties: {
167
+ stl: { type: 'string', description: '语句表正文(与 file 二选一)。' },
168
+ file: { type: 'string', description: '语句表文件路径(.awl/.txt,与 stl 二选一)。' },
169
+ outFile: { type: 'string', description: '可选:把结果写入该文件(UTF-8)。' },
170
+ summary: { type: 'boolean', description: '是否输出机械讲解行(默认 true)。' },
171
+ title: { type: 'string', description: '可选标题。' },
172
+ },
173
+ additionalProperties: false,
174
+ },
175
+ output: {
176
+ schema: {
177
+ type: 'object',
178
+ properties: { ok: { type: 'boolean' }, text: { type: 'string' } },
179
+ required: ['ok', 'text'],
180
+ additionalProperties: false,
181
+ },
182
+ render: (_args, value) => [{ type: 'text', text: value.text }],
183
+ },
184
+ async execute(args) {
185
+ let src = String(args.stl ?? '')
186
+ const file = String(args.file ?? '').trim()
187
+ if (!src && file) {
188
+ try { src = readFileSync(resolve(file), 'utf8') } catch (e) {
189
+ throw new Error(`读取失败 ${file}: ${e instanceof Error ? e.message : String(e)}`)
190
+ }
191
+ }
192
+ if (!src.trim()) throw new Error('需要 stl(正文)或 file(路径)')
193
+ const text = stlToLadder(src, { summary: args.summary !== false, title: args.title })
194
+ const outFile = String(args.outFile ?? '').trim()
195
+ if (outFile) {
196
+ writeFileSync(resolve(outFile), text, 'utf8')
197
+ return { ok: true, text: `${text}\n(已写入 ${resolve(outFile)})` }
198
+ }
199
+ return { ok: true, text }
200
+ },
201
+ })
202
+
203
+ regTool({
204
+ name: 'ladder-dvp',
205
+ description: '台达 DVP 工程(.dvp)直接出盒式文字梯形图:读工程程序区,按行(LD M1000 行首)还原成「母线+多支路」梯形图,版式与西门子 .smart 那侧同一个渲染器。不用导出指令表、不用手画。并联输出按字节里的分支/栈记录还原;触点常开常闭按前缀推定(0x22/0x24=常闭,B 系=上升沿、C 系=下降沿),未定名功能码按 ?? 原样画盒并在图下注明。参数 file=工程路径、from/to=只出某几行(行号从 1 起)、outFile=写盘。',
206
+ parameters: {
207
+ type: 'object',
208
+ properties: {
209
+ file: { type: 'string', description: '.dvp 工程文件路径。' },
210
+ from: { type: 'number', description: '可选:起始行号(从 1 起)。' },
211
+ to: { type: 'number', description: '可选:结束行号。' },
212
+ outFile: { type: 'string', description: '可选:把结果写入该文件(UTF-8)。' },
213
+ title: { type: 'string', description: '可选标题。' },
214
+ },
215
+ required: ['file'],
216
+ additionalProperties: false,
217
+ },
218
+ output: {
219
+ schema: {
220
+ type: 'object',
221
+ properties: { ok: { type: 'boolean' }, text: { type: 'string' } },
222
+ required: ['ok', 'text'],
223
+ additionalProperties: false,
224
+ },
225
+ render: (_args, value) => [{ type: 'text', text: value.text }],
226
+ },
227
+ async execute(args, exec) {
228
+ const json = await decodeDvpToJson(String(args.file ?? '').trim(), exec)
229
+ const text = await dvpLadder({
230
+ json,
231
+ from: args.from,
232
+ to: args.to,
233
+ title: args.title,
234
+ outFile: String(args.outFile ?? '').trim() || undefined,
235
+ })
236
+ const outFile = String(args.outFile ?? '').trim()
237
+ if (outFile) return { ok: true, text: `${text}\n(已写入 ${resolve(outFile)})` }
238
+ return { ok: true, text }
239
+ },
240
+ })
241
+
242
+ regTool({
243
+ name: 'ladder-auto',
244
+ description: '自动画梯形图:直接读西门子 S7-200 SMART 的 .smart 工程,按块(可选单网络)自动生成软件观感的盒式文字梯形图——母线、触点(含常闭/边沿)、比较触点(双操作数内联)、S/R/=/线圈、并联支路自动折叠,指令盒画占位框(BOX#位次,实参用 smart-box-pins 查)。输入 file=工程路径、block=块名(可选)、net=网络号(可选)、outFile=可选写盘。无需手写 spec、无需导出 STL。',
245
+ parameters: {
246
+ type: 'object',
247
+ properties: {
248
+ file: { type: 'string', description: '.smart 工程文件路径。' },
249
+ block: { type: 'string', description: '可选:块名(如 启停/主程序);不填默认第一个块。' },
250
+ net: { type: 'string', description: '可选:只画该块的第 N 个网络。' },
251
+ outFile: { type: 'string', description: '可选:把结果写入该文件(UTF-8)。' },
252
+ title: { type: 'string', description: '可选标题。' },
253
+ },
254
+ required: ['file'],
255
+ additionalProperties: false,
256
+ },
257
+ output: {
258
+ schema: {
259
+ type: 'object',
260
+ properties: { ok: { type: 'boolean' }, text: { type: 'string' } },
261
+ required: ['ok', 'text'],
262
+ additionalProperties: false,
263
+ },
264
+ render: (_args, value) => [{ type: 'text', text: value.text }],
265
+ },
266
+ async execute(args) {
267
+ const text = await autoLadder({ file: String(args.file ?? '').trim(), block: args.block ? String(args.block).trim() : undefined, net: args.net ? String(args.net).trim() : undefined, title: args.title })
268
+ const outFile = String(args.outFile ?? '').trim()
269
+ if (outFile) {
270
+ writeFileSync(resolve(outFile), text, 'utf8')
271
+ return { ok: true, text: `${text}\n(已写入 ${resolve(outFile)})` }
272
+ }
273
+ return { ok: true, text }
274
+ },
275
+ })
276
+
277
+ regCmd({
278
+ name: 'ladder-dvp',
279
+ description: '.dvp 工程 → 盒式文字梯形图(自动出图,无需 spec)',
280
+ input: { hint: '<工程.dvp> [起行] [止行] [输出.txt]' },
281
+ handler: async ({ rawInput }) => {
282
+ try {
283
+ const parts = String(rawInput ?? '').trim().split(/\s+/).filter(Boolean)
284
+ if (!parts.length) throw new Error('用法: /ladder-dvp <工程.dvp> [起行] [止行] [输出.txt]')
285
+ const [file, from, to, out] = parts
286
+ const json = await decodeDvpToJson(file)
287
+ const text = await dvpLadder({ json, from, to, outFile: out })
288
+ return { kind: 'success', text: out ? `${text}\n(已写入 ${resolve(out)})` : text }
289
+ } catch (error) {
290
+ return { kind: 'error', text: error instanceof Error ? error.message : String(error) }
291
+ }
292
+ },
293
+ })
294
+
295
+ regCmd({
296
+ name: 'ladder-auto',
297
+ description: '.smart 工程 → 盒式文字梯形图(自动出图,无需 spec)',
298
+ input: { hint: '<工程.smart> [块名] [网络号] [输出.txt]' },
299
+ handler: async ({ rawInput }) => {
300
+ try {
301
+ const parts = String(rawInput ?? '').trim().split(/\s+/).filter(Boolean)
302
+ if (!parts.length) throw new Error('用法: /ladder-auto <工程.smart> [块名] [网络号] [输出.txt]')
303
+ const [file, block, net, out] = parts
304
+ const text = await autoLadder({ file, block, net })
305
+ if (out) writeFileSync(resolve(out), text, 'utf8')
306
+ return { kind: 'success', text: out ? `${text}\n(已写入 ${resolve(out)})` : text }
307
+ } catch (error) {
308
+ return { kind: 'error', text: error instanceof Error ? error.message : String(error) }
309
+ }
310
+ },
311
+ })
312
+
313
+ regCmd({
314
+ name: 'ladder-spec',
315
+ description: '把 spec JSON 渲染成文字梯形图(软件观感:方框+两侧引脚+导线值)',
316
+ input: { hint: '<spec.json> [输出.txt]' },
317
+ handler: async ({ rawInput }) => {
318
+ try {
319
+ const raw = String(rawInput ?? '').trim()
320
+ if (!raw) throw new Error(`用法: /ladder-spec <spec.json> [输出.txt];${SPEC_HINT}`)
321
+ const [file, out] = raw.split(/\s+/)
322
+ const spec = JSON.parse(readFileSync(resolve(file), 'utf8'))
323
+ const text = render(spec)
324
+ if (out) writeFileSync(resolve(out), text, 'utf8')
325
+ return { kind: 'success', text: out ? `${text}\n(已写入 ${resolve(out)})` : text }
326
+ } catch (error) {
327
+ return { kind: 'error', text: error instanceof Error ? error.message : String(error) }
328
+ }
329
+ },
330
+ })
331
+
332
+ regCmd({
333
+ name: 'ladder-stl',
334
+ description: '语句表(STL)→ 文字梯形图+讲解',
335
+ input: { hint: '<语句表.txt> [输出.txt]' },
336
+ handler: async ({ rawInput }) => {
337
+ try {
338
+ const [file, out] = String(rawInput ?? '').trim().split(/\s+/)
339
+ if (!file) throw new Error('用法: /ladder-stl <语句表.txt> [输出.txt]')
340
+ const text = stlToLadder(readFileSync(resolve(file), 'utf8'), {})
341
+ if (out) writeFileSync(resolve(out), text, 'utf8')
342
+ return { kind: 'success', text: out ? `${text}\n(已写入 ${resolve(out)})` : text }
343
+ } catch (error) {
344
+ return { kind: 'error', text: error instanceof Error ? error.message : String(error) }
345
+ }
346
+ },
347
+ })
348
+
349
+ ctx.commands.register({
350
+ name: 'ladder',
351
+ description: '梯形图功能开关:on 打开 / off 关闭 / status 查看(立即生效,不用重启)',
352
+ input: { hint: 'on | off | status' },
353
+ handler: async ({ rawInput }) => {
354
+ const arg = String(rawInput ?? '').trim().toLowerCase()
355
+ if (arg === 'on' || arg === 'off') {
356
+ setLadderOn(arg === 'on')
357
+ return { kind: 'success', text: `梯形图已${arg === 'on' ? '开启' : '关闭'}(立即生效)。` }
358
+ }
359
+ return { kind: 'success', text: `梯形图当前:${ladderOn ? '开启' : '关闭'}。用法:/ladder on|off` }
360
+ },
361
+ })
362
+
363
+ ctx.skills.registerProvider(() => ({
364
+ name: 'ladder',
365
+ list: () => Promise.resolve(SKILLS.map(s => ({
366
+ name: s.name,
367
+ description: s.description,
368
+ invocation: { modelInvocable: false, userInvocable: true },
369
+ provider: 'ladder',
370
+ source: 'bundled',
371
+ resourceBase: { kind: 'directory', path: new URL('./assets/', import.meta.url).pathname },
372
+ rank: BUNDLED_SKILL_RANK,
373
+ locator: new URL(`./assets/${s.file}`, import.meta.url),
374
+ }))),
375
+ async get(candidate) {
376
+ const s = SKILLS.find(x => x.name === candidate.name) ?? SKILLS[0]
377
+ return { ...candidate, content: readFileSync(new URL(`./assets/${s.file}`, import.meta.url), 'utf8') }
378
+ },
379
+ }))
380
+
381
+ }
package/package.json CHANGED
@@ -1,18 +1,17 @@
1
1
  {
2
2
  "name": "dsh-plc-decode",
3
- "version": "0.2.0",
4
- "description": "在 DSH 里读工控工程文件:PLC(CoDeSys、台达、西门子 SMART)与触摸屏(西门子 WinCC flexible SMART、繁易 FStudio、欧姆龙 NB、昆仑通态 MCGS)里的程序、画面、控件、变量与地址,读成能查的报告与表。",
3
+ "version": "0.2.2",
4
+ "description": "在 DSH 里读工控工程文件:PLC(CoDeSys、台达、西门子 SMART)与触摸屏(西门子 WinCC flexible SMART、繁易 FStudio、欧姆龙 NB、昆仑通态 MCGS、威纶通 EasyBuilder Pro)里的程序、画面、控件、变量与地址,读成能查的报告与表。",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "exports": {
8
8
  ".": "./index.js",
9
- "./invariant": "./invariant.js",
10
9
  "./package.json": "./package.json"
11
10
  },
12
11
  "files": [
13
12
  "index.js",
14
13
  "hmi.js",
15
- "invariant.js",
14
+ "ladder.js",
16
15
  "cordis.patch.yml",
17
16
  "assets",
18
17
  "scripts",
@@ -22,7 +21,7 @@
22
21
  ],
23
22
  "engines": {
24
23
  "node": "^22.19.0 || >=24.0.0",
25
- "dsh": ">=0.2.1-0 <0.3.0-0"
24
+ "dsh": ">=0.2.1-0"
26
25
  },
27
26
  "dsh": {
28
27
  "bundle": {
@@ -46,6 +45,8 @@
46
45
  "omron",
47
46
  "nb-designer",
48
47
  "mcgs",
48
+ "weintek",
49
+ "easybuilder-pro",
49
50
  "industrial-automation",
50
51
  "leadshine",
51
52
  "leadsysstudio",
@@ -53,7 +54,7 @@
53
54
  ],
54
55
  "scripts": {
55
56
  "smoke": "node scripts/smoke.mjs",
56
- "check": "node --check index.js && node --check hmi.js && node --check invariant.js && node --check assets/decode-pack.mjs && node --check assets/hmismart-decode.mjs && node --check assets/hmismart-diff.mjs && node --check assets/hmismart-report.mjs && node --check assets/nb-decode.mjs && node --check assets/mcgs-decode.mjs && node --check assets/smart-lad.mjs && node --check assets/smart-net-trace.mjs"
57
+ "check": "node --check index.js && node --check hmi.js && node --check ladder.js && node --check assets/decode-pack.mjs && node --check assets/hmismart-decode.mjs && node --check assets/hmismart-diff.mjs && node --check assets/hmismart-report.mjs && node --check assets/nb-decode.mjs && node --check assets/mcgs-decode.mjs && node --check assets/smart-lad.mjs && node --check assets/smart-net-trace.mjs"
57
58
  },
58
59
  "author": "taoziliyouchong",
59
60
  "license": "MIT",
package/scripts/smoke.mjs CHANGED
@@ -15,8 +15,8 @@ import { join } from 'node:path'
15
15
 
16
16
  import { apply, inject, name as pluginName } from '../index.js'
17
17
 
18
- const EXPECTED_TOOLS = 18
19
- const EXPECTED_COMMANDS = 15
18
+ const EXPECTED_TOOLS = 19
19
+ const EXPECTED_COMMANDS = 20
20
20
 
21
21
  let failed = 0
22
22
  const pass = (m) => console.log(` ok ${m}`)
@@ -1,45 +0,0 @@
1
- # FStudio 文本库导入格式(繁易 Flexem)
2
-
3
- 做多语言时,给 FStudio 文本库**导入**用的表,格式必须与软件**导出**的 TextLib 表逐字符一致,
4
- 否则软件报"文件格式不正确"。踩过一次坑,记在这里。
5
-
6
- ## 表结构
7
-
8
- 单表,表名随意(软件导出叫 `TextLib`)。表头**必须**是这 5 列(有俄语就 6 列):
9
-
10
- | 列 | 内容 | 说明 |
11
- |---|---|---|
12
- | 1 | `Guid` | 条目唯一标识,UUID 形如 `20c4864f-29ba-46c4-b9a5-beef83fbcad2`。新条目自己生成 UUID4 |
13
- | 2 | `Name` | 条目名,建议 ≤50 字,勿换行(软件里列表显示用) |
14
- | 3 | `Status` | 状态号,**数字**:默认值节点写 `0`,多状态依次 1、2… |
15
- | 4 | `1-Chinese (Simplified, PRC)-2052` | 简体中文(LCID 2052) |
16
- | 5 | `2-English (United States)-1033` | 英文(LCID 1033) |
17
- | 6 | `3-Russian (Russia)-1049` | 俄语(LCID 1049),不用就别加这列 |
18
-
19
- - 列名里空格、括号、连字符都不能改;`Guid`/`Name`/`Status` 首字母大写。
20
- - 简体中文那列的列名尤其容易写错成"中文"——**写"中文"就导不进去**。
21
- - 一个条目若有多状态,导出的表是"Guid 只在首行、后续行 Guid 留空",同 `Name`、`Status` 递增。
22
- 自己造导入表时**每行都给 Guid** 更稳。
23
-
24
- ## 生成脚本
25
-
26
- `出文本库导入表.py`:把中文英文对照表转成上面的格式。
27
-
28
- ```
29
- python 出文本库导入表.py <对照表.xlsx> [输出.xlsx]
30
- ```
31
-
32
- - 输入表要求:有一列列名含「中文」、一列含「英文」或 `English`。
33
- - 输出:**一份**可导入的 `.xlsx`(默认只出一份,别一次给多个表)。需要 `.xls` 时加参数:`--xls`。
34
- - 每个条目 Guid 自动生成、Status=0、Name 取中文前 50 字、英文为空的行标黄底提示。
35
-
36
- ## 导入前自查(3 条)
37
-
38
- 1. 表头逐字符对:`Guid` / `Name` / `Status` / `1-Chinese (Simplified, PRC)-2052` / `2-English (United States)-1033`。
39
- 2. Guid 无重复、非空;Status 是数字不是文本。
40
- 3. 库里已有的条目先核一遍,别把同名条目导成两条。
41
-
42
- ## 相关
43
-
44
- - 文本库在工程里的位置:`Global\TextLibConfigInfo.cfg`;语言表:`HMI\System\LanguageConfigInfo.cfg`。
45
- - 画面元件想随语种变,还得逐个元件勾「使用文本库」指到条目(FStudio 使用手册 4.13.8 标签编辑器,p.570-571)。