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,174 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dvp-ladder.mjs —— 台达 DVP 文字梯形图(从解码器指令表还原,不自己读字节)
4
+ *
5
+ * 数据源唯一:`dvp_decode.py --json` 输出的每条指令(op + args,含 ORB/ANB/MPS/MRD/MPP)。
6
+ * 报告里解得出什么,图上就画什么:未定名功能码画 `?`/`??` 盒,未识别记录画成触点并在说明里点名,
7
+ * 不再静默省略。版式与西门子那侧同一个渲染器(lad-branches)。
8
+ *
9
+ * CLI:node dvp-ladder.mjs <insts.json> [from] [to]
10
+ */
11
+ import { readFileSync, writeFileSync, mkdtempSync } from 'node:fs'
12
+ import { execFile } from 'node:child_process'
13
+ import { promisify } from 'node:util'
14
+ import { tmpdir } from 'node:os'
15
+ import { dirname, join } from 'node:path'
16
+ import { fileURLToPath } from 'node:url'
17
+
18
+ const pExecFile = promisify(execFile)
19
+ const ASSETS_DIR = dirname(fileURLToPath(import.meta.url))
20
+ const LAD_BRANCHES = join(ASSETS_DIR, 'lad-branches.mjs')
21
+
22
+ const CONTACTS = [
23
+ [/^(LD|AND|OR)$/, (a) => `┤${a}├`],
24
+ [/^(LDI|ANI|ORI)$/, (a) => `┤/${a}├`],
25
+ [/^(LDP|ANDP|ORP)$/, (a) => `┤${a}↑├`],
26
+ [/^(LDF|ANDF|ORF)$/, (a) => `┤${a}↓├`],
27
+ ]
28
+ const CONTACT_RE = /^(LD|LDI|LDP|LDF|AND|ANI|ANDP|ANDF|OR|ORI|ORP|ORF)$/
29
+ const CMP_RE = /^(\?)?CMP[((]([^))]*)[))]$/
30
+
31
+ function contactGlyph(op, args) {
32
+ const a = args.join(' ')
33
+ for (const [re, f] of CONTACTS) if (re.test(op)) return f(a)
34
+ return `┤${op}${a ? ' ' + a : ''}├`
35
+ }
36
+
37
+ /** 指令流 → 支路行;acc 是「母线到输出」的导线文字,ORB/ANB 按块合并。 */
38
+ function branchRows(insts) {
39
+ const rows = []
40
+ const notes = []
41
+ let acc = ''
42
+ let hasAcc = false
43
+ const blocks = []
44
+ const stack = []
45
+ const pushAcc = () => { if (hasAcc) { blocks.push(acc); acc = ''; hasAcc = false } }
46
+ const add = (s) => { acc += s; hasAcc = true }
47
+
48
+ // 行首 LD M1000 是母线本身(rail),不再画成第一个触点
49
+ const list = insts.slice()
50
+ if (list[0] && list[0].op === 'LD' && (list[0].args ?? [])[0] === 'M1000') list.shift()
51
+ for (const raw of list) {
52
+ const op = String(raw.op ?? '')
53
+ const args = (raw.args ?? []).map(String)
54
+ if (CONTACT_RE.test(op)) {
55
+ const g = contactGlyph(op, args)
56
+ if (op.startsWith('OR')) add('∥' + g)
57
+ else if (op.startsWith('LD')) { pushAcc(); add(g) }
58
+ else add(g)
59
+ continue
60
+ }
61
+ const cm = CMP_RE.exec(op)
62
+ if (cm) {
63
+ const g = `┤${cm[2] || '?'} ${args.join(' ')}├`
64
+ if (!hasAcc && rows.length) add(rows[rows.length - 1].wire + '∥' + g)
65
+ else add(g)
66
+ continue
67
+ }
68
+ if (op.startsWith('〔?') || op.startsWith('?CMP[')) {
69
+ add(`┤${op}├`)
70
+ notes.push(`未识别记录 ${op}(报告「未识别记录」一节可对字节)`)
71
+ continue
72
+ }
73
+ if (op === 'MPS') { stack.push(acc); continue }
74
+ if (op === 'MRD') { acc = stack[stack.length - 1] ?? ''; hasAcc = acc !== ''; continue }
75
+ if (op === 'MPP') { acc = stack.pop() ?? ''; hasAcc = acc !== ''; continue }
76
+ if (op === 'ORB') {
77
+ const b = blocks.pop()
78
+ if (b !== undefined) { acc = b + '∥' + acc; hasAcc = true }
79
+ else notes.push('ORB 前没有可合并的上一个块(未还原)')
80
+ continue
81
+ }
82
+ if (op === 'ANB') {
83
+ const b = blocks.pop()
84
+ if (b !== undefined) { acc = b + acc; hasAcc = true }
85
+ else notes.push('ANB 前没有可合并的上一个块(未还原)')
86
+ continue
87
+ }
88
+ // TMR/CTR 这类「带 EN、输出还继续走」的盒:画在导线上,不当终止输出
89
+ if (op === 'TMR' || op === 'CTR') { add(`[${op} ${args.join(' ')}]`); continue }
90
+ // 其余一律当输出:OUT / SET / RST / 功能盒;输出不消费条件(同一串条件可带多个线圈)
91
+ rows.push({ wire: acc, op, args })
92
+ }
93
+ if (hasAcc && !rows.length) notes.push(`末尾还剩一段未接输出的条件(${acc})`)
94
+ return { rows, notes }
95
+ }
96
+
97
+ function toSpecRows(rows) {
98
+ return rows.map((r, i) => {
99
+ const mark = rows.length === 1 ? '──' : i === 0 ? '┬──' : i === rows.length - 1 ? '└──' : '├──'
100
+ const wire = mark + r.wire
101
+ if (/^(SET|RST)$/.test(r.op)) return { wire, coil: `${r.op} ${r.args.join(' ')}` }
102
+ if (r.op === 'OUT') return { wire, coil: r.args[0] ?? '' }
103
+ if (r.args.length === 0) return { wire, coil: r.op }
104
+ return {
105
+ wire,
106
+ box: {
107
+ name: r.op,
108
+ pins: [
109
+ { name: 'EN', src: 'cond' },
110
+ ...r.args.map((a, k) => ({ name: k === r.args.length - 1 ? 'OUT' : `IN${k + 1}`, src: a })),
111
+ ],
112
+ },
113
+ }
114
+ })
115
+ }
116
+
117
+ async function renderNet(spec) {
118
+ const dir = mkdtempSync(join(tmpdir(), 'dvp-lad-'))
119
+ const f = join(dir, 'n.json')
120
+ writeFileSync(f, JSON.stringify(spec), 'utf8')
121
+ const { stdout } = await pExecFile(process.execPath, [LAD_BRANCHES, f], { encoding: 'utf8', maxBuffer: 8 << 20 })
122
+ return stdout.replace(/\s+$/, '')
123
+ }
124
+
125
+ /** json:[{ seg, line, insts:[{op,args}] }];段号按「首条是 LD M1000」的网络编号,与报告网络号一致。 */
126
+ export async function dvpLadder({ json, from, to, title, outFile }) {
127
+ const data = JSON.parse(readFileSync(json, 'utf8'))
128
+ const nets = data.filter((r) => {
129
+ const first = (r.insts ?? [])[0] ?? {}
130
+ return first.op === 'LD' && (first.args ?? [])[0] === 'M1000'
131
+ })
132
+ if (!nets.length) throw new Error('指令表里没有网络(首条 LD M1000)')
133
+
134
+ const lo = from ? Number(from) : 1
135
+ const hi = to ? Number(to) : nets.length
136
+ const parts = [
137
+ title ?? '台达 DVP 梯形图(由解码器指令表还原)',
138
+ '',
139
+ '说明:段 N=网络 N(与解码报告的网络号一致,不是报告行号);图上每个元件都来自解码器指令表,未定名指令画 ?/?? 盒。',
140
+ '',
141
+ ]
142
+ const GROUP = 6
143
+ for (let k = 0; k < nets.length; k++) {
144
+ const no = k + 1
145
+ if (no < lo || no > hi) continue
146
+ const { rows, notes } = branchRows(nets[k].insts ?? [])
147
+ if (!rows.length) {
148
+ parts.push(`段 ${no}(无输出,仅条件)`, '')
149
+ continue
150
+ }
151
+ const specRows = toSpecRows(rows)
152
+ for (let g = 0; g < specRows.length; g += GROUP) {
153
+ const grp = specRows.slice(g, g + GROUP)
154
+ const spec = {
155
+ title: specRows.length > GROUP ? `段 ${no}(${g + 1}-${g + grp.length}/${specRows.length} 支)` : `段 ${no}`,
156
+ rail: 'M1000',
157
+ rows: grp,
158
+ comments: notes,
159
+ }
160
+ parts.push(await renderNet(spec), '')
161
+ }
162
+ }
163
+ const out = parts.join('\n')
164
+ if (outFile) writeFileSync(outFile, out, 'utf8')
165
+ return out
166
+ }
167
+
168
+ /* CLI */
169
+ if (process.argv[1] && process.argv[1].endsWith('dvp-ladder.mjs')) {
170
+ const [json, from, to] = process.argv.slice(2)
171
+ dvpLadder({ json, from, to })
172
+ .then((t) => process.stdout.write(t))
173
+ .catch((e) => { console.error(String(e)); process.exit(1) })
174
+ }
@@ -0,0 +1,305 @@
1
+ // ebpro-decode.mjs —— 威纶通 EasyBuilder Pro 工程解码(.emtp / 编译产物 .exob)
2
+ //
3
+ // 格式:.emtp 工程本体是「头 + 一大块压缩/加密数据」,读不出结构;同工程「编译」(F7)
4
+ // 生成的 .exob 是 UTF-8 明文,里面能读的有三段:
5
+ // LABE_LIB 文字库:逐条 [1B 名长][1B 状态1长][1B 状态2长] + 三段 UTF-8(只有源语言)
6
+ // 画面对象记录 'HA' + 2 字节类型码 + 若干记录;记录 = 21 字节头(+0..+7 文字度量 u16,
7
+ // +12 语言槽 id,+14..+17 文本字节数 u32)+ UTF-8 文本 + 0x00
8
+ // 其余段(TAG_DATA / MACRO_ID …)内容未定,本解码器不解析、不猜。
9
+ //
10
+ // 用法:node ebpro-decode.mjs <工程.emtp | 编译产物.exob | 工程目录> [输出目录] [--exob 路径]
11
+ // 输出(默认 <cwd>/解码归档/<工程名>/HMI解码/):
12
+ // 00-格式说明.md 来源、结构、判定口径、能解与不能解
13
+ // 10-画面.tsv 画面名(推定) / 编号(画面标题对象偏移) / 文字对象数
14
+ // 11-文字对象.tsv 所属画面(推定) / 类型码 / 语言槽 / 文字 / 度量 x y w h / 对象偏移
15
+ // 12-报警事件.tsv 对象偏移 / 语言槽 / 文本
16
+ // 13-文字库.tsv 条目名 / 状态1 / 状态2
17
+ // HMI解码报告.md 概述 + 画面清单 + 语言槽分布 + 边界
18
+ // 08-统计.json 同一批数据的结构化版
19
+ // 只读:不修改工程与编译产物。
20
+ import fs from 'node:fs'
21
+ import path from 'node:path'
22
+
23
+ const TYPES = new Set([0x1410, 0x1408, 0x0402, 0x0401])
24
+ const CTRL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f]/
25
+ const CJK = /[\u4e00-\u9fff]/
26
+ const LATIN = /[A-Za-z]{2,}/
27
+
28
+ const argv = process.argv.slice(2)
29
+ const flags = { exob: '' }
30
+ const pos = []
31
+ for (let i = 0; i < argv.length; i++) {
32
+ if (argv[i] === '--exob') flags.exob = argv[++i] || ''
33
+ else pos.push(argv[i])
34
+ }
35
+ if (!pos[0]) {
36
+ console.error('用法: node ebpro-decode.mjs <工程.emtp | 编译产物.exob | 工程目录> [输出目录] [--exob 路径]')
37
+ process.exit(2)
38
+ }
39
+
40
+ function fail(msg) { console.error(msg); process.exit(3) }
41
+
42
+ /** 输入 → { exob, emtp }:.emtp/.目录 自动找同目录同名 .exob */
43
+ function resolveInputs(arg, exobArg) {
44
+ const abs = path.resolve(arg)
45
+ if (!fs.existsSync(abs)) fail(`路径不存在: ${arg}`)
46
+ if (fs.statSync(abs).isDirectory()) {
47
+ const files = fs.readdirSync(abs)
48
+ const exobs = files.filter((f) => f.toLowerCase().endsWith('.exob'))
49
+ .map((f) => path.join(abs, f)).sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)
50
+ const emtps = files.filter((f) => f.toLowerCase().endsWith('.emtp'))
51
+ .map((f) => path.join(abs, f)).sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)
52
+ if (!exobs.length) fail(`目录里没有 .exob 编译产物:${abs}\n先在 EasyBuilder Pro 里编译(F7)再跑。`)
53
+ return { exob: exobArg ? path.resolve(exobArg) : exobs[0], emtp: emtps[0] || '' }
54
+ }
55
+ const low = abs.toLowerCase()
56
+ if (low.endsWith('.emtp')) {
57
+ if (exobArg) return { exob: path.resolve(exobArg), emtp: abs }
58
+ const sib = abs.replace(/\.emtp$/i, '.exob')
59
+ if (!fs.existsSync(sib)) fail(`找不到同名编译产物:${sib}\n.emtp 本体压缩+加密,读不动;在 EasyBuilder Pro 里编译(F7)生成 .exob 后再跑。`)
60
+ return { exob: sib, emtp: abs }
61
+ }
62
+ if (low.endsWith('.exob')) {
63
+ const sib = abs.replace(/\.exob$/i, '.emtp')
64
+ return { exob: abs, emtp: fs.existsSync(sib) ? sib : '' }
65
+ }
66
+ fail(`不认的输入:${arg}(要 .emtp 工程、.exob 编译产物或工程目录)`)
67
+ }
68
+
69
+ /** 工程本体头部:FF FE FF + 1 字节长 + UTF-16LE 的版本串(其余压缩+加密) */
70
+ function emtpMeta(p) {
71
+ if (!p || !fs.existsSync(p)) return null
72
+ const d = fs.readFileSync(p).subarray(0, 64)
73
+ const i = d.indexOf(Buffer.from([0xff, 0xfe, 0xff]))
74
+ const meta = { path: p, size: fs.statSync(p).size, mtime: fs.statSync(p).mtime }
75
+ if (i >= 0 && i + 4 <= d.length) {
76
+ const len = d[i + 3]
77
+ meta.version = d.subarray(i + 4, i + 4 + len * 2).toString('utf16le')
78
+ }
79
+ return meta
80
+ }
81
+
82
+ /** LABE_LIB:条目名 + 状态1 + 状态2(只有源语言,没有译文) */
83
+ function parseLib(buf) {
84
+ const at = buf.indexOf(Buffer.from('LABE_LIB'))
85
+ if (at < 0) return []
86
+ let p = at + 8 + 2
87
+ const out = []
88
+ while (p + 3 < buf.length) {
89
+ const nl = buf[p], t1 = buf[p + 1], t2 = buf[p + 2]
90
+ if (nl === 0 || nl > 100 || t1 > 200 || t2 > 200) break
91
+ const q = p + 3
92
+ if (q + nl + t1 + t2 > buf.length) break
93
+ const name = buf.subarray(q, q + nl).toString('utf8')
94
+ const s1 = buf.subarray(q + nl, q + nl + t1).toString('utf8')
95
+ const s2 = buf.subarray(q + nl + t1, q + nl + t1 + t2).toString('utf8')
96
+ out.push({ name, s1, s2 })
97
+ p = q + nl + t1 + t2
98
+ }
99
+ return out
100
+ }
101
+
102
+ /** 记录流:21 字节头 + 文本 + 0x00 */
103
+ function parseRecords(buf, start, limit) {
104
+ let p = start
105
+ const out = []
106
+ while (p + 21 <= limit) {
107
+ const x = buf.readUInt16LE(p)
108
+ const y = buf.readUInt16LE(p + 2)
109
+ const w = buf.readUInt16LE(p + 4)
110
+ const h = buf.readUInt16LE(p + 6)
111
+ const lang = buf[p + 12]
112
+ const len = buf.readUInt32LE(p + 14)
113
+ const tsp = p + 21
114
+ if (len === 0) { out.push({ x, y, w, h, lang, text: '', at: tsp }); p = tsp; continue }
115
+ if (len > 400 || tsp + len >= limit) break
116
+ const text = buf.subarray(tsp, tsp + len).toString('utf8')
117
+ if (buf[tsp + len] !== 0) break
118
+ out.push({ x, y, w, h, lang, text, at: tsp })
119
+ p = tsp + len + 1
120
+ }
121
+ return { recs: out, next: p }
122
+ }
123
+
124
+ /** 对象:'HA' + 2 字节类型码 + 记录 */
125
+ function parseObjects(buf) {
126
+ const out = []
127
+ let p = 0
128
+ while (true) {
129
+ const j = buf.indexOf(Buffer.from('HA'), p)
130
+ if (j < 0 || j + 4 > buf.length) break
131
+ const ty = (buf[j + 2] << 8) | buf[j + 3]
132
+ if (!TYPES.has(ty)) { p = j + 1; continue }
133
+ const { recs, next } = parseRecords(buf, j + 5, Math.min(j + 6000, buf.length))
134
+ if (recs.length) { out.push({ off: j, type: ty, recs }); p = next } else p = j + 1
135
+ }
136
+ return out
137
+ }
138
+
139
+ /** 能当文字用的记录:有字、无控制字符、语言槽 0..23(文件里夹着另一种 24 字节结构,会解出 NUL 串) */
140
+ const usable = (r) => {
141
+ const t = r.text.trim()
142
+ return t !== '' && !CTRL.test(t) && r.lang >= 0 && r.lang <= 23
143
+ }
144
+
145
+ const fmtTime = (d) => {
146
+ const p = (n) => String(n).padStart(2, '0')
147
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
148
+ }
149
+
150
+ function main() {
151
+ const { exob, emtp } = resolveInputs(pos[0], flags.exob)
152
+ const outDir = path.resolve(pos[1] || path.join(process.cwd(), '解码归档', path.basename(emtp || exob, path.extname(emtp || exob)), 'HMI解码'))
153
+ fs.mkdirSync(outDir, { recursive: true })
154
+
155
+ const buf = fs.readFileSync(exob)
156
+ const lib = parseLib(buf)
157
+ const objs = parseObjects(buf)
158
+ if (!objs.length) fail(`没解析到画面对象:${exob}\n可能这份 .exob 不是 EasyBuilder Pro 编出来的。`)
159
+
160
+ // 语言槽:源语言=含中文最多的槽(并列取小),目标语言=拉丁字母最多的槽
161
+ const cjk = new Map(), latin = new Map()
162
+ for (const o of objs) {
163
+ for (const r of o.recs) {
164
+ if (!usable(r)) continue
165
+ if (CJK.test(r.text)) cjk.set(r.lang, (cjk.get(r.lang) || 0) + 1)
166
+ else if (LATIN.test(r.text)) latin.set(r.lang, (latin.get(r.lang) || 0) + 1)
167
+ }
168
+ }
169
+ const pick = (m, dflt) => [...m.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0])[0]?.[0] ?? dflt
170
+ const zhLang = pick(cjk, null)
171
+ const enLang = pick(latin, null)
172
+
173
+ // 画面:大号标签对象(0x1408 且度量 ≥ 150×35)
174
+ const screens = objs.filter((o) => o.type === 0x1408
175
+ && o.recs.some((r) => usable(r) && r.w >= 150 && r.h >= 35))
176
+ .sort((a, b) => a.off - b.off)
177
+ .map((o) => {
178
+ let name = ''
179
+ for (const r of o.recs) if (usable(r) && r.lang === zhLang) { name = r.text.trim(); break }
180
+ if (!name) for (const r of o.recs) if (usable(r)) { name = r.text.trim(); break }
181
+ return { off: o.off, name: name || '(无标题文字)' }
182
+ })
183
+
184
+ const screenOf = (off) => {
185
+ let name = '(首屏之前)'
186
+ for (const s of screens) if (off >= s.off) name = s.name
187
+ return name
188
+ }
189
+
190
+ // 文字对象:同一对象内「同语言同文字」重复的只留一条
191
+ const words = []
192
+ for (const o of objs) {
193
+ const seen = new Set()
194
+ const recs = []
195
+ for (const r of o.recs) {
196
+ if (!usable(r)) continue
197
+ const key = JSON.stringify([r.lang, r.text.trim()])
198
+ if (seen.has(key)) continue
199
+ seen.add(key)
200
+ recs.push(r)
201
+ }
202
+ if (recs.length) words.push({ off: o.off, type: o.type, screen: screenOf(o.off), recs })
203
+ }
204
+ const events = words.filter((w) => w.type === 0x0401)
205
+ const screenWords = words.filter((w) => w.type !== 0x0401)
206
+
207
+ const langDist = {}
208
+ for (const w of words) for (const r of w.recs) langDist[r.lang] = (langDist[r.lang] || 0) + 1
209
+
210
+ const stat = {
211
+ source: {
212
+ exob: { path: exob, size: buf.length, mtime: fmtTime(fs.statSync(exob).mtime) },
213
+ emtp: emtp ? { path: emtp, size: fs.statSync(emtp).size, mtime: fmtTime(fs.statSync(emtp).mtime), version: emtpMeta(emtp)?.version } : null,
214
+ },
215
+ languages: { source: zhLang, target: enLang, distribution: langDist },
216
+ counts: { screens: screens.length, screenTextObjects: screenWords.length, events: events.length, library: lib.length },
217
+ screens,
218
+ objects: words,
219
+ library: lib,
220
+ }
221
+
222
+ const w = (name, content) => fs.writeFileSync(path.join(outDir, name), content, 'utf8')
223
+ const tsv = (head, rows) => head + '\n' + rows.map((r) => r.join('\t')).join('\n') + '\n'
224
+
225
+ w('10-画面.tsv', tsv('画面名(推定)\t编号(标题对象偏移)\t文字对象数',
226
+ screens.map((s) => [s.name, '0x' + s.off.toString(16).toUpperCase(), screenWords.filter((x) => x.screen === s.name).length])))
227
+
228
+ w('11-文字对象.tsv', tsv('所属画面(推定)\t类型码\t语言槽\t文字\t度量x\t度量y\t宽\t高\t对象偏移',
229
+ screenWords.flatMap((x) => x.recs.map((r) => [x.screen, '0x' + x.type.toString(16).toUpperCase().padStart(4, '0'),
230
+ r.lang, r.text.trim().replace(/\t/g, ' '), r.x, r.y, r.w, r.h, '0x' + x.off.toString(16).toUpperCase()]))))
231
+
232
+ w('12-报警事件.tsv', tsv('对象偏移\t语言槽\t文本',
233
+ events.flatMap((x) => x.recs.map((r) => ['0x' + x.off.toString(16).toUpperCase(), r.lang, r.text.trim().replace(/\t/g, ' ')]))))
234
+
235
+ w('13-文字库.tsv', tsv('条目名\t状态1\t状态2',
236
+ lib.map((e) => [e.name, e.s1.trim(), e.s2.trim()])))
237
+
238
+ const stale = emtp && fs.statSync(emtp).mtimeMs > fs.statSync(exob).mtimeMs + 60000
239
+
240
+ w('00-格式说明.md', [
241
+ '# 威纶通 EasyBuilder Pro(.emtp / .exob)格式说明',
242
+ '',
243
+ '| 文件 | 是什么 | 能不能读 |',
244
+ '|---|---|---|',
245
+ '| `.emtp` | 工程本体 | 头 4 字节后有 `FF FE FF + 1 字节长 + UTF-16LE` 的工程版本串;其后是压缩+加密数据,读不出结构 |',
246
+ '| `.exob` | 编译产物(软件里编译一次,与工程同名同目录) | UTF-8 明文:文字库段 `LABE_LIB` + 画面对象记录 + 若干未定段 |',
247
+ '',
248
+ '## 结构(本解码器用到的那两段)',
249
+ '',
250
+ '- `LABE_LIB`:标记后跳 2 字节,逐条 `[1B 名长][1B 状态1长][1B 状态2长]` + 三段 UTF-8。这一段**只有源语言**,没有译文。',
251
+ '- 对象记录:`HA` + 2 字节类型码 + 若干记录;记录 = 21 字节头(`+0..+7` 文字度量 u16 x/y/w/h,`+12` 语言槽 id,`+14..+17` 文本字节数 u32)+ UTF-8 文本 + `0x00`。',
252
+ '- 类型码原值:`0x1408` / `0x1410` / `0x0402` / `0x0401`;含义未判死,表里只用码不写类别名。',
253
+ '',
254
+ '## 判定口径',
255
+ '',
256
+ '- 语言槽:源语言=含中文最多的槽(并列取小);目标语言=拉丁字母最多的槽。',
257
+ '- 画面:以「度量 ≥ 150×35 的 0x1408 标签对象」当画面标题,其后的对象归这个画面;没有标题对象的画面会并到上一个。',
258
+ '- 噪声过滤:文本含控制字符、或语言槽 id 不在 0..23 的记录不是文字(文件里夹着另一种 24 字节结构,按本格式会解出 NUL 串)。',
259
+ '',
260
+ '## 能解与不能解',
261
+ '',
262
+ '- 能解:画面名(推定)、文字对象(所属画面/类型码/语言槽/文字/度量/偏移)、报警事件文本、文字库条目、工程版本。',
263
+ '- 不能解:工程本体 `.emtp`(压缩+加密);控件几何与画面坐标;PLC 地址与变量绑定(编译产物里没有可读的标签表段)。',
264
+ '- 度量列 x/y/w/h 是字串外框度量,不是画面坐标。',
265
+ '',
266
+ ].join('\n'))
267
+
268
+ const L = []
269
+ L.push(`# ${path.basename(emtp || exob, path.extname(emtp || exob))} · EasyBuilder Pro HMI 解码报告`)
270
+ L.push('')
271
+ L.push(`- 编译产物: ${path.basename(exob)}(${fmtTime(fs.statSync(exob).mtime)},${(buf.length / 1048576).toFixed(2)} MB,UTF-8 明文段)`)
272
+ L.push(emtp ? `- 工程文件: ${path.basename(emtp)}(${(fs.statSync(emtp).size / 1048576).toFixed(2)} MB,工程版本 ${emtpMeta(emtp)?.version || '未知'};本体压缩+加密)` : '- 工程文件: (未提供,只给了编译产物)')
273
+ L.push(`- 规模: 画面 ${screens.length}(推定)/ 文字对象 ${screenWords.length} / 报警事件 ${events.length} / 文字库 ${lib.length} 条`)
274
+ L.push(`- 语言槽: 源 ${zhLang ?? '未识别'} / 目标 ${enLang ?? '未识别'};分布 ${Object.entries(langDist).sort((a, b) => a[0] - b[0]).map(([k, v]) => `${k} 槽 ${v} 条`).join(',')}`)
275
+ if (stale) L.push(`- 注意: 工程比编译产物新,表是上一次编译的状态;要最新就在软件里重新编译再跑`)
276
+ L.push('')
277
+ L.push('## 画面清单')
278
+ L.push('')
279
+ L.push('| 画面名(推定) | 文字对象数 | 标题对象偏移 |')
280
+ L.push('|---|---|---|')
281
+ for (const s of screens) L.push(`| ${s.name.replace(/\|/g, '/')} | ${screenWords.filter((x) => x.screen === s.name).length} | 0x${s.off.toString(16).toUpperCase()} |`)
282
+ L.push('')
283
+ L.push('## 报警事件(前 40)')
284
+ L.push('')
285
+ L.push('| 语言槽 | 文本 |')
286
+ L.push('|---|---|')
287
+ for (const x of events.slice(0, 40)) for (const r of x.recs) L.push(`| ${r.lang} | ${r.text.trim().replace(/\|/g, '/')} |`)
288
+ L.push('')
289
+ L.push('## 文字库(前 40)')
290
+ L.push('')
291
+ L.push('| 条目名 | 状态1 | 状态2 |')
292
+ L.push('|---|---|---|')
293
+ for (const e of lib.slice(0, 40)) L.push(`| ${e.name.replace(/\|/g, '/')} | ${e.s1.trim().replace(/\|/g, '/')} | ${e.s2.trim().replace(/\|/g, '/')} |`)
294
+ L.push('')
295
+ L.push('先读这份;原始表在 10-画面.tsv / 11-文字对象.tsv / 12-报警事件.tsv / 13-文字库.tsv,结构化数据在 08-统计.json。')
296
+ w('HMI解码报告.md', L.join('\n') + '\n')
297
+ w('08-统计.json', JSON.stringify(stat, null, 1))
298
+
299
+ console.log(`OK ${path.basename(emtp || exob)} → ${outDir}`)
300
+ console.log(` 画面 ${screens.length}(推定)/ 文字对象 ${screenWords.length} / 报警事件 ${events.length} / 文字库 ${lib.length} 条`)
301
+ console.log(` 语言槽: 源 ${zhLang ?? '?'} / 目标 ${enLang ?? '?'}`)
302
+ if (stale) console.log(' 注意: 工程比编译产物新,表是上一次编译的状态')
303
+ }
304
+
305
+ main()
@@ -7,9 +7,8 @@
7
7
  | 文件 | 是什么 | 用哪个 |
