const fs = require('fs'); const path = require('path'); const HEADER_1 = 0x55; const HEADER_2 = 0xAA; const MIN_FRAME_LENGTH = 7; const CMDTYPE_DATA_SYNC = 0x01; const CMDID_REALTIME = 0x00; const CMDID_BLOOD_PRESSURE = 0x01; const REALTIME_PAYLOAD_LENGTH = 46; const BLOOD_PRESSURE_PAYLOAD_LENGTH = 5; const REALTIME_FIELDS = [ { identifier: 'AF', offset: 0, read: readInt16LE, scale: 10 }, { identifier: 'F', offset: 2, read: readInt16LE, scale: 10 }, { identifier: 'A', offset: 4, read: readUInt16LE }, { identifier: 'C', offset: 6, read: readUInt16LE }, { identifier: 'B', offset: 8, read: readUInt16LE }, { identifier: 'K', offset: 10, read: readUInt16LE }, { identifier: 'L', offset: 12, read: readUInt16LE }, { identifier: 'D', offset: 14, read: readUInt16LE }, { identifier: 'H', offset: 16, read: readInt16LE }, { identifier: 'o', offset: 18, read: readInt16LE }, { identifier: 'J', offset: 20, read: readInt16LE }, { identifier: 'U', offset: 22, read: readUInt32LE }, { identifier: 'G', offset: 26, read: readUInt16LE }, { identifier: 'Na', offset: 28, read: readUInt16LE }, { identifier: 'HCO3', offset: 30, read: readUInt16LE }, { identifier: 'O2Sat', offset: 36, read: readUInt16LE, scale: 10 }, { identifier: 'Hct', offset: 38, read: readUInt16LE, scale: 10 }, { identifier: 'Hb', offset: 40, read: readUInt16LE, scale: 10 }, { identifier: 'Tblood', offset: 42, read: readUInt16LE, scale: 10 }, { identifier: 'ktv', offset: 44, read: readUInt16LE, scale: 10 }, ]; function toHex(value) { return `0x${value.toString(16).toUpperCase().padStart(2, '0')}`; } function bytesToHex(buffer) { return Array.from(buffer, toHex).join(' '); } function crc8(bytes) { let crc = 0; for (const value of bytes) { crc ^= value; for (let index = 0; index < 8; index += 1) { if ((crc & 0x01) !== 0) { crc = ((crc >> 1) ^ 0x8C) & 0xFF; } else { crc = (crc >> 1) & 0xFF; } } } return crc & 0xFF; } function readUInt16LE(buffer, offset) { return buffer.readUInt16LE(offset); } function readInt16LE(buffer, offset) { return buffer.readInt16LE(offset); } function readUInt32LE(buffer, offset) { return buffer.readUInt32LE(offset); } function scaled(value, scale) { if (!scale) { return value; } return Number((value / scale).toFixed(1)); } function resolveFilePath(filePath) { if (path.isAbsolute(filePath)) { return filePath; } return path.join(process.cwd(), filePath); } function loadAlModelMap(filePath) { const resolvedPath = resolveFilePath(filePath); const content = fs.readFileSync(resolvedPath, 'utf8'); const model = JSON.parse(content); const map = new Map(); for (const item of model.properties || []) { if (item && item.identifier) { map.set(item.identifier, item.name || item.identifier); } } return map; } class Jh2028Decoder { constructor(options = {}) { this.buffer = Buffer.alloc(0); this.maxBufferBytes = options.maxBufferBytes || 8192; this.alModelMap = loadAlModelMap(options.alModelPath || './alModel.json'); } push(chunk) { if (!Buffer.isBuffer(chunk) || chunk.length === 0) { return []; } this.buffer = Buffer.concat([this.buffer, chunk]); if (this.buffer.length > this.maxBufferBytes) { this.buffer = Buffer.alloc(0); return [{ ok: false, publish: false, reason: 'buffer-overflow' }]; } const results = []; while (this.buffer.length >= 2) { const headerIndex = this.findHeader(); if (headerIndex < 0) { this.buffer = this.buffer.slice(Math.max(0, this.buffer.length - 1)); break; } if (headerIndex > 0) { this.buffer = this.buffer.slice(headerIndex); } if (this.buffer.length < 3) { break; } const frameLength = this.buffer[2]; if (frameLength < MIN_FRAME_LENGTH) { this.buffer = this.buffer.slice(1); continue; } if (this.buffer.length < frameLength) { break; } const frame = this.buffer.slice(0, frameLength); this.buffer = this.buffer.slice(frameLength); results.push(this.parseFrame(frame)); } return results; } findHeader() { for (let index = 0; index <= this.buffer.length - 2; index += 1) { if (this.buffer[index] === HEADER_1 && this.buffer[index + 1] === HEADER_2) { return index; } } return -1; } parseFrame(frame) { const frameLength = frame[2]; const timestamp = frame[3]; const commandType = frame[4]; const commandId = frame[5]; const payload = frame.slice(6, frame.length - 1); const receivedCrc = frame[frame.length - 1]; const expectedCrc = crc8(frame.slice(0, -1)); const rawHex = bytesToHex(frame); if (frameLength !== frame.length) { return this.buildErrorResult('length-invalid', frame, { timestamp, commandType, commandId, rawHex, }); } if (receivedCrc !== expectedCrc) { return this.buildErrorResult('crc-invalid', frame, { timestamp, commandType, commandId, rawHex, receivedCrc, expectedCrc, }); } if (commandType !== CMDTYPE_DATA_SYNC) { return this.buildSkipResult('unsupported-command-type', { timestamp, commandType, commandId, rawHex, }); } if (commandId === CMDID_REALTIME) { return this.parseRealtimeFrame(payload, { timestamp, commandType, commandId, rawHex, }); } if (commandId === CMDID_BLOOD_PRESSURE) { return this.parseBloodPressureFrame(payload, { timestamp, commandType, commandId, rawHex, }); } return this.buildSkipResult('unsupported-command-id', { timestamp, commandType, commandId, rawHex, }); } parseRealtimeFrame(payload, meta) { if (payload.length < REALTIME_PAYLOAD_LENGTH) { return this.buildErrorResult('payload-too-short', payload, meta); } const metric = {}; for (const field of REALTIME_FIELDS) { const rawValue = field.read(payload, field.offset); metric[field.identifier] = scaled(rawValue, field.scale); } return this.buildMetricResult(metric, { ...meta, protocol: 'jh2028-20260511', messageType: 'realtime', }); } parseBloodPressureFrame(payload, meta) { if (payload.length < BLOOD_PRESSURE_PAYLOAD_LENGTH) { return this.buildErrorResult('payload-too-short', payload, meta); } const systolicOrErrorCode = payload[0]; const diastolic = payload[1]; const pulse = payload[2]; const irregularPulse = payload[3]; const meanPressure = payload[4]; const hasError = systolicOrErrorCode > 0 && diastolic === 0 && pulse === 0 && irregularPulse === 0; if (hasError) { return this.buildSkipResult('blood-pressure-error-code', { ...meta, protocol: 'jh2028-20260511', messageType: 'blood-pressure', errorCode: systolicOrErrorCode, }); } return this.buildMetricResult({ N: systolicOrErrorCode, O: diastolic, P: pulse, }, { ...meta, protocol: 'jh2028-20260511', messageType: 'blood-pressure', ignored: { irregularPulse, meanPressure, }, }); } buildMetricResult(metric, meta) { const entries = Object.entries(metric) .filter(([, value]) => value !== undefined && value !== null) .filter(([identifier]) => this.alModelMap.has(identifier)); if (entries.length === 0) { return this.buildSkipResult('identifier-not-in-almodel', meta); } return { ok: true, publish: true, reason: 'ok', metric: Object.fromEntries(entries), identifiers: entries.map(([identifier]) => identifier), ...meta, }; } buildErrorResult(reason, _frame, meta = {}) { return { ok: false, publish: false, reason, ...meta, }; } buildSkipResult(reason, meta = {}) { return { ok: true, publish: false, reason, ...meta, }; } } module.exports = { BLOOD_PRESSURE_PAYLOAD_LENGTH, CMDID_BLOOD_PRESSURE, CMDID_REALTIME, CMDTYPE_DATA_SYNC, HEADER_1, HEADER_2, Jh2028Decoder, MIN_FRAME_LENGTH, REALTIME_FIELDS, REALTIME_PAYLOAD_LENGTH, bytesToHex, crc8, loadAlModelMap, toHex, };