chenyc
2026-03-22 d23dc3235324e6bbe62e507eae807435d77dfc6d
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
const net = require('net');
const iconv = require('iconv-lite');
const xml2js = require('xml2js');
const fs = require('fs');
const path = require('path');
const {
    extractFramesFromUtf16leBuffer,
    extractFramesFromText,
    guessTextEncodingFromBuffer,
} = require('./framing');
 
// ============================================================================
// 解析主流程(index.js)
// A) 在线模式:TCP 收包 -> buffer 拼接 -> framing 分帧 -> parseAndPrintData 解析
// B) 离线模式:读取文件(--decode-file) -> 判定输入编码/形态 -> 分帧 -> 解析
// C) 解析结果:控制台打印 + 可选 onFrame 回调(供 gateway.js 做缓存与转发)
// ============================================================================
 
// ================= 配置区域 =================
const CONFIG = {
    host: '127.0.0.1', // 【重要】请修改为您的透析机 IP 地址
    port: 3021,            // 3021: 自动推送模式
    reconnectInterval: 5000 // 断线重连间隔 (毫秒)
};
 
// 允许命令行覆盖连接目标:node .\index.js --host 127.0.0.1 --port 3021
const hostIdx = process.argv.indexOf('--host');
if (hostIdx !== -1 && process.argv[hostIdx + 1]) {
    CONFIG.host = process.argv[hostIdx + 1];
}
const portIdx = process.argv.indexOf('--port');
if (portIdx !== -1 && process.argv[portIdx + 1]) {
    const p = Number(process.argv[portIdx + 1]);
    if (!Number.isNaN(p) && p > 0) CONFIG.port = p;
}
 
// 参数映射表 (基于百特 Artis 协议文档)
// 如果文档中有新参数,请在此处添加
const PARAM_MAP = {
    '0': '治疗阶段 (Phase)', 
    '1': '剩余时间 (Time Rem)', 
    '2': '超滤量 (UF Vol)', 
    '3': '超滤率 (UF Rate)',
    '4': '电导率 (Cond)', 
    '5': '碳酸氢盐电导率 (Bic Cond)', 
    '6': '血流速 (Blood Flow)', 
    '7': '温度 (Temp)',
    '8': '透析液流速 (Dialysate Flow)', 
    '9': '在线置换率 (OnLine Sub Rate)',
    '10': '累计血容量 (Cum Blood Vol)',
    '12': '跨膜压 (TMP Actual)',
    '13': '静脉压 (Venous Pressure)',
    '14': '动脉压 (Arterial Pressure)',
    '32': '设定血流速 (Set Blood Flow)', 
    '34': '设定超滤量 (Set UF Vol)',
    '100': '动脉压 (Art Pressure)', // 示例补充,需核对文档
    '101': '静脉压 (Ven Pressure)'  // 示例补充,需核对文档
};
 
let bufferCache = Buffer.alloc(0);
let client = null;
let reconnectTimer = null;
let reconnectScheduledAt = null;
 
const DEBUG = process.argv.includes('--debug');
const PRINT_RX = process.argv.includes('--print-rx');
const printRxMaxIdx = process.argv.indexOf('--print-rx-max');
const PRINT_RX_MAX = (printRxMaxIdx !== -1 && process.argv[printRxMaxIdx + 1])
    ? Math.max(0, Number(process.argv[printRxMaxIdx + 1]) || 0)
    : 256;
 
const decodeFileIdx = process.argv.indexOf('--decode-file');
const DECODE_FILE = (decodeFileIdx !== -1 && process.argv[decodeFileIdx + 1])
    ? path.resolve(process.cwd(), process.argv[decodeFileIdx + 1])
    : null;
 
function toHexPreview(buf, maxBytes = 32) {
    if (!buf || buf.length === 0) return '';
    const slice = buf.slice(0, Math.min(buf.length, maxBytes));
    return slice.toString('hex').match(/../g).join(' ');
}
 