8
8
  |---|---|---|
9
9
  | `.hmismart` | 西门子 WinCC flexible SMART 触摸屏工程(Smart Line 屏) | 工具 `hmi-decode`(解码)/`hmi-diff-project`(新旧对比) |
10
- | `.fsprj` 或含 `HMI\Frame` 的目录 | 繁易 FStudio / Flexem 触摸屏工程 | 工具 `hmi-decode`(扫文本)/`fstudio-scan-texts`/`fstudio-i18n`(多语言出表) |
11
- | `<工程名>.设备_1.srt`(同目录) | 项目文本侧的序列化存储 | 主文件被锁时的旁路,见第三节 |
12
- | `Screens.xls` / `Others.xls` / `Messages.xls` | WinCC flexible「项目文本」导出成的 Excel 三件套 | 工具 `wincc-text-i18n`(分型/回写/自检),流程见技能 `wincc-text-i18n-workflow` |
10
+ | `.fsprj` 或含 `HMI\Frame` 的目录 | 繁易 FStudio / Flexem 触摸屏工程 | 工具 `hmi-decode`(画面 / 控件 / 地址表 + 元件文本扫描) |
11
+ | `<工程名>.srt`(同目录) | 项目文本侧的序列化存储 | 主文件被锁时的旁路,见第三节 |
13
12
 
