const fs = require('fs'); const path = require('path'); const config = require('./config'); const logger = require('../logger'); let cache = new Map(); let refreshInterval; function resolveTranslatorFilePath(filePath, configFilePath) { if (!filePath || typeof filePath !== 'string') return ''; if (path.isAbsolute(filePath)) return filePath; const candidates = [ path.resolve(process.cwd(), filePath) ]; if (configFilePath) { candidates.push(path.resolve(path.dirname(configFilePath), filePath)); } const matched = candidates.find((candidate) => fs.existsSync(candidate)); return matched || candidates[0]; } function normalizeConnectorType(type) { const value = String(type || '').trim().toLowerCase(); if (value === 'serial' || value === 'astm-serial') return 'astm-serial'; if (value === 'tcp-server' || value === 'hl7-tcp') return 'hl7-tcp'; if (value === 'http-json' || value === 'http') return 'http-json'; if (value === 'tcp-client') return 'tcp-client'; return ''; } function toEntityRows(entities = []) { return entities.map((entity) => { const connector = entity.connector && typeof entity.connector === 'object' ? entity.connector : {}; return { instrument_id: entity.instrument_id, enabled: entity.enabled, connector: connector.type, connectorConfig: connector, config: entity.config, translator: entity.translator }; }); } class InstrumentConfigValidationError extends Error { constructor(errors) { super(`instrument config validation failed with ${errors.length} issue(s)`); this.name = 'InstrumentConfigValidationError'; this.errors = errors; } } function validateAndLoadInstrumentConfigs({ instruments = config.instrumentEntities?.length ? toEntityRows(config.instrumentEntities) : config.instruments, configFilePath = config.configPath } = {}) { const errors = []; const entries = []; const instrumentIds = new Set(); if (configFilePath && !fs.existsSync(configFilePath)) { errors.push(`config file not found: ${configFilePath}`); throw new InstrumentConfigValidationError(errors); } if (!Array.isArray(instruments)) { errors.push('config.instruments: expected an array'); throw new InstrumentConfigValidationError(errors); } if (!instruments.length) { errors.push('config.instruments: array cannot be empty'); } for (let index = 0; index < instruments.length; index += 1) { const item = instruments[index]; const label = `instrument[${index}]`; if (!item || typeof item !== 'object' || Array.isArray(item)) { errors.push(`${label}: must be an object`); continue; } const instrumentId = item.instrument_id; const connector = normalizeConnectorType(item.connector); const translator = item.translator; if (!instrumentId || typeof instrumentId !== 'string') { errors.push(`${label}: instrument_id is required`); continue; } if (instrumentIds.has(instrumentId)) { errors.push(`${label}: duplicate instrument_id "${instrumentId}"`); continue; } instrumentIds.add(instrumentId); if (!connector) { errors.push(`${label}: connector.type is required`); continue; } if (connector === 'tcp-client') { errors.push(`${label}: connector.type tcp-client is not supported yet`); continue; } if (!translator || typeof translator !== 'object' || Array.isArray(translator)) { item.translator = {}; } const resolvedTranslator = item.translator; if (resolvedTranslator.engine && typeof resolvedTranslator.engine !== 'string') { errors.push(`${label}: translator.engine must be a string`); continue; } const translatorEngine = String(resolvedTranslator.engine || 'overrides').trim().toLowerCase(); if (translatorEngine === 'template') { if (!resolvedTranslator.file || typeof resolvedTranslator.file !== 'string') { errors.push(`${label}: translator.file is required when translator.engine=template`); continue; } const resolvedTranslatorFilePath = resolveTranslatorFilePath(resolvedTranslator.file, configFilePath); if (!fs.existsSync(resolvedTranslatorFilePath)) { errors.push(`${label}: translator.file not found: ${resolvedTranslator.file}`); continue; } resolvedTranslator.resolvedFile = resolvedTranslatorFilePath; } const connectorConfig = item.connectorConfig && typeof item.connectorConfig === 'object' ? item.connectorConfig : {}; const match = item.match && typeof item.match === 'object' ? { ...item.match } : {}; if (connector === 'astm-serial') { const comPort = connectorConfig.port || connectorConfig.comPort; if (!comPort || typeof comPort !== 'string') { errors.push(`${label}: connector.port is required for serial`); continue; } match.comPort = comPort; } if (connector === 'hl7-tcp' || connector === 'http-json') { const localPort = connectorConfig.port || connectorConfig.localPort; if (localPort !== undefined && localPort !== null && localPort !== '') { match.localPort = Number(localPort); } } entries.push({ instrument_id: instrumentId, connector, enabled: item.enabled !== false, match, config: item.config || {}, translator: resolvedTranslator, connectorConfig, files: { config: configFilePath, translator: resolvedTranslator.resolvedFile || null } }); } if (errors.length) { throw new InstrumentConfigValidationError(errors); } return entries; } async function reload() { const rows = validateAndLoadInstrumentConfigs(); const next = new Map(); rows.forEach((row) => { try { if (row.error) { throw new Error(row.error); } if (!row.instrument_id || !row.connector) { throw new Error('instrument_id and connector are required'); } next.set(row.instrument_id, { instrument_id: row.instrument_id, connector: row.connector, enabled: Boolean(row.enabled), config: row.config || {}, match: row.match || {}, translator: row.translator || {}, connectorConfig: row.connectorConfig || {}, files: row.files || null }); } catch (err) { logger.warn({ instrument: row.instrument_id, err: err.message }, 'failed parsing instrument config, skipping'); } }); cache = next; } async function init({ refreshMs = 30_000 } = {}) { await reload(); if (refreshInterval) clearInterval(refreshInterval); refreshInterval = setInterval(() => { reload().catch((err) => logger.error({ err: err.message }, 'instrument config reload failed')); }, refreshMs); } function list() { return Array.from(cache.values()); } function get(instrumentId) { return cache.get(instrumentId) || null; } function byConnector(connector) { return list().filter((entry) => entry.connector === connector && entry.enabled); } function addressesEqual(expected, actual) { if (!expected) return true; if (!actual) return false; const normalize = (value) => String(value).replace('::ffff:', ''); return normalize(expected) === normalize(actual); } function portsEqual(expected, actual) { if (expected === undefined || expected === null || expected === '') return true; if (actual === undefined || actual === null || actual === '') return false; return Number(expected) === Number(actual); } function comPortsEqual(expected, actual) { if (expected === undefined || expected === null || expected === '') return true; if (actual === undefined || actual === null || actual === '') return false; return String(expected).trim().toLowerCase() === String(actual).trim().toLowerCase(); } function matches(entry, connector, context = {}) { if (context.instrument_id && context.instrument_id !== entry.instrument_id) return false; if (!entry.enabled || entry.connector !== connector) return false; const rule = entry.match || {}; if (!portsEqual(rule.localPort, context.localPort)) return false; if (!portsEqual(rule.remotePort, context.remotePort)) return false; if (!addressesEqual(rule.remoteAddress, context.remoteAddress)) return false; if (!comPortsEqual(rule.comPort || rule.serialPort, context.comPort || context.serialPort)) return false; return true; } function resolveForMessage(connector, context = {}) { const candidates = byConnector(connector).filter((entry) => matches(entry, connector, context)); if (!candidates.length) { return { status: 'no_match', matches: [] }; } if (candidates.length > 1) { return { status: 'ambiguous', matches: candidates.map((entry) => entry.instrument_id) }; } return { status: 'matched', entry: candidates[0] }; } module.exports = { InstrumentConfigValidationError, validateAndLoadInstrumentConfigs, init, list, get, byConnector, reload, resolveForMessage };