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.
- package/README.en.md +115 -27
- package/README.md +38 -13
- package/assets/codesys-tools/dvp_decode.py +53 -4
- package/assets/codesys-tools/read_project.py +1 -1
- package/assets/decode-pack.mjs +2 -2
- package/assets/dvp-ladder.mjs +174 -0
- package/assets/ebpro-decode.mjs +305 -0
- package/assets/hmi-decode-workflow.md +7 -9
- package/assets/hmismart-decode.mjs +453 -452
- package/assets/hmismart-report.mjs +9 -14
- package/assets/lad-branches.mjs +147 -0
- package/assets/ladder-render.mjs +118 -0
- package/assets/ladder-workflow.md +80 -0
- package/assets/smart-ladder-auto.mjs +182 -0
- package/assets/stl-to-ladder.mjs +299 -0
- package/hmi.js +36 -183
- package/index.js +70 -5
- package/ladder.js +381 -0
- package/package.json +7 -6
- package/scripts/smoke.mjs +2 -2
- package/assets/FStudio/346/226/207/346/234/254/345/272/223/345/257/274/345/205/245/346/240/274/345/274/217.md +0 -45
- package/assets/fstudio-i18n-workflow.md +0 -165
- package/assets/nb-textlib-workflow.md +0 -93
- package/assets/wincc-text-i18n-workflow.md +0 -96
- package/assets/wincc_i18n.py +0 -504
- package/invariant.js +0 -10
|
@@ -34,20 +34,15 @@ const TYPE = {
|
|
|
34
34
|
|
|
35
35
|
const VAR_GROUPS = [
|
|
36
36
|
['PLC 直接地址', /^[QIMV]\d|^VD\d|^VW\d|^VB\d|^CD\d|^\d/],
|
|
37
|
-
['
|
|
38
|
-
['伺服 /
|
|
39
|
-
['
|
|
40
|
-
['
|
|
41
|
-
['
|
|
42
|
-
['
|
|
43
|
-
['
|
|
44
|
-
['
|
|
45
|
-
['
|
|
46
|
-
['画面 / 显示', /画面|隐藏/],
|
|
47
|
-
['用户 / 密码', /用户|密码|权限/],
|
|
48
|
-
['日期 / 时间', /当前[日年月]|[日月年]期/],
|
|
49
|
-
['屏操作 / 模式', /屏|启动|停止|点动|手动|调试|自动|联机|复位|运行/],
|
|
50
|
-
['备用', /备用/],
|
|
37
|
+
['报警 / 故障', /报警|故障|急停|异常|错误/],
|
|
38
|
+
['伺服 / 位置 / 编码器', /伺服|编码器|原点|位置|位移|偏移|轴/],
|
|
39
|
+
['速度 / 计数 / 产量', /速度|转速|计数|产量|累计|清零/],
|
|
40
|
+
['参数 / 设定', /参数|设定|目标|上限|下限|补偿|系数|比例|增益/],
|
|
41
|
+
['画面 / 显示', /画面|显示|隐藏|屏/],
|
|
42
|
+
['用户 / 密码 / 权限', /用户|密码|权限|登录/],
|
|
43
|
+
['日期 / 时间', /当前[日年月]|[日月年]期|时间|时钟/],
|
|
44
|
+
['操作 / 模式', /启动|停止|运行|暂停|点动|手动|自动|调试|复位|联机|使能/],
|
|
45
|
+
['备用 / 预留', /备用|预留/],
|
|
51
46
|
]
|
|
52
47
|
const grouped = new Set()
|
|
53
48
|
const varLines = []
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// lad-branches.mjs v2 —— 画「一条母线 + 多条并联支路(支路内可再并联)」的文字梯形图
|
|
3
|
+
//
|
|
4
|
+
// 画法=软件观感:触点/导线在左、指令盒画方框带引脚、线圈画在导线上。
|
|
5
|
+
// (v1 用 [INCW VW222] / (S …) 的行末写法,用户看过说"不是梯形图",已废。)
|
|
6
|
+
//
|
|
7
|
+
// 为什么需要它:
|
|
8
|
+
// · stl-to-ladder 把 LPS/LRD/LPP 拆出的并联支路当成并列的独立行(每条重复母线元件),并联结构看不见;
|
|
9
|
+
// · lad-box-inputs 只画「一个盒 + 多路并联输入」;ladder-render 一行只能一个盒、画不了盒与线圈混排。
|
|
10
|
+
// 母线上挂多条支路、末端是盒或线圈的网络用它画:内容由 spec 给、列位由脚本补,不手敲对齐。
|
|
11
|
+
//
|
|
12
|
+
// 用法:node lad-branches.mjs spec.json
|
|
13
|
+
// spec:{
|
|
14
|
+
// title?: string,
|
|
15
|
+
// rail?: string, // 母线文字(默认 SM0.0),画在第一条支路行
|
|
16
|
+
// boxCol?: number, // 盒左边线列(默认按最长支路自动算)
|
|
17
|
+
// rows: [ { wire, box?, coil? } ],
|
|
18
|
+
// vfill?: [ { col, from, to } ], // 支路内的竖向连接:第 from 条支路 → 第 to 条支路
|
|
19
|
+
// comments?: [ string ]
|
|
20
|
+
// }
|
|
21
|
+
// wire 该支路的元件画法,**从母线列(9)开始写**:第一列 = 母线竖线接点(┬ ├ └)或空格
|
|
22
|
+
// 支路内二次并联:父行在分岔列写 ┬、子行在分岔列写 └,中间用 vfill 补竖线
|
|
23
|
+
// box { name, pins: [ { name, src } ] };src:"cond" = 本支路导线接到该引脚,
|
|
24
|
+
// 其他值 = 值写在盒左侧的导线上(如 "VW222" / "0" / "6000")
|
|
25
|
+
// coil "S M23.1" / "R M23.0, 2"(画成 ──( S M23.1 ))
|
|
26
|
+
// 只写 ASCII 与制图字符;中文只出现在 title 与 comments。
|
|
27
|
+
import { readFileSync } from 'node:fs'
|
|
28
|
+
|
|
29
|
+
const file = process.argv[2]
|
|
30
|
+
if (!file) {
|
|
31
|
+
console.error('用法:node lad-branches.mjs spec.json')
|
|
32
|
+
process.exit(2)
|
|
33
|
+
}
|
|
34
|
+
const spec = JSON.parse(readFileSync(file, 'utf8'))
|
|
35
|
+
const rows = (spec.rows ?? []).filter((r) => r && r.wire)
|
|
36
|
+
if (!rows.length) {
|
|
37
|
+
console.error('spec.rows 为空')
|
|
38
|
+
process.exit(2)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const BUS = 9
|
|
42
|
+
const grid = []
|
|
43
|
+
const ensure = (r) => { while (grid.length <= r) grid.push([]) }
|
|
44
|
+
const put = (r, c, s) => {
|
|
45
|
+
ensure(r)
|
|
46
|
+
const row = grid[r]
|
|
47
|
+
while (row.length < c) row.push(' ')
|
|
48
|
+
let i = c
|
|
49
|
+
for (const ch of s) row[i++] = ch
|
|
50
|
+
}
|
|
51
|
+
const putCh = (r, c, ch) => {
|
|
52
|
+
ensure(r)
|
|
53
|
+
const row = grid[r]
|
|
54
|
+
while (row.length < c) row.push(' ')
|
|
55
|
+
row[c] = ch
|
|
56
|
+
}
|
|
57
|
+
const getCh = (r, c) => (grid[r] ?? [])[c] ?? ' '
|
|
58
|
+
const fill = (r, c0, c1, ch) => { for (let c = c0; c < c1; c++) putCh(r, c, ch) }
|
|
59
|
+
|
|
60
|
+
const longest = Math.max(...rows.map((r) => (r.wire ?? '').length))
|
|
61
|
+
const boxCol = spec.boxCol ?? Math.max(34, BUS + longest + 4)
|
|
62
|
+
const BOXW = Math.max(18, ...rows.map((r) => ((r.box?.name ?? '').length + 14)))
|
|
63
|
+
|
|
64
|
+
const lineRows = []
|
|
65
|
+
let line = 0
|
|
66
|
+
for (const r of rows) {
|
|
67
|
+
const w = r.wire ?? ''
|
|
68
|
+
if (r.coil) {
|
|
69
|
+
// 末端线圈:左段从母线列按 wire 排,再补横线到 boxCol,保证多支路线圈同列对齐
|
|
70
|
+
put(line, BUS, w)
|
|
71
|
+
fill(line, BUS + w.length, boxCol, '─')
|
|
72
|
+
put(line, boxCol, `( ${r.coil} )`)
|
|
73
|
+
lineRows.push(line)
|
|
74
|
+
line += 1
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
if (r.box) {
|
|
78
|
+
const pins = r.box.pins ?? []
|
|
79
|
+
const top = line
|
|
80
|
+
put(top, boxCol, '┌' + '─'.repeat(BOXW) + '┐')
|
|
81
|
+
let ln = top + 1
|
|
82
|
+
pins.forEach((p, i) => {
|
|
83
|
+
if (p.src === 'cond') {
|
|
84
|
+
put(ln, BUS, w + '─'.repeat(Math.max(2, boxCol - BUS - w.length)))
|
|
85
|
+
lineRows.push(ln)
|
|
86
|
+
} else {
|
|
87
|
+
const v = String(p.src ?? '')
|
|
88
|
+
const vc = Math.max(BUS + 2, boxCol - 3 - v.length)
|
|
89
|
+
put(ln, vc, v)
|
|
90
|
+
fill(ln, vc + v.length + 1, boxCol, '─')
|
|
91
|
+
}
|
|
92
|
+
put(ln, boxCol, `│ ${String(p.name ?? '').padEnd(BOXW - 2, ' ')}│`)
|
|
93
|
+
ln += 1
|
|
94
|
+
if (i === 0 && pins.length > 1) {
|
|
95
|
+
put(ln, boxCol, `│ ${String(r.box.name ?? '').padEnd(BOXW - 2, ' ')}│`)
|
|
96
|
+
ln += 1
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
if (pins.length <= 1) {
|
|
100
|
+
put(ln, boxCol, `│ ${String(r.box.name ?? '').padEnd(BOXW - 2, ' ')}│`)
|
|
101
|
+
ln += 1
|
|
102
|
+
}
|
|
103
|
+
put(ln, boxCol, '└' + '─'.repeat(BOXW) + '┘')
|
|
104
|
+
if (!pins.some((p) => p.src === 'cond')) lineRows.push(top)
|
|
105
|
+
line = ln + 1
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
if (!r.box && !r.coil && /^\s/.test(w)) {
|
|
109
|
+
// 纯标签行(触点名对齐用):原样输出,不画导线、不补横线
|
|
110
|
+
put(line, BUS, w)
|
|
111
|
+
lineRows.push(line)
|
|
112
|
+
line += 1
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
put(line, BUS, w)
|
|
116
|
+
fill(line, BUS + w.length, boxCol, '─')
|
|
117
|
+
put(line, boxCol, `( ${r.coil} )`)
|
|
118
|
+
lineRows.push(line)
|
|
119
|
+
line += 1
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const first = Math.min(...lineRows)
|
|
123
|
+
const last = Math.max(...lineRows)
|
|
124
|
+
for (let r = first; r <= last; r++) {
|
|
125
|
+
const ch = getCh(r, BUS)
|
|
126
|
+
if (!'┬├└│'.includes(ch)) putCh(r, BUS, '│')
|
|
127
|
+
}
|
|
128
|
+
for (const v of spec.vfill ?? []) {
|
|
129
|
+
const a = lineRows[v.from] ?? first
|
|
130
|
+
const b = lineRows[v.to] ?? last
|
|
131
|
+
for (let r = a + 1; r < b; r++) if (!'┬├└│'.includes(getCh(r, v.col))) putCh(r, v.col, '│')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (spec.rail !== '') {
|
|
135
|
+
const railText = spec.rail ?? 'SM0.0'
|
|
136
|
+
put(first, 2, railText)
|
|
137
|
+
fill(first, 2 + railText.length + 1, BUS, '─')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const out = []
|
|
141
|
+
if (spec.title) out.push(spec.title)
|
|
142
|
+
for (const row of grid) out.push((row ?? []).join('').replace(/\s+$/, ''))
|
|
143
|
+
if (spec.comments?.length) {
|
|
144
|
+
out.push('')
|
|
145
|
+
for (const [i, c] of spec.comments.entries()) out.push((i === 0 ? '说明:' : ' ') + c)
|
|
146
|
+
}
|
|
147
|
+
console.log(out.join('\n'))
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 文字梯形图渲染器(纯函数,零依赖)
|
|
3
|
+
*
|
|
4
|
+
* 样式是**固定**的(2026-09-18 用户认可的那一版):
|
|
5
|
+
* - 触点与导线在左边、指令盒画成方框、引脚列在方框两侧、值写在导线上
|
|
6
|
+
* - 梯体全为等宽字符;中文只出现在标题行与说明行(不参与对齐)
|
|
7
|
+
* - 对齐由本模块按"显示宽度"计算(CJK/全角按 2 列),不靠手敲空格
|
|
8
|
+
*
|
|
9
|
+
* 用法:
|
|
10
|
+
* import { renderSpec, renderNetwork, dw } from './ladder-render.mjs'
|
|
11
|
+
* renderSpec({ legend: true, networks: [ { name:'网络 6', note:'MOV_W', rows:[...], comments:[...] } ] })
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** 一个字符占几列(等宽字体下 CJK/全角 = 2,其余 = 1)。 */
|
|
15
|
+
export function dw(s) {
|
|
16
|
+
let n = 0
|
|
17
|
+
for (const ch of String(s ?? '')) {
|
|
18
|
+
const c = ch.codePointAt(0)
|
|
19
|
+
const wide = (c >= 0x1100 && c <= 0x115f) || (c >= 0x2e80 && c <= 0xa4cf) ||
|
|
20
|
+
(c >= 0xac00 && c <= 0xd7a3) || (c >= 0xf900 && c <= 0xfaff) ||
|
|
21
|
+
(c >= 0xfe30 && c <= 0xfe6f) || (c >= 0xff00 && c <= 0xff60) ||
|
|
22
|
+
(c >= 0xffe0 && c <= 0xffe6) || c >= 0x20000
|
|
23
|
+
n += wide ? 2 : 1
|
|
24
|
+
}
|
|
25
|
+
return n
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 图例行(用户看过并认可的那一行,不要随手改)。 */
|
|
29
|
+
export const LEGEND = '符号: ┤ ├ 常开触点 ┤P├ 上升沿触点 ┤==I +0├ 比较触点 ── 导线'
|
|
30
|
+
|
|
31
|
+
const DEFAULT_STYLE = {
|
|
32
|
+
indent: 3, // 缩进
|
|
33
|
+
gapMin: 4, // 左导线区在最长左侧内容之后至少留几根横线
|
|
34
|
+
leftArea: 37, // 左导线区的最小宽度(决定方框左边界落在第 3+37=40 列)
|
|
35
|
+
boxMin: 24, // 方框内部最小宽度
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 渲染一个网络;返回字符串(无行则返回空串)。 */
|
|
39
|
+
export function renderNetwork(net, style = {}) {
|
|
40
|
+
const st = { ...DEFAULT_STYLE, ...style }
|
|
41
|
+
const pad = ' '.repeat(st.indent)
|
|
42
|
+
const rows = (net?.rows ?? []).filter((r) => r && (r.left || r.pinL || r.pinR || r.out))
|
|
43
|
+
if (!rows.length) return ''
|
|
44
|
+
const head = `${net.name ?? '网络'}${net.note ? ' ── ' + net.note : ''}`
|
|
45
|
+
|
|
46
|
+
// 无方框的网络(例如直接以线圈结尾的支路):一行一根导线
|
|
47
|
+
if (net.plain) {
|
|
48
|
+
const lines = [head]
|
|
49
|
+
for (const r of rows) lines.push(pad + (r.left ?? '') + (r.out ? '── ' + r.out : ''))
|
|
50
|
+
for (const [i, c] of (net.comments ?? []).entries()) {
|
|
51
|
+
if (!String(c ?? '').trim()) continue
|
|
52
|
+
lines.push(i === 0 ? `${pad}说明:${c}` : ' '.repeat(st.indent + dw('说明:')) + c)
|
|
53
|
+
}
|
|
54
|
+
return lines.join('\n')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const leftW = Math.max(st.leftArea, Math.max(...rows.map((r) => dw(r.left ?? ''))) + st.gapMin)
|
|
58
|
+
const pinLW = Math.max(0, ...rows.map((r) => dw(r.pinL ?? '')))
|
|
59
|
+
const pinRW = Math.max(0, ...rows.map((r) => dw(r.pinR ?? '')))
|
|
60
|
+
const inside = Math.max(st.boxMin, 2 + pinLW + st.gapMin + pinRW)
|
|
61
|
+
|
|
62
|
+
const out = [head]
|
|
63
|
+
out.push(pad + ' '.repeat(leftW) + '┌' + '─'.repeat(inside) + '┐')
|
|
64
|
+
for (const r of rows) {
|
|
65
|
+
const left = r.left ?? ''
|
|
66
|
+
// 左侧内容若已以导线/触点符号收尾,就不再补空格(贴住更接近软件观感)
|
|
67
|
+
const sp = /[├┤│─┐┘]/.test(left.slice(-1)) ? 0 : 1
|
|
68
|
+
const wire = pad + left + ' '.repeat(sp) + '─'.repeat(Math.max(2, leftW - dw(left) - sp)) + '│'
|
|
69
|
+
const l = ' ' + (r.pinL ?? '')
|
|
70
|
+
const rr = (r.pinR ?? '') + ' '
|
|
71
|
+
const mid = ' '.repeat(Math.max(1, inside - dw(l) - dw(rr)))
|
|
72
|
+
out.push(wire + l + mid + rr + '│' + (r.out ? '── ' + r.out : ''))
|
|
73
|
+
}
|
|
74
|
+
out.push(pad + ' '.repeat(leftW) + '└' + '─'.repeat(inside) + '┘')
|
|
75
|
+
for (const [i, c] of (net.comments ?? []).entries()) {
|
|
76
|
+
if (!String(c ?? '').trim()) continue
|
|
77
|
+
out.push(i === 0 ? `${pad}说明:${c}` : ' '.repeat(st.indent + dw('说明:')) + c)
|
|
78
|
+
}
|
|
79
|
+
return out.join('\n')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 渲染整份(标题 + 图例 + 各网络,网络之间空一行)。 */
|
|
83
|
+
export function renderSpec(spec) {
|
|
84
|
+
const parts = []
|
|
85
|
+
if (spec?.title) parts.push(spec.title, '═'.repeat(Math.max(8, dw(spec.title))))
|
|
86
|
+
if (spec?.legend !== false) parts.push(typeof spec?.legend === 'string' ? spec.legend : LEGEND)
|
|
87
|
+
for (const net of spec?.networks ?? []) {
|
|
88
|
+
const t = renderNetwork(net, spec?.style ?? {})
|
|
89
|
+
if (t) parts.push(t)
|
|
90
|
+
}
|
|
91
|
+
return parts.join('\n\n') + '\n'
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 结构自检:返回问题列表(空数组 = 通过)。 */
|
|
95
|
+
export function lintSpec(spec) {
|
|
96
|
+
const issues = []
|
|
97
|
+
if (!Array.isArray(spec?.networks) || !spec.networks.length) issues.push('networks 为空')
|
|
98
|
+
for (const [i, n] of (spec?.networks ?? []).entries()) {
|
|
99
|
+
const at = `第 ${i + 1} 个网络(${n?.name ?? '无名'})`
|
|
100
|
+
if (!n?.name) issues.push(`${at}: 缺 name`)
|
|
101
|
+
const rows = (n?.rows ?? []).filter((r) => r && (r.left || r.pinL || r.pinR || r.out))
|
|
102
|
+
if (!rows.length) issues.push(`${at}: 没有有效行`)
|
|
103
|
+
if (!n?.plain) {
|
|
104
|
+
for (const r of rows) {
|
|
105
|
+
if (r.out && !r.pinR) issues.push(`${at}: 行「${r.left ?? ''}」有 out 但没写 pinR(右侧引脚)`)
|
|
106
|
+
if (/[\u4e00-\u9fff]/.test(String(r.left ?? ''))) issues.push(`${at}: 行「${r.left}」的 left 里混了中文——会破坏对齐,挪到 comments`)
|
|
107
|
+
if (/[\u4e00-\u9fff]/.test(String(r.out ?? ''))) issues.push(`${at}: 行 out「${r.out}」里混了中文——挪到 comments`)
|
|
108
|
+
if (/[\u4e00-\u9fff]/.test(String(r.pinL ?? '')) || /[\u4e00-\u9fff]/.test(String(r.pinR ?? ''))) {
|
|
109
|
+
issues.push(`${at}: 引脚名里混了中文——引脚用软件里的英文名`)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
for (const c of n?.comments ?? []) {
|
|
114
|
+
if (/^\s*[①②③④⑤⑥⑦⑧⑨]/.test(String(c))) continue
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return issues
|
|
118
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# ladder-workflow —— 文字梯形图的排版规范与自动成图
|
|
2
|
+
|
|
3
|
+
要交"文字梯形图"(讲解、改动方案、对照说明)时用本技能。样式已固化成代码,**不要手敲空格、不要自创排法**:
|
|
4
|
+
手敲必然对不齐,自创必然返工。两条路都走工具。
|
|
5
|
+
|
|
6
|
+
## 一、两条路
|
|
7
|
+
|
|
8
|
+
| 输入是什么 | 用哪个 | 命令 |
|
|
9
|
+
|---|---|---|
|
|
10
|
+
| 指令表/语句表(STL,含 `LD/A/O/=/TON/…`) | 工具 `ladder-from-stl` | `/ladder-stl <语句表.txt> [输出.txt]` |
|
|
11
|
+
| 只有元件与引脚的信息(截图、解码结果、口述) | 工具 `ladder-render` | `/ladder <spec.json> [输出.txt]` |
|
|
12
|
+
| 台达 DVP 工程文件 | 工具 `ladder-dvp` | `/ladder-dvp <工程.dvp> [起段] [止段]` |
|
|
13
|
+
| 西门子 S7-200 SMART 工程文件 | 工具 `ladder-auto` | `/ladder-auto <工程.smart> [块名] [网络]` |
|
|
14
|
+
|
|
15
|
+
**能拿到工程文件或 STL 就走自动那条**:串并联写在文件里,成图是确定的,不用猜;只有拿不到才走 spec 手写。
|
|
16
|
+
|
|
17
|
+
## 二、STL 自动成图
|
|
18
|
+
|
|
19
|
+
认得的写法(S7-200 / S7-200 SMART):
|
|
20
|
+
|
|
21
|
+
- 触点 `LD/LDN`、`A/AN`、`O/ON`、`NOT`
|
|
22
|
+
- 逻辑堆栈 `ALD`、`OLD`、`LPS`、`LRD`、`LPP`
|
|
23
|
+
- 边沿 `EU` → `┤P├`,`ED` → `┤N├`
|
|
24
|
+
- 比较触点两种写法都认:`LDD=`/`AD<>`/`OW>`,以及 `==I`/`<D`/`>=R`
|
|
25
|
+
- 输出 `=`(线圈)、`S`/`R`(可带个数)、`CALL`(子程序与库块)
|
|
26
|
+
- 指令盒 `MOV_`、`TON/TOF`、`CTU/CTD`、`INC/DEC`、算术、移位…
|
|
27
|
+
|
|
28
|
+
行为约定:
|
|
29
|
+
|
|
30
|
+
- 每个网络出一段梯体 + 一句机械讲解("当……时,……");`summary=false` 可关掉讲解。
|
|
31
|
+
- **认不出的写法不猜**:按盒原样画,讲解里标"未识别写法"。看完必须人工核一遍再交。
|
|
32
|
+
- 定时器/计数器的编号进盒名(`TON T57`),不当引脚;`PT`/`PV` 才是引脚。
|
|
33
|
+
- 网络分隔:`Network N`/`网络 N`;`//` 开头的行当网络注释保留。
|
|
34
|
+
|
|
35
|
+
## 三、只有元件信息时(spec 渲染)
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{
|
|
39
|
+
"title": "可选标题",
|
|
40
|
+
"legend": true,
|
|
41
|
+
"networks": [
|
|
42
|
+
{
|
|
43
|
+
"name": "网络 7",
|
|
44
|
+
"note": "盒名(可选补充)",
|
|
45
|
+
"rows": [
|
|
46
|
+
{ "left": "Alw~:SM0.0 ──┤ ├", "pinL": "EN" },
|
|
47
|
+
{ "left": "C1 ─┤==I +0├──┤P├", "pinL": "First" },
|
|
48
|
+
{ "left": "40100", "pinL": "Addr" },
|
|
49
|
+
{ "left": "2", "pinL": "Count", "pinR": "Done", "out": "M0.1" }
|
|
50
|
+
],
|
|
51
|
+
"comments": ["EN 常通——盒每扫描都被调用;", "First 只在 C1==0 的上升沿给一拍。"]
|
|
52
|
+
}
|
|
53
|
+
]
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
| 字段 | 写什么 |
|
|
58
|
+
|---|---|
|
|
59
|
+
| `rows[].left` | 方框左侧的导线与元件画法(触点、比较、常数、地址)。只写 ASCII 与制表符,**不混中文** |
|
|
60
|
+
| `rows[].pinL` / `pinR` | 方框左/右引脚名,用软件里的英文名(EN、First、Addr、DataPtr、Done、Error、ENO…) |
|
|
61
|
+
| `rows[].out` | 方框右侧导线去向(`VW198`、`M0.1`);**有 out 必须给 pinR** |
|
|
62
|
+
| `rows[]` 一行 | 一根导线;按软件的引脚顺序排 |
|
|
63
|
+
| `comments[]` | 网络下方的中文说明 |
|
|
64
|
+
| `plain: true` | 没有指令盒的网络(直接以线圈收尾):只画导线,不画方框 |
|
|
65
|
+
|
|
66
|
+
## 四、画法约定(照软件观感)
|
|
67
|
+
|
|
68
|
+
- 常开触点 `┤ ├` 常闭触点 `┤/├` 上升沿 `┤P├` 下降沿 `┤N├`
|
|
69
|
+
- 比较触点带参数写:`┤==I +0├`、`┤<I 最高速度├`
|
|
70
|
+
- 数值/地址写在导线上:`40100 ────┤ Addr`
|
|
71
|
+
- 线圈 `──( Q1.7 )`、置位复位 `──( S M25.6, 2 )`
|
|
72
|
+
- 盒名放网络标题(`name` + `note`)或盒内;不塞进引脚行
|
|
73
|
+
- 多支路(母线拉出多条并联)用自动成图的 `ladder-dvp`/`ladder-auto`;手写 spec 只适合单支路
|
|
74
|
+
|
|
75
|
+
## 五、交付前自检
|
|
76
|
+
|
|
77
|
+
1. 渲染器自带结构自检(中文混进梯体、有 out 没 pinR、空网络都会点出来)——**不过先改,别交**。
|
|
78
|
+
2. 对着源头逐行核:每根导线上的值、每个引脚名都能在程序里指出来;**不确定的接线不画成确定的样子**,宁可空着并在 comments 写"待核"。
|
|
79
|
+
3. 自动成图后重点核三处:比较触点、边沿、`LPS/LRD/LPP` 拆出来的并列支路——这三处最容易看错。
|
|
80
|
+
4. 图上看不懂的元件(画成 `?`/`〔?…〕`)不要当成已知交出去:写明是哪一段、什么字节,交给用户对账。
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* smart-ladder-auto.mjs —— .smart 工程 → 盒式文字梯形图(自动出图)
|
|
4
|
+
*
|
|
5
|
+
* 链路:spawn smart-lad.mjs 拿每块每网络的 LAD 指令序列(LD/A/O/AN/ON/EU/ED/LPS/LRD/LPP/=/S/R + 比较触点 + [盒] 占位)
|
|
6
|
+
* → 折叠成 lad-branches 的支路 spec(母线 + 多支路 + 盒 + 线圈)
|
|
7
|
+
* → spawn lad-branches.mjs 渲染(版式单一正文在它那里,这里不复制渲染逻辑)
|
|
8
|
+
*
|
|
9
|
+
* V1 边界(诚实声明):
|
|
10
|
+
* · 支持主形态:母线触点(LD) + 串/并联触点 + 边沿 + 比较触点 + S/R/=/线圈 + 单层 LPS/LRD/LPP 支路;
|
|
11
|
+
* · 指令盒(TON/CTU/CALL 等无法从元件记录拿实参的)画占位框(BOX#位次),实参用 smart-box-pins 查;
|
|
12
|
+
* · 嵌套 LPS(栈深>2)、ALD/OLD 复杂栈运算按原样标注,不猜。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { execFile } from 'node:child_process'
|
|
16
|
+
import { promisify } from 'node:util'
|
|
17
|
+
import { writeFileSync, readFileSync, mkdtempSync } from 'node:fs'
|
|
18
|
+
import { tmpdir } from 'node:os'
|
|
19
|
+
import { dirname, join } from 'node:path'
|
|
20
|
+
import { fileURLToPath } from 'node:url'
|
|
21
|
+
|
|
22
|
+
const pExecFile = promisify(execFile)
|
|
23
|
+
|
|
24
|
+
const ASSETS_DIR = dirname(fileURLToPath(import.meta.url))
|
|
25
|
+
const SMART_LAD = join(ASSETS_DIR, 'smart-lad.mjs')
|
|
26
|
+
const LAD_BRANCHES = join(ASSETS_DIR, 'lad-branches.mjs')
|
|
27
|
+
|
|
28
|
+
/* ---------- 解析 smart-lad 文本 ---------- */
|
|
29
|
+
|
|
30
|
+
/** 把 smart-lad 的输出按块/网络拆成指令行 */
|
|
31
|
+
export function parseLadText(text) {
|
|
32
|
+
const blocks = []
|
|
33
|
+
let cur = null
|
|
34
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
35
|
+
const line = rawLine.trim()
|
|
36
|
+
const bm = line.match(/^## 块:(.+)$/)
|
|
37
|
+
if (bm) { cur = { name: bm[1], nets: [] }; blocks.push(cur); continue }
|
|
38
|
+
if (!cur) continue
|
|
39
|
+
const nm = line.match(/^Network (\d+)$/)
|
|
40
|
+
if (nm) { cur.nets.push({ no: Number(nm[1]), ins: [] }); continue }
|
|
41
|
+
if (!line || line.startsWith('#')) continue
|
|
42
|
+
if (!cur.nets.length) cur.nets.push({ no: cur.nets.length + 1, ins: [] })
|
|
43
|
+
cur.nets[cur.nets.length - 1].ins.push(line)
|
|
44
|
+
}
|
|
45
|
+
return blocks
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* ---------- 指令 → 触点/盒/线圈 ---------- */
|
|
49
|
+
|
|
50
|
+
const CMP_RE = /^(L|A|O)(W|D)?(<>|<=|>=|=|<|>)$/
|
|
51
|
+
const RAIL_PASS = /^(SM0\.0|Always_On|First_Scan_On)$/i
|
|
52
|
+
|
|
53
|
+
/** 一个触点的文字画法:地址在左、触点符号在右 */
|
|
54
|
+
function contact(addr, negate, edge) {
|
|
55
|
+
if (edge) return `──${edge}├`
|
|
56
|
+
return `──${addr} ─${negate ? '┤/├' : '┤ ├'}`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 比较触点:两个操作数都内联(┤>=I LW0, +31├),不丢操作数 */
|
|
60
|
+
function cmpContact(size, rel, a, b) {
|
|
61
|
+
const sym = `${size === 'D' ? 'D' : 'I'}${rel}`
|
|
62
|
+
return `──┤${sym}│ ${a}, ${b}├`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 一条指令行 → 对 cur 的增量;返回 {kind, wire?, coil?, box?} 或 null(纯增量) */
|
|
66
|
+
function applyIns(line, st) {
|
|
67
|
+
// [盒] 占位
|
|
68
|
+
const bm = line.match(/^\[盒\]\s*←\s*位次\s*(\d+)/)
|
|
69
|
+
if (bm) return { kind: 'box', name: `BOX#${bm[1]}` }
|
|
70
|
+
|
|
71
|
+
const m = line.match(/^(\S+)\s*(.*)$/)
|
|
72
|
+
if (!m) return null
|
|
73
|
+
const op = m[1]
|
|
74
|
+
const args = m[2] ? m[2].split(',').map((s) => s.trim()) : []
|
|
75
|
+
|
|
76
|
+
const cm = op.match(CMP_RE)
|
|
77
|
+
if (cm) {
|
|
78
|
+
if (args.length < 2) return null
|
|
79
|
+
st.cur += cmpContact(cm[2], cm[3], args[0], args[1])
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
switch (op) {
|
|
84
|
+
case 'LD': case 'LDN':
|
|
85
|
+
st.cur = RAIL_PASS.test(args[0]) ? '' : contact(args[0], op === 'LDN')
|
|
86
|
+
if (!st.cur) st.rail = args[0]
|
|
87
|
+
return null
|
|
88
|
+
case 'A': st.cur += contact(args[0], false); return null
|
|
89
|
+
case 'AN': st.cur += contact(args[0], true); return null
|
|
90
|
+
case 'O': st.cur += contact(args[0], false).replace('──', '─┬─'); return null
|
|
91
|
+
case 'ON': st.cur += contact(args[0], true).replace('──', '─┬─'); return null
|
|
92
|
+
case 'EU': st.cur += contact('', false, '┤P'); return null
|
|
93
|
+
case 'ED': st.cur += contact('', false, '┤N'); return null
|
|
94
|
+
case 'LPS': st.stack.push(st.cur); return null
|
|
95
|
+
case 'LRD': if (st.stack.length) st.cur = st.stack[st.stack.length - 1]; return null
|
|
96
|
+
case 'LPP': st.cur = st.stack.pop() ?? st.cur; return null
|
|
97
|
+
case '=':
|
|
98
|
+
return { kind: 'row', wire: st.cur, coil: args[0] }
|
|
99
|
+
case 'S': case 'R':
|
|
100
|
+
return { kind: 'row', wire: st.cur, coil: `${op} ${args.join(', ')}` }
|
|
101
|
+
case 'ALD': case 'OLD':
|
|
102
|
+
st.cur += '──(ALD/OLD 汇合)'
|
|
103
|
+
return null
|
|
104
|
+
default:
|
|
105
|
+
// TON/CTU/MOV_ 等 smart-lad 直接给出的盒行:画占位框
|
|
106
|
+
return { kind: 'box', name: op.slice(0, 12), tail: args.join(' ') }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 一个网络的指令行数组 → lad-branches spec */
|
|
111
|
+
export function netToSpec(no, lines) {
|
|
112
|
+
const st = { cur: '', stack: [], rail: 'SM0.0' }
|
|
113
|
+
const rows = []
|
|
114
|
+
for (const line of lines) {
|
|
115
|
+
const r = applyIns(line, st)
|
|
116
|
+
if (!r) continue
|
|
117
|
+
if (r.kind === 'row') {
|
|
118
|
+
rows.push({ wire: st.cur || '──', coil: r.coil })
|
|
119
|
+
st.cur = st.stack.length ? st.stack[st.stack.length - 1] : ''
|
|
120
|
+
} else if (r.kind === 'box') {
|
|
121
|
+
rows.push({ wire: st.cur || '──', box: { name: r.name, pins: [{ name: 'EN', src: 'cond' }] } })
|
|
122
|
+
st.cur = st.stack.length ? st.stack[st.stack.length - 1] : ''
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (rows.length && rows.length > 1) {
|
|
126
|
+
rows[0].wire = '┬' + rows[0].wire
|
|
127
|
+
for (let i = 1; i < rows.length; i++) rows[i].wire = '└' + rows[i].wire
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
title: `网络${no}`,
|
|
131
|
+
rail: st.rail,
|
|
132
|
+
boxCol: 74,
|
|
133
|
+
rows,
|
|
134
|
+
vfill: rows.length > 1 ? [{ col: 9, from: 0, to: rows.length - 1 }] : [],
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/* ---------- 渲染(复用 lad-branches,单一正文) ---------- */
|
|
139
|
+
|
|
140
|
+
async function renderNet(spec) {
|
|
141
|
+
const dir = mkdtempSync(join(tmpdir(), 'lad-auto-'))
|
|
142
|
+
const f = join(dir, 'n.json')
|
|
143
|
+
writeFileSync(f, JSON.stringify(spec), 'utf8')
|
|
144
|
+
const { stdout } = await pExecFile(process.execPath, [LAD_BRANCHES, f], { encoding: 'utf8', maxBuffer: 4 << 20 })
|
|
145
|
+
return stdout.replace(/\s+$/, '')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** 主入口:工程文件 + 块名(可选网络号)→ 文字梯形图 */
|
|
149
|
+
export async function autoLadder({ file, block, net, title }) {
|
|
150
|
+
const { stdout } = await pExecFile(process.execPath, [SMART_LAD, file, ...(block ? [block] : [])], {
|
|
151
|
+
encoding: 'utf8', maxBuffer: 32 << 20,
|
|
152
|
+
})
|
|
153
|
+
const blocks = parseLadText(stdout)
|
|
154
|
+
if (!blocks.length) throw new Error(`smart-lad 没解析出块:${file}`)
|
|
155
|
+
const picked = block ? blocks.find((b) => b.name === block) : blocks[0]
|
|
156
|
+
if (!picked) throw new Error(`没找到块「${block}」,现有:${blocks.map((b) => b.name).join('、')}`)
|
|
157
|
+
|
|
158
|
+
const parts = [title ?? `${picked.name} · 盒式梯形图(自动生成)`, '']
|
|
159
|
+
let count = 0
|
|
160
|
+
for (const n of picked.nets) {
|
|
161
|
+
if (net && n.no !== Number(net)) continue
|
|
162
|
+
if (!n.ins.length) continue
|
|
163
|
+
const spec = netToSpec(n.no, n.ins)
|
|
164
|
+
if (!spec.rows.length) continue
|
|
165
|
+
parts.push(await renderNet(spec), '')
|
|
166
|
+
count++
|
|
167
|
+
}
|
|
168
|
+
if (!count) throw new Error('该块没有可渲染的网络')
|
|
169
|
+
const note = '说明:BOX#n=指令盒占位(实参用 smart-box-pins 按位次/区间查);比较触点两操作数内联标注。'
|
|
170
|
+
return parts.join('\n') + '\n' + note + '\n'
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/* CLI:node smart-ladder-auto.mjs <工程.smart> [块名] [网络号] [输出.txt] */
|
|
174
|
+
if (process.argv[1] && process.argv[1].endsWith('smart-ladder-auto.mjs')) {
|
|
175
|
+
const [file, block, net, out] = process.argv.slice(2)
|
|
176
|
+
autoLadder({ file, block, net })
|
|
177
|
+
.then((text) => {
|
|
178
|
+
process.stdout.write(out ? `${text}\n(已写入 ${out})\n` : text)
|
|
179
|
+
if (out) writeFileSync(out, text, 'utf8')
|
|
180
|
+
})
|
|
181
|
+
.catch((e) => { console.error(String(e)); process.exit(1) })
|
|
182
|
+
}
|