dsh-plc-decode 0.2.1 → 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.
@@ -1,452 +1,453 @@
1
- #!/usr/bin/env node
2
- /**
3
- * hmismart-decode.mjs —— 西门子 WinCC flexible SMART 触摸屏工程(.hmismart)解码器
4
- *
5
- * 用法: node hmismart-decode.mjs <工程文件.hmismart> [输出目录]
6
- * 输出(默认写到 工程文件同级 导出_hmismart_<时间戳>\):
7
- * 00-格式说明.md 容器结构、魔数、页结构、编码约定
8
- * 01-对象记录.tsv 偏移 / 类型GUID / 名称(全量,供查证)
9
- * 02-对象分类.md 按类型GUID分组的对象清单(画面/变量/元件/图片…)
10
- * 03-建表脚本.sql 文件内嵌的数据库 DDL(136 张表)
11
- * 04-解压块.txt 全部 deflate 块解压内容(含属性包、连接配置)
12
- * 05-系统消息_中文.txt 解压块里的多语言系统文本(中文)
13
- * 06-可读文本.txt 文件正文里的 UTF-16 文本(去掉结构噪声)
14
- * 07-统计.json
15
- * 只读:不修改任何原始文件。
16
- */
17
- import fs from 'node:fs'
18
- import path from 'node:path'
19
- import zlib from 'node:zlib'
20
-
21
- const args = process.argv.slice(2)
22
- if (!args[0]) { console.error('用法: node hmismart-decode.mjs <工程文件.hmismart> [输出目录]'); process.exit(1) }
23
- const src = path.resolve(args[0])
24
- if (!fs.existsSync(src)) { console.error('文件不存在: ' + src); process.exit(1) }
25
- // 默认输出:当前工作目录下的「解码归档\<工程名>\HMI解码\」,不写工程目录、不写临时目录
26
- const projectName = path.basename(src, path.extname(src))
27
- const outDir = path.resolve(args[1] || path.join(process.cwd(), '解码归档', projectName, 'HMI解码'))
28
- fs.mkdirSync(outDir, { recursive: true })
29
-
30
- const buf = fs.readFileSync(src)
31
- const stat = { file: src, size: buf.length, outputs: {} }
32
- const w = (name, content) => { fs.writeFileSync(path.join(outDir, name), content); stat.outputs[name] = Buffer.byteLength(content) }
33
-
34
- /* ---------- 1. 容器头 ---------- */
35
- const magic = [...buf.subarray(0, 8)].map(b => b.toString(16).padStart(2, '0')).join(' ')
36
- const header = {
37
- magic,
38
- size: buf.length,
39
- pages4k: buf.length / 4096,
40
- pageAligned: buf.length % 4096 === 0,
41
- }
42
-
43
- /* ---------- 2. DDL(数据库 schema) ---------- */
44
- const asciiStrings = []
45
- { let cur = '', start = 0
46
- for (let i = 0; i < buf.length; i++) {
47
- const c = buf[i]
48
- if (c >= 32 && c < 127) { if (!cur) start = i; cur += String.fromCharCode(c) }
49
- else { if (cur.length >= 6) asciiStrings.push([start, cur]); cur = '' }
50
- } }
51
- const ddl = asciiStrings.filter(([, s]) => /\b(CREATE TABLE|CREATE PROCEDURE|CREATE INDEX|ALTER TABLE|INSERT INTO|SELECT )/i.test(s))
52
- const tables = new Set()
53
- for (const [, s] of asciiStrings) for (const m of s.matchAll(/\b(Hmi[A-Za-z]{2,45}Table)\b/g)) tables.add(m[1])
54
-
55
- /* ---------- 3. 解压全部 deflate 块 ---------- */
56
- const blocks = []
57
- for (let i = 0; i + 3 <= buf.length; i++) {
58
- if (buf[i] === 0x1f && buf[i + 1] === 0x8b && buf[i + 2] === 0x08) {
59
- try { blocks.push({ off: i, out: zlib.inflateRawSync(buf.subarray(i + 10)) }) } catch { }
60
- i += 3
61
- }
62
- }
63
- const uniqBlocks = []
64
- { const seen = new Set()
65
- for (const b of blocks) { const k = b.out.length + ':' + b.out.subarray(0, 64).toString('hex'); if (!seen.has(k)) { seen.add(k); uniqBlocks.push(b) } } }
66
-
67
- /* ---------- 4. 对象记录:36 + UUIDv4 + 字段 + 名称 ---------- */
68
- const isUuid4 = (o) => {
69
- if (o + 16 > buf.length) return false
70
- if ((buf[o + 7] >> 4) !== 4) return false
71
- if ((buf[o + 8] & 0xc0) !== 0x80) return false
72
- let z = 0; for (let i = 0; i < 16; i++) if (buf[o + i] === 0) z++
73
- return z <= 3
74
- }
75
- const guidHex = (o) => [...buf.subarray(o, o + 16)].map(x => x.toString(16).padStart(2, '0')).join('')
76
- const fmtGuid = h => `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`
77
- const isNameChar = c => (c >= 0x4e00 && c <= 0x9fff) || (c >= 0x30 && c <= 0x39) || (c >= 0x41 && c <= 0x5a) || (c >= 0x61 && c <= 0x7a) || c === 0x5f || c === 0x2d || c === 0x2e || c === 0x28 || c === 0x29 || c === 0xff08 || c === 0xff09
78
- function readAt(s, limit = 60) { let j = s, str = '', ascii = 0
79
- while (j + 1 < buf.length && str.length < limit) { const c = buf[j] | (buf[j + 1] << 8)
80
- if (isNameChar(c) || c === 0x20) { str += String.fromCharCode(c); if (c < 0x80) ascii++; j += 2 } else break }
81
- return { str, ascii } }
82
- function readBest(from, back = 28) { let best = null
83
- for (let s = from; s < from + back; s++) { const r = readAt(s)
84
- if (r.str.length < 2 || r.str.length > 48) continue
85
- const cjk = /[\u4e00-\u9fff]/.test(r.str)
86
- const score = r.str.length + (cjk ? 6 : 0) + (r.ascii / r.str.length) * 4
87
- if (!best || score > best.score) best = { off: s, ...r, score } }
88
- return best }
89
- const clean = s => s.replace(/^[^\u4e00-\u9fffA-Za-z0-9]+/, '').replace(/[\s]+$/, '').trim()
90
-
91
- const rawRecs = []
92
- for (let i = 0; i + 17 <= buf.length; i++) {
93
- if (buf[i] !== 0x36 || !isUuid4(i + 1)) continue
94
- const nm = readBest(i + 17)
95
- if (!nm) continue
96
- const name = clean(nm.str)
97
- if (name.length < 2) continue
98
- rawRecs.push({ off: i, g: guidHex(i + 1), name, cjk: /[\u4e00-\u9fff]/.test(name) })
99
- }
100
- const guidCount = new Map()
101
- for (const r of rawRecs) guidCount.set(r.g, (guidCount.get(r.g) || 0) + 1)
102
-
103
- // 去噪两道:① 真类型 GUID 会被反复引用;② 真对象名里的汉字会在多个名字里复现,
104
- // 二进制误读出的"生僻字"通常只此一次(用字符 document-frequency 判定)
105
- const charDF = new Map()
106
- for (const r of rawRecs) for (const ch of new Set(r.name)) charDF.set(ch, (charDF.get(ch) || 0) + 1)
107
- // 名称紧跟记录结构字节,尾部常粘 1~2 个"生僻字";用工程常用字表判边界,只截不在表内的尾字
108
- const LEX = '报警速度时间延时开关开始结束检测故障复位启动停止画面切换变更当前自动手动调试设置密码用户产量清零位置位移编码器喷胶封盒吸盒吸托推料推托顶盒顶托挡料加料压托压盒压料整平整料输送带伺服文本变量计数寄存器标志锁机蜂鸣器原点缺料缺纸堵料剔废旋转测速测书测托测料测盒保护急停备用飞料放料托库盒子推杆隐藏工位比屏点动联机吸纸吹气气压卡盒有托回原日月年中主机少托少盒上料瓶子送料整机程序参数选择组站点数据语言字体作业主题色图形列表视图符号刻度棒图指针圆矩形线箭头图片库结口出入压舌联轴测瓶剔速差累计设定值单双头尾上下左右前后内外大小高低长短新旧的'
109
- const trimNoise = (n) => {
110
- let b = n.length
111
- while (b > 1) {
112
- const c = n[b - 1]
113
- if (LEX.includes(c)) break
114
- if (/[A-Za-z0-9_\.]/.test(c)) break
115
- b--
116
- }
117
- return n.slice(0, b)
118
- }
119
- const cjkRatioOk = (n) => {
120
- const cs = [...n].filter(c => c >= '\u4e00' && c <= '\u9fff')
121
- if (!cs.length) return true
122
- return cs.filter(c => (charDF.get(c) || 0) >= 5).length / cs.length >= 0.5
123
- }
124
- const hasNoiseChar = (n) => /[\u3400-\u4dbf\uf900-\ufaff\ufffd]/.test(n)
125
- const recs = rawRecs
126
- .filter(r => guidCount.get(r.g) >= 3 && !hasNoiseChar(r.name) && cjkRatioOk(r.name))
127
- .map(r => ({ ...r, name: trimNoise(r.name) }))
128
- .filter(r => r.name.length >= 2)
129
-
130
- /* ---------- 5. 多语言系统消息(中文) ---------- */
131
- const zhMsgs = new Set()
132
- for (const b of uniqBlocks) {
133
- const s = b.out.toString('utf8')
134
- if (!s.includes('<text>')) continue
135
- for (const m of s.matchAll(/zh-CN[^<]*<text>([\s\S]{1,200}?)<\/text>/g)) {
136
- const t = m[1].replace(/<[^>]+>/g, '').trim()
137
- if (t && t.length <= 120) zhMsgs.add(t)
138
- }
139
- }
140
-
141
- /* ---------- 6. 正文 UTF-16 文本(排除 ASCII 区与压缩数据区) ---------- */
142
- const mask = new Uint8Array(buf.length)
143
- for (let i = 0; i < buf.length - 24; i += 8) { // ASCII 密集区
144
- let a = 0; for (let j = 0; j < 24; j++) { const c = buf[i + j]; if (c >= 32 && c < 127) a++ }
145
- if (a >= 20) for (let j = 0; j < 24; j++) mask[i + j] = 1
146
- }
147
- for (const b of blocks) for (let j = b.off; j < b.off + 24 && j < buf.length; j++) mask[j] = 1 // 压缩块头
148
- const texts = new Set()
149
- for (let i = 0; i + 3 < buf.length; i += 2) {
150
- if (mask[i]) continue
151
- const c = buf[i] | (buf[i + 1] << 8)
152
- if (!((c >= 0x4e00 && c <= 0x9fff) || (c >= 0x41 && c <= 0x7a))) continue
153
- let j = i, s = ''
154
- while (j + 1 < buf.length && !mask[j]) { const cc = buf[j] | (buf[j + 1] << 8)
155
- if (isNameChar(cc) || cc === 0x20) { s += String.fromCharCode(cc); j += 2 } else break }
156
- if (s.length >= 3 && /[\u4e00-\u9fff]/.test(s)) { const t = clean(s); if (t.length >= 2) texts.add(t) }
157
- i = Math.max(i, j - 2)
158
- }
159
-
160
- /* ---------- 7. 分类 ---------- */
161
- // 已确证的类型 GUID(靠"该类型下全部名称的语义"交叉验证得出,可继续扩充)
162
- const KNOWN_TYPES = {
163
- a8d0ec5ba448dd4e8cfd1f93bab80357: '画面',
164
- '98441ece8aaa384190250908339aa88e': '变量(HMI 变量表)',
165
- '2eee2253373c9e4fb5b9bd3a205f6713': '对象类型目录(元件类型名)',
166
- '87c833306870a9498ae24da1919746b3': '画面元件(IO域/文本域/开关)',
167
- '50ed3daea5a013448ae19ed8f4043d9c': '画面元件(按钮/文本域)',
168
- e95e1fa2f2f8b946b47532cc5399bd9f: '画面元件(按钮/线)',
169
- '7cc6b8efde529f4aab60be9937564c5f': '画面元件(矩形/按钮/开关/图形)',
170
- a75c8f057bd20e45b5ca070891443d1d: '画面元件(IO域/开关/文本域)',
171
- d6ae66afa96fb3408df18d44f2585873: '画面元件(IO域/开关/文本域/报警视图)',
172
- a1b2d38181484d489401867b9fdcc709: '模板元件(模板按钮/IO域/开关)',
173
- d65b6511ea528442af7579c84c001e54: '图片资源',
174
- '0c0a9fede4f16d438fed097fa8051506': '通讯连接',
175
- '35b3711327ef66408b82c3097100029e': 'Smart 主题/样式(软件自带)',
176
- '11fea9ca9da43d4289fe389107a3e925': '字体列表',
177
- '49a4d64cb82e814292102c7fd2007115': '系统函数',
178
- d0aeebe5b8663d478fb6b5f4ac6c9d96: '项目结构节点',
179
- '3848614b96efc141bcd4124814bbf961': '项目结构节点',
180
- b0e0cad9a3bb074da9cf296d0573e254: '项目树节点',
181
- cc1746b51be8f74b8cef934dd946b81b: '系统变量(画面号/时间等)',
182
- e2a88f13a660734fbe15ec7df20c04a8: '用户与权限',
183
- ad962e667c6b5c4887ae1d834a4205b8: '报警确认组',
184
- d912bd6b8243fd4384aa4d3bfa5650ef: '用户/授权',
185
- fa8ef5c6e7da2d4b984bb1e109cf948b: '采集周期',
186
- '3957818870145243b9e0a928e3f16129': '图形列表',
187
- c67c51d88bbb4c49840423ac3926928e: '报警类别/事件',
188
- '00a28e5290ccc64f937a905f6222ffea': '数据类型与消息文件夹',
189
- '02c4e0f59ad3c34d9d6baaf4fb8a6bf6': '数据类型与文本文件夹',
190
- '7a065037c383944f9bbf977931038c84': '用户/授权管理',
191
- '912c1ca9472b964e891aa54b3f27a71f': '图库条目',
192
- '658b5e3163262b47bc8f4c14c316f7eb': '报警消息文件夹',
193
- }
194
- const PATTERNS = [
195
- ['画面', /画面/],
196
- ['画面元件', /^(按钮|文本域|IO 域|开关|矩形|线|圆|图形|报警视图|日期时间域|符号|笑脸|棒图|指针|刻度)/],
197
- ['模板元件', /^模板/],
198
- ['变量', /(报警|速度|计数|清零|加料|压盒|压托|卡盒|用户名|用户密码|当前[日年月]|^[QIMVDB]\d)/],
199
- ['图片/图形资源', /(QQ截图|微信图片|GRAPHIC|箭头|图片|起始位置)/],
200
- ['通讯/连接', /(连接|Communication|S7ONLINE|TCPIP|ETHERNET)/],
201
- ['报警/用户/配方', /(确认组|报警组|报警类别|配方|激活码|权限|管理员|操作员)/],
202
- ['系统函数', /^(ActivateScreen|GetPassword|GetUserName|InvertBit|Logoff|Logon|SetBit|SetValue|IncreaseValue|StartDataTransfer|ChangeConnection|SetLanguage|AddressCulture)/],
203
- ['数据类型', /(Internal$|SIMATIC S7)/],
204
- ['项目结构/系统对象', /(Folder|Settings|Reports|Messages|Library|Modules|Functions|Global|Prototype|Documentation|CrossReference|Devices|Images|Lists|Containers)/],
205
- ['样式/主题', /^Smart-/],
206
- ['时间/周期', /^\d+ ?(ms|s|min|h)$/],
207
- ]
208
- function classify(g) {
209
- if (KNOWN_TYPES[g]) return KNOWN_TYPES[g]
210
- const names = [...new Set(recs.filter(r => r.g === g).map(r => r.name))]
211
- if (!names.length) return '其他'
212
- // 疑似噪声区:中文名极少且名字短碎,判为二进制误命中
213
- const cjkNames = names.filter(n => /[\u4e00-\u9fff]/.test(n)).length
214
- if (cjkNames / names.length < 0.15 && names.length >= 8) return '疑似二进制区(非对象)'
215
- let best = null
216
- for (const [label, re] of PATTERNS) {
217
- const hit = names.filter(n => re.test(n)).length / names.length
218
- if (hit >= 0.5 && (!best || hit > best.hit)) best = { label, hit }
219
- }
220
- return best ? best.label : '其他/待定'
221
- }
222
-
223
- /* ---------- 7b. 结构化表(压缩块里的 XML 表;2026-11-06 新增) ---------- */
224
- // 新版 .hmismart 的压缩块里是 XML 表(HmiScreenTable / HmiScreenItemTable / HmiTextTable / HmiDeviceTable)。
225
- // 解出来的表比整份 dump 小一个数量级,按问题查表即可,不必读 dump。
226
- function buildStructTables(blockTexts) {
227
- const grab = (body, tag) => { const m = new RegExp(`<${tag}>([^<]*)</${tag}>`).exec(body); return m ? m[1] : '' }
228
- // 只在单个压缩块内匹配完整记录:跨块拼接会把「开始标签在前一块、结束标签在后一块」的
229
- // 残片吞进来(body 里混进别的表,取到错名字)。块内切不完整的记录直接丢弃。
230
- const rows = (tag) => {
231
- const out = []
232
- const re = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, 'g')
233
- for (const t of blockTexts) for (const m of t.matchAll(re)) out.push(m[1])
234
- return out
235
- }
236
- const screens = rows('HmiScreenTable').map(b => ({
237
- id: grab(b, 'ObjId'), name: grab(b, 'ObjName'), number: grab(b, 'Number'),
238
- deleted: grab(b, 'Deleted'), device: grab(b, 'DeviceNumber'), mtime: grab(b, 'LastModified'),
239
- })).filter(r => r.name)
240
- const byId = new Map(screens.map(r => [r.id, r]))
241
- const items = rows('HmiScreenItemTable').map(b => ({
242
- id: grab(b, 'ObjId'), name: grab(b, 'ObjName'), type: grab(b, 'HmiSubType'),
243
- screenId: grab(b, 'ParentId'), x: grab(b, 'LocationX'), y: grab(b, 'LocationY'),
244
- w: grab(b, 'SizeX'), h: grab(b, 'SizeY'), mtime: grab(b, 'LastModified'),
245
- })).filter(r => r.name)
246
- const itemById = new Map(items.map(r => [r.id, r]))
247
- const texts = rows('HmiTextTable').map(b => ({
248
- ownerId: grab(b, 'ContainerId'), type: grab(b, 'TextType'),
249
- sys: grab(b, 'IsSystemMessageText'), text: grab(b, 'Language0'), mtime: grab(b, 'LastModified'),
250
- })).filter(r => r.text)
251
- const devices = rows('HmiDeviceTable').map(b => ({
252
- name: grab(b, 'ObjName'), model: grab(b, 'DeviceType'), startId: grab(b, 'StartScreenId'),
253
- w: grab(b, 'ScreenSizeWidth'), h: grab(b, 'ScreenSizeHeight'), mtime: grab(b, 'LastModified'),
254
- })).filter(r => r.name)
255
- // 类型密码本:TypeCode ↔ 类型全名(.data 里判断对象/引用类型用)
256
- const types = rows('HmiTypeTable').map(b => ({
257
- code: grab(b, 'TypeCode'), name: grab(b, 'TypeName'), id: grab(b, 'TypeId'),
258
- })).filter(r => r.name && r.code)
259
- // 同一对象在多个压缩块里出现(工程内多版本快照),按主键取 LastModified 最新的一条
260
- const dedupe = (arr, keyFn) => {
261
- const m = new Map()
262
- for (const r of arr) {
263
- const k = keyFn(r), cur = m.get(k)
264
- if (!cur || String(r.mtime || '') >= String(cur.mtime || '')) m.set(k, r)
265
- }
266
- return [...m.values()]
267
- }
268
- return {
269
- screens: dedupe(screens, r => r.id || r.name),
270
- items: dedupe(items, r => r.id || r.name),
271
- texts: dedupe(texts, r => `${r.ownerId}|${r.type}|${r.text}`),
272
- devices: dedupe(devices, r => `${r.name}|${r.model}`),
273
- types: dedupe(types, r => r.code),
274
- byId, itemById,
275
- }
276
- }
277
- const struct = buildStructTables(blocks.map(b => b.out.toString('utf8')))
278
-
279
- /* ---------- 7c. 旁系 .data(ES2RT 序列化):真实变量/控件名 ---------- */
280
- // .hmismart 里只有模板骨架;真实画面/控件/变量在 <工程名>_{{GUID}}.data:
281
- // 'ES2RT' 头 + gzip → 解出 18MB 的 .NET 序列化对象流,UTF-8 字符串可读。
282
- // 目前能可靠抽出三族名字;控件→变量的绑定引用未解出(标在报告里)。
283
- function extractDataNames(file) {
284
- try {
285
- const d = fs.readFileSync(file)
286
- const gi = d.indexOf(Buffer.from([0x1f, 0x8b, 0x08]))
287
- if (gi < 0) return null
288
- const raw = zlib.inflateRawSync(d.subarray(gi + 10))
289
- const text = new TextDecoder('utf-8', { fatal: false }).decode(raw)
290
- const BAD = /hmiType|objName|esHandle|lang2Text|missingText|TextList|messageParameters|isProjectedText|System\.|Siemens\.|ES2RT|k__BackingField|Dependencies|CustomMaxTextLength|theCauseHandle|idParent/
291
- const isName = (s) => {
292
- if (s.length < 2 || s.length > 24) return false
293
- if (BAD.test(s)) return false
294
- if (/[\u4e00-\u9fff]/.test(s)) return true
295
- if (/^[QIMVWDB]\d{1,5}(\.\d)?$/i.test(s)) return true
296
- return false
297
- }
298
- const pick = (seg) => {
299
- const runs = [...seg.matchAll(/[\u4e00-\u9fffA-Za-z0-9 _.\-]{2,24}/g)].map(m => m[0].trim()).filter(isName)
300
- return runs.length ? runs[runs.length - 1] : ''
301
- }
302
- const collect = (tn) => {
303
- const names = []
304
- let p = -1
305
- while ((p = text.indexOf(tn, p + 1)) >= 0) {
306
- const n = pick(text.slice(Math.max(0, p - 600), p))
307
- if (n) names.push(n)
308
- }
309
- return [...new Set(names)]
310
- }
311
- // 控件→变量:按 ES2RT 段切分(相邻真头之间为一段),控件段内出现变量名(词边界)即绑定候选
312
- const heads = []
313
- for (let i = 0; i + 12 < raw.length; i++) {
314
- if (raw[i] === 0x45 && raw[i + 1] === 0x53 && raw[i + 2] === 0x32 && raw[i + 3] === 0x52 && raw[i + 4] === 0x54 && raw[i + 5] === 0x01) heads.push(i)
315
- }
316
- const segText = (k) => new TextDecoder('utf-8', { fatal: false }).decode(raw.subarray(heads[k], k + 1 < heads.length ? heads[k + 1] : raw.length))
317
- const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
318
- const hasWord = (t, w) => new RegExp(`(^|[^\\u4e00-\\u9fffA-Za-z0-9_])${esc(w)}([^\\u4e00-\\u9fffA-Za-z0-9_]|$)`).test(t)
319
- const tagList = [...new Set(collect('Siemens.Simatic.Hmi.Utah.Tag.HmiTag'))]
320
- const isUuid4 = (b, o) => o + 16 <= b.length && (b[o + 7] >> 4) === 4 && (b[o + 8] & 0xc0) === 0x80
321
- const uuidsOf = (b) => { const o = []; for (let i = 0; i + 16 <= b.length; i++) if (isUuid4(b, i)) o.push(b.subarray(i, i + 16).toString('hex')); return o }
322
- const segBuf = (k) => raw.subarray(heads[k], k + 1 < heads.length ? heads[k + 1] : raw.length)
323
- // 变量对象 id:每段结构固定,对象 id 在段内偏移 0x19(16 字节)
324
- const segId = (b) => b.length > 0x29 ? b.subarray(0x19, 0x29).toString('hex') : ''
325
- const tagByUuid = new Map()
326
- for (let k = 0; k < heads.length; k++) {
327
- const t = segText(k)
328
- if (!t.includes('Siemens.Simatic.Hmi.Utah.Tag.HmiTag')) continue
329
- const n = pick(t), id = segId(segBuf(k))
330
- if (n && id && !tagByUuid.has(id)) tagByUuid.set(id, n)
331
- }
332
- const bindings = []
333
- const seen = new Set()
334
- const pushB = (item, tag, by) => { const k = item + '\u0001' + tag; if (!seen.has(k)) { seen.add(k); bindings.push({ item, tag, by }) } }
335
- for (let k = 0; k < heads.length; k++) {
336
- const t = segText(k)
337
- if (!t.includes('Siemens.Simatic.Hmi.Utah.GraphX.HmiScreenItem')) continue
338
- const name = pick(t)
339
- if (!name) continue
340
- for (const w of tagList) if (w !== name && hasWord(t, w)) pushB(name, w, '同段共现')
341
- const b2 = segBuf(k)
342
- for (const u of new Set(uuidsOf(b2))) {
343
- const w = tagByUuid.get(u)
344
- if (w && w !== name) pushB(name, w, 'UUID 引用')
345
- }
346
- }
347
- return {
348
- file: path.basename(file),
349
- bytes: d.length,
350
- inflated: raw.length,
351
- segments: heads.length,
352
- screens: collect('Siemens.Simatic.Hmi.Utah.GraphX.HmiScreen'),
353
- items: collect('Siemens.Simatic.Hmi.Utah.GraphX.HmiScreenItem'),
354
- tags: collect('Siemens.Simatic.Hmi.Utah.Tag.HmiTag'),
355
- bindings,
356
- }
357
- } catch { return null }
358
- }
359
- let dataNames = null
360
- try {
361
- const dir = path.dirname(src)
362
- const cand = fs.readdirSync(dir).filter(f => f.toLowerCase().endsWith('.data'))
363
- if (cand.length) {
364
- // 取最大的那个:同目录常有两个 .data,小的那个只存键值不入对象
365
- let best = cand[0], bestSize = -1
366
- for (const f of cand) {
367
- try { const sz = fs.statSync(path.join(dir, f)).size; if (sz > bestSize) { best = f; bestSize = sz } } catch { }
368
- }
369
- dataNames = extractDataNames(path.join(dir, best))
370
- }
371
- } catch { }
372
-
373
- /* ---------- 输出 ---------- */
374
- const report = []
375
- report.push(`# .hmismart 解码报告`, '', `- 源文件:${src}`, `- 文件大小:${buf.length} 字节(${(buf.length / 1048576).toFixed(2)} MB)`, `- 文件头魔数:\`${magic}\``, `- 页结构:${header.pageAligned ? `页对齐,4096 × ${header.pages4k} 页` : '非 4096 对齐'}`, `- 压缩块:${blocks.length} 个(去重 ${uniqBlocks.length}),解压总量 ${(blocks.reduce((a, b) => a + b.out.length, 0) / 1048576).toFixed(2)} MB`, `- 内嵌数据表:${tables.size} 张`, `- 对象记录:${rawRecs.length} 条(有效类型 ${guidCount.size} 种,过滤后 ${recs.length} 条)`, `- 中文系统消息:${zhMsgs.size} 条`, `- 正文可读文本:${texts.size} 条`, '')
376
- report.push(`- 结构化表:画面 ${struct.screens.length} / 元件 ${struct.items.length} / 文本 ${struct.texts.length} / 设备 ${struct.devices.length}(来自 .hmismart 内嵌 XML 表;0 表示本格式无 XML 表,见 01/01b/02。真实画面/控件与绑定变量在旁系 {{GUID}}.data 与 .srt,不在这张表里)`)
377
- if (dataNames) report.push(`- 旁系 .data:${dataNames.file}(${(dataNames.bytes / 1048576).toFixed(1)} MB → 解压 ${(dataNames.inflated / 1048576).toFixed(1)} MB / ${dataNames.segments} 段)|变量名 ${dataNames.tags.length} / 控件名 ${dataNames.items.length} / 画面候选 ${dataNames.screens.length} / 绑定候选 ${dataNames.bindings.length}(见 20/21/22/23)`)
378
- else report.push('- 旁系 .data:未找到(同目录无 {{GUID}}.data)')
379
- w('00-格式说明.md', report.join('\n'))
380
-
381
- w('01-对象记录.tsv', '偏移\t类型GUID\t名称\n' + rawRecs.map(r => `0x${r.off.toString(16)}\t${fmtGuid(r.g)}\t${r.name}`).join('\n'))
382
- w('01b-对象记录-清洗.tsv', '偏移\t类型GUID\t名称\n' + recs.map(r => `0x${r.off.toString(16)}\t${fmtGuid(r.g)}\t${r.name}`).join('\n'))
383
-
384
- const groups = [...guidCount.entries()].filter(([, n]) => n >= 3).sort((a, b) => b[1] - a[1])
385
- const cat = new Map()
386
- for (const [g] of groups) { const k = classify(g); if (!cat.has(k)) cat.set(k, []); cat.get(k).push(g) }
387
- const catLines = ['# 对象分类清单', '', '每行 = 类型GUID|记录数|该类型下的全部唯一名称(去重)。', '']
388
- for (const [k, gs] of [...cat.entries()].sort((a, b) => b[1].length - a[1].length)) {
389
- const total = gs.reduce((a, g) => a + guidCount.get(g), 0)
390
- catLines.push(`## ${k}(${gs.length} 类 / ${total} 条)`, '')
391
- for (const g of gs) {
392
- const names = [...new Set(recs.filter(r => r.g === g).map(r => r.name))]
393
- catLines.push(`- \`${fmtGuid(g)}\` ×${guidCount.get(g)}|唯一 ${names.length}`)
394
- catLines.push(' ' + names.join('、'))
395
- }
396
- catLines.push('')
397
- }
398
- w('02-对象分类.md', catLines.join('\n'))
399
-
400
- w('03-建表脚本.sql', ddl.map(([o, s]) => `-- 0x${o.toString(16)}\n${s}`).join('\n\n'))
401
-
402
- // 解压块分两堆:工程数据(属性包/对象数据)与软件自带的多语言系统文本
403
- const isMultiLang = (s) => /<text>/.test(s) && /hu-HU|cs-CZ|ja-JP|zh-TW|de-DE|en-US|fr-FR|es-ES|it-IT|pt-PT|ru-RU|ko-KR/.test(s)
404
- const propBlocks = [], langBlocks = []
405
- for (const b of uniqBlocks) (isMultiLang(b.out.toString('utf8')) ? langBlocks : propBlocks).push(b)
406
- const dumpBlock = (b, k) => `\n===BLK#${k} @0x${b.off.toString(16)} len=${b.out.length}===\n` + b.out.toString('utf8').replace(/[^\x09\x0a\x20-\x7e\u4e00-\u9fff]/g, '·')
407
- w('04-解压块-工程数据.txt', propBlocks.map(dumpBlock).join('\n'))
408
- w('05-解压块-多语言文本.txt', langBlocks.map(dumpBlock).join('\n'))
409
-
410
- fs.writeFileSync(path.join(outDir, '06-系统消息_中文.txt'), [...zhMsgs].sort().join('\n'))
411
- stat.outputs['06-系统消息_中文.txt'] = fs.statSync(path.join(outDir, '06-系统消息_中文.txt')).size
412
-
413
- fs.writeFileSync(path.join(outDir, '07-可读文本.txt'), [...texts].sort().join('\n'))
414
- stat.outputs['07-可读文本.txt'] = fs.statSync(path.join(outDir, '07-可读文本.txt')).size
415
-
416
- /* 结构化表落盘:一份问题一张表,AI 按问题只读对应那份 */
417
- const tsv = (head, lines) => head + '\n' + lines.join('\n') + (lines.length ? '\n' : '')
418
- const ownerOf = id => (struct.itemById.get(id) || struct.byId.get(id) || { name: String(id).slice(0, 8) }).name
419
- if (struct.screens.length) {
420
- w('10-画面.tsv', tsv('画面名\t编号\t已删除\t设备号\tObjId', struct.screens.map(r => `${r.name}\t${r.number}\t${r.deleted}\t${r.device}\t${r.id}`)))
421
- }
422
- if (struct.items.length) {
423
- w('11-元件.tsv', tsv('画面\t元件名\t类型\tX\tY\t宽\t高\tObjId', struct.items.map(r => `${(struct.byId.get(r.screenId) || {}).name || ''}\t${r.name}\t${r.type}\t${r.x}\t${r.y}\t${r.w}\t${r.h}\t${r.id}`)))
424
- }
425
- if (struct.texts.length) {
426
- w('12-文本.tsv', tsv('所属\t文本类型\t系统消息\t文本', struct.texts.map(r => `${ownerOf(r.ownerId)}\t${r.type}\t${r.sys}\t${r.text.replace(/\t/g, ' ')}`)))
427
- }
428
- if (struct.devices.length) {
429
- w('13-设备.tsv', tsv('设备名\t型号\t起始画面ID\t起始画面\t分辨率', struct.devices.map(r => {
430
- const s = struct.byId.get(r.startId)
431
- return `${r.name}\t${r.model}\t${r.startId}\t${s ? s.name : '(不在画面表)'}\t${r.w}×${r.h}`
432
- })))
433
- }
434
- if (struct.types.length) {
435
- w('24-类型表.tsv', tsv('TypeCode\t类型全名', struct.types.map(r => `${r.code}\t${r.name}`)))
436
- }
437
-
438
- /* 旁系 .data 名字清单:真实变量/控件在这里,.hmismart 里只有模板骨架 */
439
- if (dataNames) {
440
- w('20-变量.data.tsv', '变量名\n' + dataNames.tags.join('\n') + (dataNames.tags.length ? '\n' : ''))
441
- w('21-控件.data.tsv', '控件名\n' + dataNames.items.join('\n') + (dataNames.items.length ? '\n' : ''))
442
- w('22-画面候选.data.txt', '# 画面候选(ES2RT 邻近名,可能混入控件名;未与画面表对齐)\n' + dataNames.screens.join('\n') + '\n')
443
- if (dataNames.bindings.length) {
444
- w('23-控件绑定.data.tsv', '控件\t变量\t依据\n' + dataNames.bindings.map(b => `${b.item}\t${b.tag}\t${b.by}`).join('\n') + '\n')
445
- }
446
- }
447
-
448
- w('08-统计.json', JSON.stringify({ ...stat, tables: [...tables].sort(), blocks: blocks.length, uniqBlocks: uniqBlocks.length, propBlocks: propBlocks.length, langBlocks: langBlocks.length, blockBytes: blocks.reduce((a, b) => a + b.out.length, 0), recs: rawRecs.length, recsKept: recs.length, guidTypes: guidCount.size, zhMsgs: zhMsgs.size, texts: texts.size, structRows: { screens: struct.screens.length, items: struct.items.length, texts: struct.texts.length, devices: struct.devices.length }, dataNames: dataNames ? { file: dataNames.file, bytes: dataNames.bytes, inflated: dataNames.inflated, segments: dataNames.segments, tags: dataNames.tags.length, items: dataNames.items.length, screens: dataNames.screens.length, bindings: dataNames.bindings.length } : null }, null, 2))
449
-
450
- console.log(`[完成] 输出目录:${outDir}`)
451
- for (const [k, v] of Object.entries(stat.outputs)) console.log(` ${k} ${(v / 1024).toFixed(1)} KB`)
452
- console.log(` 内嵌表 ${tables.size} 张|对象记录 ${rawRecs.length}→${recs.length} 条|类型 ${guidCount.size} 种|压缩块 ${blocks.length}(唯一 ${uniqBlocks.length}:工程数据 ${propBlocks.length} / 多语言 ${langBlocks.length})|中文系统消息 ${zhMsgs.size}|可读文本 ${texts.size}`)
1
+ #!/usr/bin/env node
2
+ /**
3
+ * hmismart-decode.mjs —— 西门子 WinCC flexible SMART 触摸屏工程(.hmismart)解码器
4
+ *
5
+ * 用法: node hmismart-decode.mjs <工程文件.hmismart> [输出目录]
6
+ * 输出(默认写到 工程文件同级 导出_hmismart_<时间戳>\):
7
+ * 00-格式说明.md 容器结构、魔数、页结构、编码约定
8
+ * 01-对象记录.tsv 偏移 / 类型GUID / 名称(全量,供查证)
9
+ * 02-对象分类.md 按类型GUID分组的对象清单(画面/变量/元件/图片…)
10
+ * 03-建表脚本.sql 文件内嵌的数据库 DDL(136 张表)
11
+ * 04-解压块.txt 全部 deflate 块解压内容(含属性包、连接配置)
12
+ * 05-系统消息_中文.txt 解压块里的多语言系统文本(中文)
13
+ * 06-可读文本.txt 文件正文里的 UTF-16 文本(去掉结构噪声)
14
+ * 07-统计.json
15
+ * 只读:不修改任何原始文件。
16
+ */
17
+ import fs from 'node:fs'
18
+ import path from 'node:path'
19
+ import zlib from 'node:zlib'
20
+
21
+ const args = process.argv.slice(2)
22
+ if (!args[0]) { console.error('用法: node hmismart-decode.mjs <工程文件.hmismart> [输出目录]'); process.exit(1) }
23
+ const src = path.resolve(args[0])
24
+ if (!fs.existsSync(src)) { console.error('文件不存在: ' + src); process.exit(1) }
25
+ // 默认输出:当前工作目录下的「解码归档\<工程名>\HMI解码\」,不写工程目录、不写临时目录
26
+ const projectName = path.basename(src, path.extname(src))
27
+ const outDir = path.resolve(args[1] || path.join(process.cwd(), '解码归档', projectName, 'HMI解码'))
28
+ fs.mkdirSync(outDir, { recursive: true })
29
+
30
+ const buf = fs.readFileSync(src)
31
+ const stat = { file: src, size: buf.length, outputs: {} }
32
+ const w = (name, content) => { fs.writeFileSync(path.join(outDir, name), content); stat.outputs[name] = Buffer.byteLength(content) }
33
+
34
+ /* ---------- 1. 容器头 ---------- */
35
+ const magic = [...buf.subarray(0, 8)].map(b => b.toString(16).padStart(2, '0')).join(' ')
36
+ const header = {
37
+ magic,
38
+ size: buf.length,
39
+ pages4k: buf.length / 4096,
40
+ pageAligned: buf.length % 4096 === 0,
41
+ }
42
+
43
+ /* ---------- 2. DDL(数据库 schema) ---------- */
44
+ const asciiStrings = []
45
+ { let cur = '', start = 0
46
+ for (let i = 0; i < buf.length; i++) {
47
+ const c = buf[i]
48
+ if (c >= 32 && c < 127) { if (!cur) start = i; cur += String.fromCharCode(c) }
49
+ else { if (cur.length >= 6) asciiStrings.push([start, cur]); cur = '' }
50
+ } }
51
+ const ddl = asciiStrings.filter(([, s]) => /\b(CREATE TABLE|CREATE PROCEDURE|CREATE INDEX|ALTER TABLE|INSERT INTO|SELECT )/i.test(s))
52
+ const tables = new Set()
53
+ for (const [, s] of asciiStrings) for (const m of s.matchAll(/\b(Hmi[A-Za-z]{2,45}Table)\b/g)) tables.add(m[1])
54
+
55
+ /* ---------- 3. 解压全部 deflate 块 ---------- */
56
+ const blocks = []
57
+ for (let i = 0; i + 3 <= buf.length; i++) {
58
+ if (buf[i] === 0x1f && buf[i + 1] === 0x8b && buf[i + 2] === 0x08) {
59
+ try { blocks.push({ off: i, out: zlib.inflateRawSync(buf.subarray(i + 10)) }) } catch { }
60
+ i += 3
61
+ }
62
+ }
63
+ const uniqBlocks = []
64
+ { const seen = new Set()
65
+ for (const b of blocks) { const k = b.out.length + ':' + b.out.subarray(0, 64).toString('hex'); if (!seen.has(k)) { seen.add(k); uniqBlocks.push(b) } } }
66
+
67
+ /* ---------- 4. 对象记录:36 + UUIDv4 + 字段 + 名称 ---------- */
68
+ const isUuid4 = (o) => {
69
+ if (o + 16 > buf.length) return false
70
+ if ((buf[o + 7] >> 4) !== 4) return false
71
+ if ((buf[o + 8] & 0xc0) !== 0x80) return false
72
+ let z = 0; for (let i = 0; i < 16; i++) if (buf[o + i] === 0) z++
73
+ return z <= 3
74
+ }
75
+ const guidHex = (o) => [...buf.subarray(o, o + 16)].map(x => x.toString(16).padStart(2, '0')).join('')
76
+ const fmtGuid = h => `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`
77
+ const isNameChar = c => (c >= 0x4e00 && c <= 0x9fff) || (c >= 0x30 && c <= 0x39) || (c >= 0x41 && c <= 0x5a) || (c >= 0x61 && c <= 0x7a) || c === 0x5f || c === 0x2d || c === 0x2e || c === 0x28 || c === 0x29 || c === 0xff08 || c === 0xff09
78
+ function readAt(s, limit = 60) { let j = s, str = '', ascii = 0
79
+ while (j + 1 < buf.length && str.length < limit) { const c = buf[j] | (buf[j + 1] << 8)
80
+ if (isNameChar(c) || c === 0x20) { str += String.fromCharCode(c); if (c < 0x80) ascii++; j += 2 } else break }
81
+ return { str, ascii } }
82
+ function readBest(from, back = 28) { let best = null
83
+ for (let s = from; s < from + back; s++) { const r = readAt(s)
84
+ if (r.str.length < 2 || r.str.length > 48) continue
85
+ const cjk = /[\u4e00-\u9fff]/.test(r.str)
86
+ const score = r.str.length + (cjk ? 6 : 0) + (r.ascii / r.str.length) * 4
87
+ if (!best || score > best.score) best = { off: s, ...r, score } }
88
+ return best }
89
+ const clean = s => s.replace(/^[^\u4e00-\u9fffA-Za-z0-9]+/, '').replace(/[\s]+$/, '').trim()
90
+
91
+ const rawRecs = []
92
+ for (let i = 0; i + 17 <= buf.length; i++) {
93
+ if (buf[i] !== 0x36 || !isUuid4(i + 1)) continue
94
+ const nm = readBest(i + 17)
95
+ if (!nm) continue
96
+ const name = clean(nm.str)
97
+ if (name.length < 2) continue
98
+ rawRecs.push({ off: i, g: guidHex(i + 1), name, cjk: /[\u4e00-\u9fff]/.test(name) })
99
+ }
100
+ const guidCount = new Map()
101
+ for (const r of rawRecs) guidCount.set(r.g, (guidCount.get(r.g) || 0) + 1)
102
+
103
+ // 去噪两道:① 真类型 GUID 会被反复引用;② 真对象名里的汉字会在多个名字里复现,
104
+ // 二进制误读出的"生僻字"通常只此一次(用字符 document-frequency 判定)
105
+ const charDF = new Map()
106
+ for (const r of rawRecs) for (const ch of new Set(r.name)) charDF.set(ch, (charDF.get(ch) || 0) + 1)
107
+ // 名称紧跟记录结构字节,尾部常粘 1~2 个误读出来的字;用常用字集合判边界,只截集合外的尾字。
108
+ // 集合已按字符去重平铺(只判成员关系,不构成词);遇到误截时往集合里补该字即可。
109
+ const LEX = '上下业中主书产伺位低体作保值停入关内出切列刻前剔加动单卡压原参双变口右号后启吸吹喷器回图圆堵备复外大头始子字存定寄密封小少尾屏工左差带平年序库废度延开当形志急户手托护报择指挡换据推放故数整文料新旋日旧时更月有服本机杆束标检棒止比气测清点片瓶用画的盒矩短码移程站符箭累纸线组结编缺置联胶自舌色藏蜂表视言警计设试语调转轴输送选速量针锁长间隐障零面顶题飞高鸣'
110
+ const trimNoise = (n) => {
111
+ let b = n.length
112
+ while (b > 1) {
113
+ const c = n[b - 1]
114
+ if (LEX.includes(c)) break
115
+ if (/[A-Za-z0-9_\.]/.test(c)) break
116
+ b--
117
+ }
118
+ return n.slice(0, b)
119
+ }
120
+ const cjkRatioOk = (n) => {
121
+ const cs = [...n].filter(c => c >= '\u4e00' && c <= '\u9fff')
122
+ if (!cs.length) return true
123
+ return cs.filter(c => (charDF.get(c) || 0) >= 5).length / cs.length >= 0.5
124
+ }
125
+ const hasNoiseChar = (n) => /[\u3400-\u4dbf\uf900-\ufaff\ufffd]/.test(n)
126
+ const recs = rawRecs
127
+ .filter(r => guidCount.get(r.g) >= 3 && !hasNoiseChar(r.name) && cjkRatioOk(r.name))
128
+ .map(r => ({ ...r, name: trimNoise(r.name) }))
129
+ .filter(r => r.name.length >= 2)
130
+
131
+ /* ---------- 5. 多语言系统消息(中文) ---------- */
132
+ const zhMsgs = new Set()
133
+ for (const b of uniqBlocks) {
134
+ const s = b.out.toString('utf8')
135
+ if (!s.includes('<text>')) continue
136
+ for (const m of s.matchAll(/zh-CN[^<]*<text>([\s\S]{1,200}?)<\/text>/g)) {
137
+ const t = m[1].replace(/<[^>]+>/g, '').trim()
138
+ if (t && t.length <= 120) zhMsgs.add(t)
139
+ }
140
+ }
141
+
142
+ /* ---------- 6. 正文 UTF-16 文本(排除 ASCII 区与压缩数据区) ---------- */
143
+ const mask = new Uint8Array(buf.length)
144
+ for (let i = 0; i < buf.length - 24; i += 8) { // ASCII 密集区
145
+ let a = 0; for (let j = 0; j < 24; j++) { const c = buf[i + j]; if (c >= 32 && c < 127) a++ }
146
+ if (a >= 20) for (let j = 0; j < 24; j++) mask[i + j] = 1
147
+ }
148
+ for (const b of blocks) for (let j = b.off; j < b.off + 24 && j < buf.length; j++) mask[j] = 1 // 压缩块头
149
+ const texts = new Set()
150
+ for (let i = 0; i + 3 < buf.length; i += 2) {
151
+ if (mask[i]) continue
152
+ const c = buf[i] | (buf[i + 1] << 8)
153
+ if (!((c >= 0x4e00 && c <= 0x9fff) || (c >= 0x41 && c <= 0x7a))) continue
154
+ let j = i, s = ''
155
+ while (j + 1 < buf.length && !mask[j]) { const cc = buf[j] | (buf[j + 1] << 8)
156
+ if (isNameChar(cc) || cc === 0x20) { s += String.fromCharCode(cc); j += 2 } else break }
157
+ if (s.length >= 3 && /[\u4e00-\u9fff]/.test(s)) { const t = clean(s); if (t.length >= 2) texts.add(t) }
158
+ i = Math.max(i, j - 2)
159
+ }
160
+
161
+ /* ---------- 7. 分类 ---------- */
162
+ // 已确证的类型 GUID(靠"该类型下全部名称的语义"交叉验证得出,可继续扩充)
163
+ const KNOWN_TYPES = {
164
+ a8d0ec5ba448dd4e8cfd1f93bab80357: '画面',
165
+ '98441ece8aaa384190250908339aa88e': '变量(HMI 变量表)',
166
+ '2eee2253373c9e4fb5b9bd3a205f6713': '对象类型目录(元件类型名)',
167
+ '87c833306870a9498ae24da1919746b3': '画面元件(IO域/文本域/开关)',
168
+ '50ed3daea5a013448ae19ed8f4043d9c': '画面元件(按钮/文本域)',
169
+ e95e1fa2f2f8b946b47532cc5399bd9f: '画面元件(按钮/线)',
170
+ '7cc6b8efde529f4aab60be9937564c5f': '画面元件(矩形/按钮/开关/图形)',
171
+ a75c8f057bd20e45b5ca070891443d1d: '画面元件(IO域/开关/文本域)',
172
+ d6ae66afa96fb3408df18d44f2585873: '画面元件(IO域/开关/文本域/报警视图)',
173
+ a1b2d38181484d489401867b9fdcc709: '模板元件(模板按钮/IO域/开关)',
174
+ d65b6511ea528442af7579c84c001e54: '图片资源',
175
+ '0c0a9fede4f16d438fed097fa8051506': '通讯连接',
176
+ '35b3711327ef66408b82c3097100029e': 'Smart 主题/样式(软件自带)',
177
+ '11fea9ca9da43d4289fe389107a3e925': '字体列表',
178
+ '49a4d64cb82e814292102c7fd2007115': '系统函数',
179
+ d0aeebe5b8663d478fb6b5f4ac6c9d96: '项目结构节点',
180
+ '3848614b96efc141bcd4124814bbf961': '项目结构节点',
181
+ b0e0cad9a3bb074da9cf296d0573e254: '项目树节点',
182
+ cc1746b51be8f74b8cef934dd946b81b: '系统变量(画面号/时间等)',
183
+ e2a88f13a660734fbe15ec7df20c04a8: '用户与权限',
184
+ ad962e667c6b5c4887ae1d834a4205b8: '报警确认组',
185
+ d912bd6b8243fd4384aa4d3bfa5650ef: '用户/授权',
186
+ fa8ef5c6e7da2d4b984bb1e109cf948b: '采集周期',
187
+ '3957818870145243b9e0a928e3f16129': '图形列表',
188
+ c67c51d88bbb4c49840423ac3926928e: '报警类别/事件',
189
+ '00a28e5290ccc64f937a905f6222ffea': '数据类型与消息文件夹',
190
+ '02c4e0f59ad3c34d9d6baaf4fb8a6bf6': '数据类型与文本文件夹',
191
+ '7a065037c383944f9bbf977931038c84': '用户/授权管理',
192
+ '912c1ca9472b964e891aa54b3f27a71f': '图库条目',
193
+ '658b5e3163262b47bc8f4c14c316f7eb': '报警消息文件夹',
194
+ }
195
+ const PATTERNS = [
196
+ ['画面', /画面/],
197
+ ['画面元件', /^(按钮|文本域|IO 域|开关|矩形|线|圆|图形|报警视图|日期时间域|符号|笑脸|棒图|指针|刻度)/],
198
+ ['模板元件', /^模板/],
199
+ ['变量', /(报警|速度|计数|清零|用户名|用户密码|当前[日年月]|^[QIMVDB]\d)/],
200
+ ['图片/图形资源', /(截图|图片|照片|GRAPHIC|箭头|起始位置)/],
201
+ ['通讯/连接', /(连接|Communication|S7ONLINE|TCPIP|ETHERNET)/],
202
+ ['报警/用户/配方', /(确认组|报警组|报警类别|配方|激活码|权限|管理员|操作员)/],
203
+ ['系统函数', /^(ActivateScreen|GetPassword|GetUserName|InvertBit|Logoff|Logon|SetBit|SetValue|IncreaseValue|StartDataTransfer|ChangeConnection|SetLanguage|AddressCulture)/],
204
+ ['数据类型', /(Internal$|SIMATIC S7)/],
205
+ ['项目结构/系统对象', /(Folder|Settings|Reports|Messages|Library|Modules|Functions|Global|Prototype|Documentation|CrossReference|Devices|Images|Lists|Containers)/],
206
+ ['样式/主题', /^Smart-/],
207
+ ['时间/周期', /^\d+ ?(ms|s|min|h)$/],
208
+ ]
209
+ function classify(g) {
210
+ if (KNOWN_TYPES[g]) return KNOWN_TYPES[g]
211
+ const names = [...new Set(recs.filter(r => r.g === g).map(r => r.name))]
212
+ if (!names.length) return '其他'
213
+ // 疑似噪声区:中文名极少且名字短碎,判为二进制误命中
214
+ const cjkNames = names.filter(n => /[\u4e00-\u9fff]/.test(n)).length
215
+ if (cjkNames / names.length < 0.15 && names.length >= 8) return '疑似二进制区(非对象)'
216
+ let best = null
217
+ for (const [label, re] of PATTERNS) {
218
+ const hit = names.filter(n => re.test(n)).length / names.length
219
+ if (hit >= 0.5 && (!best || hit > best.hit)) best = { label, hit }
220
+ }
221
+ return best ? best.label : '其他/待定'
222
+ }
223
+
224
+ /* ---------- 7b. 结构化表(压缩块里的 XML 表;2026-11-06 新增) ---------- */
225
+ // 新版 .hmismart 的压缩块里是 XML 表(HmiScreenTable / HmiScreenItemTable / HmiTextTable / HmiDeviceTable)。
226
+ // 解出来的表比整份 dump 小一个数量级,按问题查表即可,不必读 dump。
227
+ function buildStructTables(blockTexts) {
228
+ const grab = (body, tag) => { const m = new RegExp(`<${tag}>([^<]*)</${tag}>`).exec(body); return m ? m[1] : '' }
229
+ // 只在单个压缩块内匹配完整记录:跨块拼接会把「开始标签在前一块、结束标签在后一块」的
230
+ // 残片吞进来(body 里混进别的表,取到错名字)。块内切不完整的记录直接丢弃。
231
+ const rows = (tag) => {
232
+ const out = []
233
+ const re = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, 'g')
234
+ for (const t of blockTexts) for (const m of t.matchAll(re)) out.push(m[1])
235
+ return out
236
+ }
237
+ const screens = rows('HmiScreenTable').map(b => ({
238
+ id: grab(b, 'ObjId'), name: grab(b, 'ObjName'), number: grab(b, 'Number'),
239
+ deleted: grab(b, 'Deleted'), device: grab(b, 'DeviceNumber'), mtime: grab(b, 'LastModified'),
240
+ })).filter(r => r.name)
241
+ const byId = new Map(screens.map(r => [r.id, r]))
242
+ const items = rows('HmiScreenItemTable').map(b => ({
243
+ id: grab(b, 'ObjId'), name: grab(b, 'ObjName'), type: grab(b, 'HmiSubType'),
244
+ screenId: grab(b, 'ParentId'), x: grab(b, 'LocationX'), y: grab(b, 'LocationY'),
245
+ w: grab(b, 'SizeX'), h: grab(b, 'SizeY'), mtime: grab(b, 'LastModified'),
246
+ })).filter(r => r.name)
247
+ const itemById = new Map(items.map(r => [r.id, r]))
248
+ const texts = rows('HmiTextTable').map(b => ({
249
+ ownerId: grab(b, 'ContainerId'), type: grab(b, 'TextType'),
250
+ sys: grab(b, 'IsSystemMessageText'), text: grab(b, 'Language0'), mtime: grab(b, 'LastModified'),
251
+ })).filter(r => r.text)
252
+ const devices = rows('HmiDeviceTable').map(b => ({
253
+ name: grab(b, 'ObjName'), model: grab(b, 'DeviceType'), startId: grab(b, 'StartScreenId'),
254
+ w: grab(b, 'ScreenSizeWidth'), h: grab(b, 'ScreenSizeHeight'), mtime: grab(b, 'LastModified'),
255
+ })).filter(r => r.name)
256
+ // 类型密码本:TypeCode ↔ 类型全名(.data 里判断对象/引用类型用)
257
+ const types = rows('HmiTypeTable').map(b => ({
258
+ code: grab(b, 'TypeCode'), name: grab(b, 'TypeName'), id: grab(b, 'TypeId'),
259
+ })).filter(r => r.name && r.code)
260
+ // 同一对象在多个压缩块里出现(工程内多版本快照),按主键取 LastModified 最新的一条
261
+ const dedupe = (arr, keyFn) => {
262
+ const m = new Map()
263
+ for (const r of arr) {
264
+ const k = keyFn(r), cur = m.get(k)
265
+ if (!cur || String(r.mtime || '') >= String(cur.mtime || '')) m.set(k, r)
266
+ }
267
+ return [...m.values()]
268
+ }
269
+ return {
270
+ screens: dedupe(screens, r => r.id || r.name),
271
+ items: dedupe(items, r => r.id || r.name),
272
+ texts: dedupe(texts, r => `${r.ownerId}|${r.type}|${r.text}`),
273
+ devices: dedupe(devices, r => `${r.name}|${r.model}`),
274
+ types: dedupe(types, r => r.code),
275
+ byId, itemById,
276
+ }
277
+ }
278
+ const struct = buildStructTables(blocks.map(b => b.out.toString('utf8')))
279
+
280
+ /* ---------- 7c. 旁系 .data(ES2RT 序列化):真实变量/控件名 ---------- */
281
+ // .hmismart 里只有模板骨架;真实画面/控件/变量在 <工程名>_{{GUID}}.data:
282
+ // 'ES2RT' 头 + gzip → 解出 18MB 的 .NET 序列化对象流,UTF-8 字符串可读。
283
+ // 目前能可靠抽出三族名字;控件→变量的绑定引用未解出(标在报告里)。
284
+ function extractDataNames(file) {
285
+ try {
286
+ const d = fs.readFileSync(file)
287
+ const gi = d.indexOf(Buffer.from([0x1f, 0x8b, 0x08]))
288
+ if (gi < 0) return null
289
+ const raw = zlib.inflateRawSync(d.subarray(gi + 10))
290
+ const text = new TextDecoder('utf-8', { fatal: false }).decode(raw)
291
+ const BAD = /hmiType|objName|esHandle|lang2Text|missingText|TextList|messageParameters|isProjectedText|System\.|Siemens\.|ES2RT|k__BackingField|Dependencies|CustomMaxTextLength|theCauseHandle|idParent/
292
+ const isName = (s) => {
293
+ if (s.length < 2 || s.length > 24) return false
294
+ if (BAD.test(s)) return false
295
+ if (/[\u4e00-\u9fff]/.test(s)) return true
296
+ if (/^[QIMVWDB]\d{1,5}(\.\d)?$/i.test(s)) return true
297
+ return false
298
+ }
299
+ const pick = (seg) => {
300
+ const runs = [...seg.matchAll(/[\u4e00-\u9fffA-Za-z0-9 _.\-]{2,24}/g)].map(m => m[0].trim()).filter(isName)
301
+ return runs.length ? runs[runs.length - 1] : ''
302
+ }
303
+ const collect = (tn) => {
304
+ const names = []
305
+ let p = -1
306
+ while ((p = text.indexOf(tn, p + 1)) >= 0) {
307
+ const n = pick(text.slice(Math.max(0, p - 600), p))
308
+ if (n) names.push(n)
309
+ }
310
+ return [...new Set(names)]
311
+ }
312
+ // 控件→变量:按 ES2RT 段切分(相邻真头之间为一段),控件段内出现变量名(词边界)即绑定候选
313
+ const heads = []
314
+ for (let i = 0; i + 12 < raw.length; i++) {
315
+ if (raw[i] === 0x45 && raw[i + 1] === 0x53 && raw[i + 2] === 0x32 && raw[i + 3] === 0x52 && raw[i + 4] === 0x54 && raw[i + 5] === 0x01) heads.push(i)
316
+ }
317
+ const segText = (k) => new TextDecoder('utf-8', { fatal: false }).decode(raw.subarray(heads[k], k + 1 < heads.length ? heads[k + 1] : raw.length))
318
+ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
319
+ const hasWord = (t, w) => new RegExp(`(^|[^\\u4e00-\\u9fffA-Za-z0-9_])${esc(w)}([^\\u4e00-\\u9fffA-Za-z0-9_]|$)`).test(t)
320
+ const tagList = [...new Set(collect('Siemens.Simatic.Hmi.Utah.Tag.HmiTag'))]
321
+ const isUuid4 = (b, o) => o + 16 <= b.length && (b[o + 7] >> 4) === 4 && (b[o + 8] & 0xc0) === 0x80
322
+ const uuidsOf = (b) => { const o = []; for (let i = 0; i + 16 <= b.length; i++) if (isUuid4(b, i)) o.push(b.subarray(i, i + 16).toString('hex')); return o }
323
+ const segBuf = (k) => raw.subarray(heads[k], k + 1 < heads.length ? heads[k + 1] : raw.length)
324
+ // 变量对象 id:每段结构固定,对象 id 在段内偏移 0x19(16 字节)
325
+ const segId = (b) => b.length > 0x29 ? b.subarray(0x19, 0x29).toString('hex') : ''
326
+ const tagByUuid = new Map()
327
+ for (let k = 0; k < heads.length; k++) {
328
+ const t = segText(k)
329
+ if (!t.includes('Siemens.Simatic.Hmi.Utah.Tag.HmiTag')) continue
330
+ const n = pick(t), id = segId(segBuf(k))
331
+ if (n && id && !tagByUuid.has(id)) tagByUuid.set(id, n)
332
+ }
333
+ const bindings = []
334
+ const seen = new Set()
335
+ const pushB = (item, tag, by) => { const k = item + '\u0001' + tag; if (!seen.has(k)) { seen.add(k); bindings.push({ item, tag, by }) } }
336
+ for (let k = 0; k < heads.length; k++) {
337
+ const t = segText(k)
338
+ if (!t.includes('Siemens.Simatic.Hmi.Utah.GraphX.HmiScreenItem')) continue
339
+ const name = pick(t)
340
+ if (!name) continue
341
+ for (const w of tagList) if (w !== name && hasWord(t, w)) pushB(name, w, '同段共现')
342
+ const b2 = segBuf(k)
343
+ for (const u of new Set(uuidsOf(b2))) {
344
+ const w = tagByUuid.get(u)
345
+ if (w && w !== name) pushB(name, w, 'UUID 引用')
346
+ }
347
+ }
348
+ return {
349
+ file: path.basename(file),
350
+ bytes: d.length,
351
+ inflated: raw.length,
352
+ segments: heads.length,
353
+ screens: collect('Siemens.Simatic.Hmi.Utah.GraphX.HmiScreen'),
354
+ items: collect('Siemens.Simatic.Hmi.Utah.GraphX.HmiScreenItem'),
355
+ tags: collect('Siemens.Simatic.Hmi.Utah.Tag.HmiTag'),
356
+ bindings,
357
+ }
358
+ } catch { return null }
359
+ }
360
+ let dataNames = null
361
+ try {
362
+ const dir = path.dirname(src)
363
+ const cand = fs.readdirSync(dir).filter(f => f.toLowerCase().endsWith('.data'))
364
+ if (cand.length) {
365
+ // 取最大的那个:同目录常有两个 .data,小的那个只存键值不入对象
366
+ let best = cand[0], bestSize = -1
367
+ for (const f of cand) {
368
+ try { const sz = fs.statSync(path.join(dir, f)).size; if (sz > bestSize) { best = f; bestSize = sz } } catch { }
369
+ }
370
+ dataNames = extractDataNames(path.join(dir, best))
371
+ }
372
+ } catch { }
373
+
374
+ /* ---------- 输出 ---------- */
375
+ const report = []
376
+ report.push(`# .hmismart 解码报告`, '', `- 源文件:${src}`, `- 文件大小:${buf.length} 字节(${(buf.length / 1048576).toFixed(2)} MB)`, `- 文件头魔数:\`${magic}\``, `- 页结构:${header.pageAligned ? `页对齐,4096 × ${header.pages4k} 页` : '非 4096 对齐'}`, `- 压缩块:${blocks.length} 个(去重 ${uniqBlocks.length}),解压总量 ${(blocks.reduce((a, b) => a + b.out.length, 0) / 1048576).toFixed(2)} MB`, `- 内嵌数据表:${tables.size} 张`, `- 对象记录:${rawRecs.length} 条(有效类型 ${guidCount.size} 种,过滤后 ${recs.length} 条)`, `- 中文系统消息:${zhMsgs.size} 条`, `- 正文可读文本:${texts.size} 条`, '')
377
+ report.push(`- 结构化表:画面 ${struct.screens.length} / 元件 ${struct.items.length} / 文本 ${struct.texts.length} / 设备 ${struct.devices.length}(来自 .hmismart 内嵌 XML 表;0 表示本格式无 XML 表,见 01/01b/02。真实画面/控件与绑定变量在旁系 {{GUID}}.data 与 .srt,不在这张表里)`)
378
+ if (dataNames) report.push(`- 旁系 .data:${dataNames.file}(${(dataNames.bytes / 1048576).toFixed(1)} MB → 解压 ${(dataNames.inflated / 1048576).toFixed(1)} MB / ${dataNames.segments} 段)|变量名 ${dataNames.tags.length} / 控件名 ${dataNames.items.length} / 画面候选 ${dataNames.screens.length} / 绑定候选 ${dataNames.bindings.length}(见 20/21/22/23)`)
379
+ else report.push('- 旁系 .data:未找到(同目录无 {{GUID}}.data)')
380
+ w('00-格式说明.md', report.join('\n'))
381
+
382
+ w('01-对象记录.tsv', '偏移\t类型GUID\t名称\n' + rawRecs.map(r => `0x${r.off.toString(16)}\t${fmtGuid(r.g)}\t${r.name}`).join('\n'))
383
+ w('01b-对象记录-清洗.tsv', '偏移\t类型GUID\t名称\n' + recs.map(r => `0x${r.off.toString(16)}\t${fmtGuid(r.g)}\t${r.name}`).join('\n'))
384
+
385
+ const groups = [...guidCount.entries()].filter(([, n]) => n >= 3).sort((a, b) => b[1] - a[1])
386
+ const cat = new Map()
387
+ for (const [g] of groups) { const k = classify(g); if (!cat.has(k)) cat.set(k, []); cat.get(k).push(g) }
388
+ const catLines = ['# 对象分类清单', '', '每行 = 类型GUID|记录数|该类型下的全部唯一名称(去重)。', '']
389
+ for (const [k, gs] of [...cat.entries()].sort((a, b) => b[1].length - a[1].length)) {
390
+ const total = gs.reduce((a, g) => a + guidCount.get(g), 0)
391
+ catLines.push(`## ${k}(${gs.length} 类 / ${total} 条)`, '')
392
+ for (const g of gs) {
393
+ const names = [...new Set(recs.filter(r => r.g === g).map(r => r.name))]
394
+ catLines.push(`- \`${fmtGuid(g)}\` ×${guidCount.get(g)}|唯一 ${names.length}`)
395
+ catLines.push(' ' + names.join('、'))
396
+ }
397
+ catLines.push('')
398
+ }
399
+ w('02-对象分类.md', catLines.join('\n'))
400
+
401
+ w('03-建表脚本.sql', ddl.map(([o, s]) => `-- 0x${o.toString(16)}\n${s}`).join('\n\n'))
402
+
403
+ // 解压块分两堆:工程数据(属性包/对象数据)与软件自带的多语言系统文本
404
+ const isMultiLang = (s) => /<text>/.test(s) && /hu-HU|cs-CZ|ja-JP|zh-TW|de-DE|en-US|fr-FR|es-ES|it-IT|pt-PT|ru-RU|ko-KR/.test(s)
405
+ const propBlocks = [], langBlocks = []
406
+ for (const b of uniqBlocks) (isMultiLang(b.out.toString('utf8')) ? langBlocks : propBlocks).push(b)
407
+ const dumpBlock = (b, k) => `\n===BLK#${k} @0x${b.off.toString(16)} len=${b.out.length}===\n` + b.out.toString('utf8').replace(/[^\x09\x0a\x20-\x7e\u4e00-\u9fff]/g, '·')
408
+ w('04-解压块-工程数据.txt', propBlocks.map(dumpBlock).join('\n'))
409
+ w('05-解压块-多语言文本.txt', langBlocks.map(dumpBlock).join('\n'))
410
+
411
+ fs.writeFileSync(path.join(outDir, '06-系统消息_中文.txt'), [...zhMsgs].sort().join('\n'))
412
+ stat.outputs['06-系统消息_中文.txt'] = fs.statSync(path.join(outDir, '06-系统消息_中文.txt')).size
413
+
414
+ fs.writeFileSync(path.join(outDir, '07-可读文本.txt'), [...texts].sort().join('\n'))
415
+ stat.outputs['07-可读文本.txt'] = fs.statSync(path.join(outDir, '07-可读文本.txt')).size
416
+
417
+ /* 结构化表落盘:一份问题一张表,AI 按问题只读对应那份 */
418
+ const tsv = (head, lines) => head + '\n' + lines.join('\n') + (lines.length ? '\n' : '')
419
+ const ownerOf = id => (struct.itemById.get(id) || struct.byId.get(id) || { name: String(id).slice(0, 8) }).name
420
+ if (struct.screens.length) {
421
+ w('10-画面.tsv', tsv('画面名\t编号\t已删除\t设备号\tObjId', struct.screens.map(r => `${r.name}\t${r.number}\t${r.deleted}\t${r.device}\t${r.id}`)))
422
+ }
423
+ if (struct.items.length) {
424
+ w('11-元件.tsv', tsv('画面\t元件名\t类型\tX\tY\t宽\t高\tObjId', struct.items.map(r => `${(struct.byId.get(r.screenId) || {}).name || ''}\t${r.name}\t${r.type}\t${r.x}\t${r.y}\t${r.w}\t${r.h}\t${r.id}`)))
425
+ }
426
+ if (struct.texts.length) {
427
+ w('12-文本.tsv', tsv('所属\t文本类型\t系统消息\t文本', struct.texts.map(r => `${ownerOf(r.ownerId)}\t${r.type}\t${r.sys}\t${r.text.replace(/\t/g, ' ')}`)))
428
+ }
429
+ if (struct.devices.length) {
430
+ w('13-设备.tsv', tsv('设备名\t型号\t起始画面ID\t起始画面\t分辨率', struct.devices.map(r => {
431
+ const s = struct.byId.get(r.startId)
432
+ return `${r.name}\t${r.model}\t${r.startId}\t${s ? s.name : '(不在画面表)'}\t${r.w}×${r.h}`
433
+ })))
434
+ }
435
+ if (struct.types.length) {
436
+ w('24-类型表.tsv', tsv('TypeCode\t类型全名', struct.types.map(r => `${r.code}\t${r.name}`)))
437
+ }
438
+
439
+ /* 旁系 .data 名字清单:真实变量/控件在这里,.hmismart 里只有模板骨架 */
440
+ if (dataNames) {
441
+ w('20-变量.data.tsv', '变量名\n' + dataNames.tags.join('\n') + (dataNames.tags.length ? '\n' : ''))
442
+ w('21-控件.data.tsv', '控件名\n' + dataNames.items.join('\n') + (dataNames.items.length ? '\n' : ''))
443
+ w('22-画面候选.data.txt', '# 画面候选(ES2RT 邻近名,可能混入控件名;未与画面表对齐)\n' + dataNames.screens.join('\n') + '\n')
444
+ if (dataNames.bindings.length) {
445
+ w('23-控件绑定.data.tsv', '控件\t变量\t依据\n' + dataNames.bindings.map(b => `${b.item}\t${b.tag}\t${b.by}`).join('\n') + '\n')
446
+ }
447
+ }
448
+
449
+ w('08-统计.json', JSON.stringify({ ...stat, tables: [...tables].sort(), blocks: blocks.length, uniqBlocks: uniqBlocks.length, propBlocks: propBlocks.length, langBlocks: langBlocks.length, blockBytes: blocks.reduce((a, b) => a + b.out.length, 0), recs: rawRecs.length, recsKept: recs.length, guidTypes: guidCount.size, zhMsgs: zhMsgs.size, texts: texts.size, structRows: { screens: struct.screens.length, items: struct.items.length, texts: struct.texts.length, devices: struct.devices.length }, dataNames: dataNames ? { file: dataNames.file, bytes: dataNames.bytes, inflated: dataNames.inflated, segments: dataNames.segments, tags: dataNames.tags.length, items: dataNames.items.length, screens: dataNames.screens.length, bindings: dataNames.bindings.length } : null }, null, 2))
450
+
451
+ console.log(`[完成] 输出目录:${outDir}`)
452
+ for (const [k, v] of Object.entries(stat.outputs)) console.log(` ${k} ${(v / 1024).toFixed(1)} KB`)
453
+ console.log(` 内嵌表 ${tables.size} 张|对象记录 ${rawRecs.length}→${recs.length} 条|类型 ${guidCount.size} 种|压缩块 ${blocks.length}(唯一 ${uniqBlocks.length}:工程数据 ${propBlocks.length} / 多语言 ${langBlocks.length})|中文系统消息 ${zhMsgs.size}|可读文本 ${texts.size}`)