diff --git a/CHANGELOG.md b/CHANGELOG.md index 848990a3..532b8cc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,16 +21,35 @@ Starting from v. 2.1.0, the legacy old api of matter.js have been completely rem For this reason there is no compatibility with the old versions of the plugins. -You need to update all plugins you use and Matterbridge in the same moment. +You need to update all plugins you use and Matterbridge in the same moment. I suggest to first update all plugins without restarting and then to update Matterbridge so when it restarts, all versions will be the latest. +If you use docker, all plugins are already installed in the image so you just need to pull the new image. + Compatibility list: matterbridge-shelly v. 1.1.5 matterbridge-zigbee2mqtt v. 2.4.4 matterbridge-somfy-tahoma v. 1.2.3 matterbridge-hass v. 0.0.8 +## [2.1.4] - 2025-02-07 + +### Added + +- [frontend]: Added memorycheck before cleanup. +- [platform]: Added a check for not latin characters. +- [platform]: Added a check for already registered device names. + +### Changed + +- [package]: Update matter.js to 0.12.3. +- [matter.js]: Since matter.js storage cannot properly encode non latin names, they are encoded before passing them to matter.js. + + + Buy me a coffee + + ## [2.1.3] - 2025-02-04 ### Added diff --git a/README-DOCKER.md b/README-DOCKER.md index 5d29e280..77722567 100644 --- a/README-DOCKER.md +++ b/README-DOCKER.md @@ -48,11 +48,13 @@ The container must have full access to the host network (needed for mdns). ``` sudo docker run --name matterbridge \ - -v ${HOME}/Matterbridge:/root/Matterbridge \ - -v ${HOME}/.matterbridge:/root/.matterbridge \ + -v /home//Matterbridge:/root/Matterbridge \ + -v /home//.matterbridge:/root/.matterbridge \ --network host --restart always -d luligu/matterbridge:latest ``` +Replace USER with your user name (i.e. ubuntu or pi). + You may need to adapt the script to your setup. ### Run with docker compose @@ -63,14 +65,16 @@ The docker-compose.yml file is available in the docker directory of the package services: matterbridge: container_name: matterbridge - image: luligu/matterbridge:latest # Matterbridge image with the latest tag - network_mode: host # Ensures the Matter mdns works - restart: always # Ensures the container always restarts automatically + image: luligu/matterbridge:latest # Matterbridge image with the tag latest + network_mode: host # Ensures the Matter mdns works + restart: always # Ensures the container always restarts automatically volumes: - - "${HOME}/Matterbridge:/root/Matterbridge" # Mounts the Matterbridge plugin directory - - "${HOME}/.matterbridge:/root/.matterbridge" # Mounts the Matterbridge storage directory + - "/home//Matterbridge:/root/Matterbridge" # Mounts the Matterbridge plugin directory + - "/home//.matterbridge:/root/.matterbridge" # Mounts the Matterbridge storage directory ``` +Replace USER with your user name (i.e. ubuntu or pi). + copy it in the home directory or edit the existing one to add the matterbridge service. Then start docker compose with: diff --git a/package-lock.json b/package-lock.json index a9f4770f..68a4cb16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "matterbridge", - "version": "2.1.3", + "version": "2.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "matterbridge", - "version": "2.1.3", + "version": "2.1.4", "license": "Apache-2.0", "dependencies": { - "@matter/main": "0.12.2", + "@matter/main": "0.12.3", "archiver": "7.0.1", "express": "4.21.2", "glob": "11.0.1", @@ -1319,65 +1319,65 @@ } }, "node_modules/@matter/general": { - "version": "0.12.2", - "resolved": "https://registry.npmjs.org/@matter/general/-/general-0.12.2.tgz", - "integrity": "sha512-D8S2CQHD7KI8L9lxeRgFCLDziXZRAEHqAJei7e0d5Jqh5thPH6S2HUcSX+je6EZDFTshguMgboW09lhXnUR8lA==", + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/@matter/general/-/general-0.12.3.tgz", + "integrity": "sha512-1dya8bKRAhNkXD5xDWapVSO4Wfug8Qw2RWw74NZQNtGKbyBuNQsCnShtOdYGLnkjf/7XUGsYGmCG8hKepBHnbQ==", "license": "Apache-2.0", "dependencies": { "@noble/curves": "^1.8.1" } }, "node_modules/@matter/main": { - "version": "0.12.2", - "resolved": "https://registry.npmjs.org/@matter/main/-/main-0.12.2.tgz", - "integrity": "sha512-bMHBLKmXpLmV/bkOxyhc+j+BhagFg5W6x/TJjfdZv0dZolkD1yT5zGZClmdGaLhhvyuYEKGKlecCM9uQksgxnA==", + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/@matter/main/-/main-0.12.3.tgz", + "integrity": "sha512-PjbSuQfm7pv/opqODgU7JhA5Gj/6TxiFiDr+i4NWFit+/NyVorDpjCKTpCZmj3XD7QhEp0iKeDG7KKBqrQ4TUQ==", "license": "Apache-2.0", "dependencies": { - "@matter/general": "0.12.2", - "@matter/model": "0.12.2", - "@matter/node": "0.12.2", - "@matter/protocol": "0.12.2", - "@matter/types": "0.12.2", + "@matter/general": "0.12.3", + "@matter/model": "0.12.3", + "@matter/node": "0.12.3", + "@matter/protocol": "0.12.3", + "@matter/types": "0.12.3", "@noble/curves": "^1.8.1" }, "optionalDependencies": { - "@matter/nodejs": "0.12.2" + "@matter/nodejs": "0.12.3" } }, "node_modules/@matter/model": { - "version": "0.12.2", - "resolved": "https://registry.npmjs.org/@matter/model/-/model-0.12.2.tgz", - "integrity": "sha512-xM8hwqVepb6YpvjE9jXacA4YypPHJm0latei7YTZystgvh8lUHDk5dkLELyrXzbNYTs//LMPAbj6SIsz9CorPA==", + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/@matter/model/-/model-0.12.3.tgz", + "integrity": "sha512-wuiBuUR45lno2PMx1YPhwZyaG7+20iBlb5SlnzYfI5j6fJv1WyYv22APDDPKDglPj4rP2RABCyCUfd1/K5EfXg==", "license": "Apache-2.0", "dependencies": { - "@matter/general": "0.12.2", + "@matter/general": "0.12.3", "@noble/curves": "^1.8.1" } }, "node_modules/@matter/node": { - "version": "0.12.2", - "resolved": "https://registry.npmjs.org/@matter/node/-/node-0.12.2.tgz", - "integrity": "sha512-/VHnBHf6bFf6fVP6SvTrJDtKCzfZ6j5DTuEPRQFbdH1ph7OGyzVXjVRwoLPTzo2XT/VrTPcFNB38UNxTky1zQA==", + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/@matter/node/-/node-0.12.3.tgz", + "integrity": "sha512-+bIYQtjCR053lgCd8mlnIp6ufm+H+zJGlZp8cdMX6IkkvT0vRqF2QxDtHpQn8CtzjnFzg+O5j0R4jdss2SluXg==", "license": "Apache-2.0", "dependencies": { - "@matter/general": "0.12.2", - "@matter/model": "0.12.2", - "@matter/protocol": "0.12.2", - "@matter/types": "0.12.2", + "@matter/general": "0.12.3", + "@matter/model": "0.12.3", + "@matter/protocol": "0.12.3", + "@matter/types": "0.12.3", "@noble/curves": "^1.8.1" } }, "node_modules/@matter/nodejs": { - "version": "0.12.2", - "resolved": "https://registry.npmjs.org/@matter/nodejs/-/nodejs-0.12.2.tgz", - "integrity": "sha512-sMnczk+3+7tr5OQDntPP7TuZP83/jC9jG713J1iJxU77cv/GMKIecKfAOrbFl50Ga+4zZTfIV9Mpekn50IT0ig==", + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/@matter/nodejs/-/nodejs-0.12.3.tgz", + "integrity": "sha512-Jx6TIYvl+2WlD9Zh6eMdClsjoPQjIkR3qhYGPbbhHlO7QXZWZz7d83jo1yShHWQrqnnfqqrkXy6iLBphrZ4SSg==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@matter/general": "0.12.2", - "@matter/node": "0.12.2", - "@matter/protocol": "0.12.2", - "@matter/types": "0.12.2", + "@matter/general": "0.12.3", + "@matter/node": "0.12.3", + "@matter/protocol": "0.12.3", + "@matter/types": "0.12.3", "node-localstorage": "^3.0.5" }, "engines": { @@ -1385,25 +1385,25 @@ } }, "node_modules/@matter/protocol": { - "version": "0.12.2", - "resolved": "https://registry.npmjs.org/@matter/protocol/-/protocol-0.12.2.tgz", - "integrity": "sha512-BhfPqRgnNy+Fx6VWffFtA8aGdspvzxp+TY5PJpAqExBLwGB/kc/knagOOfLvSjSyT/gdZmIxep3Iw2ycm7UYYA==", + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/@matter/protocol/-/protocol-0.12.3.tgz", + "integrity": "sha512-mUL6rSXdm0LaS/i6bxjBQlZ/0fi8hXoinBytbKNkztfDOB9I3FeNlgp9FJxZ+ZJtrsPleMg4q+loLU7uNQi30w==", "license": "Apache-2.0", "dependencies": { - "@matter/general": "0.12.2", - "@matter/model": "0.12.2", - "@matter/types": "0.12.2", + "@matter/general": "0.12.3", + "@matter/model": "0.12.3", + "@matter/types": "0.12.3", "@noble/curves": "^1.8.1" } }, "node_modules/@matter/types": { - "version": "0.12.2", - "resolved": "https://registry.npmjs.org/@matter/types/-/types-0.12.2.tgz", - "integrity": "sha512-sTujir3YTDrbFnkccCrc9zdSx4AGRHdOeVBLPvVORNieHoKlBeKLBtCA9GsjRbbpnmSrpPe9Fl6z1MkCWblAmw==", + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/@matter/types/-/types-0.12.3.tgz", + "integrity": "sha512-4nlu7nCW+V/Sed1XkJoPvtdRz0wIv8o9YSDj4To3yGR77HDqK9nbLXxpM7J+PQmGQ0/xEX1ylWU9X2dSIsH8WQ==", "license": "Apache-2.0", "dependencies": { - "@matter/general": "0.12.2", - "@matter/model": "0.12.2", + "@matter/general": "0.12.3", + "@matter/model": "0.12.3", "@noble/curves": "^1.8.1" } }, @@ -2659,9 +2659,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001697", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001697.tgz", - "integrity": "sha512-GwNPlWJin8E+d7Gxq96jxM6w0w+VFeyyXRsjU58emtkYqnbwHqXm5uT2uCmO0RQE9htWknOP4xtBlLmM/gWxvQ==", + "version": "1.0.30001698", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001698.tgz", + "integrity": "sha512-xJ3km2oiG/MbNU8G6zIq6XRZ6HtAOVXsbOrP/blGazi52kc5Yy7b6sDA5O+FbROzRrV7BSTllLHuNvmawYUJjw==", "dev": true, "funding": [ { @@ -3108,9 +3108,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.91", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.91.tgz", - "integrity": "sha512-sNSHHyq048PFmZY4S90ax61q+gLCs0X0YmcOII9wG9S2XwbVr+h4VW2wWhnbp/Eys3cCwTxVF292W3qPaxIapQ==", + "version": "1.5.95", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.95.tgz", + "integrity": "sha512-XNsZaQrgQX+BG37BRQv+E+HcOZlWhqYaDoVVNCws/WrYYdbGrkR1qCDJ2mviBF3flCs6/BTa4O7ANfFTFZk6Dg==", "dev": true, "license": "ISC" }, @@ -5576,9 +5576,9 @@ } }, "node_modules/object-inspect": { - "version": "1.13.3", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", - "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "license": "MIT", "engines": { "node": ">= 0.4" diff --git a/package.json b/package.json index b667b73c..27c19259 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matterbridge", - "version": "2.1.3", + "version": "2.1.4", "description": "Matterbridge plugin manager for Matter", "author": "https://github.com/Luligu", "license": "Apache-2.0", @@ -147,7 +147,7 @@ "install:jest": "npm install --save-dev jest ts-jest @types/jest eslint-plugin-jest && npm run test" }, "dependencies": { - "@matter/main": "0.12.2", + "@matter/main": "0.12.3", "archiver": "7.0.1", "express": "4.21.2", "glob": "11.0.1", diff --git a/src/deviceManager.test.ts b/src/deviceManager.test.ts index 610f8495..964ae54f 100644 --- a/src/deviceManager.test.ts +++ b/src/deviceManager.test.ts @@ -10,7 +10,6 @@ import { MatterbridgeEndpoint } from './matterbridgeEndpoint.js'; import { DeviceManager } from './deviceManager.js'; import { PluginManager } from './pluginManager.js'; import { contactSensor, occupancySensor } from './matterbridgeDeviceTypes.js'; -import { MdnsService } from '@matter/main/protocol'; // Default colors const plg = '\u001B[38;5;33m'; @@ -38,9 +37,7 @@ describe('DeviceManager with mocked devices', () => { afterAll(async () => { // Close the Matterbridge instance - const server = matterbridge.serverNode; await matterbridge.destroyInstance(); - await server?.env.get(MdnsService)[Symbol.asyncDispose](); // Restore the mocked AnsiLogger.log method loggerLogSpy.mockRestore(); // Restore the mocked console.log @@ -195,7 +192,6 @@ describe('DeviceManager with real devices', () => { // Close the Matterbridge instance const server = matterbridge.serverNode; await matterbridge.destroyInstance(); - await server?.env.get(MdnsService)[Symbol.asyncDispose](); // Restore the mocked AnsiLogger.log method loggerLogSpy.mockRestore(); // Restore the mocked console.log diff --git a/src/frontend.test.ts b/src/frontend.test.ts index de7c619a..119e6fac 100644 --- a/src/frontend.test.ts +++ b/src/frontend.test.ts @@ -1166,9 +1166,7 @@ describe('Matterbridge frontend', () => { expect((matterbridge as any).plugins.size).toBe(0); // Close the Matterbridge instance - const server = matterbridge.serverNode; await matterbridge.destroyInstance(); - await server?.env.get(MdnsService)[Symbol.asyncDispose](); expect(loggerLogSpy).toHaveBeenCalledWith(LogLevel.DEBUG, `WebSocket server closed successfully`); expect(loggerLogSpy).toHaveBeenCalledWith(LogLevel.NOTICE, `Cleanup completed. Shutting down...`); diff --git a/src/frontend.ts b/src/frontend.ts index 55de2c0d..bc6a5f8b 100644 --- a/src/frontend.ts +++ b/src/frontend.ts @@ -924,6 +924,20 @@ export class Frontend { } async stop() { + // Start the memory check. This will not allow the process to exit but will log the memory usage for 5 minutes. + if (hasParameter('memorycheck')) { + await new Promise((resolve) => { + this.log.debug(`***Memory check started for ${getIntParameter('memorycheck') ?? 5 * 60 * 1000} ms`); + setTimeout( + () => { + this.log.debug(`***Memory check stopped after ${getIntParameter('memorycheck') ?? 5 * 60 * 1000} ms`); + resolve(); + }, + getIntParameter('memorycheck') ?? 5 * 60 * 1000, + ); + }); + } + // Close the http server if (this.httpServer) { this.httpServer.close(); @@ -1015,7 +1029,7 @@ export class Frontend { }; this.log.debug( - `***Cpu usage ${CYAN}${cpuUsage.padStart(6, ' ')} %${db} - Memory usage rss ${CYAN}${memoryUsage.rss}${db} heapTotal ${CYAN}${memoryUsage.heapTotal}${db} heapUsed ${CYAN}${memoryUsage.heapUsed}${db} external ${memoryUsage.external} arrayBuffers ${memoryUsage.arrayBuffers}`, + `***Cpu usage: ${CYAN}${cpuUsage.padStart(6, ' ')} %${db} - Memory usage: rss ${CYAN}${memoryUsage.rss}${db} heapTotal ${CYAN}${memoryUsage.heapTotal}${db} heapUsed ${CYAN}${memoryUsage.heapUsed}${db} external ${memoryUsage.external} arrayBuffers ${memoryUsage.arrayBuffers}`, ); }; interval(); @@ -1045,7 +1059,7 @@ export class Frontend { }; // eslint-disable-next-line no-console console.log( - `${YELLOW}Cpu usage${db} ${CYAN}${memory.cpu.padStart(6, ' ')} %${db} - ${YELLOW}Memory usage${db} rss ${CYAN}${memoryUsage.rss}${db} heapTotal ${CYAN}${memoryUsage.heapTotal}${db} heapUsed ${CYAN}${memoryUsage.heapUsed}${db} external ${memoryUsage.external} arrayBuffers ${memoryUsage.arrayBuffers}${rs}`, + `${YELLOW}Cpu usage:${db} ${CYAN}${memory.cpu.padStart(6, ' ')} %${db} - ${YELLOW}Memory usage:${db} rss ${CYAN}${memoryUsage.rss}${db} heapTotal ${CYAN}${memoryUsage.heapTotal}${db} heapUsed ${CYAN}${memoryUsage.heapUsed}${db} external ${memoryUsage.external} arrayBuffers ${memoryUsage.arrayBuffers}${rs}`, ); } this.memoryData = []; diff --git a/src/matterbridge.bridge.test.ts b/src/matterbridge.bridge.test.ts index bc76a939..9eb2d4f3 100644 --- a/src/matterbridge.bridge.test.ts +++ b/src/matterbridge.bridge.test.ts @@ -10,7 +10,6 @@ import { jest } from '@jest/globals'; import { AnsiLogger, db, LogLevel, nf, rs, UNDERLINE, UNDERLINEOFF } from 'node-ansi-logger'; import { Matterbridge } from './matterbridge.js'; import { wait, waiter } from './utils/utils.js'; -import { MdnsService } from '@matter/main/protocol'; import { Environment, StorageService } from '@matter/main'; import path from 'path'; import os from 'os'; @@ -83,7 +82,6 @@ describe('Matterbridge loadInstance() and cleanup() -bridge mode', () => { afterAll(async () => { // Restore all mocks jest.restoreAllMocks(); - // console.log('Matterbridge test -bridge mode'); }, 30000); test('Matterbridge.loadInstance(true) -bridge mode', async () => { @@ -160,9 +158,7 @@ describe('Matterbridge loadInstance() and cleanup() -bridge mode', () => { test('Matterbridge.destroyInstance() -bridge mode', async () => { // Close the Matterbridge instance - const server = matterbridge.serverNode; await matterbridge.destroyInstance(); - await server?.env.get(MdnsService)[Symbol.asyncDispose](); expect((matterbridge as any).log.log).toHaveBeenCalledWith(LogLevel.NOTICE, `Cleanup completed. Shutting down...`); }, 60000); diff --git a/src/matterbridge.childbridge.test.ts b/src/matterbridge.childbridge.test.ts index 038ea2ce..62e5ed07 100644 --- a/src/matterbridge.childbridge.test.ts +++ b/src/matterbridge.childbridge.test.ts @@ -9,7 +9,6 @@ import { jest } from '@jest/globals'; import { AnsiLogger, db, LogLevel, nf, rs, UNDERLINE, UNDERLINEOFF } from 'node-ansi-logger'; import { Matterbridge } from './matterbridge.js'; import { wait, waiter } from './utils/utils.js'; -import { MdnsService } from '@matter/main/protocol'; import { Environment, StorageService } from '@matter/main'; import path from 'path'; import os from 'os'; @@ -82,7 +81,6 @@ describe('Matterbridge loadInstance() and cleanup() -childbridge mode', () => { afterAll(async () => { // Restore all mocks jest.restoreAllMocks(); - // console.log('Matterbridge test -childbridge mode'); }); test('Matterbridge.loadInstance(true) -childbridge mode', async () => { diff --git a/src/matterbridge.test.ts b/src/matterbridge.test.ts index 9e146c27..750dc4eb 100644 --- a/src/matterbridge.test.ts +++ b/src/matterbridge.test.ts @@ -1,13 +1,13 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-explicit-any */ -process.argv = ['node', 'matterbridge.test.js', '-frontend', '0', '-profile', 'Jest']; +process.argv = ['node', 'matterbridge.test.js', '-frontend', '0', '-profile', 'Jest', '-logger', 'debug', '-matterlogger', 'debug']; import { jest } from '@jest/globals'; // jest.mock('@project-chip/matter-node.js/util'); -import { AnsiLogger, db, LogLevel, nf } from 'node-ansi-logger'; +import { AnsiLogger, db, LogLevel, nf, TimestampFormat } from 'node-ansi-logger'; import { hasParameter } from './utils/utils.js'; import { Matterbridge } from './matterbridge.js'; import { RegisteredPlugin, SessionInformation } from './matterbridgeTypes.js'; @@ -81,7 +81,9 @@ describe('Matterbridge', () => { let matterbridge: Matterbridge; test('Matterbridge.loadInstance(false)', async () => { + expect((Matterbridge as any).instance).toBeUndefined(); matterbridge = await Matterbridge.loadInstance(false); + matterbridge.log = new AnsiLogger({ logName: 'Matterbridge', logTimestampFormat: TimestampFormat.TIME_MILLIS, logLevel: LogLevel.DEBUG }); expect(matterbridge).toBeDefined(); expect(matterbridge.profile).toBe('Jest'); expect(matterbridge.nodeStorageName).toBe('storage.Jest'); @@ -89,7 +91,7 @@ describe('Matterbridge', () => { expect(matterbridge.matterbrideLoggerFile).toBe('matterbridge.Jest.log'); expect(matterbridge.matterLoggerFile).toBe('matter.Jest.log'); expect((matterbridge as any).initialized).toBeFalsy(); - expect((matterbridge as any).log).toBeUndefined(); + expect((matterbridge as any).log).toBeDefined(); expect((matterbridge as any).homeDirectory).toBe(''); expect((matterbridge as any).matterbridgeDirectory).toBe(''); expect((matterbridge as any).globalModulesDirectory).toBe(''); @@ -111,6 +113,7 @@ describe('Matterbridge', () => { }); test('Matterbridge.loadInstance(true)', async () => { + expect((Matterbridge as any).instance).toBeDefined(); matterbridge = await Matterbridge.loadInstance(true); expect((matterbridge as any).initialized).toBeFalsy(); if (!(matterbridge as any).initialized) await matterbridge.initialize(); @@ -148,6 +151,7 @@ describe('Matterbridge', () => { test('Matterbridge.loadInstance(true) with frontend', async () => { process.argv = ['node', 'matterbridge.test.js', '-frontend', '8081', '-profile', 'Jest']; + expect((Matterbridge as any).instance).toBeUndefined(); matterbridge = await Matterbridge.loadInstance(true); expect((matterbridge as any).initialized).toBeTruthy(); if (!(matterbridge as any).initialized) await matterbridge.initialize(); @@ -179,6 +183,7 @@ describe('Matterbridge', () => { let matterbridge: Matterbridge; beforeAll(async () => { + process.argv = ['node', 'matterbridge.test.js', '-frontend', '0', '-profile', 'Jest', '-logger', 'debug', '-matterlogger', 'debug']; matterbridge = await Matterbridge.loadInstance(true); if (!(matterbridge as any).initialized) await matterbridge.initialize(); }); @@ -191,6 +196,8 @@ describe('Matterbridge', () => { test('Matterbridge profile', async () => { expect(matterbridge).toBeDefined(); expect(matterbridge.profile).toBe('Jest'); + expect((matterbridge as any).initialized).toBe(true); + expect((matterbridge as any).hasCleanupStarted).toBe(false); }); test('should do a partial mock of AnsiLogger', () => { @@ -273,17 +280,23 @@ describe('Matterbridge', () => { }); test('matterbridge -help', async () => { + expect((matterbridge as any).initialized).toBe(true); + expect((matterbridge as any).hasCleanupStarted).toBe(false); + const shutdownPromise = new Promise((resolve) => { matterbridge.on('shutdown', resolve); }); process.argv = ['node', 'matterbridge.test.js', '-frontend', '0', '-help']; await (matterbridge as any).parseCommandLine(); - expect(loggerLogSpy).toHaveBeenCalled(); + expect(loggerLogSpy).toHaveBeenCalledWith(LogLevel.INFO, expect.stringContaining('Usage: matterbridge [options]')); await shutdownPromise; matterbridge.removeAllListeners('shutdown'); }, 60000); test('matterbridge -list', async () => { + expect((matterbridge as any).initialized).toBe(true); + expect((matterbridge as any).hasCleanupStarted).toBe(false); + const shutdownPromise = new Promise((resolve) => { matterbridge.on('shutdown', resolve); }); @@ -296,6 +309,9 @@ describe('Matterbridge', () => { }, 60000); test('matterbridge -logstorage', async () => { + expect((matterbridge as any).initialized).toBe(true); + expect((matterbridge as any).hasCleanupStarted).toBe(false); + const shutdownPromise = new Promise((resolve) => { matterbridge.on('shutdown', resolve); }); @@ -307,6 +323,9 @@ describe('Matterbridge', () => { }, 60000); test('matterbridge -loginterfaces', async () => { + expect((matterbridge as any).initialized).toBe(true); + expect((matterbridge as any).hasCleanupStarted).toBe(false); + const shutdownPromise = new Promise((resolve) => { matterbridge.on('shutdown', resolve); }); @@ -318,6 +337,9 @@ describe('Matterbridge', () => { }, 60000); test('matterbridge -reset', async () => { + expect((matterbridge as any).initialized).toBe(true); + expect((matterbridge as any).hasCleanupStarted).toBe(false); + const shutdownPromise = new Promise((resolve) => { matterbridge.on('shutdown', resolve); }); @@ -329,6 +351,9 @@ describe('Matterbridge', () => { }, 60000); test('matterbridge -reset xxx', async () => { + expect((matterbridge as any).initialized).toBe(false); + expect((matterbridge as any).hasCleanupStarted).toBe(false); + const shutdownPromise = new Promise((resolve) => { matterbridge.on('shutdown', resolve); }); @@ -342,6 +367,9 @@ describe('Matterbridge', () => { }, 60000); test('matterbridge -add mockPlugin1', async () => { + expect((matterbridge as any).initialized).toBe(false); + expect((matterbridge as any).hasCleanupStarted).toBe(false); + const shutdownPromise = new Promise((resolve) => { matterbridge.on('shutdown', resolve); }); @@ -366,6 +394,9 @@ describe('Matterbridge', () => { }, 60000); test('matterbridge -disable mockPlugin1', async () => { + expect((matterbridge as any).initialized).toBe(false); + expect((matterbridge as any).hasCleanupStarted).toBe(false); + const shutdownPromise = new Promise((resolve) => { matterbridge.on('shutdown', resolve); }); @@ -386,6 +417,9 @@ describe('Matterbridge', () => { }, 60000); test('matterbridge -enable mockPlugin1', async () => { + expect((matterbridge as any).initialized).toBe(false); + expect((matterbridge as any).hasCleanupStarted).toBe(false); + const shutdownPromise = new Promise((resolve) => { matterbridge.on('shutdown', resolve); }); @@ -406,6 +440,9 @@ describe('Matterbridge', () => { }, 60000); test('matterbridge -remove mockPlugin1', async () => { + expect((matterbridge as any).initialized).toBe(false); + expect((matterbridge as any).hasCleanupStarted).toBe(false); + const shutdownPromise = new Promise((resolve) => { matterbridge.on('shutdown', resolve); }); @@ -421,6 +458,9 @@ describe('Matterbridge', () => { }, 60000); test('matterbridge -add mockPlugin1/2/3', async () => { + expect((matterbridge as any).initialized).toBe(false); + expect((matterbridge as any).hasCleanupStarted).toBe(false); + const shutdownPromise = new Promise((resolve) => { matterbridge.on('shutdown', resolve); }); @@ -468,6 +508,9 @@ describe('Matterbridge', () => { }, 60000); test('matterbridge start mockPlugin1/2/3', async () => { + expect((matterbridge as any).initialized).toBe(false); + expect((matterbridge as any).hasCleanupStarted).toBe(false); + process.argv = ['node', 'matterbridge.test.js', '-frontend', '0']; const plugins = (await (matterbridge as any).plugins.array()) as RegisteredPlugin[]; expect(plugins).toHaveLength(3); @@ -492,15 +535,14 @@ describe('Matterbridge', () => { }, 10000); test('matterbridge -factoryreset', async () => { - const shutdownPromise = new Promise((resolve) => { - matterbridge.on('shutdown', resolve); - }); + expect((matterbridge as any).initialized).toBe(false); + expect((matterbridge as any).hasCleanupStarted).toBe(false); + process.argv = ['node', 'matterbridge.test.js', '-frontend', '0', '-factoryreset']; await (matterbridge as any).parseCommandLine(); - expect((matterbridge as any).log.log).toHaveBeenCalledWith(LogLevel.INFO, 'Factory reset done! Remove all paired fabrics from the controllers.'); + expect(loggerLogSpy).toHaveBeenCalledWith(LogLevel.INFO, 'Factory reset done! Remove all paired fabrics from the controllers.'); expect((matterbridge as any).plugins).toHaveLength(0); - // await shutdownPromise; matterbridge.removeAllListeners('shutdown'); - }, 60000); + }, 10000); }); }); diff --git a/src/matterbridge.ts b/src/matterbridge.ts index 0d088d9b..c3572d06 100644 --- a/src/matterbridge.ts +++ b/src/matterbridge.ts @@ -45,7 +45,7 @@ import { Frontend } from './frontend.js'; // @matter import { DeviceTypeId, Endpoint as EndpointNode, Logger, LogLevel as MatterLogLevel, LogFormat as MatterLogFormat, VendorId, StorageContext, StorageManager, StorageService, Environment, ServerNode, FabricIndex, SessionsBehavior } from '@matter/main'; -import { DeviceCommissioner, ExposedFabricInformation, FabricAction, PaseClient } from '@matter/main/protocol'; +import { DeviceCommissioner, ExposedFabricInformation, FabricAction, MdnsService, PaseClient } from '@matter/main/protocol'; import { AggregatorEndpoint } from '@matter/main/endpoints'; // Default colors @@ -222,9 +222,24 @@ export class Matterbridge extends EventEmitter { * */ async destroyInstance() { + // Save server nodes to close + const servers: ServerNode[] = []; + if (this.bridgeMode === 'bridge') { + if (this.serverNode) servers.push(this.serverNode); + } + if (this.bridgeMode === 'childbridge') { + for (const plugin of this.plugins.array()) { + if (plugin.serverNode) servers.push(plugin.serverNode); + } + } + // Cleanup await this.cleanup('destroying instance...', false); - // await matterServerNode.env.get(MdnsService)[Symbol.asyncDispose](); - // this.log.info(`Closed ${matterServerNode.id} MdnsService`); + // Close servers mdns service + for (const server of servers) { + await server.env.get(MdnsService)[Symbol.asyncDispose](); + this.log.info(`Closed ${server.id} MdnsService`); + } + // Wait for the cleanup to finish await new Promise((resolve) => { setTimeout(resolve, 1000); }); @@ -745,13 +760,13 @@ export class Matterbridge extends EventEmitter { process.removeAllListeners('unhandledRejection'); this.exceptionHandler = async (error: Error) => { - this.log.fatal('Unhandled Exception detected at:', error.stack || error, rs); + this.log.error('Unhandled Exception detected at:', error.stack || error, rs); // await this.cleanup('Unhandled Exception detected, cleaning up...'); }; process.on('uncaughtException', this.exceptionHandler); this.rejectionHandler = async (reason, promise) => { - this.log.fatal('Unhandled Rejection detected at:', promise, 'reason:', reason instanceof Error ? reason.stack : reason, rs); + this.log.error('Unhandled Rejection detected at:', promise, 'reason:', reason instanceof Error ? reason.stack : reason, rs); // await this.cleanup('Unhandled Rejection detected, cleaning up...'); }; process.on('unhandledRejection', this.rejectionHandler); @@ -1387,6 +1402,8 @@ export class Matterbridge extends EventEmitter { } this.hasCleanupStarted = false; this.initialized = false; + } else { + this.log.debug('Cleanup already started...'); } } diff --git a/src/matterbridgeAccessoryPlatform.test.ts b/src/matterbridgeAccessoryPlatform.test.ts index 2dc01451..1f0656a6 100644 --- a/src/matterbridgeAccessoryPlatform.test.ts +++ b/src/matterbridgeAccessoryPlatform.test.ts @@ -8,7 +8,6 @@ import { jest } from '@jest/globals'; import { AnsiLogger } from 'node-ansi-logger'; import { Matterbridge } from './matterbridge.js'; import { MatterbridgeAccessoryPlatform } from './matterbridgeAccessoryPlatform.js'; -import { MdnsService } from '@matter/main/protocol'; describe('Matterbridge accessory platform', () => { beforeAll(async () => { @@ -41,9 +40,7 @@ describe('Matterbridge accessory platform', () => { expect(platform.type).toBe('AccessoryPlatform'); // Close the Matterbridge instance - const server = matterbridge.serverNode; await matterbridge.destroyInstance(); - await server?.env.get(MdnsService)[Symbol.asyncDispose](); expect((Matterbridge as any).instance).toBeUndefined(); }, 60000); }); diff --git a/src/matterbridgeDynamicPlatform.test.ts b/src/matterbridgeDynamicPlatform.test.ts index 98fcdff9..22233b30 100644 --- a/src/matterbridgeDynamicPlatform.test.ts +++ b/src/matterbridgeDynamicPlatform.test.ts @@ -8,7 +8,6 @@ import { jest } from '@jest/globals'; import { AnsiLogger, LogLevel } from 'node-ansi-logger'; import { Matterbridge } from './matterbridge.js'; import { MatterbridgeDynamicPlatform } from './matterbridgeDynamicPlatform.js'; -import { MdnsService } from '@matter/main/protocol'; describe('Matterbridge dynamic platform', () => { beforeAll(async () => { @@ -41,9 +40,7 @@ describe('Matterbridge dynamic platform', () => { expect(platform.type).toBe('DynamicPlatform'); // Close the Matterbridge instance - const server = matterbridge.serverNode; await matterbridge.destroyInstance(); - await server?.env.get(MdnsService)[Symbol.asyncDispose](); expect((Matterbridge as any).instance).toBeUndefined(); }, 60000); }); diff --git a/src/matterbridgeEndpoint-default.test.ts b/src/matterbridgeEndpoint-default.test.ts index 509440fc..f5989b61 100644 --- a/src/matterbridgeEndpoint-default.test.ts +++ b/src/matterbridgeEndpoint-default.test.ts @@ -83,7 +83,6 @@ import { TotalVolatileOrganicCompoundsConcentrationMeasurementServer, } from '@matter/node/behaviors'; import { updateAttribute } from './matterbridgeEndpointHelpers.js'; -import { MdnsService } from '@matter/main/protocol'; describe('MatterbridgeEndpoint class', () => { let matterbridge: Matterbridge; @@ -176,9 +175,7 @@ describe('MatterbridgeEndpoint class', () => { afterAll(async () => { // Close the Matterbridge instance - const server = matterbridge.serverNode; await matterbridge.destroyInstance(); - await server?.env.get(MdnsService)[Symbol.asyncDispose](); // Restore all mocks jest.restoreAllMocks(); @@ -922,7 +919,7 @@ describe('MatterbridgeEndpoint class', () => { // eslint-disable-next-line jest/expect-expect test('pause before cleanup', async () => { - await new Promise((resolve) => setTimeout(resolve, 5000)); // Pause for 5 seconds + await new Promise((resolve) => setTimeout(resolve, 5000)); // Pause for 5 seconds to allow matter.js promises to settle }, 60000); }); }); diff --git a/src/matterbridgeEndpoint-matterjs.test.ts b/src/matterbridgeEndpoint-matterjs.test.ts index e089ad3b..76d8a160 100644 --- a/src/matterbridgeEndpoint-matterjs.test.ts +++ b/src/matterbridgeEndpoint-matterjs.test.ts @@ -345,7 +345,7 @@ describe('MatterbridgeEndpoint class', () => { test('close server node', async () => { expect(server).toBeDefined(); await server.close(); - await server.env.get(MdnsService)[Symbol.asyncDispose](); + await server.env.get(MdnsService)[Symbol.asyncDispose](); // loadInstance(false) so destroyInstance() does not stop the mDNS service }); }); }); diff --git a/src/matterbridgeEndpoint.test.ts b/src/matterbridgeEndpoint.test.ts index b8364b43..18714aaa 100644 --- a/src/matterbridgeEndpoint.test.ts +++ b/src/matterbridgeEndpoint.test.ts @@ -61,7 +61,7 @@ import { ThermostatUserInterfaceConfigurationServer, TimeSynchronizationServer, } from '@matter/node/behaviors'; -import { getAttributeId, getClusterId } from './matterbridgeEndpointHelpers.js'; +import { checkNotLatinCharacters, generateUniqueId, getAttributeId, getClusterId } from './matterbridgeEndpointHelpers.js'; describe('MatterbridgeEndpoint class', () => { let matterbridge: Matterbridge; @@ -177,6 +177,117 @@ describe('MatterbridgeEndpoint class', () => { expect(loggerLogSpy).toHaveBeenCalledWith(LogLevel.INFO, expect.stringContaining(`\x1B[39mMatterbridge.Matterbridge.${device.uniqueStorageKey.replaceAll(' ', '')} \x1B[0mready`)); } + test('conversion of not latin characters', async () => { + // Should return false for Latin-based text (including accents) + expect(checkNotLatinCharacters('Hello World')).toBe(false); + expect(checkNotLatinCharacters('café au lait')).toBe(false); + expect(checkNotLatinCharacters('München-2024')).toBe(false); + expect(checkNotLatinCharacters('Tōkyō_2024')).toBe(false); + expect(checkNotLatinCharacters("L'éclair du matin")).toBe(false); + expect(checkNotLatinCharacters('São Paulo')).toBe(false); + expect(checkNotLatinCharacters('Résumé and naïve')).toBe(false); + + // Should return false for numbers, dashes, and underscores + expect(checkNotLatinCharacters('123456')).toBe(false); + expect(checkNotLatinCharacters('Hello_123')).toBe(false); + expect(checkNotLatinCharacters('test-underscore_')).toBe(false); + expect(checkNotLatinCharacters('text-with-dash')).toBe(false); + + // Special characters should return false + expect(checkNotLatinCharacters('.,> { + // Sample names + const latinNames = ['Hello World', 'café au lait', 'München-2024', 'Tōkyō_2024', "L'éclair du matin", 'São Paulo', 'Résumé and naïve', '123456', 'Hello_123', 'test-underscore_', 'text-with-dash']; + + const specialChars = ['.,>(); + + [...latinNames, ...specialChars, ...nonLatinNames].forEach((name) => { + const hash = generateUniqueId(name); + expect(hash).toHaveLength(32); // MD5 produces 32-char hex strings + expect(uniqueIds.has(name)).toBe(false); // Ensure no duplicates + uniqueIds.set(name, hash); + }); + + // The same input should generate the same hash + uniqueIds.forEach((hash, name) => { + expect(generateUniqueId(name)).toBe(hash); + }); + + // Different names should generate different hashes + const hashes = [...uniqueIds.values()]; + const uniqueHashes = new Set(hashes); + expect(uniqueHashes.size).toBe(hashes.length); // Ensure no hash collisions + }); + + test('constructor with non latin', async () => { + const nonLatinNames = [ + '버튼 - 작은방 (버튼-작은방)', + '거실 공기 관리 - 샤오미 공기 청정기 속도 조절 (거실공기관리-샤오미공기청정기속도조절)', + '난방 - 온도관리 방법 설정 (난방-온도관리방법설정)', + '단후이 로봇청소기 예약/일정 (단후이로봇청소기예약/일정)', + '단후이 로봇청소기 시계/지역시간 설정(mqtt) (단후이로봇청소기시계/지역시간설정(mqtt))', + '애드온을 다시 시작(DuckDNS) (애드온을다시시작(DuckDNS))', + '외출 중 기기 제어 - 외출 3시간 이상시 끄기 (외출중기기제어-외출3시간이상시끄기)', + '메인 등(L1) 조명 자동 컨트롤 (메인등(L1)조명자동컨트롤)', + '작은방 공기 관리 - 샤오미 공기 청정기 속도 조절 (작은방공기관리-샤오미공기청정기속도조절)', + '알림 - haos 메모이 시용이 90% 이상으로 시스템을 재시작합니다 (알림-haos메모이시용이90%이상으로시스템을재시작합니다)', + '알림 - 가스 검침기의 카운트를 다시 설정해 주세요 (알림-가스검침기의카운트를다시설정해주세요)', + '알림 - 배달원이 고객님의 음식을 픽업했습니다 (알림-배달원이고객님의음식을픽업했습니다)', + '알림 - 조리기기 관리가 수동으로 변경되었습니다 (알림-조리기기관리가수동으로변경되었습니다)', + '알림 - 주문이 거의 도착했습니다 (알림-주문이거의도착했습니다)', + '알림 - 현재 외부에는 비가 오고 있습니다 (알림-현재외부에는비가오고있습니다)', + '알림 - NAS의 보안 위험이 감지 되었습니디. 보안 어드바이저를 확인해 주세요 (알림-NAS의보안위험이감지되었습니디보안어드바이저를확인해주세요)', + ]; + let n = 1000; + for (const name of nonLatinNames) { + const device = new MatterbridgeEndpoint(onOffOutlet, { uniqueStorageKey: name, endpointId: EndpointNumber(n++) }); + expect(device).toBeDefined(); + expect(device.id).toBe(generateUniqueId(name)); + await add(device); + } + }); + test('constructor', async () => { const deviceType = onOffLight; const device = new MatterbridgeEndpoint(deviceType, { uniqueStorageKey: 'OnOffLight1' }); diff --git a/src/matterbridgeEndpoint.ts b/src/matterbridgeEndpoint.ts index 08f4e232..d3e6e871 100644 --- a/src/matterbridgeEndpoint.ts +++ b/src/matterbridgeEndpoint.ts @@ -67,6 +67,8 @@ import { getAttributeId, setAttribute, getAttribute, + checkNotLatinCharacters, + generateUniqueId, } from './matterbridgeEndpointHelpers.js'; // @matter @@ -291,6 +293,11 @@ export class MatterbridgeEndpoint extends Endpoint { }; const endpointV8 = MutableEndpoint(deviceTypeDefinitionV8); + // Check if the uniqueStorageKey is valid + if (options.uniqueStorageKey && checkNotLatinCharacters(options.uniqueStorageKey)) { + options.uniqueStorageKey = generateUniqueId(options.uniqueStorageKey); + } + // Convert the options to an Endpoint.Options const optionsV8 = { id: options.uniqueStorageKey?.replace(/[ .]/g, ''), diff --git a/src/matterbridgeEndpointHelpers.ts b/src/matterbridgeEndpointHelpers.ts index 80fac5df..b19ca868 100644 --- a/src/matterbridgeEndpointHelpers.ts +++ b/src/matterbridgeEndpointHelpers.ts @@ -111,6 +111,24 @@ export function lowercaseFirstLetter(name: string): string { return name.charAt(0).toLowerCase() + name.slice(1); } +export function checkNotLatinCharacters(deviceName: string): boolean { + const nonLatinRegexList = [ + /[\u0400-\u04FF\u0500-\u052F]/, // Cyrillic + /[\u2E80-\u9FFF]/, // CJK (Chinese, Japanese, Korean) + /[\uAC00-\uD7AF]/, // Korean Hangul + /[\u0600-\u06FF\u0750-\u077F]/, // Arabic, Persian + /[\u0590-\u05FF]/, // Hebrew + /[\u0900-\u097F]/, // Devanagari (Hindi, Sanskrit) + /[\u0E00-\u0E7F]/, // Thai + /[\u1200-\u137F]/, // Ethiopic (Amharic, Tigrinya) + ]; + return nonLatinRegexList.some((regex) => regex.test(deviceName)); +} + +export function generateUniqueId(deviceName: string): string { + return createHash('md5').update(deviceName).digest('hex'); // MD5 hash of the device name +} + export function createUniqueId(param1: string, param2: string, param3: string, param4: string) { const hash = createHash('md5'); hash.update(param1 + param2 + param3 + param4); diff --git a/src/matterbridgePlatform.test.ts b/src/matterbridgePlatform.test.ts index bfd9764e..856eace4 100644 --- a/src/matterbridgePlatform.test.ts +++ b/src/matterbridgePlatform.test.ts @@ -2,38 +2,73 @@ /* eslint-disable jest/no-conditional-expect */ /* eslint-disable @typescript-eslint/no-explicit-any */ -process.argv = ['node', 'matterbridge.test.js', '-frontend', '0', '-profile', 'Jest']; +process.argv = ['node', 'matterbridge.test.js', '-frontend', '0', '-profile', 'JestPlatform']; import { jest } from '@jest/globals'; -import { AnsiLogger, CYAN, db, LogLevel, pl, wr } from 'node-ansi-logger'; +import { AnsiLogger, CYAN, db, er, LogLevel, pl, wr } from 'node-ansi-logger'; import { NodeStorageManager } from 'node-persist-manager'; import { Matterbridge } from './matterbridge.js'; import { MatterbridgePlatform } from './matterbridgePlatform.js'; import { contactSensor, humiditySensor, powerSource, temperatureSensor } from './matterbridgeDeviceTypes.js'; import { MatterbridgeEndpoint } from './matterbridgeEndpoint.js'; +import { Environment, StorageService } from '@matter/main'; +import path from 'path'; +import os from 'os'; describe('Matterbridge platform', () => { let matterbridge: Matterbridge; let platform: MatterbridgePlatform; - beforeAll(async () => { - // Mock the AnsiLogger.log method - jest.spyOn(AnsiLogger.prototype, 'log').mockImplementation((level: string, message: string, ...parameters: any[]) => { - // console.log(`Mocked log: ${level} - ${message}`, ...parameters); + let loggerLogSpy: jest.SpiedFunction; + let consoleLogSpy: jest.SpiedFunction; + let consoleDebugSpy: jest.SpiedFunction; + let consoleInfoSpy: jest.SpiedFunction; + let consoleWarnSpy: jest.SpiedFunction; + let consoleErrorSpy: jest.SpiedFunction; + const debug = false; + + if (!debug) { + // Spy on and mock AnsiLogger.log + loggerLogSpy = jest.spyOn(AnsiLogger.prototype, 'log').mockImplementation((level: string, message: string, ...parameters: any[]) => { + // + }); + // Spy on and mock console.log + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation((...args: any[]) => { + // }); - jest.spyOn(AnsiLogger.prototype, 'debug').mockImplementation((message: string, ...parameters: any[]) => { - // console.log(`Mocked debug: ${message}`, ...parameters); + // Spy on and mock console.debug + consoleDebugSpy = jest.spyOn(console, 'debug').mockImplementation((...args: any[]) => { + // }); - jest.spyOn(AnsiLogger.prototype, 'info').mockImplementation((message: string, ...parameters: any[]) => { - // console.log(`Mocked info: ${message}`, ...parameters); + // Spy on and mock console.info + consoleInfoSpy = jest.spyOn(console, 'info').mockImplementation((...args: any[]) => { + // }); - jest.spyOn(AnsiLogger.prototype, 'warn').mockImplementation((message: string, ...parameters: any[]) => { - // console.log(`Mocked warn: ${message}`, ...parameters); + // Spy on and mock console.warn + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation((...args: any[]) => { + // }); - jest.spyOn(AnsiLogger.prototype, 'error').mockImplementation((message: string, ...parameters: any[]) => { - // console.log(`Mocked error: ${message}`, ...parameters); + // Spy on and mock console.error + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation((...args: any[]) => { + // }); + } else { + // Spy on AnsiLogger.log + loggerLogSpy = jest.spyOn(AnsiLogger.prototype, 'log'); + // Spy on console.log + consoleLogSpy = jest.spyOn(console, 'log'); + // Spy on console.debug + consoleDebugSpy = jest.spyOn(console, 'debug'); + // Spy on console.info + consoleInfoSpy = jest.spyOn(console, 'info'); + // Spy on console.warn + consoleWarnSpy = jest.spyOn(console, 'warn'); + // Spy on console.error + consoleErrorSpy = jest.spyOn(console, 'error'); + } + + beforeAll(async () => { jest.spyOn(Matterbridge.prototype, 'addBridgedEndpoint').mockImplementation((pluginName: string, device: MatterbridgeEndpoint) => { // console.log(`Mocked addBridgedEndpoint: ${pluginName} ${device.name}`); return Promise.resolve(); @@ -56,14 +91,30 @@ describe('Matterbridge platform', () => { // Destroy the Matterbridge instance await matterbridge.destroyInstance(); - // Restore the mocked AnsiLogger.log method - (AnsiLogger.prototype.log as jest.Mock).mockRestore(); + // Restore all mocks + jest.restoreAllMocks(); }, 60000); beforeEach(() => { + // Clear all mocks jest.clearAllMocks(); }); + test('should clear JestPlatform', async () => { + // Clear all storage contexts + const environment = Environment.default; + environment.vars.set('path.root', path.join(os.homedir(), '.matterbridge', 'matterstorage.JestPlatform')); + const matterStorageService = environment.get(StorageService); + expect(matterStorageService).toBeDefined(); + const matterStorageManager = await matterStorageService.open('Matterbridge'); + expect(matterStorageManager).toBeDefined(); + await matterStorageManager?.createContext('persist').clearAll(); + await matterStorageManager?.createContext('events')?.clearAll(); + await matterStorageManager?.createContext('fabrics')?.clearAll(); + await matterStorageManager?.createContext('root')?.clearAll(); + await matterStorageManager?.createContext('sessions')?.clearAll(); + }); + test('should be instance of MattebridgePlatform', () => { expect(platform).toBeInstanceOf(MatterbridgePlatform); }); @@ -72,7 +123,7 @@ describe('Matterbridge platform', () => { platform = new MatterbridgePlatform(matterbridge, new AnsiLogger({ logName: 'Matterbridge platform' }), { name: 'test', type: 'type', debug: false, unregisterOnShutdown: false }); expect(platform.storage).toBeDefined(); expect(platform.storage).toBeInstanceOf(NodeStorageManager); - expect(platform.log.debug).toHaveBeenCalledWith(expect.stringContaining('Creating storage for plugin test')); + expect(loggerLogSpy).toHaveBeenCalledWith(LogLevel.DEBUG, expect.stringContaining('Creating storage for plugin test')); }); test('should do a partial mock of AnsiLogger', () => { @@ -86,8 +137,8 @@ describe('Matterbridge platform', () => { }); test('onStart should throw an error if not overridden', async () => { - (platform.log.debug as jest.Mock).mockClear(); - expect.assertions(2); + // (platform.log.debug as jest.Mock).mockClear(); + // expect.assertions(2); try { await platform.onStart('test reason'); } catch (error) { @@ -283,8 +334,8 @@ describe('Matterbridge platform', () => { testDevice.number = 100; (matterbridge as any).devices.set(testDevice); expect(await platform.checkEndpointNumbers()).toBe(1); - expect(platform.log.warn).not.toHaveBeenCalled(); - expect(platform.log.debug).toHaveBeenCalledWith(`Setting endpoint number for device ${CYAN}${testDevice.uniqueId}${db} to ${CYAN}${testDevice.maybeNumber}${db}`); + expect(loggerLogSpy).not.toHaveBeenCalledWith(LogLevel.WARN, expect.anything()); + expect(loggerLogSpy).toHaveBeenCalledWith(LogLevel.DEBUG, `Setting endpoint number for device ${CYAN}${testDevice.uniqueId}${db} to ${CYAN}${testDevice.maybeNumber}${db}`); }); test('checkEndpointNumbers should check the testDevice', async () => { @@ -295,8 +346,8 @@ describe('Matterbridge platform', () => { testDevice.number = 100; (matterbridge as any).devices.set(testDevice); expect(await platform.checkEndpointNumbers()).toBe(1); - expect(platform.log.warn).not.toHaveBeenCalled(); - expect(platform.log.debug).not.toHaveBeenCalledWith(`Setting endpoint number for device ${CYAN}${testDevice.uniqueId}${db} to ${CYAN}${testDevice.maybeNumber}${db}`); + expect(loggerLogSpy).not.toHaveBeenCalledWith(LogLevel.WARN, expect.anything()); + expect(loggerLogSpy).not.toHaveBeenCalledWith(LogLevel.DEBUG, `Setting endpoint number for device ${CYAN}${testDevice.uniqueId}${db} to ${CYAN}${testDevice.maybeNumber}${db}`); }); test('checkEndpointNumbers should not check the testDevice', async () => { @@ -307,8 +358,8 @@ describe('Matterbridge platform', () => { testDevice.number = 101; (matterbridge as any).devices.set(testDevice); expect(await platform.checkEndpointNumbers()).toBe(1); - expect(platform.log.warn).toHaveBeenCalledWith(`Endpoint number for device ${CYAN}${testDevice.deviceName}${wr} changed from ${CYAN}100${wr} to ${CYAN}101${wr}`); - expect(platform.log.debug).not.toHaveBeenCalledWith(`Setting endpoint number for device ${CYAN}${testDevice.uniqueId}${db} to ${CYAN}${testDevice.maybeNumber}${db}`); + expect(loggerLogSpy).toHaveBeenCalledWith(LogLevel.WARN, `Endpoint number for device ${CYAN}${testDevice.deviceName}${wr} changed from ${CYAN}100${wr} to ${CYAN}101${wr}`); + expect(loggerLogSpy).not.toHaveBeenCalledWith(LogLevel.DEBUG, `Setting endpoint number for device ${CYAN}${testDevice.uniqueId}${db} to ${CYAN}${testDevice.maybeNumber}${db}`); }); test('checkEndpointNumbers should not check the testDevice without uniqueId', async () => { @@ -320,7 +371,7 @@ describe('Matterbridge platform', () => { (matterbridge as any).devices.set(testDevice); testDevice.uniqueId = undefined; expect(await platform.checkEndpointNumbers()).toBe(1); - expect(platform.log.debug).toHaveBeenCalledWith(`Not checking device ${testDevice.deviceName} without uniqueId or maybeNumber`); + expect(loggerLogSpy).toHaveBeenCalledWith(LogLevel.DEBUG, `Not checking device ${testDevice.deviceName} without uniqueId or maybeNumber`); }); test('checkEndpointNumbers should check the testDevice with child endpoints', async () => { @@ -339,10 +390,10 @@ describe('Matterbridge platform', () => { expect(await testDevice.getChildEndpoints()).toHaveLength(2); jest.clearAllMocks(); expect(await platform.checkEndpointNumbers()).toBe(3); - expect(platform.log.warn).not.toHaveBeenCalledWith(`Endpoint number for device ${CYAN}${testDevice.uniqueId}${wr} changed from ${CYAN}100${wr} to ${CYAN}101${wr}`); - expect(platform.log.debug).not.toHaveBeenCalledWith(`Setting endpoint number for device ${CYAN}${testDevice.uniqueId}${db} to ${CYAN}${testDevice.maybeNumber}${db}`); - expect(platform.log.debug).toHaveBeenCalledWith(`Setting child endpoint number for device ${CYAN}${testDevice.uniqueId}${db}.${CYAN}child1${db} to ${CYAN}201${db}`); - expect(platform.log.debug).toHaveBeenCalledWith(`Setting child endpoint number for device ${CYAN}${testDevice.uniqueId}${db}.${CYAN}child2${db} to ${CYAN}202${db}`); + expect(loggerLogSpy).not.toHaveBeenCalledWith(LogLevel.WARN, `Endpoint number for device ${CYAN}${testDevice.uniqueId}${wr} changed from ${CYAN}100${wr} to ${CYAN}101${wr}`); + expect(loggerLogSpy).not.toHaveBeenCalledWith(LogLevel.DEBUG, `Setting endpoint number for device ${CYAN}${testDevice.uniqueId}${db} to ${CYAN}${testDevice.maybeNumber}${db}`); + expect(loggerLogSpy).toHaveBeenCalledWith(LogLevel.DEBUG, `Setting child endpoint number for device ${CYAN}${testDevice.uniqueId}${db}.${CYAN}child1${db} to ${CYAN}201${db}`); + expect(loggerLogSpy).toHaveBeenCalledWith(LogLevel.DEBUG, `Setting child endpoint number for device ${CYAN}${testDevice.uniqueId}${db}.${CYAN}child2${db} to ${CYAN}202${db}`); }); test('checkEndpointNumbers should validate the testDevice with child endpoints', async () => { @@ -361,9 +412,9 @@ describe('Matterbridge platform', () => { expect(await testDevice.getChildEndpoints()).toHaveLength(2); jest.clearAllMocks(); expect(await platform.checkEndpointNumbers()).toBe(3); - expect(platform.log.warn).not.toHaveBeenCalled(); - expect(platform.log.debug).toHaveBeenCalledWith('Checking endpoint numbers...'); - expect(platform.log.debug).toHaveBeenCalledWith('Endpoint numbers check completed.'); + expect(loggerLogSpy).not.toHaveBeenCalledWith(LogLevel.WARN, expect.anything()); + expect(loggerLogSpy).toHaveBeenCalledWith(LogLevel.DEBUG, 'Checking endpoint numbers...'); + expect(loggerLogSpy).toHaveBeenCalledWith(LogLevel.DEBUG, 'Endpoint numbers check completed.'); }); test('checkEndpointNumbers should not validate the testDevice with child endpoints', async () => { @@ -382,28 +433,28 @@ describe('Matterbridge platform', () => { expect(await testDevice.getChildEndpoints()).toHaveLength(2); jest.clearAllMocks(); expect(await platform.checkEndpointNumbers()).toBe(3); - expect(platform.log.warn).toHaveBeenCalled(); - expect(platform.log.debug).toHaveBeenCalledTimes(2); - expect(platform.log.debug).toHaveBeenCalledWith('Checking endpoint numbers...'); - expect(platform.log.debug).toHaveBeenCalledWith('Endpoint numbers check completed.'); + expect(loggerLogSpy).toHaveBeenCalledTimes(4); + expect(loggerLogSpy).toHaveBeenCalledWith(LogLevel.DEBUG, 'Checking endpoint numbers...'); + expect(loggerLogSpy).toHaveBeenCalledWith(LogLevel.DEBUG, 'Endpoint numbers check completed.'); }); test('onConfigure should log a message', async () => { await platform.onConfigure(); - expect(platform.log.debug).toHaveBeenCalledWith('Configuring platform '); + expect(loggerLogSpy).toHaveBeenCalledWith(LogLevel.DEBUG, 'Configuring platform '); }); test('onChangeLoggerLevel should log a debug message if not overridden', async () => { await platform.onChangeLoggerLevel(LogLevel.DEBUG); - expect(platform.log.debug).toHaveBeenCalledWith("The plugin doesn't override onChangeLoggerLevel. Logger level set to: debug"); + expect(loggerLogSpy).toHaveBeenCalledWith(LogLevel.DEBUG, "The plugin doesn't override onChangeLoggerLevel. Logger level set to: debug"); }); test('onShutdown should log a message', async () => { await platform.onShutdown('test reason'); - expect(platform.log.debug).toHaveBeenCalledWith('Shutting down platform ', 'test reason'); + expect(loggerLogSpy).toHaveBeenCalledWith(LogLevel.DEBUG, 'Shutting down platform ', 'test reason'); }); test('registerDevice calls matterbridge.addBridgedEndpoint with correct parameters', async () => { + await platform.unregisterAllDevices(); const testDevice = new MatterbridgeEndpoint(powerSource); testDevice.createDefaultBasicInformationClusterServer('test', 'serial01234', 0xfff1, 'Matterbridge', 0x8001, 'Test device'); await platform.registerDevice(testDevice); @@ -412,6 +463,7 @@ describe('Matterbridge platform', () => { }); test('unregisterDevice calls matterbridge.removeBridgedEndpoint with correct parameters', async () => { + await platform.unregisterAllDevices(); const testDevice = new MatterbridgeEndpoint(powerSource); testDevice.createDefaultBasicInformationClusterServer('test', 'serial01234', 0xfff1, 'Matterbridge', 0x8001, 'Test device'); await platform.unregisterDevice(testDevice); @@ -424,4 +476,20 @@ describe('Matterbridge platform', () => { expect(platform.registeredEndpoints.size).toBe(0); expect(matterbridge.removeAllBridgedEndpoints).toHaveBeenCalled(); }); + + test('registerDevice should log error if the device name already exist', async () => { + await platform.unregisterAllDevices(); + expect(platform.registeredEndpoints.size).toBe(0); + expect(platform.registeredEndpointsByName.size).toBe(0); + expect(matterbridge.removeAllBridgedEndpoints).toHaveBeenCalled(); + + platform.registeredEndpoints.set('test', new MatterbridgeEndpoint(powerSource)); + platform.registeredEndpointsByName.set('test', new MatterbridgeEndpoint(powerSource)); + const device = new MatterbridgeEndpoint(powerSource); + device.createDefaultBasicInformationClusterServer('test', 'serial01234', 0xfff1, 'Matterbridge', 0x8001, 'Test device'); + await platform.registerDevice(device); + expect(platform.registeredEndpoints.size).toBe(1); + expect(platform.registeredEndpointsByName.size).toBe(1); + expect(loggerLogSpy).toHaveBeenCalledWith(LogLevel.ERROR, `Device with name ${CYAN}${device.deviceName}${er} is already registered. The device will not be added. Please change the device name.`); + }); }); diff --git a/src/matterbridgePlatform.ts b/src/matterbridgePlatform.ts index 176c29e8..9fc84e72 100644 --- a/src/matterbridgePlatform.ts +++ b/src/matterbridgePlatform.ts @@ -24,10 +24,11 @@ // Matterbridge import { Matterbridge } from './matterbridge.js'; import { MatterbridgeEndpoint } from './matterbridgeEndpoint.js'; +import { checkNotLatinCharacters } from './matterbridgeEndpointHelpers.js'; import { isValidArray, isValidObject, isValidString } from './utils/utils.js'; // AnsiLogger module -import { AnsiLogger, CYAN, db, LogLevel, nf, wr } from './logger/export.js'; +import { AnsiLogger, CYAN, db, er, LogLevel, nf, wr } from './logger/export.js'; // Storage module import { NodeStorage, NodeStorageManager } from './storage/export.js'; @@ -58,11 +59,18 @@ export class MatterbridgePlatform { public name = ''; // Will be set by the loadPlugin() method using the package.json value. public type = ''; // Will be set by the extending classes. public version = '1.0.0'; // Will be set by the loadPlugin() method using the package.json value. + + // Platform storage public storage: NodeStorageManager | undefined; public context: NodeStorage | undefined; + + // Device and entity selection public selectDevice = new Map(); public selectEntity = new Map(); - public registeredEndpoints = new Map(); + + // Registered devices + public registeredEndpoints = new Map(); // uniqueId, MatterbridgeEndpoint + public registeredEndpointsByName = new Map(); // deviceName, MatterbridgeEndpoint /** * Creates an instance of the base MatterbridgePlatform. It is extended by the MatterbridgeAccessoryPlatform and MatterbridgeServicePlatform classes. @@ -110,7 +118,7 @@ export class MatterbridgePlatform { } /** - * This method can be overridden in the extended class. Call super.onShutdown() to run checkEndpointNumbers(). + * This method can be overridden in the extended class. Call super.onShutdown() to run checkEndpointNumbers() and cleanup memory. * It is called when the platform is shutting down. * Use this method to clean up any resources. * @param {string} [reason] - The reason for shutting down. @@ -118,6 +126,14 @@ export class MatterbridgePlatform { async onShutdown(reason?: string) { this.log.debug(`Shutting down platform ${this.name}`, reason); await this.checkEndpointNumbers(); + this.selectDevice.clear(); + this.selectEntity.clear(); + this.registeredEndpoints.clear(); + this.registeredEndpointsByName.clear(); + await this.context?.close(); + this.context = undefined; + await this.storage?.close(); + this.storage = undefined; } /** @@ -128,14 +144,31 @@ export class MatterbridgePlatform { this.log.debug(`The plugin doesn't override onChangeLoggerLevel. Logger level set to: ${logLevel}`); } + /** + * Check if a device with this name is already registered in the platform. + * @param {string} deviceName - The device name to check. + * @returns {boolean} True if the device is already registered, false otherwise. + */ + hasDeviceName(deviceName: string): boolean { + return this.registeredEndpointsByName.has(deviceName); + } + /** * Registers a device with the Matterbridge platform. * @param {MatterbridgeEndpoint} device - The device to register. */ async registerDevice(device: MatterbridgeEndpoint) { device.plugin = this.name; + if (device.deviceName && this.registeredEndpointsByName.has(device.deviceName)) { + this.log.error(`Device with name ${CYAN}${device.deviceName}${er} is already registered. The device will not be added. Please change the device name.`); + return; + } + if (device.deviceName && checkNotLatinCharacters(device.deviceName)) { + this.log.debug(`Device with name ${CYAN}${device.deviceName}${db} has non latin characters.`); + } await this.matterbridge.addBridgedEndpoint(this.name, device); if (device.uniqueId) this.registeredEndpoints.set(device.uniqueId, device); + if (device.deviceName) this.registeredEndpointsByName.set(device.deviceName, device); } /** @@ -145,6 +178,7 @@ export class MatterbridgePlatform { async unregisterDevice(device: MatterbridgeEndpoint) { await this.matterbridge.removeBridgedEndpoint(this.name, device); if (device.uniqueId) this.registeredEndpoints.delete(device.uniqueId); + if (device.deviceName) this.registeredEndpointsByName.delete(device.deviceName); } /** @@ -153,6 +187,7 @@ export class MatterbridgePlatform { async unregisterAllDevices() { await this.matterbridge.removeAllBridgedEndpoints(this.name); this.registeredEndpoints.clear(); + this.registeredEndpointsByName.clear(); } /** diff --git a/src/utils/utils.test.ts b/src/utils/utils.test.ts index acaa578b..c34b60ac 100644 --- a/src/utils/utils.test.ts +++ b/src/utils/utils.test.ts @@ -16,6 +16,7 @@ import { isValidNull, isValidUndefined, createZip, + getNpmPackageVersion, } from './utils'; import { promises as fs } from 'fs'; import path from 'path'; @@ -410,4 +411,36 @@ describe('Utils test', () => { const size = await createZip(path.join('test', 'array.zip'), 'package.json', '*.js', path.join('src', 'utils')); expect(size).toBeGreaterThan(0); }, 60000); + + it('should get the latest version', async () => { + const version = await getNpmPackageVersion('matterbridge'); + expect(version).toBeDefined(); + expect(typeof version).toBe('string'); + // console.log('Latest version:', version); + }, 60000); + + it('should get the latest dev version', async () => { + const devVersion = await getNpmPackageVersion('matterbridge', 'dev', 1000); + expect(devVersion).toBeDefined(); + expect(typeof devVersion).toBe('string'); + // console.log('Latest version tag dev:', devVersion); + }, 60000); + + it('should get version for tag latest and dev', async () => { + const version = await getNpmPackageVersion('matterbridge', 'latest', 1000); + expect(version).toBeDefined(); + expect(typeof version).toBe('string'); + // console.log('Latest version:', version); + + const devVersion = await getNpmPackageVersion('matterbridge', 'dev', 1000); + expect(devVersion).toBeDefined(); + expect(typeof devVersion).toBe('string'); + // console.log('Latest version tag dev:', devVersion); + + expect(devVersion).not.toBe(version); + }, 60000); + + it('should not get the latest version of a non existing package', async () => { + await expect(getNpmPackageVersion('matterbridge1234567')).rejects.toThrow('Failed to fetch data. Status code: 404'); + }, 60000); }); diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 3efff149..a27b3920 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -23,14 +23,11 @@ // Node.js modules import os from 'os'; -import { createWriteStream, statSync } from 'fs'; import path from 'path'; -import * as dns from 'dns'; +import { createWriteStream, statSync } from 'fs'; import { promises as fs } from 'fs'; +import * as dns from 'dns'; -// Package modules -// import archiver, { ArchiverError, EntryData } from 'archiver'; -// import { glob } from 'glob'; import type { ArchiverError, EntryData } from 'archiver'; // AnsiLogger module @@ -640,3 +637,61 @@ export function getIntParameter(name: string): number | undefined { if (!isValidNumber(intValue)) return undefined; return intValue; } + +/** + * Retrieves the version of an npm package from the npm registry. + * + * @param {string} packageName - The name of the npm package. + * @param {string} [tag='latest'] - The tag of the package version to retrieve (default is 'latest'). + * @param {number} [timeout=5000] - The timeout duration in milliseconds (default is 5000ms). + * @returns {Promise} A promise that resolves to the version string of the package. + * @throws {Error} If the request fails or the tag is not found. + */ +export async function getNpmPackageVersion(packageName: string, tag = 'latest', timeout = 5000): Promise { + const https = await import('https'); + return new Promise((resolve, reject) => { + const url = `https://registry.npmjs.org/${packageName}`; + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + controller.abort(); + reject(new Error(`Request timed out after ${timeout / 1000} seconds`)); + }, timeout); + + const req = https.get(url, { signal: controller.signal }, (res) => { + let data = ''; + + if (res.statusCode !== 200) { + clearTimeout(timeoutId); + res.resume(); // Discard response data to close the socket properly + req.destroy(); // Forcefully close the request + reject(new Error(`Failed to fetch data. Status code: ${res.statusCode}`)); + return; + } + + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('end', () => { + clearTimeout(timeoutId); + try { + const jsonData = JSON.parse(data); + // console.log(`Package ${packageName} tag ${tag}`, jsonData); + const version = jsonData['dist-tags']?.[tag]; + if (version) { + resolve(version); + } else { + reject(new Error(`Tag "${tag}" not found for package "${packageName}"`)); + } + } catch (error) { + reject(new Error(`Failed to parse response JSON: ${error instanceof Error ? error.message : error}`)); + } + }); + }); + + req.on('error', (error) => { + clearTimeout(timeoutId); + reject(new Error(`Request failed: ${error instanceof Error ? error.message : error}`)); + }); + }); +}