// 透析机协议解析模块 // 按《山外山血透机远程通讯协议(V1.11)》实现: // - 帧头:0x55555555(4 字节) // - 公共头:机器类型(1) + 机器编号(5) + 运行模式(1) + 数据帧类型(1) + 协议版本(1) + 保留(7) // - 数据区长度:运行参数帧(0x1F)≈220B,报警帧(0x26)≈150B,血压帧(0x29)≈150B const logger = require("./logger"); class ProtocolParser { constructor() { this.headerBytes = Buffer.from([0x55, 0x55, 0x55, 0x55]); // 公共头总长度:4(帧头) + 1 + 5 + 1 + 1 + 1 + 7 = 20 this.baseHeaderLength = 20; } /** * 根据帧类型返回数据区长度 * @param {number} frameType * @returns {number|null} */ getDataLength(frameType) { switch (frameType) { case 0x1f: // 运行参数帧 // 文档标注“数据长度 220 字节”,结合实际抓包:整帧长度就是 220, // 即 20 字节公共头 + 200 字节数据区,这里返回的数据区长度应为 200。 return 200; case 0x26: // 报警信息帧 // 文档标注“数据长度 150 字节”,实际抓包显示 **整帧长度就是 150**, // 即 20 字节公共头 + 130 字节数据区,这里返回的数据区长度应为 130。 return 130; case 0x29: // 血压测量数据帧 // 同报警帧,整帧 150 字节 => 数据区 130 字节。 return 130; default: return null; } } /** * 从设备 buffer 中尽可能提取完整报文 * @param {Buffer} buffer 当前累计 buffer * @returns {{ frames: Buffer[], remaining: Buffer }} */ extractFrames(buffer) { const frames = []; let offset = 0; while (buffer.length - offset >= this.baseHeaderLength) { const start = buffer.indexOf(this.headerBytes, offset); if (start === -1) { // 没找到帧头:仅保留最后 3 字节,兼容下一次 data 事件和帧头跨包拼接 const keepTail = this.headerBytes.length - 1; const remaining = buffer.length > keepTail ? buffer.slice(buffer.length - keepTail) : buffer; return { frames, remaining }; } // 头部不完整,等待更多数据 if (buffer.length - start < this.baseHeaderLength) { break; } const frameType = buffer[start + 11]; // 帧头后第 11 字节为数据帧类型 const dataLen = this.getDataLength(frameType); if (!dataLen) { logger.warn("Unknown frame type, skip header", { frameType }); // 跳过这 4 字节帧头,继续往后找,避免卡死 offset = start + 4; continue; } const totalLen = this.baseHeaderLength + dataLen; if (buffer.length - start < totalLen) { // 不够一整帧,等待下次数据 break; } const frame = buffer.slice(start, start + totalLen); frames.push(frame); offset = start + totalLen; } const remaining = buffer.slice(offset); return { frames, remaining }; } /** * 工具:按字节长度读取无符号整数(运行参数帧使用小端) * @param {Buffer} buf * @param {number} offset * @param {number} length 1/2/4 */ readUInt(buf, offset, length) { if (offset + length > buf.length) return null; if (length === 1) return buf.readUInt8(offset); if (length === 2) return buf.readUInt16LE(offset); if (length === 4) return buf.readUInt32LE(offset); return null; } mapMachineType(type) { switch (type) { case 0x01: return "SWS-4000"; case 0x02: return "SWS-4000A"; case 0x31: return "SWS-6000"; case 0x32: return "SWS-6000A"; default: return "unknown"; } } mapRunMode(mode) { switch (mode) { case 0x00: return "待机"; case 0x01: return "透析"; case 0x02: return "滤过"; case 0x03: return "透析滤过"; case 0x04: return "序贯-透析"; case 0x05: return "单纯超滤"; case 0x06: return "序贯-单超"; case 0x07: return "预充"; case 0x08: return "清洗"; case 0x09: return "清洗消毒"; case 0x14: return "透析结束"; case 0x15: return "清洗结束"; case 0x16: return "透析滤过结束"; case 0x17: return "单纯超滤结束"; default: return `MODE_${mode}`; } } buildTimeString(parts) { const [year, month, day, hour, minute, second] = parts; if (!year) return null; const y = year.toString().padStart(4, "0"); const m = String(month || 0).padStart(2, "0"); const d = String(day || 0).padStart(2, "0"); const h = String(hour || 0).padStart(2, "0"); const mi = String(minute || 0).padStart(2, "0"); const s = String(second || 0).padStart(2, "0"); return `${y}-${m}-${d} ${h}:${mi}:${s}`; } /** * 解析运行参数帧(0x1F),返回与 schema.json 对齐的字段对象 * * 说明: * - 下表中的“偏移 / 长度”来自《运行参数帧》数据区定义; * - “协议含义”是 PDF 表格中的中文说明; * - "对应标识符" 是你 `schema.json` 里的 `identifier`,便于后续属性映射。 * * 运行参数区字段对照(节选): * * | 偏移 | 长度 | 协议含义 | 对应标识符 | * |------|------|--------------------------------|-------------------| * | 0 | 4 | 设置治疗时间 (s) | SetTreatmentTime | * | 4 | 4 | 已治疗时间 (s) | K | * | 8 | 2 | 血泵流量 (ml/min) | D | * | 10 | 1 | 血泵运行标志 0/1 停止/运行 | xlyxbj | * | 11 | 1 | 抗凝方式 0/1 无抗凝/肝素抗凝 | klfs | * | 12 | 1 | 肝素泵运行标志 0/1 停止/运行 | z | * | 13 | 2 | 肝素泵流量 (ml/h×10) | E(还原后保存) | * | 15 | 2 | 肝素提前结束时间 (min) | gstqjssj | * | 17 | 4 | 超滤总量 (ml) | A | * | 21 | 4 | 已超滤量 (ml) | B | * | 25 | 4 | 超滤率 (ml/h) | C | * | 29 | 1 | 超滤泵运行标志 | cllyxbj | * | 30 | 1 | 旁路标志 0/1 关/开 | plbj | * | 31 | 2 | 透析液流量 (ml/min) | L | * | 33 | 2 | 透析液实际温度 (℃×10) | F(还原后保存) | * | 35 | 2 | 透析液电导值 (mS/cm×100) | G(还原后保存) | * | 37 | 4 | 补液总量 (ml) | pyzl | * | 41 | 4 | 已补入置换液量 (ml) | ypyzhyl | * | 45 | 1 | 补液补入模式 0/1 前/后稀释 | pyprfs | * | 46 | 2 | 内毒素滤器1使用时间 (h) | ldslq | * | 48 | 2 | 内毒素滤器2使用时间 (h) | ldslq2sysj | * | 50 | 4 | 机器总运行时间 (min) | jqzyxsj | * | 54 | 2 | 动脉压 (mmHg) | o | * | 56 | 2 | 静脉压 (mmHg) | H | * | 58 | 2 | 跨膜压 (mmHg) | J | * | 60 | 2 | 透析液压 (kPa×10) | I(还原后保存) | * | 62 | 1 | 尿素下降率 (%) | lsxjl | * | 64 | 2 | 实时清除率值 (×100) | ssqclz(还原) | * | 66 | 2 | 静脉血温 (℃×10) | jmyxh(还原) | * | 68 | 2 | 动脉血温 (℃×10) | dmyxw(还原) | * | 69 | 1 | 相对血容量 (%) | xdxrl | */ parseRunParams(data) { const r = (off, len) => this.readUInt(data, off, len); const rInt16 = (off) => off + 2 <= data.length ? data.readInt16LE(off) : null; // 带符号 16 位,小端 const result = {}; // 以下偏移和长度依据协议截图整理,必要时可结合抓包微调 // 偏移 0,长度 4 字节 // 协议字段:设置治疗时间(s),这里转换为“分钟”保存 // schema 标识符:SetTreatmentTime(按分钟上报) const SetTreatmentTimeSec = r(0, 4); if (SetTreatmentTimeSec != null) { // 结果四舍五入到整数分钟 result.SetTreatmentTime = Math.round(SetTreatmentTimeSec / 60); } // 偏移 4,4 字节 // 协议字段:已治疗时间(s),这里同样按“分钟”保存 // schema 标识符:K(按分钟上报) const Ksec = r(4, 4); // 已透析时间(s) if (Ksec != null) { result.K = Math.round(Ksec / 60); } // 偏移 8,2 字节 // 协议字段:血泵流量(ml/min) // schema 标识符:D const D = r(8, 2); // 血泵流量 ml/min if (D != null) result.D = D; // 偏移 10,1 字节 // 协议字段:血泵运行标志 0/1-停止/运行 // schema 标识符:xlyxbj const xlyxbj = r(10, 1); if (xlyxbj != null) result.xlyxbj = xlyxbj; // 偏移 11,1 字节 // 协议字段:抗凝方式 0/1-无抗凝/肝素抗凝 // schema 标识符:klfs const klfs = r(11, 1); if (klfs != null) result.klfs = klfs; // 偏移 12,1 字节 // 协议字段:肝素泵运行标志 0/1-停止/运行 // schema 标识符:z const z = r(12, 1); if (z != null) result.z = z; // 偏移 13,2 字节 // 协议字段:肝素泵流量(ml/h),数值放大 10 倍 // schema 标识符:E(在物模型中为 int,但这里解析时就做缩放) const Eraw = r(13, 2); // 肝素泵流量 ml/h,放大 10 倍 if (Eraw != null) result.E = Eraw / 10; // 偏移 15,2 字节 // 协议字段:肝素提前结束时间(min) // schema 标识符:gstqjssj const gstqjssj = r(15, 2); if (gstqjssj != null) result.gstqjssj = gstqjssj; // 偏移 17,4 字节 // 协议字段:超滤总量(ml),这里按 L 保存,保留 3 位小数 // schema 标识符:A const Araw = r(17, 4); // 超滤总量 ml if (Araw != null) { result.A = (Araw / 1000).toFixed(3); // L } // 偏移 21,4 字节 // 协议字段:已超滤量(ml),这里按 L 保存,保留 3 位小数 // schema 标识符:B const Braw = r(21, 4); // 已超滤量 ml if (Braw != null) { result.B = (Braw / 1000).toFixed(3); // L } // 偏移 29,1 字节 // 协议字段:超滤泵运行标志 0/1-停止/运行 // schema 标识符:cllyxbj const cllyxbj = r(29, 1); if (cllyxbj != null) result.cllyxbj = cllyxbj; // 偏移 30,1 字节 // 协议字段:旁路标志 0/1-关闭/打开 // schema 标识符:plbj const plbj = r(30, 1); if (plbj != null) result.plbj = plbj; // 偏移 31,2 字节 // 协议字段:透析液流量(ml/min) // schema 标识符:L const L = r(31, 2); // 透析液流量 ml/min if (L != null) result.L = L; // 偏移 33,2 字节 // 协议字段:透析液实际温度(℃),数值放大 10 倍 // schema 标识符:F const Fraw = r(33, 2); // 透析液温度,放大 10 倍 if (Fraw != null) result.F = Fraw / 10; // 偏移 35,2 字节 // 协议字段:透析液电导值(mS/cm),数值放大 100 倍 // schema 标识符:G const Graw = r(35, 2); // 透析液电导值,放大 100 倍 if (Graw != null) result.G = Graw / 100; // 偏移 25,4 字节 // 协议字段:超滤率(ml/h),这里按 L/h 保存,保留 3 位小数 // schema 标识符:C const Craw = r(25, 4); if (Craw != null) { result.C = (Craw / 1000).toFixed(3); // L/h } // 偏移 37,4 字节 // 协议字段:补液总量(ml) // schema 标识符:pyzl const pyzl = r(37, 4); if (pyzl != null) result.pyzl = pyzl; // 偏移 41,4 字节 // 协议字段:已补入置换液量(ml) // schema 标识符:ypyzhyl const ypyzhyl = r(41, 4); if (ypyzhyl != null) result.ypyzhyl = ypyzhyl; // 偏移 45,1 字节 // 协议字段:补液补入模式 0/1-前稀释/后稀释 // schema 标识符:pyprfs const pyprfs = r(45, 1); if (pyprfs != null) result.pyprfs = pyprfs; // 偏移 46,2 字节 // 协议字段:内毒素滤器1使用时间(h) // schema 标识符:ldslq const ldslq = r(46, 2); if (ldslq != null) result.ldslq = ldslq; // 偏移 48,2 字节 // 协议字段:内毒素滤器2使用时间(h) // schema 标识符:ldslq2sysj const ldslq2sysj = r(48, 2); if (ldslq2sysj != null) result.ldslq2sysj = ldslq2sysj; // 偏移 50,4 字节 // 协议字段:机器总运行时间(min) // schema 标识符:jqzyxsj const jqzyxsj = r(50, 4); if (jqzyxsj != null) result.jqzyxsj = jqzyxsj; // 偏移 54,2 字节 // 协议字段:动脉压(mmHg),为带符号数,负值表示负压 // schema 标识符:o const o = rInt16(54); // 动脉压 if (o != null) result.o = o; // 偏移 56,2 字节 // 协议字段:静脉压(mmHg),一般为正值,这里同样按带符号解析以兼容异常情况 // schema 标识符:H const H = rInt16(56); // 静脉压 if (H != null) result.H = H; // 偏移 58,2 字节 // 协议字段:跨膜压(mmHg) // schema 标识符:J const J = rInt16(58); // 跨膜压 if (J != null) result.J = J; // 偏移 60,2 字节 // 协议字段:透析液压(kPa),数值放大 10 倍,可为负值 // schema 标识符:I const Iraw = rInt16(60); // 透析液压 kPa,放大 10 倍 if (Iraw != null) result.I = Iraw / 10; // 偏移 62,1 字节 // 协议字段:尿素下降率(%) // schema 标识符:lsxjl const lsxjl = r(62, 1); // 尿素下降率 % if (lsxjl != null) result.lsxjl = lsxjl; // 偏移 64,2 字节 // 协议字段:实时清除率值,数值放大 100 倍 // schema 标识符:ssqclz const ssqclzRaw = r(64, 2); // 实时清除率,放大 100 倍 if (ssqclzRaw != null) result.ssqclz = ssqclzRaw / 100; // 偏移 66,2 字节 // 协议字段:静脉血温(℃),数值放大 10 倍 // schema 标识符:jmyxh const jmyxhRaw = r(66, 2); // 静脉血温,放大 10 倍 if (jmyxhRaw != null) result.jmyxh = jmyxhRaw / 10; // 偏移 68,2 字节 // 协议字段:动脉血温(℃),数值放大 10 倍 // schema 标识符:dmyxw const dmyxwRaw = r(68, 2); // 动脉血温,放大 10 倍 if (dmyxwRaw != null) result.dmyxw = dmyxwRaw / 10; // 偏移 69,1 字节 // 协议字段:相对血容量(%) // schema 标识符:xdxrl const xdxrl = r(69, 1); // 相对血容量 % if (xdxrl != null) result.xdxrl = xdxrl; return result; } /** * 解析报警帧(0x26) * * 数据区字段对照: * - 偏移 0,长度 2:报警编号(小端)→ 这里记为 alarmCode * - 偏移 2,长度 1:报警类型 → 0/1=消除报警/产生报警,对应 schema 中的 `bjlx` * - 偏移 3~?: 年(2 字节小端)、月、日、时、分、秒 → 组合为 `bjsj`(报警时间,字符串格式 YYYY-MM-DD HH:MM:SS) */ parseAlarm(data) { // 报警帧里的 16 位数值(报警编号、年份)使用小端序, // 与运行参数帧的大端序不同,这里单独处理。 const r8 = (off) => (off < data.length ? data.readUInt8(off) : null); const r16le = (off) => off + 2 <= data.length ? data.readUInt16LE(off) : null; const alarmCode = r16le(0); const alarmType = r8(2); // 0=解除,1=产生 const year = r16le(3); const month = r8(5); const day = r8(6); const hour = r8(7); const minute = r8(8); const second = r8(9); const alarmTime = this.buildTimeString([year, month, day, hour, minute, second]); return { alarmCode, bjlx: alarmType, bjsj: alarmTime }; } /** * 解析血压测量帧(0x29) * * 数据区字段对照(结合血压数据表与 schema): * - 偏移 0,1 字节:测量模式 0/1=手动/自动 → 这里记为 `bpMode` * - 偏移 1,1 字节:测量结果 0/1=测量出错/测量成功 → `bpResult` * - 偏移 2 起:年/月/日/时/分/秒 → 组装为测量时间,挂到运行参数表字段 `M`(BPM监测时间) * - 偏移 13,2 字节:收缩压(mmHg) → schema 标识符 `N` * - 偏移 15,2 字节:舒张压(mmHg) → schema 标识符 `O` * - 偏移 17,2 字节:脉搏/心率 → schema 标识符 `P` * - 偏移 19,2 字节:平均动脉压 → schema 标识符 `BPMPJDMY` */ parseBloodPressure(data) { // 血压帧的数据区里,多字节数值使用小端序(低字节在前), // 与运行参数帧/报警帧的大端序不同,这里单独按小端解析。 const r8 = (off) => (off < data.length ? data.readUInt8(off) : null); const r16le = (off) => off + 2 <= data.length ? data.readUInt16LE(off) : null; const bpMode = r8(0); // 测量模式:0/1 手动/自动 const bpResult = r8(1); // 结果:0/1 出错/成功 // 年:2 字节小端;月/日/时/分/秒:1 字节 const year = r16le(2); const month = r8(4); const day = r8(5); const hour = r8(6); const minute = r8(7); const second = r8(8); const bpTime = this.buildTimeString([year, month, day, hour, minute, second]); // 根据抓包数据校正: // - 偏移 9,2 字节小端:收缩压 N(例如 0x81 0x00 => 129) // - 偏移 11,2 字节小端:舒张压 O(例如 0x5D 0x00 => 93) // - 偏移 13,2 字节小端:脉搏 P (例如 0x56 0x00 => 86) // - 偏移 15,2 字节小端:平均动脉压 BPMPJDMY(例如 0x69 0x00 => 105) const systolic = r16le(9); // 收缩压 N const diastolic = r16le(11); // 舒张压 O const heartRate = r16le(13); // 脉搏 P const map = r16le(15); // 平均动脉压 BPMPJDMY return { bpMode, bpResult, M: bpTime, N: systolic, O: diastolic, P: heartRate, BPMPJDMY: map }; } /** * 将一帧报文解析为结构化对象。 * @param {Buffer} frame 完整帧(包含公共头 + 数据区) * @param {string} ip 设备 IP * @returns {{ deviceNumber: string, data: object }} */ parseFrame(frame, ip) { const now = new Date(); const suedtime = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String( now.getDate() ).padStart(2, "0")} ${String(now.getHours()).padStart(2, "0")}:${String( now.getMinutes() ).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`; const machineTypeByte = frame[4]; const machineNumberBuf = frame.slice(5, 10); // 5 字节机器号 // 按协议:机器编号为 5 字节无符号整数,小端在前。 // 例如字节 E1 73 CB 72 01 => 十进制 6220903393。 let deviceNumber = ""; if (machineNumberBuf.length === 5) { let id = 0; for (let i = 0; i < 5; i++) { id += machineNumberBuf[i] * Math.pow(256, i); // 小端累加 } deviceNumber = String(id); } if (!deviceNumber) { // 回退为十六进制字符串,避免完全丢失标识 deviceNumber = Array.from(machineNumberBuf) .map((b) => b.toString(16).padStart(2, "0")) .join(""); } const runModeByte = frame[10]; const frameType = frame[11]; const protocolVersion = frame[12]; const dataBuf = frame.slice(this.baseHeaderLength); const baseData = { suedtime, deviceType: this.mapMachineType(machineTypeByte), IPAddress: ip, n: deviceNumber, jqyxms: this.mapRunMode(runModeByte), jqyxmsRaw: runModeByte, frameType, protocolVersion }; if (frameType === 0x1f) { Object.assign(baseData, this.parseRunParams(dataBuf)); } else if (frameType === 0x26) { Object.assign(baseData, this.parseAlarm(dataBuf)); } else if (frameType === 0x29) { Object.assign(baseData, this.parseBloodPressure(dataBuf)); } else { logger.warn("Unhandled frame type", { frameType }); } const finalDeviceNumber = deviceNumber || ip || "unknown"; logger.debug("Parsed frame", { ip, deviceNumber: finalDeviceNumber, frameType }); return { deviceNumber: finalDeviceNumber, data: baseData }; } } module.exports = ProtocolParser;