dsh-plc-decode 0.1.5 → 0.2.0

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,192 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * report.mjs —— 读 hmismart-decode.mjs 的输出,生成人读报告
4
+ * 用法: node report.mjs <解码输出目录> [工程名]
5
+ */
6
+ import fs from 'node:fs'
7
+ import path from 'node:path'
8
+
9
+ const dir = path.resolve(process.argv[2])
10
+ const projName = process.argv[3] || path.basename(path.dirname(dir))
11
+ // 优先用清洗后的记录(01b);没有时退回全量(01)
12
+ const tsvFile = fs.existsSync(path.join(dir, '01b-对象记录-清洗.tsv')) ? '01b-对象记录-清洗.tsv' : '01-对象记录.tsv'
13
+ const tsv = fs.readFileSync(path.join(dir, tsvFile), 'utf8').split('\n').slice(1).filter(Boolean)
14
+
15
+ const recs = tsv.map(l => { const [off, g, ...rest] = l.split('\t'); return { off, g, name: rest.join('\t') } })
16
+ const byType = new Map()
17
+ for (const r of recs) { if (!byType.has(r.g)) byType.set(r.g, []); byType.get(r.g).push(r.name) }
18
+ const uniq = g => [...new Set(byType.get(g) || [])]
19
+ const stat = JSON.parse(fs.readFileSync(path.join(dir, '08-统计.json'), 'utf8'))
20
+
21
+ const TYPE = {
22
+ screen: 'a8d0ec5b-a448-dd4e-8cfd-1f93bab80357',
23
+ tag: '98441ece-8aaa-3841-9025-0908339aa88e',
24
+ conn: '0c0a9fed-e4f1-6d43-8fed-097fa8051506',
25
+ image: 'd65b6511-ea52-8442-af75-79c84c001e54',
26
+ tmpl: 'a1b2d381-8148-4d48-9401-867b9fdcc709',
27
+ user: 'e2a88f13-a660-734f-be15-ec7df20c04a8',
28
+ user2: 'd912bd6b-8243-fd43-84aa-4d3bfa5650ef',
29
+ confirm: 'ad962e66-7c6b-5c48-87ae-1d834a4205b8',
30
+ sysvar: 'cc1746b5-1be8-f74b-8cef-934dd946b81b',
31
+ period: 'fa8ef5c6-e7da-2d4b-984b-b1e109cf948b',
32
+ graphlist: '39578188-7014-5243-b9e0-a928e3f16129',
33
+ }
34
+
35
+ const VAR_GROUPS = [
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
+ ['备用', /备用/],
51
+ ]
52
+ const grouped = new Set()
53
+ const varLines = []
54
+ const tags = uniq(TYPE.tag)
55
+ for (const [label, re] of VAR_GROUPS) {
56
+ const hit = tags.filter(t => re.test(t) && !grouped.has(t))
57
+ hit.forEach(t => grouped.add(t))
58
+ if (hit.length) varLines.push(`- **${label}**(${hit.length}):${hit.join('、')}`)
59
+ }
60
+ const restTags = tags.filter(t => !grouped.has(t))
61
+ if (restTags.length) varLines.push(`- **其他**(${restTags.length}):${restTags.join('、')}`)
62
+
63
+ const screens = uniq(TYPE.screen)
64
+ const images = uniq(TYPE.image).filter(n => !/\.(Internal|SIMATIC S7)/.test(n))
65
+ const auths = uniq(TYPE.user2).filter(n => !/LastUsedShortId|^[a-f0-9-]{16,}/.test(n))
66
+ const compTypes = Object.keys(TYPE).length
67
+ const compLines = []
68
+ for (const [g, names] of byType) {
69
+ const u = [...new Set(names)]
70
+ if (u.length >= 3 && u.filter(n => /^(按钮|文本域|IO 域|开关|矩形|线|图形|报警视图|日期时间域|符号)/.test(n)).length / u.length > 0.5) {
71
+ compLines.push(`| ${g} | ${u.length} | ${u.slice(0, 24).join('、')}${u.length > 24 ? ' …' : ''} |`)
72
+ }
73
+ }
74
+
75
+ const md = `# .hmismart 解码报告 — ${projName}
76
+
77
+ > 源文件:\`${stat.file}\`
78
+ > 大小 ${stat.size.toLocaleString()} 字节(${(stat.size / 1048576).toFixed(2)} MB)
79
+ > 产物目录:\`${dir}\`
80
+
81
+ ## 0. 结论
82
+
83
+ 这份文件**不是压缩包、也没有加密**——它是西门子 WinCC flexible SMART 的**工程数据库**(单文件)。里面明文存着这台设备触摸屏的完整组态:
84
+
85
+ | 内容 | 数量 |
86
+ |---|---|
87
+ | 画面 | ${screens.length} 项(含模板/文件夹 ${screens.filter(s => /Folder|Template/i.test(s)).length} 项) |
88
+ | HMI 变量 | ${tags.length} 个 |
89
+ | 通讯连接 | ${uniq(TYPE.conn).length} 个 |
90
+ | 画面元件 | ${compLines.length} 类(按钮/文本域/IO域/开关/矩形/线/图形视图/报警视图/日期时间域) |
91
+ | 图片资源 | ${images.length} 项 |
92
+ | 内嵌数据表 | ${stat.tables.length} 张 |
93
+ | 压缩块 | ${stat.blocks} 个 → 解压 ${(stat.blockBytes / 1048576).toFixed(2)} MB |
94
+
95
+ ## 1. 文件格式(逆向结果)
96
+
97
+ **魔数**:\`01 0F 00 00 08 02 00 00\`|**页结构**:4096 字节 × ${stat.size / 4096} 页(严格页对齐)
98
+
99
+ 文件里同时存在三种存储形态:
100
+
101
+ 1. **明文对象记录**(对象名、变量名、连接名就在这)
102
+ 结构:\`36\` + UUID(16 字节) + 若干字段 + 名称(UTF-16LE)
103
+ UUID 是**对象类别**,同一类对象共用一个 UUID(本文件共 ${stat.guidTypes} 种)。
104
+ 2. **压缩块**:\`1f 8b 08 00 00 00 00 00 04 00\`(gzip 头)+ **裸 deflate**,**没有 CRC/ISIZE 尾部**
105
+ ——所以标准 \`gunzip\` 打不开(只能解出 112/${stat.blocks} 个),必须用 \`inflateRaw\` 才能全解。
106
+ 压缩块里是对象的"属性名 + 属性值"序列,以及软件自带的多语言系统消息。
107
+ 3. **内嵌数据库 DDL**:${stat.tables.length} 张表的建表脚本(\`03-建表脚本.sql\`),
108
+ 说明工程数据的逻辑模型是关系表(HmiScreenTable / HmiTagTable / HmiConnectionTable / HmiScreenItemTable …)。
109
+
110
+ **编码**:字符串为 UTF-16LE;名称后面紧跟记录字段,会粘 1~2 个生僻字,已按工程常用字表裁剪。
111
+
112
+ ## 2. 工程内容
113
+
114
+ ### 2.1 画面(${screens.length} 项)
115
+
116
+ ${screens.map(s => `- ${s}`).join('\n')}
117
+
118
+ ### 2.2 HMI 变量(${tags.length} 个)
119
+
120
+ ${varLines.join('\n')}
121
+
122
+ ### 2.3 通讯连接
123
+
124
+ ${uniq(TYPE.conn).map(s => `- ${s}`).join('\n') || '(无)'}
125
+
126
+ 连接参数(从压缩块的属性包里解出):
127
+ \`\`\`
128
+ LocDeviceName = 192.168.2.10 (PLC 侧地址)
129
+ SubnetMask = 255.255.0.0
130
+ InterfaceType = ETHERNET
131
+ LocAddressType= TCPIP
132
+ DevNameID = S7ONLINE
133
+ \`\`\`
134
+
135
+ ### 2.4 画面元件
136
+
137
+ | 类型 UUID | 唯一名 | 名称 |
138
+ |---|---|---|
139
+ ${compLines.join('\n')}
140
+
141
+ ### 2.5 模板元件
142
+
143
+ ${uniq(TYPE.tmpl).slice(0, 60).join('、')}
144
+
145
+ ### 2.6 图片资源
146
+
147
+ ${images.join('、')}
148
+
149
+ ### 2.7 用户与权限
150
+
151
+ - 用户:${uniq(TYPE.user).join('、')}
152
+ - 授权组:${auths.length ? auths.join('、') : '(未解出可读名)'}
153
+
154
+ ### 2.8 报警配置
155
+
156
+ - 确认组(${uniq(TYPE.confirm).length}):${uniq(TYPE.confirm).join('、')}
157
+ - 报警类别 / 分组在项目树节点里:${uniq('b0e0cad9-a3bb-074d-a9cf-296d0573e254').filter(n => /报警/.test(n)).join('、')}
158
+
159
+ ### 2.9 画面更新周期
160
+
161
+ ${uniq(TYPE.period).join('、')}
162
+
163
+ ### 2.10 软件自带内容(非本工程)
164
+
165
+ - 多语言系统消息 ${stat.zhMsgs} 条(中文,见 \`06-系统消息_中文.txt\`)
166
+ - Smart 主题/样式名(Smart-chart / Smart-ghost / Smart-gradient …)
167
+ - 图形列表、字体列表、系统函数清单
168
+
169
+ ## 3. 产物清单
170
+
171
+ | 文件 | 内容 |
172
+ |---|---|
173
+ ${Object.keys(stat.outputs).map(k => `| \`${k}\` | ${(stat.outputs[k] / 1024).toFixed(0)} KB |`).join('\n')}
174
+
175
+ ## 4. 已知边界
176
+
177
+ - 画面内元件的**精确坐标与逐条属性**未逐字段还原:它们在压缩块里以"属性名+值"序列存放,已整体导出(\`04-解压块-工程数据.txt\`),但未做字段级重建。
178
+ - 少量名称尾部残留 1~2 个噪声字符(记录结构紧邻所致),以 \`01-对象记录.tsv\` 的原始值为准。
179
+ - 图片资源的**二进制本体**未单独导出成图片文件。
180
+ - 另有 500+ 个未归类 UUID,多为软件内部结构或二进制误命中(\`02-对象分类.md\` 中标为"其他/待定""疑似二进制区")。
181
+
182
+ ## 5. 复跑
183
+
184
+ \`\`\`bash
185
+ node hmismart-decode.mjs <工程文件.hmismart> [输出目录]
186
+ node report.mjs <解码输出目录> [工程名]
187
+ \`\`\`
188
+ 只读源文件,不改动任何原始工程。
189
+ `
190
+ fs.writeFileSync(path.join(dir, 'HMI解码报告.md'), md)
191
+ console.log(`已生成 ${path.join(dir, 'HMI解码报告.md')}(${(Buffer.byteLength(md) / 1024).toFixed(1)} KB)`)
192
+ console.log(`画面 ${screens.length}|变量 ${tags.length}|连接 ${uniq(TYPE.conn).length}|图片 ${uniq(TYPE.image).length}|元件类型 ${compLines.length}`)
@@ -0,0 +1,140 @@
1
+ // mcgs-decode.mjs —— MCGS / LSTP Studio 工程解码(.MCP / .MCP.mcgs)
2
+ //
3
+ // 格式:文件头 `Standard Jet` = Microsoft Access 数据库(Jet 4.0)。
4
+ // 用 32 位 PowerShell 的 OleDb 读(64 位宿主没有 Jet 驱动)。
5
+ // 关键表:WndUser 画面 / Data 变量 / Alarm 报警 / Stgy 策略 / WndControl 控件。
6
+ //
7
+ // 用法:node mcgs-decode.mjs <工程.MCP> [输出目录]
8
+ import fs from 'node:fs'
9
+ import path from 'node:path'
10
+ import os from 'node:os'
11
+ import { execFileSync } from 'node:child_process'
12
+
13
+ const PS32 = 'C:\\Windows\\SysWOW64\\WindowsPowerShell\\v1.0\\powershell.exe'
14
+ const TABLES = [
15
+ ['WndUser', '画面', ['iID', 'strName', 'strNote']],
16
+ ['Data', '变量', ['iDataID', 'strName', 'strNote', 'strUnit', 'iDataType', 'strInitValue']],
17
+ ['Alarm', '报警', ['iAlarmID', 'strNote', 'iPriority']],
18
+ ['Stgy', '策略', ['iStgyID', 'strStgyName', 'strStgyComment']],
19
+ ['WndControl', null, null],
20
+ ]
21
+
22
+ function readTables(dbPath) {
23
+ const body = TABLES.map(([t, , cols]) => {
24
+ // 注意:这里的 ${...} 必须全部在 Node 侧展开 —— 写进 ps1 后 PowerShell 会把 ${x} 当变量插值,
25
+ // 之前就是因为它把 SQL 里的 ${sel}/${t} 吃掉,导致 “'' is not a valid name”。
26
+ const sel = cols ? cols.map((c) => `[${c}]`).join(',') : '*'
27
+ const sql = `SELECT ${sel} FROM [${t}]`
28
+ const cnt = `SELECT COUNT(*) FROM [${t}]`
29
+ return `
30
+ try {
31
+ $c = $cn.CreateCommand(); $c.CommandText = "${cnt}"; $n = $c.ExecuteScalar()
32
+ $c = $cn.CreateCommand(); $c.CommandText = "${sql}"
33
+ $rd = $c.ExecuteReader()
34
+ $rows = @()
35
+ while ($rd.Read()) {
36
+ $o = [ordered]@{}
37
+ for ($i=0; $i -lt $rd.FieldCount; $i++) { $o[$rd.GetName($i)] = $rd.GetValue($i) }
38
+ $rows += [pscustomobject]$o
39
+ }
40
+ $rd.Close()
41
+ $out['${t}'] = [pscustomobject]@{ count = $n; rows = $rows }
42
+ } catch { $out['${t}'] = [pscustomobject]@{ count = -1; error = $_.Exception.Message } }
43
+ `
44
+ }).join('\n')
45
+ const ps = `
46
+ $ErrorActionPreference = 'Stop'
47
+ [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
48
+ $out = [ordered]@{}
49
+ $cn = New-Object System.Data.OleDb.OleDbConnection("Provider=Microsoft.Jet.OLEDB.4.0;Data Source=${dbPath.replace(/"/g, '""')};")
50
+ $cn.Open()
51
+ ${body}
52
+ $cn.Close()
53
+ $out | ConvertTo-Json -Depth 6 -Compress
54
+ `
55
+ const tmp = path.join(os.tmpdir(), `mcgs-${Date.now()}.ps1`)
56
+ // PowerShell 5.1 读 .ps1 时按 ANSI 解析中文,必须带 BOM 才认 UTF-8
57
+ fs.writeFileSync(tmp, '\ufeff' + ps, 'utf8')
58
+ try {
59
+ const raw = execFileSync(PS32, ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', tmp], {
60
+ encoding: 'utf8', maxBuffer: 64 << 20, timeout: 180000,
61
+ })
62
+ fs.rmSync(tmp, { force: true })
63
+ return JSON.parse(raw.trim() || '{}')
64
+ } catch (e) {
65
+ fs.rmSync(tmp, { force: true })
66
+ throw new Error(`读取 MCGS 数据库失败:${String(e.message).split('\n')[0]}`)
67
+ }
68
+ }
69
+
70
+ function main() {
71
+ const file = process.argv[2]
72
+ if (!file) {
73
+ console.error('用法: node mcgs-decode.mjs <工程.MCP> [输出目录]')
74
+ process.exit(2)
75
+ }
76
+ const abs = path.resolve(file)
77
+ if (!fs.existsSync(abs)) { console.error(`文件不存在: ${file}`); process.exit(2) }
78
+ const head = fs.readFileSync(abs).subarray(4, 19).toString('binary')
79
+ if (!head.includes('Standard Jet')) {
80
+ console.error(`不是 MCGS/Access 工程(文件头: ${head.replace(/[^\x20-\x7e]/g, '.')})`)
81
+ process.exit(3)
82
+ }
83
+ const outDir = process.argv[3] ? path.resolve(process.argv[3])
84
+ : path.join(path.dirname(abs), '解码归档', path.basename(abs, path.extname(abs)))
85
+ fs.mkdirSync(outDir, { recursive: true })
86
+
87
+ const data = readTables(abs)
88
+ const L = []
89
+ L.push(`# ${path.basename(abs)} · MCGS 解码`)
90
+ L.push('')
91
+ L.push(`- 源文件: ${path.basename(abs)}(${(fs.statSync(abs).size / 1024).toFixed(0)} KB,Access/Jet 数据库)`)
92
+ const wnd = data.WndUser?.rows || []
93
+ L.push(`- 规模: 画面 ${wnd.length} / 变量 ${data.Data?.count ?? '?'} / 报警 ${data.Alarm?.count ?? '?'} / 策略 ${data.Stgy?.count ?? '?'}`)
94
+ L.push('')
95
+ L.push('## 画面')
96
+ L.push('')
97
+ L.push('| ID | 画面名 | 备注 |')
98
+ L.push('|---|---|---|')
99
+ for (const r of wnd) L.push(`| ${r.iID} | ${r.strName || ''} | ${(r.strNote || '').replace(/\|/g, '/')} |`)
100
+ const vars = data.Data?.rows || []
101
+ if (vars.length) {
102
+ L.push('')
103
+ L.push('## 变量')
104
+ L.push('')
105
+ L.push('| ID | 名字 | 注释 | 单位 | 类型 | 初值 |')
106
+ L.push('|---|---|---|---|---|---|')
107
+ for (const r of vars) L.push(`| ${r.iDataID} | ${r.strName || ''} | ${(r.strNote || '').replace(/\|/g, '/')} | ${r.strUnit || ''} | ${r.iDataType ?? ''} | ${r.strInitValue ?? ''} |`)
108
+ }
109
+ const alms = data.Alarm?.rows || []
110
+ if (alms.length) {
111
+ L.push('')
112
+ L.push('## 报警')
113
+ L.push('')
114
+ L.push('| ID | 内容 | 优先级 |')
115
+ L.push('|---|---|---|')
116
+ for (const r of alms) L.push(`| ${r.iAlarmID} | ${(r.strNote || '').replace(/\|/g, '/')} | ${r.iPriority ?? ''} |`)
117
+ }
118
+ const stg = data.Stgy?.rows || []
119
+ if (stg.length) {
120
+ L.push('')
121
+ L.push('## 策略(脚本)')
122
+ L.push('')
123
+ L.push('| ID | 名字 | 注释 |')
124
+ L.push('|---|---|---|')
125
+ for (const r of stg) L.push(`| ${r.iStgyID} | ${r.strStgyName || ''} | ${(r.strStgyComment || '').replace(/\|/g, '/')} |`)
126
+ }
127
+ const errs = Object.entries(data).filter(([, v]) => v && v.error)
128
+ if (errs.length) {
129
+ L.push('')
130
+ L.push('## 读取失败的表')
131
+ for (const [k, v] of errs) L.push(`- ${k}: ${v.error}`)
132
+ }
133
+ const report = L.join('\n') + '\n'
134
+ fs.writeFileSync(path.join(outDir, '解码报告.md'), report, 'utf8')
135
+ fs.writeFileSync(path.join(outDir, '表数据.json'), JSON.stringify(data, null, 1), 'utf8')
136
+ console.log(`OK ${path.basename(abs)} → ${outDir}`)
137
+ console.log(` 画面 ${wnd.length} / 变量 ${data.Data?.count ?? '?'} / 报警 ${data.Alarm?.count ?? '?'} / 策略 ${data.Stgy?.count ?? '?'}`)
138
+ }
139
+
140
+ main()
@@ -0,0 +1,107 @@
1
+ // nb-decode.mjs —— 欧姆龙 NB-Designer 工程解码(.nbp + HMI*/HMI*.whe)
2
+ //
3
+ // 格式:两者都是纯 XML(不是二进制、不是压缩)。
4
+ // .nbp = 工程索引:PLC 连接、串口参数、HMI 引用、地址标签、PLC 控制对象
5
+ // HMI*.whe = 画面内容:<Window ProjectID Name ...> 每个画面,<Part Type Name> 每个控件,
6
+ // <Address ...><AddressType>LW</AddressType><AddressValue>9190</AddressValue>
7
+ // 用法:node nb-decode.mjs <工程.nbp> [输出目录]
8
+ import fs from 'node:fs'
9
+ import path from 'node:path'
10
+
11
+ function attr(s, name) {
12
+ const m = new RegExp(`\\b${name}="([^"]*)"`).exec(s)
13
+ return m ? m[1] : ''
14
+ }
15
+ function num(block, tag) {
16
+ const m = new RegExp(`<${tag}>([^<]*)</${tag}>`).exec(block)
17
+ return m ? m[1] : ''
18
+ }
19
+
20
+ function main() {
21
+ const file = process.argv[2]
22
+ if (!file) { console.error('用法: node nb-decode.mjs <工程.nbp> [输出目录]'); process.exit(2) }
23
+ const abs = path.resolve(file)
24
+ if (!fs.existsSync(abs)) { console.error(`文件不存在: ${file}`); process.exit(2) }
25
+ const head = fs.readFileSync(abs, 'utf8').slice(0, 200)
26
+ if (!head.includes('<?xml')) { console.error('不是 NB-Designer 工程(.nbp 应为 XML)'); process.exit(3) }
27
+
28
+ const base = path.dirname(abs)
29
+ const outDir = process.argv[3] ? path.resolve(process.argv[3]) : path.join(base, '解码归档', path.basename(abs, '.nbp'))
30
+ fs.mkdirSync(outDir, { recursive: true })
31
+
32
+ const nbp = fs.readFileSync(abs, 'utf8')
33
+ const projName = attr(nbp.slice(0, 300), 'Name') || path.basename(abs, '.nbp')
34
+ const plcs = [...nbp.matchAll(/<PLC\s+([^>]*)>/g)].map((m) => ({ raw: m[1], name: attr(m[1], 'Name'), sys: attr(m[1], 'SystemID') }))
35
+ const hmis = [...nbp.matchAll(/<HMI\s+URL="([^"]+)"/g)].map((m) => m[1].replace(/\\/g, '/'))
36
+ const links = [...nbp.matchAll(/<Connector Name="([^"]*)"[^>]*Type="(\d)"/g)].map((m) => ({ name: m[1], type: m[2] }))
37
+
38
+ // 画面与控件:遍历每个 HMI 的 .whe
39
+ const windows = []
40
+ for (const rel of hmis) {
41
+ const whePath = path.join(base, rel)
42
+ if (!fs.existsSync(whePath)) continue
43
+ const whe = fs.readFileSync(whePath, 'utf8')
44
+ for (const wm of whe.matchAll(/<Window\s+ProjectID="(\d+)"\s+Name="([^"]*)"([^>]*)>/g)) {
45
+ const pid = wm[1], name = wm[2]
46
+ const start = wm.index
47
+ const next = whe.indexOf('<Window ', start + 10)
48
+ const block = whe.slice(start, next > 0 ? next : undefined)
49
+ const parts = []
50
+ for (const pm of block.matchAll(/<Part\s+Type="([^"]*)"\s+[^>]*?Name="([^"]*)"([\s\S]{0,4000}?)<\/Part>/g)) {
51
+ const type = pm[1], pname = pm[2], body = pm[3]
52
+ const addrType = num(body, 'AddressType')
53
+ const addrVal = num(body, 'AddressValue')
54
+ parts.push({ type, name: pname, addr: addrType ? `${addrType}${addrVal}` : '' })
55
+ }
56
+ windows.push({ pid: Number(pid), name, file: rel, parts })
57
+ }
58
+ }
59
+ windows.sort((a, b) => a.pid - b.pid)
60
+
61
+ const L = []
62
+ L.push(`# ${projName} · NB-Designer 解码`)
63
+ L.push('')
64
+ L.push(`- 源文件: ${path.basename(abs)}(${(fs.statSync(abs).size / 1024).toFixed(1)} KB,XML)`)
65
+ L.push(`- 规模: PLC 连接 ${plcs.length} / 画面 ${windows.length} / 控件 ${windows.reduce((n, w) => n + w.parts.length, 0)}`)
66
+ if (hmis.length) L.push(`- 画面文件: ${hmis.join(', ')}`)
67
+ L.push('')
68
+ L.push('## PLC 连接')
69
+ L.push('')
70
+ L.push('| 名字 | 系统 ID |')
71
+ L.push('|---|---|')
72
+ for (const p of plcs) L.push(`| ${p.name} | ${p.sys} |`)
73
+ if (links.length) {
74
+ L.push('')
75
+ L.push('## 连接器')
76
+ L.push('')
77
+ L.push('| 名字 | 类型 |')
78
+ L.push('|---|---|')
79
+ for (const l of links) L.push(`| ${l.name} | ${l.type} |`)
80
+ }
81
+ L.push('')
82
+ L.push('## 画面与控件')
83
+ L.push('')
84
+ L.push('| 画面 ID | 画面名 | 控件数 | 控件类型分布 |')
85
+ L.push('|---|---|---|---|')
86
+ for (const w of windows) {
87
+ const byType = {}
88
+ for (const p of w.parts) byType[p.type] = (byType[p.type] || 0) + 1
89
+ const dist = Object.entries(byType).map(([k, v]) => `${k}×${v}`).join(',') || '—'
90
+ L.push(`| ${w.pid} | ${w.name} | ${w.parts.length} | ${dist} |`)
91
+ }
92
+ const withAddr = windows.flatMap((w) => w.parts.filter((p) => p.addr).map((p) => ({ w: w.name, ...p })))
93
+ if (withAddr.length) {
94
+ L.push('')
95
+ L.push('## 带地址的控件(前 60)')
96
+ L.push('')
97
+ L.push('| 画面 | 控件 | 类型 | 地址 |')
98
+ L.push('|---|---|---|---|')
99
+ for (const p of withAddr.slice(0, 60)) L.push(`| ${p.w} | ${p.name} | ${p.type} | ${p.addr} |`)
100
+ }
101
+ fs.writeFileSync(path.join(outDir, '解码报告.md'), L.join('\n') + '\n', 'utf8')
102
+ fs.writeFileSync(path.join(outDir, '画面控件.json'), JSON.stringify({ projName, plcs, hmis, windows }, null, 1), 'utf8')
103
+ console.log(`OK ${projName} → ${outDir}`)
104
+ console.log(` PLC ${plcs.length} / 画面 ${windows.length} / 控件 ${windows.reduce((n, w) => n + w.parts.length, 0)}`)
105
+ }
106
+
107
+ main()