function printReceivedChunk(data) {
    // 调试辅助:打印原始 chunk 的 hex + UTF16LE 视图,用于排查乱码/截断/粘包。
    if (!PRINT_RX || !data || data.length === 0) return;
 
    const max = PRINT_RX_MAX;
    const showAll = max === 0;
    const shown = showAll ? data : data.slice(0, Math.min(data.length, max));
    const truncated = shown.length < data.length;
 
    const hex = shown.toString('hex');
    const hexPretty = hex ? hex.match(/../g).join(' ') : '';
 
    let utf16Text = '';
    try {
        const evenLen = shown.length % 2 === 0 ? shown.length : shown.length - 1;
        const utf16Buf = shown.slice(0, Math.max(0, evenLen));
        utf16Text = iconv.decode(utf16Buf, 'utf16le').replace(/^\uFEFF/, '');
    } catch (_) {
        utf16Text = '[UTF-16LE 解码失败]';
    }
 
    console.log(`\n🧩 收包 chunk: ${data.length} bytes${truncated ? ` (仅显示前 ${shown.length} bytes)` : ''}`);
    console.log(`HEX: ${hexPretty}`);
    console.log('UTF16LE:');
    console.log(utf16Text);
}
 
function extractLikelyHexBytesFromText(text) {
    // Prefer long runs like: "3C 00 3F 00 ..." to avoid RTF/header noise.
    const candidates = text.match(/[0-9A-Fa-f]{2}(?:\s+[0-9A-Fa-f]{2}){40,}/g);
    if (candidates && candidates.length) {
        let best = candidates[0];
        for (const c of candidates) {
            if (c.length > best.length) best = c;
        }
        const hex = best.split(/\s+/g).filter(Boolean).join('');
        return hex;
    }
 
    // Fallback: strip anything not hex.
    return text
        .replace(/0x/gi, '')
        .replace(/\\x/gi, '')
        .replace(/[^0-9a-fA-F]/g, '');
}
 
function decodeHexTextToBytes(text) {
    let cleaned = extractLikelyHexBytesFromText(text);
    if (!cleaned || cleaned.length < 40) {
        throw new Error('未在文件中提取到足够的 hex 字节(可能不是 hex 文本/或格式不支持)');
    }
    if (cleaned.length % 2 !== 0) {
        console.warn('⚠️ hex 长度不是偶数(可能复制/截断),已丢弃最后 1 个半字节继续尝试解析');
        cleaned = cleaned.slice(0, -1);
    }
    return Buffer.from(cleaned, 'hex');
}
 
