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,
|
};
|