const fs = require('fs');
|
const path = require('path');
|
|
const JHM_HEADER_1 = 0xEE;
|
const JHM_HEADER_2 = 0x55;
|
const JHM_FRAME_LENGTH = 8;
|
const BP_HEADER_1 = 0xAA;
|
const BP_HEADER_2 = 0x55;
|
const BP_COMMAND = 0xBA;
|
const MIN_HEADER_LENGTH = 2;
|
|
const COMMAND_RULES = {
|
0x01: { identifier: 'F', decode: (payload) => uint32BE(payload) / 10 },
|
0x02: { identifier: null, decode: (payload) => uint32BE(payload) / 10 },
|
0x03: { identifier: 'A', decode: (payload) => mlToL(uint32BE(payload)) },
|
0x04: { identifier: 'sysj', decode: (payload) => (uint16BE(payload[0], payload[1]) * 60) + uint16BE(payload[2], payload[3]) },
|
0x05: { identifier: 'C', decode: (payload) => mlToL(uint32BE(payload)) },
|
0x06: { identifier: 'B', decode: (payload) => mlToL(uint32BE(payload)) },
|
0x07: { identifier: 'L', decode: (payload) => uint32BE(payload) },
|
0x08: { identifier: 'D', decode: (payload) => uint32BE(payload) },
|
0x09: { identifier: 'H', decode: (payload) => signed24WithFlag(payload[0], payload.slice(1)) },
|
0x0A: { identifier: 'o', decode: (payload) => signed24WithFlag(payload[0], payload.slice(1)) },
|
0x0B: { identifier: 'J', decode: (payload) => signed24WithFlag(payload[0], payload.slice(1)) },
|
0x0C: { identifier: 'U', decode: (payload) => uint32BE(payload) },
|
0x0D: { identifier: 'G', decode: (payload) => uint24BE(payload.slice(1)) / 10 },
|
0x0E: { identifier: 'Na', decode: (payload) => uint32BE(payload) },
|
0x0F: { identifier: 'HCO3', decode: (payload) => uint32BE(payload) },
|
};
|
|
function toHex(value) {
|
return `0x${value.toString(16).toUpperCase().padStart(2, '0')}`;
|
}
|
|
function bytesToHex(buffer) {
|
return Array.from(buffer, toHex).join(' ');
|
}
|
|
function checksum(command, payload) {
|
return (command + payload[0] + payload[1] + payload[2] + payload[3]) & 0xFF;
|
}
|
|
function additiveChecksum(bytes) {
|
let sum = 0;
|
|
for (const value of bytes) {
|
sum = (sum + value) & 0xFF;
|
}
|
|
return sum;
|
}
|
|
function uint32BE(bytes) {
|
return ((bytes[0] * 0x1000000) + (bytes[1] << 16) + (bytes[2] << 8) + bytes[3]) >>> 0;
|
}
|
|
function uint24BE(bytes) {
|
return ((bytes[0] << 16) + (bytes[1] << 8) + bytes[2]) >>> 0;
|
}
|
|
function uint16BE(high, low) {
|
return ((high << 8) | low) >>> 0;
|
}
|
|
function mlToL(valueInMl) {
|
return Number((valueInMl / 1000).toFixed(3));
|
}
|
|
function signed24WithFlag(flag, bytes) {
|
const rawValue = uint24BE(bytes);
|
return flag === 0x01 ? -rawValue : rawValue;
|
}
|
|
function resolveFilePath(filePath) {
|
if (path.isAbsolute(filePath)) {
|
return filePath;
|
}
|
|
return path.join(process.cwd(), filePath);
|
}
|
|
function loadAlModelMap(filePath) {
|
const resolvedPath = resolveFilePath(filePath);
|
const fileContent = fs.readFileSync(resolvedPath, 'utf8');
|
const model = JSON.parse(fileContent);
|
const map = new Map();
|
|
for (const item of model.properties || []) {
|
map.set(item.identifier, item.name);
|
}
|
|
return map;
|
}
|
|
class JhmDecoder {
|
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 >= MIN_HEADER_LENGTH) {
|
const header = this.findHeader();
|
|
if (!header) {
|
this.buffer = this.buffer.slice(Math.max(0, this.buffer.length - 1));
|
break;
|
}
|
|
if (header.index > 0) {
|
this.buffer = this.buffer.slice(header.index);
|
}
|
|
if (header.protocol === 'jhm') {
|
if (this.buffer.length < JHM_FRAME_LENGTH) {
|
break;
|
}
|
|
const frame = this.buffer.slice(0, JHM_FRAME_LENGTH);
|
this.buffer = this.buffer.slice(JHM_FRAME_LENGTH);
|
results.push(this.parseJhmFrame(frame));
|
continue;
|
}
|
|
if (this.buffer.length < 3) {
|
break;
|
}
|
|
const frameLength = this.buffer[2];
|
|
if (frameLength < 6) {
|
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.parseBloodPressureFrame(frame));
|
}
|
|
return results;
|
}
|
|
findHeader() {
|
for (let index = 0; index <= this.buffer.length - 2; index += 1) {
|
if (this.buffer[index] === JHM_HEADER_1 && this.buffer[index + 1] === JHM_HEADER_2) {
|
return { index, protocol: 'jhm' };
|
}
|
|
if (this.buffer[index] === BP_HEADER_1 && this.buffer[index + 1] === BP_HEADER_2) {
|
return { index, protocol: 'blood-pressure' };
|
}
|
}
|
|
return null;
|
}
|
|
buildMetricResult({ command, rawHex, metric, extra = {} }) {
|
const publishedEntries = Object.entries(metric || {})
|
.filter(([, value]) => value !== undefined && value !== null)
|
.filter(([identifier]) => this.alModelMap.has(identifier));
|
|
if (publishedEntries.length === 0) {
|
return {
|
ok: true,
|
publish: false,
|
reason: 'identifier-not-in-almodel',
|
command,
|
commandHex: toHex(command),
|
rawHex,
|
...extra,
|
};
|
}
|
|
const publishedMetric = Object.fromEntries(publishedEntries);
|
const identifiers = publishedEntries.map(([identifier]) => identifier);
|
const result = {
|
ok: true,
|
publish: true,
|
reason: 'ok',
|
command,
|
commandHex: toHex(command),
|
rawHex,
|
metric: publishedMetric,
|
identifiers,
|
...extra,
|
};
|
|
if (identifiers.length === 1) {
|
const [identifier] = identifiers;
|
result.identifier = identifier;
|
result.name = this.alModelMap.get(identifier);
|
result.value = publishedMetric[identifier];
|
}
|
|
return result;
|
}
|
|
parseJhmFrame(frame) {
|
const command = frame[2];
|
const payload = frame.slice(3, 7);
|
const receivedChecksum = frame[7];
|
const expectedChecksum = checksum(command, payload);
|
const rawHex = bytesToHex(frame);
|
|
if (receivedChecksum !== expectedChecksum) {
|
return {
|
ok: false,
|
publish: false,
|
reason: 'checksum-invalid',
|
command,
|
commandHex: toHex(command),
|
rawHex,
|
};
|
}
|
|
const rule = COMMAND_RULES[command];
|
|
if (!rule) {
|
return {
|
ok: true,
|
publish: false,
|
reason: 'unsupported-command',
|
command,
|
commandHex: toHex(command),
|
rawHex,
|
};
|
}
|
|
if (!rule.identifier) {
|
return {
|
ok: true,
|
publish: false,
|
reason: 'identifier-missing',
|
command,
|
commandHex: toHex(command),
|
rawHex,
|
};
|
}
|
|
if (!this.alModelMap.has(rule.identifier)) {
|
return {
|
ok: true,
|
publish: false,
|
reason: 'identifier-not-in-almodel',
|
command,
|
commandHex: toHex(command),
|
rawHex,
|
identifier: rule.identifier,
|
};
|
}
|
|
const value = rule.decode(payload);
|
|
return this.buildMetricResult({
|
command,
|
rawHex,
|
metric: {
|
[rule.identifier]: value,
|
},
|
});
|
}
|
|
parseBloodPressureFrame(frame) {
|
const frameLength = frame[2];
|
const command = frame[3];
|
const payload = frame.slice(4, frame.length - 1);
|
const receivedChecksum = frame[frame.length - 1];
|
const expectedChecksum = additiveChecksum(frame.slice(0, -1));
|
const rawHex = bytesToHex(frame);
|
|
if (frameLength !== frame.length) {
|
return {
|
ok: false,
|
publish: false,
|
reason: 'length-invalid',
|
command,
|
commandHex: toHex(command),
|
rawHex,
|
protocol: 'blood-pressure',
|
};
|
}
|
|
if (receivedChecksum !== expectedChecksum) {
|
return {
|
ok: false,
|
publish: false,
|
reason: 'checksum-invalid',
|
command,
|
commandHex: toHex(command),
|
rawHex,
|
protocol: 'blood-pressure',
|
};
|
}
|
|
if (command !== BP_COMMAND) {
|
return {
|
ok: true,
|
publish: false,
|
reason: 'unsupported-command',
|
command,
|
commandHex: toHex(command),
|
rawHex,
|
protocol: 'blood-pressure',
|
};
|
}
|
|
if (payload.length < 4) {
|
return {
|
ok: false,
|
publish: false,
|
reason: 'payload-too-short',
|
command,
|
commandHex: toHex(command),
|
rawHex,
|
protocol: 'blood-pressure',
|
};
|
}
|
|
const metric = {
|
N: uint16BE(payload[0], payload[1]),
|
O: payload[2],
|
P: payload[3],
|
};
|
return this.buildMetricResult({
|
command,
|
rawHex,
|
metric,
|
extra: {
|
protocol: 'blood-pressure',
|
},
|
});
|
}
|
}
|
|
module.exports = {
|
BP_COMMAND,
|
BP_HEADER_1,
|
BP_HEADER_2,
|
COMMAND_RULES,
|
FRAME_LENGTH: JHM_FRAME_LENGTH,
|
JHM_FRAME_LENGTH,
|
JHM_HEADER_1,
|
JHM_HEADER_2,
|
JhmDecoder,
|
additiveChecksum,
|
bytesToHex,
|
checksum,
|
loadAlModelMap,
|
signed24WithFlag,
|
toHex,
|
uint16BE,
|
uint24BE,
|
uint32BE,
|
};
|