UNPKG

matterbridge

Version:
922 lines • 136 kB
/** * 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