matterbridge
Version:
Matterbridge plugin manager for Matter
890 lines • 123 kB
JavaScript
/**
* This file contains the class Frontend.
*
* @file frontend.ts
* @author Luca Liguori
* @date 2025-01-13
* @version 1.0.2
*
* 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. *
*/
// @matter
import { Logger, LogLevel as MatterLogLevel, LogFormat as MatterLogFormat, Lifecycle } from '@matter/main';
import { BridgedDeviceBasicInformation, PowerSource } from '@matter/main/clusters';
// Node modules
import { createServer } from 'node:http';
import https from 'node:https';
import os from 'node:os';
import path from 'node:path';
import { promises as fs } from 'node:fs';
// 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, wr, YELLOW, nt } from './logger/export.js';
// Matterbridge
import { createZip, isValidArray, isValidNumber, isValidObject, isValidString, isValidBoolean, withTimeout } from './utils/export.js';
import { plg } from './matterbridgeTypes.js';
import { hasParameter } from './utils/export.js';
import { capitalizeFirstLetter } from './matterbridgeEndpointHelpers.js';
import spawn from './utils/spawn.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 {
matterbridge;
log;
port = 8283;
initializeError = false;
expressApp;
httpServer;
httpsServer;
webSocketServer;
constructor(matterbridge) {
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
this.httpServer = createServer(this.expressApp);
// 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}`);
});
}
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}`);
});
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
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.initializeError = true;
return;
});
}
else {
// Load the SSL certificate, the private key and optionally the CA certificate
let cert;
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}`);
return;
}
let key;
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}`);
return;
}
let ca;
try {
ca = await fs.readFile(path.join(this.matterbridge.matterbridgeDirectory, 'certs/ca.pem'), 'utf8');
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}`);
}
const serverOptions = { cert, key, ca };
// Create an HTTPS server with the SSL certificate and private key (ca is optional) and attach the express app
this.httpsServer = https.createServer(serverOptions, this.expressApp);
// 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}`);
});
}
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}`);
});
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
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.initializeError = true;
return;
});
}
if (this.initializeError)
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.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) => {
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.webSocketServer.on('error', (ws, error) => {
this.log.error(`WebSocketServer error: ${error}`);
});
// Subscribe to cli events
const { cliEmitter } = await import('./cli.js');
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.getBaseRegisteredPlugins());
});
// 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.matterbrideLoggerFile), 'utf8');
res.type('text/plain');
res.send(data);
}
catch (error) {
this.log.error(`Error reading matterbridge log file ${this.matterbridge.matterbrideLoggerFile}: ${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.matterbrideLoggerFile}: ${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.matterbrideLoggerFile)}`);
try {
await fs.access(path.join(this.matterbridge.matterbridgeDirectory, this.matterbridge.matterbrideLoggerFile), fs.constants.F_OK);
const data = await fs.readFile(path.join(this.matterbridge.matterbridgeDirectory, this.matterbridge.matterbrideLoggerFile), 'utf8');
await fs.writeFile(path.join(os.tmpdir(), this.matterbridge.matterbrideLoggerFile), data, 'utf-8');
}
catch (error) {
await fs.writeFile(path.join(os.tmpdir(), this.matterbridge.matterbrideLoggerFile), '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(this.matterbridge.matterbridgeDirectory, this.matterbridge.matterbrideLoggerFile), 'matterbridge.log', (error) => {
res.download(path.join(os.tmpdir(), this.matterbridge.matterbrideLoggerFile), 'matterbridge.log', (error) => {
if (error) {
this.log.error(`Error downloading log file ${this.matterbridge.matterbrideLoggerFile}: ${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.matterbrideLoggerFile)}`);
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) => {
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) => {
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) => {
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) => {
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) => {
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) => {
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 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) => {
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;
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')) {
await spawn.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) {
// 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) {
this.log.error(`Error closing WebSocket server: ${error}`);
}
else {
this.log.debug('WebSocket server closed successfully');
}
resolve();
});
}), 10000, false);
this.webSocketServer.removeAllListeners();
this.webSocketServer = undefined;
}
// Close the http server
if (this.httpServer) {
await withTimeout(new Promise((resolve) => {
this.httpServer?.close((error) => {
if (error) {
this.log.error(`Error closing http server: ${error}`);
}
else {
this.log.debug('Http server closed successfully');
}
resolve();
});
}), 10000, false);
this.httpServer.removeAllListeners();
this.httpServer = undefined;
this.log.debug('Frontend http server closed successfully');
}
// Close the https server
if (this.httpsServer) {
await withTimeout(new Promise((resolve) => {
this.httpsServer?.close((error) => {
if (error) {
this.log.error(`Error closing https server: ${error}`);
}
else {
this.log.debug('Https server closed successfully');
}
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() {
const { lastCpuUsage } = await import('./cli.js');
// 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.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');
this.matterbridge.matterbridgeInformation.matterbridgePaired = this.matterbridge.matterbridgePaired;
this.matterbridge.matterbridgeInformation.matterbridgeQrPairingCode = this.matterbridge.matterbridgeQrPairingCode;
this.matterbridge.matterbridgeInformation.matterbridgeManualPairingCode = this.matterbridge.matterbridgeManualPairingCode;
this.matterbridge.matterbridgeInformation.matterbridgeFabricInformations = this.matterbridge.matterbridgeFabricInformations;
this.matterbridge.matterbridgeInformation.matterbridgeSessionInformations = this.matterbridge.matterbridgeSessionInformations;
this.matterbridge.matterbridgeInformation.profile = this.matterbridge.profile;
return { systemInformation: this.matterbridge.systemInformation, matterbridgeInformation: this.matterbridge.matterbridgeInformation };
}
/**
* Retrieves the reachable attribute.
* @param {MatterbridgeDevice} device - The MatterbridgeDevice 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 (this.matterbridge.bridgeMode === 'childbridge')
return true;
return false;
}
/**
* Retrieves the power source attribute.
* @param {MatterbridgeEndpoint} endpoint - The MatterbridgeDevice object.
* @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 cluster text description from a given device.
* @param {MatterbridgeDevice} device - The MatterbridgeDevice object.
* @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 getAttribute = (device, cluster, attribute) => {
let value = undefined;
Object.entries(device.state)
.filter(([clusterName]) => clusterName.toLowerCase() === cluster.toLowerCase())
.forEach(([, clusterAttributes]) => {
Object.entries(clusterAttributes)
.filter(([attributeName]) => attributeName.toLowerCase() === attribute.toLowerCase())
.forEach(([, attributeValue]) => {
value = attributeValue;
});
});
if (value === undefined)
this.log.error(`Cluster ${cluster} or attribute ${attribute} not found in device ${device.deviceName}`);
return value;
};
const getUserLabel = (device) => {
const labelList = getAttribute(device, 'userLabel', 'labelList');
if (!labelList)
return;
const composed = labelList.find((entry) => entry.label === 'composed');
if (composed)
return 'Composed: ' + composed.value;
else
return '';
};
const getFixedLabel = (device) => {
const labelList = getAttribute(device, 'fixedLabel', 'labelList');
if (!labelList)
return;
const composed = labelList.find((entry) => entry.label === 'composed');
if (composed)
return 'Composed: ' + composed.value;
else
return '';
};
let attributes = '';
let supportedModes = [];
/*
Object.keys(device.behaviors.supported).forEach((clusterName) => {
const clusterBehavior = device.behaviors.supported[lowercaseFirstLetter(clusterName)] as ClusterBehavior.Type | undefined;
// console.log(`Device: ${device.deviceName} => Cluster: ${clusterName} Behavior: ${clusterBehavior?.id}`, clusterBehavior);
if (clusterBehavior && clusterBehavior.cluster && clusterBehavior.cluster.attributes) {
Object.entries(clusterBehavior.cluster.attributes).forEach(([attributeName, attribute]) => {
// console.log(`${device.deviceName} => Cluster: ${clusterName} Attribute: ${attributeName}`, attribute);
});
}
});
*/
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' && attributeName === 'lockState')
attributes += `State: ${attributeValue === 1 ? 'Locked' : 'Not locked'} `;
if (clusterName === 'thermostat' && attributeName === 'localTemperature' && isValidNumber(attributeValue))
attributes += `Temperature: ${attributeValue / 100}°C `;
if (clusterName === 'thermostat' && attributeName === 'occupiedHeatingSetpoint' && isValidNumber(attributeValue))
attributes += `Heat to: ${attributeValue / 100}°C `;
if (clusterName === 'thermostat' && attributeName === 'occupiedCoolingSetpoint' && isValidNumber(attributeValue))
attributes += `Cool to: ${attributeValue / 100}°C `;
const modeClusters = ['modeSelect', 'rvcRunMode', 'rvcCleanMode', 'laundryWasherMode', 'ovenMode', 'microwaveOvenMode'];
if (modeClusters.includes(clusterName) && attributeName === 'supportedModes') {
supportedModes = attributeValue;
}
if (modeClusters.includes(clusterName) && attributeName === 'currentMode') {
const supportedMode = supportedModes.find((mode) => mode.mode === attributeValue);
if (supportedMode)
attributes += `Mode: ${supportedMode.label} `;
else
attributes += `Mode: ${attributeValue} `;
}
const operationalStateClusters = ['operationalState', 'rvcOperationalState'];
if (operationalStateClusters.includes(clusterName) && attributeName === 'operationalState')
attributes += `OpState: ${attributeValue} `;
if (clusterName === 'pumpConfigurationAndControl' && attributeName === 'operationMode')
attributes += `Mode: ${attributeValue} `;
if (clusterName === 'valveConfigurationAndControl' && attributeName === 'currentState')
attributes += `State: ${attributeValue} `;
if (clusterName === 'levelControl' && attributeName === 'currentLevel')
attributes += `Level: ${attributeValue} `;
if (clusterName === 'colorControl' && attributeName === 'colorMode' && isValidNumber(attributeValue, 0, 2))
attributes += `Mode: ${['HS', 'XY', 'CT'][attributeValue]} `;
if (clusterName === 'colorControl' && getAttribute(device, 'colorControl', 'colorMode') === 0 && attributeName === 'currentHue' && isValidNumber(attributeValue))
attributes += `Hue: ${Math.round(attributeValue)} `;
if (clusterName === 'colorControl' && getAttribute(device, 'colorControl', 'colorMode') === 0 && attributeName === 'currentSaturation' && isValidNumber(attributeValue))
attributes += `Saturation: ${Math.round(attributeValue)} `;
if (clusterName === 'colorControl' && getAttribute(device, 'colorControl', 'colorMode') === 1 && attributeName === 'currentX' && isValidNumber(attributeValue))
attributes += `X: ${Math.round(attributeValue / 655.36) / 100} `;
if (clusterName === 'colorControl' && getAttribute(device, 'colorControl', 'colorMode') === 1 && attributeName === 'currentY' && isValidNumber(attributeValue))
attributes += `Y: ${Math.round(attributeValue / 655.36) / 100} `;
if (clusterName === 'colorControl' && getAttribute(device, 'colorControl', 'colorMode') === 2 && attributeName === 'colorTemperatureMireds' && isValidNumber(attributeValue))
attributes += `ColorTemp: ${Math.round(attributeValue)} `;
if (clusterName === 'booleanState' && attributeName === 'stateValue')
attributes += `Contact: ${attributeValue} `;
if (clusterName === 'booleanStateConfiguration' && attributeName === 'alarmsActive' && isValidObject(attributeValue))
attributes += `Active alarms: ${stringify(attributeValue)} `;
if (clusterName === 'smokeCoAlarm' && attributeName === 'smokeState')
attributes += `Smoke: ${attributeValue} `;
if (clusterName === 'smokeCoAlarm' && attributeName === 'coState')
attributes += `Co: ${attributeValue} `;
if (clusterName === 'fanControl' && attributeName === 'fanMode')
attributes += `Mode: ${attributeValue} `;
if (clusterName === 'fanControl' && attributeName === 'percentCurrent')
attributes += `Percent: ${attributeValue} `;
if (clusterName === 'fanControl' && attributeName === 'speedCurrent')