UNPKG

matterbridge

Version:
890 lines 123 kB
/** * 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')