matterbridge
Version:
Matterbridge plugin manager for Matter
922 lines • 136 kB
JavaScript
/**
* This file contains the class Frontend.
*
* @file frontend.ts
* @author Luca Liguori
* @created 2025-01-13
* @version 1.2.0
* @license Apache-2.0
*
* Copyright 2025, 2026, 2027 Luca Liguori.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Node modules
import { createServer } from 'node:http';
import https from 'node:https';
import os from 'node:os';
import path from 'node:path';
import { existsSync, promises as fs } from 'node:fs';
import EventEmitter from 'node:events';
// Third-party modules
import express from 'express';
import WebSocket, { WebSocketServer } from 'ws';
import multer from 'multer';
// AnsiLogger module
import { AnsiLogger, stringify, debugStringify, CYAN, db, er, nf, rs, UNDERLINE, UNDERLINEOFF, YELLOW, nt } from 'node-ansi-logger';
// @matter
import { Logger, LogLevel as MatterLogLevel, LogFormat as MatterLogFormat, Lifecycle } from '@matter/main';
import { BridgedDeviceBasicInformation, PowerSource } from '@matter/main/clusters';
// Matterbridge
import { createZip, isValidArray, isValidNumber, isValidObject, isValidString, isValidBoolean, withTimeout, hasParameter } from './utils/export.js';
import { plg } from './matterbridgeTypes.js';
import { capitalizeFirstLetter, getAttribute } from './matterbridgeEndpointHelpers.js';
import { cliEmitter, lastCpuUsage } from './cliEmitter.js';
/**
* Websocket message ID for logging.
*
* @constant {number}
*/
export const WS_ID_LOG = 0;
/**
* Websocket message ID indicating a refresh is needed.
*
* @constant {number}
*/
export const WS_ID_REFRESH_NEEDED = 1;
/**
* Websocket message ID indicating a restart is needed.
*
* @constant {number}
*/
export const WS_ID_RESTART_NEEDED = 2;
/**
* Websocket message ID indicating a cpu update.
*
* @constant {number}
*/
export const WS_ID_CPU_UPDATE = 3;
/**
* Websocket message ID indicating a memory update.
*
* @constant {number}
*/
export const WS_ID_MEMORY_UPDATE = 4;
/**
* Websocket message ID indicating an uptime update.
*
* @constant {number}
*/
export const WS_ID_UPTIME_UPDATE = 5;
/**
* Websocket message ID indicating a snackbar message.
*
* @constant {number}
*/
export const WS_ID_SNACKBAR = 6;
/**
* Websocket message ID indicating matterbridge has un update available.
*
* @constant {number}
*/
export const WS_ID_UPDATE_NEEDED = 7;
/**
* Websocket message ID indicating a state update.
*
* @constant {number}
*/
export const WS_ID_STATEUPDATE = 8;
/**
* Websocket message ID indicating to close a permanent snackbar message.
*
* @constant {number}
*/
export const WS_ID_CLOSE_SNACKBAR = 9;
/**
* Websocket message ID indicating a shelly system update.
* check:
* curl -k http://127.0.0.1:8101/api/updates/sys/check
* perform:
* curl -k http://127.0.0.1:8101/api/updates/sys/perform
*
* @constant {number}
*/
export const WS_ID_SHELLY_SYS_UPDATE = 100;
/**
* Websocket message ID indicating a shelly main update.
* check:
* curl -k http://127.0.0.1:8101/api/updates/main/check
* perform:
* curl -k http://127.0.0.1:8101/api/updates/main/perform
*
* @constant {number}
*/
export const WS_ID_SHELLY_MAIN_UPDATE = 101;
export class Frontend extends EventEmitter {
matterbridge;
log;
port = 8283;
expressApp;
httpServer;
httpsServer;
webSocketServer;
constructor(matterbridge) {
super();
this.matterbridge = matterbridge;
this.log = new AnsiLogger({ logName: 'Frontend', logTimestampFormat: 4 /* TimestampFormat.TIME_MILLIS */, logLevel: hasParameter('debug') ? "debug" /* LogLevel.DEBUG */ : "info" /* LogLevel.INFO */ });
}
set logLevel(logLevel) {
this.log.logLevel = logLevel;
}
async start(port = 8283) {
this.port = port;
this.log.debug(`Initializing the frontend ${hasParameter('ssl') ? 'https' : 'http'} server on port ${YELLOW}${this.port}${db}`);
// Initialize multer with the upload directory
const uploadDir = path.join(this.matterbridge.matterbridgeDirectory, 'uploads');
await fs.mkdir(uploadDir, { recursive: true });
const upload = multer({ dest: uploadDir });
// Create the express app that serves the frontend
this.expressApp = express();
// Inject logging/debug wrapper for route/middleware registration
/*
const methods = ['get', 'post', 'put', 'delete', 'use'];
for (const method of methods) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const original = (this.expressApp as any)[method].bind(this.expressApp);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this.expressApp as any)[method] = (path: any, ...rest: any) => {
try {
console.log(`[DEBUG] Registering ${method.toUpperCase()} route:`, path);
return original(path, ...rest);
} catch (err) {
console.error(`[ERROR] Failed to register route: ${path}`);
throw err;
}
};
}
*/
// Log all requests to the server for debugging
/*
this.expressApp.use((req, res, next) => {
this.log.debug(`***Received request on expressApp: ${req.method} ${req.url}`);
next();
});
*/
// Serve static files from '/static' endpoint
this.expressApp.use(express.static(path.join(this.matterbridge.rootDirectory, 'frontend/build')));
if (!hasParameter('ssl')) {
// Create an HTTP server and attach the express app
try {
this.log.debug(`Creating HTTP server...`);
this.httpServer = createServer(this.expressApp);
}
catch (error) {
this.log.error(`Failed to create HTTP server: ${error}`);
this.emit('server_error', error);
return;
}
// Listen on the specified port
if (hasParameter('ingress')) {
this.httpServer.listen(this.port, '0.0.0.0', () => {
this.log.info(`The frontend http server is listening on ${UNDERLINE}http://0.0.0.0:${this.port}${UNDERLINEOFF}${rs}`);
this.emit('server_listening', 'http', this.port, '0.0.0.0');
});
}
else {
this.httpServer.listen(this.port, () => {
if (this.matterbridge.systemInformation.ipv4Address !== '')
this.log.info(`The frontend http server is listening on ${UNDERLINE}http://${this.matterbridge.systemInformation.ipv4Address}:${this.port}${UNDERLINEOFF}${rs}`);
if (this.matterbridge.systemInformation.ipv6Address !== '')
this.log.info(`The frontend http server is listening on ${UNDERLINE}http://[${this.matterbridge.systemInformation.ipv6Address}]:${this.port}${UNDERLINEOFF}${rs}`);
this.emit('server_listening', 'http', this.port);
});
}
this.httpServer.on('error', (error) => {
this.log.error(`Frontend http server error listening on ${this.port}`);
switch (error.code) {
case 'EACCES':
this.log.error(`Port ${this.port} requires elevated privileges`);
break;
case 'EADDRINUSE':
this.log.error(`Port ${this.port} is already in use`);
break;
}
this.emit('server_error', error);
return;
});
}
else {
let cert;
let key;
let ca;
let fullChain;
let pfx;
let passphrase;
let httpsServerOptions = {};
if (existsSync(path.join(this.matterbridge.matterbridgeDirectory, 'certs/cert.p12'))) {
// Load the p12 certificate and the passphrase
try {
pfx = await fs.readFile(path.join(this.matterbridge.matterbridgeDirectory, 'certs/cert.p12'));
this.log.info(`Loaded p12 certificate file ${path.join(this.matterbridge.matterbridgeDirectory, 'certs/cert.p12')}`);
}
catch (error) {
this.log.error(`Error reading p12 certificate file ${path.join(this.matterbridge.matterbridgeDirectory, 'certs/cert.p12')}: ${error}`);
this.emit('server_error', error);
return;
}
try {
passphrase = await fs.readFile(path.join(this.matterbridge.matterbridgeDirectory, 'certs/cert.pass'), 'utf8');
passphrase = passphrase.trim(); // Ensure no extra characters
this.log.info(`Loaded p12 passphrase file ${path.join(this.matterbridge.matterbridgeDirectory, 'certs/cert.pass')}`);
}
catch (error) {
this.log.error(`Error reading p12 passphrase file ${path.join(this.matterbridge.matterbridgeDirectory, 'certs/cert.pass')}: ${error}`);
this.emit('server_error', error);
return;
}
httpsServerOptions = { pfx, passphrase };
}
else {
// Load the SSL certificate, the private key and optionally the CA certificate. If the CA certificate is present, it will be used to create a full chain certificate.
try {
cert = await fs.readFile(path.join(this.matterbridge.matterbridgeDirectory, 'certs/cert.pem'), 'utf8');
this.log.info(`Loaded certificate file ${path.join(this.matterbridge.matterbridgeDirectory, 'certs/cert.pem')}`);
}
catch (error) {
this.log.error(`Error reading certificate file ${path.join(this.matterbridge.matterbridgeDirectory, 'certs/cert.pem')}: ${error}`);
this.emit('server_error', error);
return;
}
try {
key = await fs.readFile(path.join(this.matterbridge.matterbridgeDirectory, 'certs/key.pem'), 'utf8');
this.log.info(`Loaded key file ${path.join(this.matterbridge.matterbridgeDirectory, 'certs/key.pem')}`);
}
catch (error) {
this.log.error(`Error reading key file ${path.join(this.matterbridge.matterbridgeDirectory, 'certs/key.pem')}: ${error}`);
this.emit('server_error', error);
return;
}
try {
ca = await fs.readFile(path.join(this.matterbridge.matterbridgeDirectory, 'certs/ca.pem'), 'utf8');
fullChain = `${cert}\n${ca}`;
this.log.info(`Loaded CA certificate file ${path.join(this.matterbridge.matterbridgeDirectory, 'certs/ca.pem')}`);
}
catch (error) {
this.log.info(`CA certificate file ${path.join(this.matterbridge.matterbridgeDirectory, 'certs/ca.pem')} not loaded: ${error}`);
}
httpsServerOptions = { cert: fullChain ?? cert, key, ca };
}
if (hasParameter('mtls')) {
httpsServerOptions.requestCert = true; // Request client certificate
httpsServerOptions.rejectUnauthorized = true; // Require client certificate validation
}
// Create an HTTPS server with the SSL certificate and private key (ca is optional) and attach the express app
try {
this.log.debug(`Creating HTTPS server...`);
this.httpsServer = https.createServer(httpsServerOptions, this.expressApp);
}
catch (error) {
this.log.error(`Failed to create HTTPS server: ${error}`);
this.emit('server_error', error);
return;
}
// Listen on the specified port
if (hasParameter('ingress')) {
this.httpsServer.listen(this.port, '0.0.0.0', () => {
this.log.info(`The frontend https server is listening on ${UNDERLINE}https://0.0.0.0:${this.port}${UNDERLINEOFF}${rs}`);
this.emit('server_listening', 'https', this.port, '0.0.0.0');
});
}
else {
this.httpsServer.listen(this.port, () => {
if (this.matterbridge.systemInformation.ipv4Address !== '')
this.log.info(`The frontend https server is listening on ${UNDERLINE}https://${this.matterbridge.systemInformation.ipv4Address}:${this.port}${UNDERLINEOFF}${rs}`);
if (this.matterbridge.systemInformation.ipv6Address !== '')
this.log.info(`The frontend https server is listening on ${UNDERLINE}https://[${this.matterbridge.systemInformation.ipv6Address}]:${this.port}${UNDERLINEOFF}${rs}`);
this.emit('server_listening', 'https', this.port);
});
}
this.httpsServer.on('error', (error) => {
this.log.error(`Frontend https server error listening on ${this.port}`);
switch (error.code) {
case 'EACCES':
this.log.error(`Port ${this.port} requires elevated privileges`);
break;
case 'EADDRINUSE':
this.log.error(`Port ${this.port} is already in use`);
break;
}
this.emit('server_error', error);
return;
});
}
// Create a WebSocket server and attach it to the http or https server
const wssPort = this.port;
const wssHost = hasParameter('ssl') ? `wss://${this.matterbridge.systemInformation.ipv4Address}:${wssPort}` : `ws://${this.matterbridge.systemInformation.ipv4Address}:${wssPort}`;
this.log.debug(`Creating WebSocketServer on host ${CYAN}${wssHost}${db}...`);
this.webSocketServer = new WebSocketServer(hasParameter('ssl') ? { server: this.httpsServer } : { server: this.httpServer });
this.webSocketServer.on('connection', (ws, request) => {
const clientIp = request.socket.remoteAddress;
// Set the global logger callback for the WebSocketServer
let callbackLogLevel = "notice" /* LogLevel.NOTICE */;
if (this.matterbridge.matterbridgeInformation.loggerLevel === "info" /* LogLevel.INFO */ || this.matterbridge.matterbridgeInformation.matterLoggerLevel === MatterLogLevel.INFO)
callbackLogLevel = "info" /* LogLevel.INFO */;
if (this.matterbridge.matterbridgeInformation.loggerLevel === "debug" /* LogLevel.DEBUG */ || this.matterbridge.matterbridgeInformation.matterLoggerLevel === MatterLogLevel.DEBUG)
callbackLogLevel = "debug" /* LogLevel.DEBUG */;
AnsiLogger.setGlobalCallback(this.wssSendMessage.bind(this), callbackLogLevel);
this.log.debug(`WebSocketServer logger global callback set to ${callbackLogLevel}`);
this.log.info(`WebSocketServer client "${clientIp}" connected to Matterbridge`);
ws.on('message', (message) => {
this.wsMessageHandler(ws, message);
});
ws.on('ping', () => {
this.log.debug('WebSocket client ping');
ws.pong();
});
ws.on('pong', () => {
this.log.debug('WebSocket client pong');
});
ws.on('close', () => {
this.log.info('WebSocket client disconnected');
if (this.webSocketServer?.clients.size === 0) {
AnsiLogger.setGlobalCallback(undefined);
this.log.debug('All WebSocket clients disconnected. WebSocketServer logger global callback removed');
}
});
ws.on('error', (error) => {
// istanbul ignore next
this.log.error(`WebSocket client error: ${error}`);
});
});
this.webSocketServer.on('close', () => {
this.log.debug(`WebSocketServer closed`);
});
this.webSocketServer.on('listening', () => {
this.log.info(`The WebSocketServer is listening on ${UNDERLINE}${wssHost}${UNDERLINEOFF}${rs}`);
this.emit('websocket_server_listening', wssHost);
});
this.webSocketServer.on('error', (ws, error) => {
this.log.error(`WebSocketServer error: ${error}`);
});
// Subscribe to cli events
cliEmitter.removeAllListeners();
cliEmitter.on('uptime', (systemUptime, processUptime) => {
this.wssSendUptimeUpdate(systemUptime, processUptime);
});
cliEmitter.on('memory', (totalMememory, freeMemory, rss, heapTotal, heapUsed, external, arrayBuffers) => {
this.wssSendMemoryUpdate(totalMememory, freeMemory, rss, heapTotal, heapUsed, external, arrayBuffers);
});
cliEmitter.on('cpu', (cpuUsage) => {
this.wssSendCpuUpdate(cpuUsage);
});
// Endpoint to validate login code
// curl -X POST "http://localhost:8283/api/login" -H "Content-Type: application/json" -d "{\"password\":\"Here\"}"
this.expressApp.post('/api/login', express.json(), async (req, res) => {
const { password } = req.body;
this.log.debug('The frontend sent /api/login', password);
if (!this.matterbridge.nodeContext) {
this.log.error('/api/login nodeContext not found');
res.json({ valid: false });
return;
}
try {
const storedPassword = await this.matterbridge.nodeContext.get('password', '');
if (storedPassword === '' || password === storedPassword) {
this.log.debug('/api/login password valid');
res.json({ valid: true });
}
else {
this.log.warn('/api/login error wrong password');
res.json({ valid: false });
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
}
catch (error) {
this.log.error('/api/login error getting password');
res.json({ valid: false });
}
});
// Endpoint to provide health check for docker
this.expressApp.get('/health', (req, res) => {
this.log.debug('Express received /health');
const healthStatus = {
status: 'ok', // Indicate service is healthy
uptime: process.uptime(), // Server uptime in seconds
timestamp: new Date().toISOString(), // Current timestamp
};
res.status(200).json(healthStatus);
});
// Endpoint to provide memory usage details
this.expressApp.get('/memory', async (req, res) => {
this.log.debug('Express received /memory');
// Memory usage from process
const memoryUsageRaw = process.memoryUsage();
const memoryUsage = {
rss: this.formatMemoryUsage(memoryUsageRaw.rss),
heapTotal: this.formatMemoryUsage(memoryUsageRaw.heapTotal),
heapUsed: this.formatMemoryUsage(memoryUsageRaw.heapUsed),
external: this.formatMemoryUsage(memoryUsageRaw.external),
arrayBuffers: this.formatMemoryUsage(memoryUsageRaw.arrayBuffers),
};
// V8 heap statistics
const { default: v8 } = await import('node:v8');
const heapStatsRaw = v8.getHeapStatistics();
const heapSpacesRaw = v8.getHeapSpaceStatistics();
// Format heapStats
const heapStats = Object.fromEntries(Object.entries(heapStatsRaw).map(([key, value]) => [key, this.formatMemoryUsage(value)]));
// Format heapSpaces
const heapSpaces = heapSpacesRaw.map((space) => ({
...space,
space_size: this.formatMemoryUsage(space.space_size),
space_used_size: this.formatMemoryUsage(space.space_used_size),
space_available_size: this.formatMemoryUsage(space.space_available_size),
physical_space_size: this.formatMemoryUsage(space.physical_space_size),
}));
const { default: module } = await import('node:module');
const loadedModules = module._cache ? Object.keys(module._cache).sort() : [];
const memoryReport = {
memoryUsage,
heapStats,
heapSpaces,
loadedModules,
};
res.status(200).json(memoryReport);
});
// Endpoint to provide settings
this.expressApp.get('/api/settings', express.json(), async (req, res) => {
this.log.debug('The frontend sent /api/settings');
res.json(await this.getApiSettings());
});
// Endpoint to provide plugins
this.expressApp.get('/api/plugins', async (req, res) => {
this.log.debug('The frontend sent /api/plugins');
res.json(this.getPlugins());
});
// Endpoint to provide devices
this.expressApp.get('/api/devices', async (req, res) => {
this.log.debug('The frontend sent /api/devices');
const devices = await this.getDevices();
res.json(devices);
});
// Endpoint to view the matterbridge log
this.expressApp.get('/api/view-mblog', async (req, res) => {
this.log.debug('The frontend sent /api/view-mblog');
try {
const data = await fs.readFile(path.join(this.matterbridge.matterbridgeDirectory, this.matterbridge.matterbridgeLoggerFile), 'utf8');
res.type('text/plain');
res.send(data);
}
catch (error) {
this.log.error(`Error reading matterbridge log file ${this.matterbridge.matterbridgeLoggerFile}: ${error instanceof Error ? error.message : error}`);
res.status(500).send('Error reading matterbridge log file. Please enable the matterbridge log on file in the settings.');
}
});
// Endpoint to view the matter.js log
this.expressApp.get('/api/view-mjlog', async (req, res) => {
this.log.debug('The frontend sent /api/view-mjlog');
try {
const data = await fs.readFile(path.join(this.matterbridge.matterbridgeDirectory, this.matterbridge.matterLoggerFile), 'utf8');
res.type('text/plain');
res.send(data);
}
catch (error) {
this.log.error(`Error reading matter log file ${this.matterbridge.matterLoggerFile}: ${error instanceof Error ? error.message : error}`);
res.status(500).send('Error reading matter log file. Please enable the matter log on file in the settings.');
}
});
// Endpoint to view the shelly log
this.expressApp.get('/api/shellyviewsystemlog', async (req, res) => {
this.log.debug('The frontend sent /api/shellyviewsystemlog');
try {
const data = await fs.readFile(path.join(this.matterbridge.matterbridgeDirectory, 'shelly.log'), 'utf8');
res.type('text/plain');
res.send(data);
}
catch (error) {
this.log.error(`Error reading shelly log file ${this.matterbridge.matterbridgeLoggerFile}: ${error instanceof Error ? error.message : error}`);
res.status(500).send('Error reading shelly log file. Please create the shelly system log before loading it.');
}
});
// Endpoint to download the matterbridge log
this.expressApp.get('/api/download-mblog', async (req, res) => {
this.log.debug(`The frontend sent /api/download-mblog ${path.join(this.matterbridge.matterbridgeDirectory, this.matterbridge.matterbridgeLoggerFile)}`);
try {
await fs.access(path.join(this.matterbridge.matterbridgeDirectory, this.matterbridge.matterbridgeLoggerFile), fs.constants.F_OK);
const data = await fs.readFile(path.join(this.matterbridge.matterbridgeDirectory, this.matterbridge.matterbridgeLoggerFile), 'utf8');
await fs.writeFile(path.join(os.tmpdir(), this.matterbridge.matterbridgeLoggerFile), data, 'utf-8');
}
catch (error) {
await fs.writeFile(path.join(os.tmpdir(), this.matterbridge.matterbridgeLoggerFile), 'Enable the matterbridge log on file in the settings to download the matterbridge log.', 'utf-8');
this.log.debug(`Error in /api/download-mblog: ${error instanceof Error ? error.message : error}`);
}
res.type('text/plain');
res.download(path.join(os.tmpdir(), this.matterbridge.matterbridgeLoggerFile), 'matterbridge.log', (error) => {
/* istanbul ignore if */
if (error) {
this.log.error(`Error downloading log file ${this.matterbridge.matterbridgeLoggerFile}: ${error instanceof Error ? error.message : error}`);
res.status(500).send('Error downloading the matterbridge log file');
}
});
});
// Endpoint to download the matter log
this.expressApp.get('/api/download-mjlog', async (req, res) => {
this.log.debug(`The frontend sent /api/download-mjlog ${path.join(this.matterbridge.matterbridgeDirectory, this.matterbridge.matterbridgeLoggerFile)}`);
try {
await fs.access(path.join(this.matterbridge.matterbridgeDirectory, this.matterbridge.matterLoggerFile), fs.constants.F_OK);
const data = await fs.readFile(path.join(this.matterbridge.matterbridgeDirectory, this.matterbridge.matterLoggerFile), 'utf8');
await fs.writeFile(path.join(os.tmpdir(), this.matterbridge.matterLoggerFile), data, 'utf-8');
}
catch (error) {
await fs.writeFile(path.join(os.tmpdir(), this.matterbridge.matterLoggerFile), 'Enable the matter log on file in the settings to download the matter log.', 'utf-8');
this.log.debug(`Error in /api/download-mblog: ${error instanceof Error ? error.message : error}`);
}
res.type('text/plain');
res.download(path.join(os.tmpdir(), this.matterbridge.matterLoggerFile), 'matter.log', (error) => {
/* istanbul ignore if */
if (error) {
this.log.error(`Error downloading log file ${this.matterbridge.matterLoggerFile}: ${error instanceof Error ? error.message : error}`);
res.status(500).send('Error downloading the matter log file');
}
});
});
// Endpoint to download the shelly log
this.expressApp.get('/api/shellydownloadsystemlog', async (req, res) => {
this.log.debug('The frontend sent /api/shellydownloadsystemlog');
try {
await fs.access(path.join(this.matterbridge.matterbridgeDirectory, 'shelly.log'), fs.constants.F_OK);
const data = await fs.readFile(path.join(this.matterbridge.matterbridgeDirectory, 'shelly.log'), 'utf8');
await fs.writeFile(path.join(os.tmpdir(), 'shelly.log'), data, 'utf-8');
}
catch (error) {
await fs.writeFile(path.join(os.tmpdir(), 'shelly.log'), 'Create the Shelly system log before downloading it.', 'utf-8');
this.log.debug(`Error in /api/shellydownloadsystemlog: ${error instanceof Error ? error.message : error}`);
}
res.type('text/plain');
res.download(path.join(os.tmpdir(), 'shelly.log'), 'shelly.log', (error) => {
/* istanbul ignore if */
if (error) {
this.log.error(`Error downloading Shelly system log file: ${error instanceof Error ? error.message : error}`);
res.status(500).send('Error downloading Shelly system log file');
}
});
});
// Endpoint to download the matterbridge storage directory
this.expressApp.get('/api/download-mbstorage', async (req, res) => {
this.log.debug('The frontend sent /api/download-mbstorage');
await createZip(path.join(os.tmpdir(), `matterbridge.${this.matterbridge.nodeStorageName}.zip`), path.join(this.matterbridge.matterbridgeDirectory, this.matterbridge.nodeStorageName));
res.download(path.join(os.tmpdir(), `matterbridge.${this.matterbridge.nodeStorageName}.zip`), `matterbridge.${this.matterbridge.nodeStorageName}.zip`, (error) => {
/* istanbul ignore if */
if (error) {
this.log.error(`Error downloading file ${`matterbridge.${this.matterbridge.nodeStorageName}.zip`}: ${error instanceof Error ? error.message : error}`);
res.status(500).send('Error downloading the matterbridge storage file');
}
});
});
// Endpoint to download the matter storage file
this.expressApp.get('/api/download-mjstorage', async (req, res) => {
this.log.debug('The frontend sent /api/download-mjstorage');
await createZip(path.join(os.tmpdir(), `matterbridge.${this.matterbridge.matterStorageName}.zip`), path.join(this.matterbridge.matterbridgeDirectory, this.matterbridge.matterStorageName));
res.download(path.join(os.tmpdir(), `matterbridge.${this.matterbridge.matterStorageName}.zip`), `matterbridge.${this.matterbridge.matterStorageName}.zip`, (error) => {
/* istanbul ignore if */
if (error) {
this.log.error(`Error downloading the matter storage matterbridge.${this.matterbridge.matterStorageName}.zip: ${error instanceof Error ? error.message : error}`);
res.status(500).send('Error downloading the matter storage zip file');
}
});
});
// Endpoint to download the matterbridge plugin directory
this.expressApp.get('/api/download-pluginstorage', async (req, res) => {
this.log.debug('The frontend sent /api/download-pluginstorage');
await createZip(path.join(os.tmpdir(), `matterbridge.pluginstorage.zip`), this.matterbridge.matterbridgePluginDirectory);
res.download(path.join(os.tmpdir(), `matterbridge.pluginstorage.zip`), `matterbridge.pluginstorage.zip`, (error) => {
/* istanbul ignore if */
if (error) {
this.log.error(`Error downloading file matterbridge.pluginstorage.zip: ${error instanceof Error ? error.message : error}`);
res.status(500).send('Error downloading the matterbridge plugin storage file');
}
});
});
// Endpoint to download the matterbridge plugin config files
this.expressApp.get('/api/download-pluginconfig', async (req, res) => {
this.log.debug('The frontend sent /api/download-pluginconfig');
await createZip(path.join(os.tmpdir(), `matterbridge.pluginconfig.zip`), path.relative(process.cwd(), path.join(this.matterbridge.matterbridgeDirectory, '*.config.json')));
res.download(path.join(os.tmpdir(), `matterbridge.pluginconfig.zip`), `matterbridge.pluginconfig.zip`, (error) => {
/* istanbul ignore if */
if (error) {
this.log.error(`Error downloading file matterbridge.pluginconfig.zip: ${error instanceof Error ? error.message : error}`);
res.status(500).send('Error downloading the matterbridge plugin config file');
}
});
});
// Endpoint to download the matterbridge backup (created with the backup command)
this.expressApp.get('/api/download-backup', async (req, res) => {
this.log.debug('The frontend sent /api/download-backup');
res.download(path.join(os.tmpdir(), `matterbridge.backup.zip`), `matterbridge.backup.zip`, (error) => {
/* istanbul ignore if */
if (error) {
this.log.error(`Error downloading file matterbridge.backup.zip: ${error instanceof Error ? error.message : error}`);
res.status(500).send(`Error downloading file matterbridge.backup.zip: ${error instanceof Error ? error.message : error}`);
}
});
});
// Endpoint to upload a package
this.expressApp.post('/api/uploadpackage', upload.single('file'), async (req, res) => {
const { filename } = req.body;
const file = req.file;
/* istanbul ignore if */
if (!file || !filename) {
this.log.error(`uploadpackage: invalid request: file and filename are required`);
res.status(400).send('Invalid request: file and filename are required');
return;
}
this.wssSendSnackbarMessage(`Installing package ${filename}. Please wait...`, 0);
// Define the path where the plugin file will be saved
const filePath = path.join(this.matterbridge.matterbridgeDirectory, 'uploads', filename);
try {
// Move the uploaded file to the specified path
await fs.rename(file.path, filePath);
this.log.info(`File ${plg}${filename}${nf} uploaded successfully`);
// Install the plugin package
if (filename.endsWith('.tgz')) {
const { spawnCommand } = await import('./utils/spawn.js');
await spawnCommand(this.matterbridge, 'npm', ['install', '-g', filePath, '--omit=dev', '--verbose']);
this.log.info(`Plugin package ${plg}${filename}${nf} installed successfully. Full restart required.`);
this.wssSendCloseSnackbarMessage(`Installing package ${filename}. Please wait...`);
this.wssSendSnackbarMessage(`Installed package ${filename}`, 10, 'success');
this.wssSendRestartRequired();
res.send(`Plugin package ${filename} uploaded and installed successfully`);
}
else
res.send(`File ${filename} uploaded successfully`);
}
catch (err) {
this.log.error(`Error uploading or installing plugin package file ${plg}${filename}${er}:`, err);
this.wssSendCloseSnackbarMessage(`Installing package ${filename}. Please wait...`);
this.wssSendSnackbarMessage(`Error uploading or installing plugin package ${filename}`, 10, 'error');
res.status(500).send(`Error uploading or installing plugin package ${filename}`);
}
});
// Fallback for routing (must be the last route)
this.expressApp.use((req, res) => {
this.log.debug(`The frontend sent ${req.url} method ${req.method}: sending index.html as fallback`);
res.sendFile(path.join(this.matterbridge.rootDirectory, 'frontend/build/index.html'));
});
this.log.debug(`Frontend initialized on port ${YELLOW}${this.port}${db} static ${UNDERLINE}${path.join(this.matterbridge.rootDirectory, 'frontend/build')}${UNDERLINEOFF}${rs}`);
}
async stop() {
this.log.debug('Stopping the frontend...');
// Remove listeners from the express app
if (this.expressApp) {
this.expressApp.removeAllListeners();
this.expressApp = undefined;
this.log.debug('Frontend app closed successfully');
}
// Close the WebSocket server
if (this.webSocketServer) {
this.log.debug('Closing WebSocket server...');
// Close all active connections
this.webSocketServer.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.close();
}
});
await withTimeout(new Promise((resolve) => {
this.webSocketServer?.close((error) => {
if (error) {
// istanbul ignore next
this.log.error(`Error closing WebSocket server: ${error}`);
}
else {
this.log.debug('WebSocket server closed successfully');
this.emit('websocket_server_stopped');
}
resolve();
});
}), 10000, false);
this.webSocketServer.removeAllListeners();
this.webSocketServer = undefined;
}
// Close the http server
if (this.httpServer) {
this.log.debug('Closing http server...');
await withTimeout(new Promise((resolve) => {
this.httpServer?.close((error) => {
if (error) {
// istanbul ignore next
this.log.error(`Error closing http server: ${error}`);
}
else {
this.log.debug('Http server closed successfully');
this.emit('server_stopped');
}
resolve();
});
}), 10000, false);
this.httpServer.removeAllListeners();
this.httpServer = undefined;
this.log.debug('Frontend http server closed successfully');
}
// Close the https server
if (this.httpsServer) {
this.log.debug('Closing https server...');
await withTimeout(new Promise((resolve) => {
this.httpsServer?.close((error) => {
if (error) {
// istanbul ignore next
this.log.error(`Error closing https server: ${error}`);
}
else {
this.log.debug('Https server closed successfully');
this.emit('server_stopped');
}
resolve();
});
}), 10000, false);
this.httpsServer.removeAllListeners();
this.httpsServer = undefined;
this.log.debug('Frontend https server closed successfully');
}
this.log.debug('Frontend stopped successfully');
}
// Function to format bytes to KB, MB, or GB
formatMemoryUsage = (bytes) => {
if (bytes >= 1024 ** 3) {
return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
}
else if (bytes >= 1024 ** 2) {
return `${(bytes / 1024 ** 2).toFixed(2)} MB`;
}
else {
return `${(bytes / 1024).toFixed(2)} KB`;
}
};
// Function to format system uptime with only the most significant unit
formatOsUpTime = (seconds) => {
if (seconds >= 86400) {
const days = Math.floor(seconds / 86400);
return `${days} day${days !== 1 ? 's' : ''}`;
}
if (seconds >= 3600) {
const hours = Math.floor(seconds / 3600);
return `${hours} hour${hours !== 1 ? 's' : ''}`;
}
if (seconds >= 60) {
const minutes = Math.floor(seconds / 60);
return `${minutes} minute${minutes !== 1 ? 's' : ''}`;
}
return `${seconds} second${seconds !== 1 ? 's' : ''}`;
};
/**
* Retrieves the api settings data.
*
* @returns {Promise<{ matterbridgeInformation: MatterbridgeInformation, systemInformation: SystemInformation }>} A promise that resolve in the api settings object.
*/
async getApiSettings() {
// Update the system information
this.matterbridge.systemInformation.totalMemory = this.formatMemoryUsage(os.totalmem());
this.matterbridge.systemInformation.freeMemory = this.formatMemoryUsage(os.freemem());
this.matterbridge.systemInformation.systemUptime = this.formatOsUpTime(os.uptime());
this.matterbridge.systemInformation.processUptime = this.formatOsUpTime(Math.floor(process.uptime()));
this.matterbridge.systemInformation.cpuUsage = lastCpuUsage.toFixed(2) + ' %';
this.matterbridge.systemInformation.rss = this.formatMemoryUsage(process.memoryUsage().rss);
this.matterbridge.systemInformation.heapTotal = this.formatMemoryUsage(process.memoryUsage().heapTotal);
this.matterbridge.systemInformation.heapUsed = this.formatMemoryUsage(process.memoryUsage().heapUsed);
// Update the matterbridge information
this.matterbridge.matterbridgeInformation.bridgeMode = this.matterbridge.bridgeMode;
this.matterbridge.matterbridgeInformation.restartMode = this.matterbridge.restartMode;
this.matterbridge.matterbridgeInformation.profile = this.matterbridge.profile;
this.matterbridge.matterbridgeInformation.loggerLevel = this.matterbridge.log.logLevel;
this.matterbridge.matterbridgeInformation.matterLoggerLevel = Logger.defaultLogLevel;
this.matterbridge.matterbridgeInformation.mattermdnsinterface = this.matterbridge.mdnsInterface;
this.matterbridge.matterbridgeInformation.matteripv4address = this.matterbridge.ipv4address;
this.matterbridge.matterbridgeInformation.matteripv6address = this.matterbridge.ipv6address;
this.matterbridge.matterbridgeInformation.matterPort = (await this.matterbridge.nodeContext?.get('matterport', 5540)) ?? 5540;
this.matterbridge.matterbridgeInformation.matterDiscriminator = await this.matterbridge.nodeContext?.get('matterdiscriminator');
this.matterbridge.matterbridgeInformation.matterPasscode = await this.matterbridge.nodeContext?.get('matterpasscode');
// Update the matterbridge information in bridge mode
if (this.matterbridge.bridgeMode === 'bridge' && this.matterbridge.serverNode && !this.matterbridge.hasCleanupStarted) {
this.matterbridge.matterbridgeInformation.matterbridgePaired = this.matterbridge.serverNode.state.commissioning.commissioned;
this.matterbridge.matterbridgeInformation.matterbridgeQrPairingCode = this.matterbridge.matterbridgeInformation.matterbridgeEndAdvertise ? undefined : this.matterbridge.serverNode.state.commissioning.pairingCodes.qrPairingCode;
this.matterbridge.matterbridgeInformation.matterbridgeManualPairingCode = this.matterbridge.matterbridgeInformation.matterbridgeEndAdvertise ? undefined : this.matterbridge.serverNode.state.commissioning.pairingCodes.manualPairingCode;
this.matterbridge.matterbridgeInformation.matterbridgeFabricInformations = this.matterbridge.sanitizeFabricInformations(Object.values(this.matterbridge.serverNode.state.commissioning.fabrics));
this.matterbridge.matterbridgeInformation.matterbridgeSessionInformations = this.matterbridge.sanitizeSessionInformation(Object.values(this.matterbridge.serverNode.state.sessions.sessions));
}
return { systemInformation: this.matterbridge.systemInformation, matterbridgeInformation: this.matterbridge.matterbridgeInformation };
}
/**
* Retrieves the reachable attribute.
*
* @param {MatterbridgeEndpoint} device - The MatterbridgeEndpoint object.
* @returns {boolean} The reachable attribute.
*/
getReachability(device) {
if (!device.lifecycle.isReady || device.construction.status !== Lifecycle.Status.Active)
return false;
if (device.hasClusterServer(BridgedDeviceBasicInformation.Cluster.id))
return device.getAttribute(BridgedDeviceBasicInformation.Cluster.id, 'reachable');
if (device.mode === 'server' && device.serverNode && device.serverNode.state.basicInformation.reachable !== undefined)
return device.serverNode.state.basicInformation.reachable;
if (this.matterbridge.bridgeMode === 'childbridge')
return true;
return false;
}
/**
* Retrieves the power source attribute.
*
* @param {MatterbridgeEndpoint} endpoint - The MatterbridgeDevice to retrieve the power source from.
* @returns {'ac' | 'dc' | 'ok' | 'warning' | 'critical' | undefined} The power source attribute.
*/
getPowerSource(endpoint) {
if (!endpoint.lifecycle.isReady || endpoint.construction.status !== Lifecycle.Status.Active)
return undefined;
const powerSource = (device) => {
const featureMap = device.getAttribute(PowerSource.Cluster.id, 'featureMap');
if (featureMap.wired) {
const wiredCurrentType = device.getAttribute(PowerSource.Cluster.id, 'wiredCurrentType');
return ['ac', 'dc'][wiredCurrentType];
}
if (featureMap.battery) {
const batChargeLevel = device.getAttribute(PowerSource.Cluster.id, 'batChargeLevel');
return ['ok', 'warning', 'critical'][batChargeLevel];
}
return;
};
// Root endpoint
if (endpoint.hasClusterServer(PowerSource.Cluster.id))
return powerSource(endpoint);
// Child endpoints
for (const child of endpoint.getChildEndpoints()) {
if (child.hasClusterServer(PowerSource.Cluster.id))
return powerSource(child);
}
}
/**
* Retrieves the commissioned status, matter pairing codes, fabrics and sessions from a given device in server mode.
*
* @param {MatterbridgeEndpoint} device - The MatterbridgeEndpoint to retrieve the data from.
* @returns {ApiDevicesMatter | undefined} An ApiDevicesMatter object or undefined if not found.
*/
getMatterDataFromDevice(device) {
if (device.mode === 'server' && device.serverNode) {
return {
commissioned: device.serverNode.state.commissioning.commissioned,
qrPairingCode: device.serverNode.state.commissioning.pairingCodes.qrPairingCode,
manualPairingCode: device.serverNode.state.commissioning.pairingCodes.manualPairingCode,
fabricInformations: this.matterbridge.sanitizeFabricInformations(Object.values(device.serverNode.state.commissioning.fabrics)),
sessionInformations: this.matterbridge.sanitizeSessionInformation(Object.values(device.serverNode.state.sessions.sessions)),
};
}
}
/**
* Retrieves the cluster text description from a given device.
* The output is a string with the attributes description of the cluster servers in the device to show in the frontend.
*
* @param {MatterbridgeEndpoint} device - The MatterbridgeEndpoint to retrieve the cluster text from.
* @returns {string} The attributes description of the cluster servers in the device.
*/
getClusterTextFromDevice(device) {
if (!device.lifecycle.isReady || device.construction.status !== Lifecycle.Status.Active)
return '';
const getUserLabel = (device) => {
const labelList = getAttribute(device, 'userLabel', 'labelList');
if (labelList) {
const composed = labelList.find((entry) => entry.label === 'composed');
if (composed)
return 'Composed: ' + composed.value;
}
// istanbul ignore next cause is not reachable
return '';
};
const getFixedLabel = (device) => {
const labelList = getAttribute(device, 'fixedLabel', 'labelList');
if (labelList) {
const composed = labelList.find((entry) => entry.label === 'composed');
if (composed)
return 'Composed: ' + composed.value;
}
// istanbul ignore next cause is not reacheable
return '';
};
let attributes = '';
let supportedModes = [];
device.forEachAttribute((clusterName, clusterId, attributeName, attributeId, attributeValue) => {
// console.log(`${device.deviceName} => Cluster: ${clusterName}-${clusterId} Attribute: ${attributeName}-${attributeId} Value(${typeof attributeValue}): ${attributeValue}`);
if (typeof attributeValue === 'undefined' || attributeValue === undefined)
return;
if (clusterName === 'onOff' && attributeName === 'onOff')
attributes += `OnOff: ${attributeValue} `;
if (clusterName === 'switch' && attributeName === 'currentPosition')
attributes += `Position: ${attributeValue} `;
if (clusterName === 'windowCovering' && attributeName === 'currentPositionLiftPercent100ths' && isValidNumber(attributeValue, 0, 10000))
attributes += `Cover position: ${attributeValue / 100}% `;
if (clusterName === 'doorLock' && attributeNam