function decodeFileAndParse(filePath) {
    // 离线解析入口:支持 hex/bin/base64 文本,自动识别 utf16le/utf8。
    if (!fs.existsSync(filePath)) {
        console.error(`文件不存在: ${filePath}`);
        process.exitCode = 1;
        return;
    }
 
    const args = process.argv;
    const forcedEncoding = args.includes('--utf16le') ? 'utf16le' : (args.includes('--utf8') ? 'utf8' : null);
    const modeHex = args.includes('--hex');
    const modeBin = args.includes('--bin');
    const modeB64 = args.includes('--b64');
 
    const raw = fs.readFileSync(filePath);
    let bytes = raw;
    let source = 'binary';
 
    if (modeHex || (!modeBin && !modeB64)) {
        // Default prefer hex for text-like inputs, especially RTF-wrapped hex dumps.
        try {
            const asText = raw.toString('utf8');
            bytes = decodeHexTextToBytes(asText);
            source = 'hex';
        } catch (e) {
            if (modeHex) throw e;
            // If not forced, fall back to treating as binary.
            bytes = raw;
            source = 'binary';
        }
    } else if (modeB64) {
        const asText = raw.toString('utf8').replace(/\s+/g, '');
        bytes = Buffer.from(asText, 'base64');
        source = 'base64';
    }
 
    const encoding = forcedEncoding || guessTextEncodingFromBuffer(bytes);
    console.log(`输入: ${filePath} (source=${source}, bytes=${bytes.length}, encoding=${encoding})`);
 
    if (encoding === 'utf16le') {
        const { frames, remainderBuffer } = extractFramesFromUtf16leBuffer(bytes);
        if (frames.length === 0) {
            // 兜底:常见“抓包/复制截断”,没拿到 </ORU_R31>,但前面可能已包含完整 OBX 段。
            let work = bytes;
            if (work.length % 2 === 1) work = work.slice(0, work.length - 1);
            const text = iconv.decode(work, 'utf16le').replace(/^\uFEFF/, '');
            const hasRoot = text.includes('<ORU_R31') || text.includes('<ORF_R04');
            const obxBlocks = text.match(/<OBX>[\s\S]*?<\/OBX>/g) || [];
            if (hasRoot && obxBlocks.length > 0) {
                console.log(`⚠️ 未找到完整帧(疑似截断),尝试从片段提取 ${obxBlocks.length} 个 OBX 解析`);
                const wrapped = `<?xml version="1.0" encoding="UTF-8"?>\n<ORU_R31>\n  <ORU_R31.OBSERVATION>\n${obxBlocks.join('\n')}\n  </ORU_R31.OBSERVATION>\n</ORU_R31>`;
                parseAndPrintData(wrapped);
                return;
            }
 
            console.log('未找到 ORU_R31/ORF_R04 XML 完整帧');
            if (DEBUG) console.log(`remainder=${remainderBuffer.length} bytes; head=${toHexPreview(bytes)}`);
            return;
        }
        console.log(`找到 ${frames.length} 条 XML 帧`);
        frames.forEach((xml, idx) => {
            console.log(`\n===== Frame #${idx + 1} =====`);
            parseAndPrintData(xml.replace(/^\uFEFF/, '').trim());
        });
        if (remainderBuffer && remainderBuffer.length) {
            console.warn(`⚠️ 末尾还有 ${remainderBuffer.length} bytes 未形成完整帧(可能抓包截断/拼接不完整)`);
        }
        return;
    }
 
    const text = bytes.toString('utf8');
    const { frames, remainderText } = extractFramesFromText(text);
    if (frames.length === 0) {
        const hasRoot = text.includes('<ORU_R31') || text.includes('<ORF_R04');
        const obxBlocks = text.match(/<OBX>[\s\S]*?<\/OBX>/g) || [];
        if (hasRoot && obxBlocks.length > 0) {
            console.log(`⚠️ 未找到完整帧(疑似截断),尝试从片段提取 ${obxBlocks.length} 个 OBX 解析`);
            const wrapped = `<?xml version="1.0" encoding="UTF-8"?>\n<ORU_R31>\n  <ORU_R31.OBSERVATION>\n${obxBlocks.join('\n')}\n  </ORU_R31.OBSERVATION>\n</ORU_R31>`;
            parseAndPrintData(wrapped);
            return;
        }
 
        console.log('未找到 ORU_R31/ORF_R04 XML 完整帧');
        if (DEBUG) console.log(`remainderChars=${remainderText.length}`);
        return;
    }
    console.log(`找到 ${frames.length} 条 XML 帧`);
    frames.forEach((xml, idx) => {
        console.log(`\n===== Frame #${idx + 1} =====`);
        parseAndPrintData(xml.replace(/^\uFEFF/, '').trim());
    });
    if (remainderText && remainderText.length) {
        console.warn(`⚠️ 末尾还有 ${remainderText.length} chars 未形成完整帧(可能抓包截断/拼接不完整)`);
    }
}
 
// ================= 数据收集(可选) =================
const COLLECT = {
    enabled: process.argv.includes('--collect'),
    outFile: path.resolve(__dirname, 'artis_frames.ndjson'),
};
 
const outIdx = process.argv.indexOf('--out');
if (outIdx !== -1 && process.argv[outIdx + 1]) {
    COLLECT.outFile = path.resolve(process.cwd(), process.argv[outIdx + 1]);
}
 
let collectStream = null;
function ensureCollector() {
    if (!COLLECT.enabled) return;
    if (collectStream) return;
    collectStream = fs.createWriteStream(COLLECT.outFile, { flags: 'a' });
    console.log(`🧾 收集已启用:${COLLECT.outFile}`);
}
 
function recordFrame(frameObj) {
    if (!COLLECT.enabled) return;
    ensureCollector();
    try {
        collectStream.write(`${JSON.stringify(frameObj)}\n`);
    } catch (e) {
        console.warn('⚠️ 写入收集文件失败:', e && e.message ? e.message : String(e));
    }
}
 
// ================= 主逻辑 =================
 