14
13
  **第一原则与 PLC 侧相同:先要"软件里能导出的东西"**。触摸屏这边对应的是:画面清单/变量表(软件里能导 CSV 或看变量表)——导出来对账,比在工程里挖快一个数量级。
15
14
 
@@ -30,9 +29,9 @@
30
29
  `.hmismart` 被 WinCC 打开时是**独占锁**(连 `FileShare.ReadWrite` 都打不开)。这时不必关软件,同目录的 `{{GUID}}.data` 就是工程数据的旁路:
31
30
 
32
31
  - 结构:`ES2RT` 头(前 10 字节)+ gzip 流(`1F 8B 08` 起),解出来是 .NET 序列化对象图(20MB 量级),**UTF-8,字符串可读**。
33
- - 你能直接从中拿到:**变量表全部标签名**(含 `文件夹_N\MW5212` 这类「以地址命名」的标签与中文命名标签)、**连接与驱动名**(如 `driver.Modicon MODBUS Standard V3`)、画面/控件文本。
32
+ - 你能直接从中拿到:**变量表全部标签名**(含以地址命名的标签与中文命名标签)、**连接与驱动名**、画面/控件文本。
34
33
  - 拿不到:**变量的 Modbus 地址号**(二进制存,工程里没有明文字样)——读不到就直说读不到,别编;判定靠现场按一下看对应位是否变,或让用户从变量表导一份出来。
