From 7885cede659f3255be56f77c1eef2ada7387d6f1 Mon Sep 17 00:00:00 2001
From: chenyc <501753378@qq.com>
Date: 星期日, 22 三月 2026 16:23:21 +0800
Subject: [PATCH] 初始化项目
---
src/protocol.js | 572 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 files changed, 572 insertions(+), 0 deletions(-)
diff --git a/src/protocol.js b/src/protocol.js
new file mode 100644
index 0000000..5b260b2
--- /dev/null
+++ b/src/protocol.js
@@ -0,0 +1,572 @@
+// 透析机协议解析模块
+// 按《山外山血透机远程通讯协议(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;
--
Gitblit v1.8.0