tinylink/core/config/instrumentConfig.js

296 lines
9.4 KiB
JavaScript

const fs = require('fs');
const path = require('path');
const config = require('./config');
const logger = require('../util/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 defaultTranslatorFile(instrumentId) {
if (!instrumentId || typeof instrumentId !== 'string') return '';
return path.join('config', `${instrumentId}.map`);
}
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 (typeof translator === 'string') {
const translatorName = translator.trim();
if (!translatorName) {
errors.push(`${label}: translator name cannot be empty`);
continue;
}
item.translator = {
engine: 'template',
file: defaultTranslatorFile(translatorName)
};
} else 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') {
const configuredFile = typeof resolvedTranslator.file === 'string' && resolvedTranslator.file.trim()
? resolvedTranslator.file.trim()
: defaultTranslatorFile(instrumentId);
if (!configuredFile) {
errors.push(`${label}: translator.file could not be resolved`);
continue;
}
const resolvedTranslatorFilePath = resolveTranslatorFilePath(configuredFile, configFilePath);
if (!fs.existsSync(resolvedTranslatorFilePath)) {
errors.push(`${label}: translator.file not found: ${configuredFile}`);
continue;
}
resolvedTranslator.file = configuredFile;
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
};