UNPKG

@pmouli/isy-matter-server

Version:

Service to expose an ISY device as a Matter Border router

489 lines 20 kB
#!/usr/bin/env node import { stat, unlink } from 'fs'; import path from 'path'; import { Command } from 'commander'; import { config } from 'dotenv'; import { expand } from 'dotenv-expand'; import { chmod } from 'fs/promises'; import { findPackageJson, logStringify, writeDebugFile, writeFile } from 'isy-nodejs/Utils'; import { createServer } from 'net'; import { exit } from 'process'; import { promisify } from 'util'; import winston from 'winston'; import { authenticate } from './authenticate.js'; import './utils.js'; var ServerState; (function (ServerState) { ServerState[ServerState["Stopped"] = 0] = "Stopped"; ServerState[ServerState["Starting"] = 1] = "Starting"; ServerState[ServerState["Running"] = 2] = "Running"; ServerState[ServerState["Stopping"] = 3] = "Stopping"; })(ServerState || (ServerState = {})); let bridgeServerState = ServerState.Stopped; let interfaceState = ServerState.Stopped; const format = winston.format; const myFormat = format.combine(format.splat(), winston.format.printf((info) => { const d = new Date(); const dStr = d.getFullYear() + '-' + zPad2(d.getMonth() + 1) + '-' + zPad2(d.getDate()) + ' ' + zPad2(d.getHours()) + ':' + zPad2(d.getMinutes()) + ':' + zPad2(d.getSeconds()); return `${dStr} ${info.level}: ${info.label}: ${info.message}`; }), format.colorize({ all: true })); function zPad2(str) { return str.toString().padStart(2, '0'); } function loadConfigs() { let isyConfig = { host: process.env.ISY_HOST_URL ?? 'eisy.local', password: process.env.ISY_PASSWORD, port: process.env.ISY_HOST_PORT ?? 8080, protocol: process.env.ISY_HOST_PROTOCOL ?? 'http', username: process.env.ISY_USERNAME ?? 'admin' }; let matterConfig = { passcode: Number(process.env.MATTER_PASSCODE), discriminator: Number(process.env.MATTER_DISCRIMINATOR), port: process.env.MATTER_PORT, productId: Number.parseInt(process.env.MATTER_PRODUCTID), vendorId: Number.parseInt(process.env.MATTER_VENDORID), logLevels: { 'iox-matter': process.env.IOX_MATTER_LOG_LEVEL ?? process.env.MATTER_LOG_LEVEL ?? process.env.LOG_LEVEL ?? 'debug', 'matter.js': process.env.MATTER_JS_LOG_LEVEL ?? process.env.MATTER_LOG_LEVEL ?? process.env.LOG_LEVEL ?? 'info', isy: process.env.ISY_LOG_LEVEL ?? process.env.MATTER_LOG_LEVEL ?? process.env.LOG_LEVEL ?? 'debug' }, excludeDevicesByDefault: options.devices.length > 0 ? true : false, deviceConfig: options.filter && options.filter != '' ? [ { applyTo: { predicate: (p) => /slave/.test(p.label.toLowerCase()) || new RegExp(options.filter, 'i').test(p.label) }, options: { exclude: true } } ] : [ { applyTo: { predicate: (p) => /slave/.test(p.label.toLowerCase()) }, options: { exclude: true } } ] }; let serverConfig = { logLevel: process.env.LOG_LEVEL ?? `debug`, logPath: process.env.LOG_PATH ?? process.cwd() + '/matter_server.log', workingDir: process.env.WORKING_DIR ?? process.cwd() }; writeDebugFile(logStringify({ isyConfig, matterConfig, serverConfig }), 'initialConfigs.json', logger, serverConfig.workingDir); return { isyConfig, matterConfig, serverConfig }; } let apiInfo = {}; let isyConfig; let matterConfig; let serverConfig; let isy; let serverNode; let pluginEnv; let options = { autoStart: false, dependencies: 'static', env: '.env', requireAuth: true, openSocket: false, reset: false, devices: [], filter: '' }; let authenticated = false; let logger; const tagFormat = format((info) => { info.type = 'log'; return info; })(); const matterServiceSockPath = '/tmp/ns2matter'; let socketServer; let matterServer; let clientLogTransport; let client; function createLogger() { return winston.loggers.add('server', { format: winston.format.label({ label: 'server' }), transports: [new winston.transports.Console({ level: 'info', format: myFormat }), new winston.transports.File({ filename: serverConfig.logPath, level: 'debug', format: myFormat, zippedArchive: true, maxFiles: 5, maxsize: 1000000 })], exitOnError: false, levels: winston.config.cli.levels, level: 'debug' }); } async function startSocketServer() { socketServer = createServer(async (socket) => { //socket.write('Echo server\r\n'); //let loggerStream = pipeline(addLogHeaderStream,socket); clientLogTransport = new winston.transports.Stream({ stream: socket, level: 'info', format: format.combine(tagFormat, format.json()) }); logger.add(clientLogTransport); if (client) { logger.error('Previous client already connected. Disconnecting previous client.'); await new Promise((resolve) => { client.end(() => { client.destroy(); resolve(); }); }); } logger.info('Client connected'); client = socket; client .on('data', async (data) => { logger.debug(`Received: ${data.toString()}`); let s = data.toString(); let messages = parseConcatenatedJSON(s); for (const message of messages) { await processMessage(message); } return; }) .on('end', () => { logger.remove(clientLogTransport); clientLogTransport = null; logger.info('Client disconnected'); client = null; authenticated = false; }); }); socketServer.on('error', (err) => { logger.error('Socket server error: ' + err); exit(1); }); try { await promisify(stat)(matterServiceSockPath); logger.info('Removing leftover socket.'); try { await promisify(unlink)(matterServiceSockPath); } catch (e) { logger.error(`Error unlinking socket. ${e.message}`); process.exit(0); } } finally { return new Promise((resolve, reject) => { socketServer.listen(matterServiceSockPath, async () => { try { logger.info('Socket bound.'); logger.info('Setting socket permissions.'); await chmod(matterServiceSockPath, 0o777); //TODO: Set to group only resolve(socketServer); } catch (e) { reject(e); } }); }); } } /*stat(matterServiceSockPath, function (err, stats) { if (err) { // start server console.log('No leftover socket found.'); server.listen(matterServiceSockPath, () => { console.log('Server bound'); }); } else { // remove file then start server console.log('Removing leftover socket.'); unlink(matterServiceSockPath, function (err) { if (err) { // This should never happen. console.error(err); process.exit(0); } server.listen(matterServiceSockPath, () => { console.log('Server bound'); }); }); } });*/ function parseConcatenatedJSON(json) { try { let s = '[' + json.trim().replace(/}\s*{/g, '},{') + ']'; return JSON.parse(s); //NOTE: will not handle brackets in quoted strings } catch { console.error('Error parsing concatenated JSON: ' + json); return null; } } let overrides = {}; async function updateConfigs(config) { if (config.isyConfig) isyConfig = Object.assign(isyConfig, config.isyConfig); if (config.matterConfig) matterConfig = Object.assign(matterConfig, config.matterConfig); if (config.serverConfig) serverConfig = Object.assign(serverConfig, config.serverConfig); await writeDebugFile(logStringify({ isyConfig, matterConfig, serverConfig }), 'effectiveConfigs.json', logger, serverConfig.workingDir); await writeFile(logStringify(config), 'overrides.json', serverConfig.workingDir); } async function processMessage(msg) { try { if (msg.type !== 'auth' && !authenticated) { logger.warn('Not authenticated. Ignoring message: ' + logStringify(msg)); return; } //console.log(data.toString()); switch (msg.type) { case 'auth': console.log('Authenticating: ' + logStringify(msg)); authenticated = await authenticate(msg.credential); if (authenticated) logger.info('Client successfully authenticated.'); else logger.warn('Authentication failed. Terminating client connection.'); await promisify(client?.end)(); break; case 'isyConfig': delete msg.type; isyConfig = Object.assign(isyConfig, msg); console.log('ISY api config update: ' + logStringify(isyConfig)); await writeDebugFile(logStringify(isyConfig), 'isyConfig.json', logger, serverConfig.workingDir); break; case 'matterConfig': delete msg.type; matterConfig = Object.assign(matterConfig, msg); console.log('Matter bridge config update: ' + logStringify(matterConfig)); logger.info('Matter bridge config updated. Restarting bridge server'); await stopBridgeServer(); await startBridgeServer(); logger.info('Matter bridge server restarted'); await writeDebugFile(logStringify(matterConfig), 'matterConfig.json', logger, serverConfig.workingDir); break; case 'serverConfig': delete msg.type; serverConfig = Object.assign(serverConfig, msg); logger.transports[2].level = serverConfig.logLevel; //logger.transports[1].filename = serverConfig.logPath; console.log('Server config update: ' + logStringify(serverConfig)); await writeDebugFile(logStringify(serverConfig), 'serverConfig.json', logger, serverConfig.workingDir); break; case 'clientEnv': pluginEnv = msg.env; console.log('Plugin environment variables: ' + logStringify(pluginEnv)); break; case 'command': switch (msg.command) { case 'start': logger.info('Matter bridge start requested'); await startBridgeServer(); client.write(JSON.stringify({ pairingInfo: matterServer.getPairingCode() })); break; case 'stop': logger.info('Matter bridge stop requested'); await stopBridgeServer(); break; case 'requestPairingCode': client.write(JSON.stringify({ pairingInfo: matterServer.getPairingCode() })); break; } break; } } catch (e) { logger.error(`Error processing msg: ${msg}: ${e.message}`); } } async function startBridgeServer() { if (bridgeServerState === ServerState.Starting || bridgeServerState === ServerState.Running) { logger.warn('Bridge server already starting or running'); return; } if (!isyConfig || !matterConfig) { logger.error('Missing configuration'); return; } if (!authenticated) { logger.error('Not authenticated'); return; } if (isy || serverNode) { logger.warn('Already started'); return; } bridgeServerState = ServerState.Starting; try { logger.info('Connecting to IoX'); isy = await loadISYInterface(); //await isy.initialize(); logger.info('Connected to IoX'); serverNode = await loadBridgeServer(); bridgeServerState = ServerState.Running; logger.info('Matter bridge online'); logger.info('*'.repeat(80)); logger.info(`IoX firmware version: ${isy.firmwareVersion}`); logger.info(`IoX model: ${isy.productName}`); logger.info(`IoX model number: ${isy.productId}`); logger.info(`IoX api version: ${apiInfo.isy_nodejs.version}`); logger.info(`Matter api version: ${apiInfo.matter_js.version}`); logger.info('*'.repeat(80)); } catch (e) { bridgeServerState = ServerState.Stopped; if (e instanceof Error) { logger.error(`Error starting bridge: ${e.message}`, e); } else logger.error(`Error starting bridge: ${e}`); try { isy[Symbol.dispose](); isy = undefined; } finally { try { serverNode.close(); serverNode[Symbol.asyncDispose](); serverNode = undefined; } catch { } } } } async function loadISYInterface() { let modulePath = 'isy-nodejs/ISY'; if (options.dependencies === 'plugin') { logger.info('Locating ISY api from plugin dependencies'); if (!pluginEnv) { logger.error('Plugin environment not set'); } else { modulePath = path.resolve('isy-nodejs', 'node_modules', pluginEnv.PLUGIN_PATH); logger.info('IoX api located: ' + modulePath); } } logger.info('Loading IoX api from ' + modulePath); let ISYNS = (await import(modulePath)).ISY; let pj = await findPackageJson('isy-nodejs'); let apiMeta = { name: pj.name, version: pj.version, path: pj.path.toString() }; logger.info(`IoX api loaded`); logApiInfo(apiMeta); apiInfo.isy_nodejs = apiMeta; return ISYNS.create(isyConfig, logger, serverConfig.workingDir); } async function logApiInfo(apiMeta) { logger.info(`module: ${apiMeta.name}, version: ${apiMeta.version}, location: ${apiMeta.path}`); } async function loadBridgeServer() { if (!matterServer) { logger.info('Loading ISY to matter bridge api'); matterServer = await import('isy-matter/Bridge/Server'); let pj = await findPackageJson('isy-matter'); apiInfo.isy_matter = { name: pj.name, version: pj.version, path: pj.path.toString() }; let pj2 = await findPackageJson('@matter/main'); apiInfo.matter_js = { name: pj2.name, version: pj2.version, path: pj2.path.toString() }; logger.info(`ISY to matter bridge & matter.js api loaded`); logApiInfo(apiInfo.isy_matter); logApiInfo(apiInfo.matter_js); } logger.info('Starting matter bridge server'); return matterServer.create(isy, matterConfig); } async function stopBridgeServer() { const startState = bridgeServerState; try { if (!isy || !serverNode || startState === ServerState.Stopped) { logger.warn('Matter bridge not started'); return; } if (startState === ServerState.Stopping) { logger.warn('Bridge server already stopping'); return; } bridgeServerState = ServerState.Stopping; if (serverNode) { logger.info('Stopping bridge server'); await serverNode.close(); await serverNode.prepareRuntimeShutdown(); await serverNode[Symbol.asyncDispose](); serverNode = undefined; matterServer = undefined; logger.info('Matter bridge stopped'); } if (isy) { logger.info('Disconnecting from ISY'); isy[Symbol.dispose](); isy = undefined; logger.info('Disconnected from ISY'); } bridgeServerState = ServerState.Stopped; } catch (e) { logger.error(`Error stopping bridge server ${e.message}`, e); //bridgeServerState = ServerState.Stopping; } } async function stopSocketServer() { try { if (socketServer) { logger.info('Stopping socket server'); delete logger.transports[2]; clientLogTransport = null; if (client) await promisify(client?.end)(); if (socketServer) await promisify(socketServer.close)(); client = null; logger.info('Socket server stopped'); } } catch (e) { logger.error(`Error stopping socket server ${e.message}`); } } process.on('SIGINT', async () => { await stopBridgeServer(); await stopSocketServer(); process.exit(0); }); process.on('SIGTERM', async () => { await stopBridgeServer(); await stopSocketServer(); process.exit(0); }); process.on('uncaughtException', async (err) => { logger.error('Uncaught exception: ' + err.message, err); }); const program = new Command(); program.option('-a, --autoStart', 'Start matter bridge server on startup', false).option('-l, --dependencies', 'Load dependencies - static (from local node_modules), plugin (from plugin node_modules)', 'static').option('-e, --env [PATH]', 'Path to environment file', '.env').option('-d, --devices <DEVICES>', 'list of device (addresses) to expose', []).option('--filter <FILTER>', 'devices names to exclude', '').option('-r, --requireAuth', 'Require authentication to start bridge server', false).option('-s, --openSocket', 'Open socket to receive requests from plugin/client', false).option('-f, --factoryReset', 'Reset bridge server to initial state', false); program.parse(); options = program.opts(); let envPath = path.resolve(process.cwd(), options.env); let env = expand(config({ path: envPath })); if (options.autoStart) { options.requireAuth = false; /* Since we are starting the bridge server immediately, nothing to authenticate */ } else { options.openSocket = true; /* If we are not auto starting, we need to open the socket to receive commands */ } console.log(`Environment variables loaded from ${path}: ${logStringify(env)}`); //console.debug(`All environment variables: ${logStringify(process.env)}`); ({ isyConfig, matterConfig, serverConfig } = loadConfigs()); logger = createLogger(); logger.info(`Options: ${logStringify(options)}`); logger.debug(`All environment variables: ${logStringify(process.env)}`); logger.info(`LOGNAME: ${process.env.LOGNAME}, USER: ${process.env.USER}`); if (process.env.LOGNAME == 'root' || process.env.LOGNAME == 'polyglot' || process.env.USER == 'polyglot' || process.env.LOGNAME == 'isy' || process.env.USER == 'isy') { logger.info(`Running as ${process.env.LOGNAME}. Enabling connection to IoX over domain socket`); isyConfig.socketPath = '/tmp/ns2isy182652'; } else if (options.autoStart && !isyConfig.password) { logger.error('Auto start requires ISY password to be configured or running as polyglot or isy'); exit(1); } logger.info(`ISY config: ${logStringify(isyConfig)}`); logger.info(`Matter config: ${logStringify(matterConfig)}`); logger.info(`Server config: ${logStringify(serverConfig)}`); if (!options.requireAuth) { authenticated = true; } if (options.openSocket) { logger.info('Starting socket server'); await startSocketServer(); } else { logger.info('Running standalone'); } if (options.autoStart || options.reset) { logger.info('Autostart enabled'); await startBridgeServer(); } //main(); //# sourceMappingURL=server.js.map //# sourceMappingURL=server.js.map //# sourceMappingURL=server.js.map