function startClient() {
    // 在线 TCP 入口:连接透析机,接收实时推送并持续解析。
    if (reconnectTimer) {
        clearTimeout(reconnectTimer);
        reconnectTimer = null;
        reconnectScheduledAt = null;
    }
 
    if (client) {
        client.destroy();
    }
 
    client = new net.Socket();
    client.setKeepAlive(true, 10_000);
 
    console.log(`\n🔄 正在连接百特 Artis 透析机 (${CONFIG.host}:${CONFIG.port})...`);
 
    client.connect(CONFIG.port, CONFIG.host, () => {
        console.log('✅ 连接成功!等待数据推送...\n');
 
        // 可选:开启收集器
        ensureCollector();
    });
 
    client.on('end', () => {
        console.log('📴 远端已结束连接 (FIN)');
    });
 
    client.on('timeout', () => {
        console.log('⏱️ Socket 超时');
        try { client.destroy(); } catch (_) {}
    });
 
    // 接收数据
    client.on('data', (data) => {
        printReceivedChunk(data);
 
        // 1. 将新数据拼接到缓存区 (处理 TCP 分包/粘包)
        bufferCache = Buffer.concat([bufferCache, data]);
 
        // 2. 从原始字节流中提取出尽可能多的完整 XML 帧(处理粘包/半包/多帧连发)
        try {
            const { frames, remainderBuffer } = extractFramesFromUtf16leBuffer(bufferCache);
            bufferCache = remainderBuffer;
 
            if (DEBUG) {
                console.log(`📥 收到 ${data.length} bytes;本次提取 ${frames.length} 帧;缓存剩余 ${bufferCache.length} bytes;head=${toHexPreview(data)}`);
            }
 
            frames.forEach((xmlStr) => {
                const normalized = xmlStr.replace(/^\uFEFF/, '').trim();
                parseAndPrintData(normalized, { onFrame: recordFrame });
            });
        } catch (err) {
            if (DEBUG) {
                console.log(`📥 收到 ${data.length} bytes;当前缓存 ${bufferCache.length} bytes;尚未形成完整帧;head=${toHexPreview(data)}`);
            }
            // 保持缓存,等待后续数据补齐。
        }
    });
 
    client.on('error', (err) => {
        console.error(`❌ 连接错误: ${err.message}`);
        handleDisconnect();
    });
 
    client.on('close', (hadError) => {
        console.log(`🔌 连接已断开${hadError ? ' (伴随错误)' : ''}`);
        handleDisconnect();
    });
}
 
// 处理断开重连
function handleDisconnect() {
    if (reconnectTimer) return;
    reconnectScheduledAt = Date.now();
    console.log(`⏳ ${CONFIG.reconnectInterval/1000}秒后尝试重连...`);
    reconnectTimer = setTimeout(() => {
        reconnectTimer = null;
        reconnectScheduledAt = null;
        startClient();
    }, CONFIG.reconnectInterval);
}
 