35
- - 用法:按具体串搜(`MW5212`、`驱动名`、中文标签名),避开宽 pattern。
34
+ - 用法:按具体串搜(具体地址串、驱动名、中文标签名),避开宽 pattern。
36
35
 
37
36
  ## 三、`.srt` 项目文本(主文件锁着时的另一条旁路)
38
37
 
@@ -41,13 +40,12 @@
41
40
  - **陷阱**:① 中文不要用 GBK 解(UTF-16 才对齐);② 抽「中文→英文」对时跳过元素引用/变量地址/空串;③ 截图「项目文本」页可与 `.srt` 内容逐条吻合,能当真值校准。
42
41
  - **旁系文件**:`*.transfer`(UTF-16 XML,含设备型号/页面清单)、`*.data`(见上)、`*.chk/.cnk/.rdf/.pwx`(校验/密码,跳过)。
43
42
 
44
- ## 四、繁易 FStudio(多语言另见专篇)
43
+ ## 四、繁易 FStudio
45
44
 
46
- - 工具:`hmi-decode`(读工程→扫元件文本)、`fstudio-scan-texts`(只看要翻什么)、`fstudio-i18n`(出对照表 + 可导入文本库的表)。
47
- - 方法与踩坑见同插件技能 **`fstudio-i18n-workflow`**:列名逐字符一致、元件文字常写死不走文本库、原工程英文槽常是错填。
45
+ - 工具:`hmi-decode`(读工程 → 画面 / 控件 / 控件地址表,并扫元件文本)。
48
46
 
49
47
  ## 五、交付与边界
50
48
 
51
49
  - 产物约定:`.hmismart` 写 `<工作区>\解码归档\<工程名>\HMI解码\`;FStudio 写 `<工程目录>\多语言产出\`。**不写系统临时目录、不污染工程目录**。
52
50
  - 只读源工程;`解码归档` 里的报告是某个版本的快照,拿旧报告对账前先比源文件的**大小 + 修改时间**。
53
- - 屏上地址为什么长成"不是西门子那种格式"(M 区名、Modbus 号、命名习惯):见 PLC 侧技能 `codesys-teach-workflow` §5——那是工程地址面的事,不在这里重复。
51
+ - 屏上地址不是西门子那种格式时(M 区名、Modbus 号、命名习惯):先解同机 PLC 工程(`plc-decode`),看 M 区 IO 映射与参数表,再回来看屏地址。