// 透析机协议解析模块
|
// 按《山外山血透机远程通讯协议(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;
|