// 解析 XML 并打印
function parseAndPrintData(xmlStr, options = {}) {
    // 解析策略:
    // 1) 先走 xml2js 严格解析(结构化准确)
    // 2) 失败则走 OBX 片段兜底(容忍非严格/损坏 XML)
    // 3) 最终产出 params + latest,并可通过 onFrame 回调给上层网关
    const parser = new xml2js.Parser({ 
        explicitArray: false, // 如果只有一个节点,不强制转为数组
        mergeAttrs: false     // 属性单独处理
    });
 
    const unescapeXmlText = (s) => {
        if (s == null) return '';
        return String(s)
            .replace(/&lt;/g, '<')
            .replace(/&gt;/g, '>')
            .replace(/&amp;/g, '&')
            .replace(/&quot;/g, '"')
            .replace(/&apos;/g, "'");
    };
 
    const extractMetaFromXmlText = (text) => {
        const meta = {
            device: '',
            timestamp: '',
        };
 
        // MSH.4 is often SW version + SN, e.g. SW_8.60.02_SN_34856
        const msh4 = pickFirst(text, [
            /<MSH\.4>[\s\S]*?<HD\.1>([^<]*)<\/HD\.1>/i,
            /<MSH\.4>\s*([^<\s][^<]*)\s*<\/MSH\.4>/i,
        ]);
        const msh3 = pickFirst(text, [
            /<MSH\.3>[\s\S]*?<HD\.1>([^<]*)<\/HD\.1>/i,
            /<MSH\.3>\s*([^<\s][^<]*)\s*<\/MSH\.3>/i,
        ]);
        meta.device = msh4 || msh3;
 
        meta.timestamp = pickFirst(text, [
            /<MSH\.7>[\s\S]*?<TS\.1>([^<]*)<\/TS\.1>/i,
            /<MSH\.7>\s*([^<\s][^<]*)\s*<\/MSH\.7>/i,
        ]);
 
        return meta;
    };
 
    const pickFirst = (text, patterns) => {
        for (const re of patterns) {
            const m = text.match(re);
            if (m && m[1] != null) return unescapeXmlText(m[1]).trim();
        }
        return '';
    };
 
    const parseObxBlockLoosely = (obxXml) => {
        const paramId = pickFirst(obxXml, [
            /<OBX\.3>[\s\S]*?<CE\.1>([^<]*)<\/CE\.1>/i,
            /<OBX\.3>\s*([^<\s][^<]*)\s*<\/OBX\.3>/i,
        ]);
        if (!paramId) return null;
 
        const paramNameRaw = pickFirst(obxXml, [
            /<OBX\.3>[\s\S]*?<CE\.2>([^<]*)<\/CE\.2>/i,
        ]) || null;
 
        const value = pickFirst(obxXml, [
            /<OBX\.5>[\s\S]*?<FN\.1>([^<]*)<\/FN\.1>/i,
            /<OBX\.5>[\s\S]*?<ST>([^<]*)<\/ST>/i,
            /<OBX\.5>\s*([^<\s][^<]*)\s*<\/OBX\.5>/i,
        ]);
 
        const unit = pickFirst(obxXml, [
            /<OBX\.6>[\s\S]*?<CE\.1>([^<]*)<\/CE\.1>/i,
            /<OBX\.6>[\s\S]*?<CE\.2>([^<]*)<\/CE\.2>/i,
            /<OBX\.6>\s*([^<\s][^<]*)\s*<\/OBX\.6>/i,
        ]);
 
        const displayName = PARAM_MAP[String(paramId)] || paramNameRaw || `未知参数(${paramId})`;
        return {
            id: String(paramId),
            name: displayName,
            rawName: paramNameRaw || undefined,
            value: value,
            unit: unit,
        };
    };
 
    const salvageObxAndPrint = () => {
        // 非严格 XML 兜底解析:直接正则提取 <OBX>...</OBX>,尽可能保留业务数据。
        const obxBlocks = xmlStr.match(/<OBX>[\s\S]*?<\/OBX>/g) || [];
        if (obxBlocks.length === 0) return false;
 
        const meta = extractMetaFromXmlText(xmlStr);
 
        const receivedAt = new Date().toISOString();
        const messageType = xmlStr.includes('<ORF_R04') ? 'ORF_R04' : 'ORU_R31';
        const params = [];
        const latest = {};
 
        for (const block of obxBlocks) {
            const p = parseObxBlockLoosely(block);
            if (!p) continue;
            params.push(p);
            latest[p.id] = p;
        }
 
        if (params.length === 0) return false;
 
        console.warn(`⚠️ XML 非严格/损坏,已从片段提取 ${params.length}/${obxBlocks.length} 个 OBX 兜底解析`);
        console.log('--- 📊 收到新数据帧 ---');
        if (meta.device || meta.timestamp) {
            console.log(`  设备: ${meta.device || '--'} | 时间: ${meta.timestamp || '--'}`);
        }
        params.forEach(p => {
            const unitStr = (p.unit && p.unit !== '-' && p.unit !== 'NULL') ? ` ${p.unit}` : '';
            console.log(`  [${p.id}] ${p.name}: ${p.value}${unitStr}`);
        });
 
        if (typeof options.onFrame === 'function') {
            options.onFrame({
                receivedAt,
                messageType,
                device: meta.device || undefined,
                timestamp: meta.timestamp || undefined,
                params,
                latest,
            });
        }
        console.log('------------------------\n');
        return true;
    };
 
    const asArray = (val) => {
        if (!val) return [];
        return Array.isArray(val) ? val : [val];
    };
 
    const collectObxFromObservationGroup = (obsGroup, target) => {
        asArray(obsGroup).forEach(item => {
            if (!item) return;
            const obx = item.OBX;
            if (!obx) return;
            target.push(...asArray(obx));
        });
    };
 
    parser.parseString(xmlStr, (err, result) => {
        if (err) {
            // 兜底:部分设备/抓包数据存在重复/缺失标签,导致不是严格 XML。
            // 若帧内有完整 OBX 段,则仍可提取参数。
            if (!options.__salvaged && salvageObxAndPrint()) return;
 
            console.error('❌ XML 解析失败:', err.message);
            return;
        }
 
        console.log('--- 📊 收到新数据帧 ---');
        
        // 兼容 ORU (自动推送) 和 ORF (查询响应)
        const root = result.ORU_R31 || result.ORF_R04;
        
        if (!root) {
            console.log('⚠️ 无法识别根节点,可能是非标准消息');
            return;
        }
 
        // 提取 OBX 数据段
        // 结构路径通常是: MSH -> OBR -> [OBSERVATION] -> OBX
        // 注意:xml2js 的结构取决于具体 XML 层级,这里做通用提取
        
        let obxItems = [];
 
        // 优先:与你的协议文档示例一致(OBSERVATION 为根节点的直接子节点)
        collectObxFromObservationGroup(root['ORU_R31.OBSERVATION'], obxItems);
        collectObxFromObservationGroup(root['ORF_R04.OBSERVATION'], obxItems);
        
        // 尝试从 OBR 下的 OBSERVATION 组获取
        asArray(root.OBR).forEach(obr => {
            if (!obr) return;
            const obsGroup = obr['ORU_R31.OBSERVATION'] || obr['ORF_R04.OBSERVATION'];
            collectObxFromObservationGroup(obsGroup, obxItems);
        });
        
        // 如果没有找到,尝试直接在根节点下找 (某些简化结构)
        if (obxItems.length === 0 && root.OBX) {
            obxItems = asArray(root.OBX);
        }
 
        if (obxItems.length === 0) {
            console.log('ℹ️ 消息中未找到 OBX 数据段');
            return;
        }
 
        const receivedAt = new Date().toISOString();
        const messageType = result.ORU_R31 ? 'ORU_R31' : 'ORF_R04';
        const msh = root.MSH;
        const meta = {
            device: (msh && msh['MSH.4'] && msh['MSH.4']['HD.1']) ? msh['MSH.4']['HD.1']
                : ((msh && msh['MSH.3'] && msh['MSH.3']['HD.1']) ? msh['MSH.3']['HD.1'] : ''),
            timestamp: (msh && msh['MSH.7'] && msh['MSH.7']['TS.1']) ? msh['MSH.7']['TS.1'] : '',
        };
 
        if (meta.device || meta.timestamp) {
            console.log(`  设备: ${meta.device || '--'} | 时间: ${meta.timestamp || '--'}`);
        }
        const params = [];
        const latest = {};
 
        // 遍历并打印每个参数
        obxItems.forEach(obx => {
            // 1. 获取参数 ID (OBX.3)
            // 结构可能是 CE (CE.1=ID, CE.2=Name) 或直接是字符串
            const idObj = obx['OBX.3'];
            let paramId = null;
            let paramNameRaw = null;
 
            if (idObj && typeof idObj === 'object') {
                paramId = idObj['CE.1'];
                paramNameRaw = idObj['CE.2'];
            } else if (idObj) {
                paramId = idObj;
            }
 
            if (!paramId) return;
 
            // 2. 获取中文名称 (查表 或 使用原始名)
            const displayName = PARAM_MAP[paramId] || paramNameRaw || `未知参数(${paramId})`;
 
            // 3. 获取数值 (OBX.5)
            // 结构可能是 FN (FN.1=Value), ST, 或直接值
            const valObj = obx['OBX.5'];
            let value = '';
            if (valObj && typeof valObj === 'object') {
                value = valObj['FN.1'] || valObj['ST'] || JSON.stringify(valObj);
            } else if (valObj) {
                value = valObj;
            }
 
            // 4. 获取单位 (OBX.6)
            const unitObj = obx['OBX.6'];
            let unit = '';
            if (unitObj && typeof unitObj === 'object') {
                unit = unitObj['CE.1'] || unitObj['CE.2'];
            } else if (unitObj) {
                unit = unitObj;
            }
 
            // 格式化输出
            const unitStr = (unit && unit !== '-' && unit !== 'NULL') ? ` ${unit}` : '';
            console.log(`  [${paramId}] ${displayName}: ${value}${unitStr}`);
 
            const paramObj = {
                id: String(paramId),
                name: displayName,
                rawName: paramNameRaw || undefined,
                value: value,
                unit: unit,
            };
            params.push(paramObj);
            latest[String(paramId)] = paramObj;
        });
 
        if (typeof options.onFrame === 'function') {
            options.onFrame({
                receivedAt,
                messageType,
                device: meta.device || undefined,
                timestamp: meta.timestamp || undefined,
                params,
                latest,
            });
        }
        
        console.log('------------------------\n');
    });
}
 
// 启动
if (require.main === module) {
    if (DECODE_FILE) {
        try {
            decodeFileAndParse(DECODE_FILE);
        } catch (e) {
            console.error(`解码失败: ${e && e.message ? e.message : String(e)}`);
            process.exitCode = 1;
        }
    } else {
        startClient();
    }
}
 
module.exports = {
    parseAndPrintData,
};