@pmouli/isy-matter-server
Version:
Service to expose an ISY device as a Matter Border router
489 lines • 20 kB
JavaScript
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