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