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) { // 兜底:常见“抓包/复制截断”,没拿到 ,但前面可能已包含完整 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('[\s\S]*?<\/OBX>/g) || []; if (hasRoot && obxBlocks.length > 0) { console.log(`⚠️ 未找到完整帧(疑似截断),尝试从片段提取 ${obxBlocks.length} 个 OBX 解析`); const wrapped = `\n\n \n${obxBlocks.join('\n')}\n \n`; 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('[\s\S]*?<\/OBX>/g) || []; if (hasRoot && obxBlocks.length > 0) { console.log(`⚠️ 未找到完整帧(疑似截断),尝试从片段提取 ${obxBlocks.length} 个 OBX 解析`); const wrapped = `\n\n \n${obxBlocks.join('\n')}\n \n`; 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(/</g, '<') .replace(/>/g, '>') .replace(/&/g, '&') .replace(/"/g, '"') .replace(/'/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, [ /[\s\S]*?([^<]*)<\/HD\.1>/i, /\s*([^<\s][^<]*)\s*<\/MSH\.4>/i, ]); const msh3 = pickFirst(text, [ /[\s\S]*?([^<]*)<\/HD\.1>/i, /\s*([^<\s][^<]*)\s*<\/MSH\.3>/i, ]); meta.device = msh4 || msh3; meta.timestamp = pickFirst(text, [ /[\s\S]*?([^<]*)<\/TS\.1>/i, /\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, [ /[\s\S]*?([^<]*)<\/CE\.1>/i, /\s*([^<\s][^<]*)\s*<\/OBX\.3>/i, ]); if (!paramId) return null; const paramNameRaw = pickFirst(obxXml, [ /[\s\S]*?([^<]*)<\/CE\.2>/i, ]) || null; const value = pickFirst(obxXml, [ /[\s\S]*?([^<]*)<\/FN\.1>/i, /[\s\S]*?([^<]*)<\/ST>/i, /\s*([^<\s][^<]*)\s*<\/OBX\.5>/i, ]); const unit = pickFirst(obxXml, [ /[\s\S]*?([^<]*)<\/CE\.1>/i, /[\s\S]*?([^<]*)<\/CE\.2>/i, /\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 兜底解析:直接正则提取 ...,尽可能保留业务数据。 const obxBlocks = xmlStr.match(/[\s\S]*?<\/OBX>/g) || []; if (obxBlocks.length === 0) return false; const meta = extractMetaFromXmlText(xmlStr); const receivedAt = new Date().toISOString(); const messageType = xmlStr.includes(' { 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, };