diff --git a/.gitignore b/.gitignore index 15c9d10..3cf0e74 100644 --- a/.gitignore +++ b/.gitignore @@ -48,4 +48,5 @@ bridge-devices.json bridge-groups.json # other stuff +matterbridge-shelly TODO.md diff --git a/.npmignore b/.npmignore index 3abbd73..02eafbd 100644 --- a/.npmignore +++ b/.npmignore @@ -162,5 +162,6 @@ jest.config.js screenshot # other stuff +matterbridge-shelly TODO.md yellow-button.png \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index e1beec1..476fd3c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,11 +18,11 @@ "ws": "^8.18.0" }, "devDependencies": { - "@eslint/js": "^9.7.0", + "@eslint/js": "^9.8.0", "@types/eslint__js": "^8.42.3", "@types/jest": "^29.5.12", "@types/multicast-dns": "^7.2.4", - "@types/node": "^20.14.12", + "@types/node": "^22.0.0", "@types/ws": "^8.5.11", "eslint-config-prettier": "^9.1.0", "eslint-plugin-jest": "^28.6.0", @@ -777,9 +777,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.7.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.7.0.tgz", - "integrity": "sha512-ChuWDQenef8OSFnvuxv0TCVxEwmu3+hPNKvM9B34qpM0rDRbjL8t5QkQeHHeAfsKQjuH9wS82WeCi1J/owatng==", + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.8.0.tgz", + "integrity": "sha512-MfluB7EUfxXtv3i/++oh89uzAr4PDI4nn201hsp+qaXqsjAWzinlZEHEfPgAX4doIlKvPG/i0A9dpKxOLII8yA==", "dev": true, "license": "MIT", "engines": { @@ -1637,12 +1637,12 @@ } }, "node_modules/@types/node": { - "version": "20.14.12", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.12.tgz", - "integrity": "sha512-r7wNXakLeSsGT0H1AU863vS2wa5wBOK4bWMjZz2wj+8nBx+m5PeIn0k8AloSLpRuiwdRQZwarZqHE4FNArPuJQ==", + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.0.0.tgz", + "integrity": "sha512-VT7KSYudcPOzP5Q0wfbowyNLaVR8QWUdw+088uFWwfvpY6uCWaXpqV6ieLAu9WBcnTa7H4Z5RLK8I5t2FuOcqw==", "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.11.1" } }, "node_modules/@types/readable-stream": { @@ -5957,9 +5957,9 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "version": "6.11.1", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.11.1.tgz", + "integrity": "sha512-mIDEX2ek50x0OlRgxryxsenE5XaQD4on5U2inY7RApK3SOJpofyw7uW2AyfMKkhAxXIceo2DeWGVGwyvng1GNQ==", "license": "MIT" }, "node_modules/update-browserslist-db": { diff --git a/package.json b/package.json index f6275ff..4804c84 100644 --- a/package.json +++ b/package.json @@ -101,11 +101,11 @@ "ws": "^8.18.0" }, "devDependencies": { - "@eslint/js": "^9.7.0", + "@eslint/js": "^9.8.0", "@types/eslint__js": "^8.42.3", "@types/jest": "^29.5.12", "@types/multicast-dns": "^7.2.4", - "@types/node": "^20.14.12", + "@types/node": "^22.0.0", "@types/ws": "^8.5.11", "eslint-config-prettier": "^9.1.0", "eslint-plugin-jest": "^28.6.0", diff --git a/src/index.test.ts b/src/index.test.ts index 794e349..c347154 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -11,23 +11,17 @@ describe('initializePlugin', () => { beforeEach(() => { mockMatterbridge = { addBridgedDevice: jest.fn() } as unknown as Matterbridge; - mockLog = { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() } as unknown as AnsiLogger; + mockLog = { fatal: jest.fn(), error: jest.fn(), warn: jest.fn(), notice: jest.fn(), info: jest.fn(), debug: jest.fn() } as unknown as AnsiLogger; mockConfig = { - 'name': 'matterbridge-test', + 'name': 'matterbridge-shelly', 'type': 'DynamicPlatform', - 'noDevices': false, - 'throwLoad': false, - 'throwStart': false, - 'throwConfigure': false, - 'throwShutdown': false, + 'debug': false, 'unregisterOnShutdown': false, - 'delayStart': false, } as PlatformConfig; }); - it('should return an instance of TestPlatform', () => { - const result = initializePlugin(mockMatterbridge, mockLog, mockConfig); - - expect(result).toBeInstanceOf(ShellyPlatform); + it('should return an instance of ShellyPlatform', () => { + const platform = initializePlugin(mockMatterbridge, mockLog, mockConfig); + expect(platform).toBeInstanceOf(ShellyPlatform); }); }); diff --git a/src/mdnsScanner.test.ts b/src/mdnsScanner.test.ts index 2d13b8f..35b84a1 100644 --- a/src/mdnsScanner.test.ts +++ b/src/mdnsScanner.test.ts @@ -1,15 +1,24 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable jest/no-done-callback */ +import { LogLevel } from 'node-ansi-logger'; import { MdnsScanner, DiscoveredDeviceListener, DiscoveredDevice } from './mdnsScanner'; +import { jest } from '@jest/globals'; -describe('Shellies test', () => { - const mdns = new MdnsScanner(); +describe('Shellies MdnsScanner test', () => { + let consoleLogSpy: jest.SpiedFunction; + + const mdns = new MdnsScanner(LogLevel.INFO); const discoveredDeviceListener: DiscoveredDeviceListener = async (device: DiscoveredDevice) => { // eslint-disable-next-line no-console - console.log(`Discovered shelly device: ${device.id} at ${device.host} port ${device.port} gen ${device.gen}`); + console.log(`Shellies MdnsScanner test: discovered shelly device: ${device.id} at ${device.host} port ${device.port} gen ${device.gen}`); }; beforeAll(() => { + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation((...args: any[]) => { + // console.error(`Mocked console.log: ${args}`); + }); mdns.on('discovered', discoveredDeviceListener); }); @@ -40,14 +49,14 @@ describe('Shellies test', () => { }); test('Start discover', (done) => { - mdns.start(60, true); + mdns.start(3000, true); expect(mdns.isScanning).toBeTruthy(); setTimeout(() => { mdns.stop(); done(); expect(mdns.isScanning).toBeFalsy(); - }, 65000); - }, 70000); + }, 5000); + }, 10000); test('Log discovered', () => { const size = mdns.logPeripheral(); diff --git a/src/mdnsScanner.ts b/src/mdnsScanner.ts index aa008ae..54f8c0e 100644 --- a/src/mdnsScanner.ts +++ b/src/mdnsScanner.ts @@ -34,6 +34,10 @@ export interface DiscoveredDevice { export type DiscoveredDeviceListener = (data: DiscoveredDevice) => void; +/** + * Creates an instance of MdnsScanner. + * @param {LogLevel} logLevel - The log level for the scanner. Defaults to LogLevel.INFO. + */ export class MdnsScanner extends EventEmitter { private discoveredDevices = new Map(); public readonly log; @@ -47,20 +51,34 @@ export class MdnsScanner extends EventEmitter { this.log = new AnsiLogger({ logName: 'ShellyMdnsDiscover', logTimestampFormat: TimestampFormat.TIME_MILLIS, logLevel }); } + /** + * Gets a value indicating whether the MdnsScanner is currently scanning. + * + * @returns {boolean} A boolean value indicating whether the MdnsScanner is scanning. + */ get isScanning() { return this._isScanning; } + /** + * Sends an mDNS query for shelly devices. + */ private sendQuery() { this.scanner?.query([ { name: '_http._tcp.local', type: 'PTR' }, { name: '_shelly._tcp.local', type: 'PTR' }, ]); - this.log.info('Sent mDNS query for shelly devices.'); + this.log.debug('Sent mDNS query for shelly devices.'); } + /** + * Starts the mDNS query service for shelly devices. + * + * @param {number} shutdownTimeout - The timeout value in milliseconds to stop the MdnsScanner (optional, if not provided the MdnsScanner will not stop). + * @param {boolean} debug - Indicates whether to enable debug mode (default: false). + */ start(shutdownTimeout?: number, debug = false) { - this.log.info('Starting mDNS query service for shelly devices...'); + this.log.debug('Starting mDNS query service for shelly devices...'); this._isScanning = true; this.scanner = mdns(); @@ -150,34 +168,43 @@ export class MdnsScanner extends EventEmitter { if (debug) this.log.debug(`--- end ---`); }); + // Send the query and set the timeout to send it again every 60 seconds this.sendQuery(); - this.queryTimeout = setInterval(() => { this.sendQuery(); }, 60 * 1000); + // Set the timeout to stop the scanner if it is defined if (shutdownTimeout && shutdownTimeout > 0) { this.scannerTimeout = setTimeout(() => { this.stop(); - }, shutdownTimeout * 1000); + }, shutdownTimeout); } - this.log.info('Started mDNS query service for shelly devices.'); + this.log.debug('Started mDNS query service for shelly devices.'); } + /** + * Stops the MdnsScanner query service. + */ stop() { - this.log.info('Stopping mDNS query service...'); + this.log.debug('Stopping mDNS query service...'); if (this.scannerTimeout) clearTimeout(this.scannerTimeout); - if (this.queryTimeout) clearTimeout(this.queryTimeout); - this._isScanning = false; this.scannerTimeout = undefined; + if (this.queryTimeout) clearTimeout(this.queryTimeout); this.queryTimeout = undefined; + this._isScanning = false; + this.scanner?.removeAllListeners(); this.scanner?.destroy(); this.scanner = undefined; this.removeAllListeners(); this.logPeripheral(); - this.log.info('Stopped mDNS query service.'); + this.log.debug('Stopped mDNS query service.'); } + /** + * Logs information about discovered shelly devices. + * @returns The number of discovered devices. + */ logPeripheral() { this.log.info(`Discovered ${this.discoveredDevices.size} shelly devices:`); // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/src/platform.test.ts b/src/platform.test.ts index b01f493..193fa74 100644 --- a/src/platform.test.ts +++ b/src/platform.test.ts @@ -1,33 +1,21 @@ +/* eslint-disable no-console */ /* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { Matterbridge, MatterbridgeDevice, PlatformConfig } from 'matterbridge'; +import { wait } from 'matterbridge/utils'; import { AnsiLogger, db, idn, LogLevel, nf, rs } from 'matterbridge/logger'; import { ShellyPlatform } from './platform'; import { jest } from '@jest/globals'; -describe('TestPlatform', () => { +describe('ShellyPlatform', () => { let mockMatterbridge: Matterbridge; let mockLog: AnsiLogger; let mockConfig: PlatformConfig; let shellyPlatform: ShellyPlatform; - // const log = new AnsiLogger({ logName: 'shellyDeviceTest', logTimestampFormat: TimestampFormat.TIME_MILLIS, logDebug: true }); - // Mock the AnsiLogger.log method - jest.spyOn(AnsiLogger.prototype, 'log').mockImplementation((level: string, message: string, ...parameters: any[]) => { - // console.log(`Mocked log: ${level} - ${message}`, ...parameters); - }); - jest.spyOn(AnsiLogger.prototype, 'debug').mockImplementation((message: string, ...parameters: any[]) => { - // console.log(`Mocked debug: ${message}`, ...parameters); - }); - jest.spyOn(AnsiLogger.prototype, 'info').mockImplementation((message: string, ...parameters: any[]) => { - // console.log(`Mocked info: ${message}`, ...parameters); - }); - jest.spyOn(AnsiLogger.prototype, 'warn').mockImplementation((message: string, ...parameters: any[]) => { - // console.log(`Mocked warn: ${message}`, ...parameters); - }); - jest.spyOn(AnsiLogger.prototype, 'error').mockImplementation((message: string, ...parameters: any[]) => { - // console.log(`Mocked error: ${message}`, ...parameters); - }); + let loggerLogSpy: jest.SpiedFunction<(level: LogLevel, message: string, ...parameters: any[]) => void>; + let consoleLogSpy: jest.SpiedFunction; + jest.spyOn(Matterbridge.prototype, 'addBridgedDevice').mockImplementation((pluginName: string, device: MatterbridgeDevice) => { // console.log(`Mocked addBridgedDevice: ${pluginName} ${device.name}`); return Promise.resolve(); @@ -41,49 +29,95 @@ describe('TestPlatform', () => { return Promise.resolve(); }); - beforeEach(() => { + beforeAll(() => { // Creates the mocks for Matterbridge, AnsiLogger, and PlatformConfig mockMatterbridge = { addBridgedDevice: jest.fn(), matterbridgeDirectory: '', removeAllBridgedDevices: jest.fn() } as unknown as Matterbridge; - mockLog = { fatal: jest.fn(), error: jest.fn(), warn: jest.fn(), notice: jest.fn(), info: jest.fn(), debug: jest.fn() } as unknown as AnsiLogger; + mockLog = { + fatal: jest.fn((message) => { + console.log(`Fatal: ${message}`); + }), + error: jest.fn((message) => { + console.log(`Error: ${message}`); + }), + warn: jest.fn((message) => { + console.log(`Warn: ${message}`); + }), + notice: jest.fn((message) => { + console.log(`Notice: ${message}`); + }), + info: jest.fn((message) => { + console.log(`Info: ${message}`); + }), + debug: jest.fn((message) => { + console.log(`Debug: ${message}`); + }), + } as unknown as AnsiLogger; mockConfig = { - 'name': 'matterbridge-test', + 'name': 'matterbridge-shelly', 'type': 'DynamicPlatform', + 'username': 'admin', + 'password': 'tango', + 'exposeSwitch': 'outlet', + 'exposeInput': 'contact', + 'exposePowerMeter': 'matter13', + 'blackList': [], + 'whiteList': [], + 'enableMdnsDiscover': true, + 'enableStorageDiscover': true, + 'resetStorageDiscover': false, + 'enableConfigDiscover': false, + 'enableBleDiscover': false, + 'deviceIp': {}, 'debug': false, 'unregisterOnShutdown': false, } as PlatformConfig; - shellyPlatform = new ShellyPlatform(mockMatterbridge, mockLog, mockConfig); + // Spy on and mock the AnsiLogger.log method + loggerLogSpy = jest.spyOn(AnsiLogger.prototype, 'log').mockImplementation((level: string, message: string, ...parameters: any[]) => { + // console.log(`Mocked log: ${level} - ${message}`, ...parameters); + }); - // Clears the call history of mockLog.info before each test + // Spy on and mock console.log + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation((...args: any[]) => { + // Mock implementation or empty function + }); + }); + + beforeEach(() => { + // Clears the call history of mockLog.* before each test + (mockLog.fatal as jest.Mock).mockClear(); (mockLog.error as jest.Mock).mockClear(); (mockLog.warn as jest.Mock).mockClear(); + (mockLog.notice as jest.Mock).mockClear(); (mockLog.info as jest.Mock).mockClear(); (mockLog.debug as jest.Mock).mockClear(); + // Clears the call history before each test + loggerLogSpy.mockClear(); + consoleLogSpy.mockClear(); }); - afterEach(() => { - (shellyPlatform as any).shelly.destroy(); + afterAll(() => { + // }); - // eslint-disable-next-line jest/no-commented-out-tests - /* it('should initialize platform with config name', () => { shellyPlatform = new ShellyPlatform(mockMatterbridge, mockLog, mockConfig); - expect((mockLog.info as jest.Mock).mock.calls[0]).toEqual([`Initializing platform: ${idn}${mockConfig.name}${rs}${nf}`]); + expect(mockLog.debug).toHaveBeenCalledWith(`Initializing platform: ${idn}${mockConfig.name}${rs}${db}`); }); -*/ it('should call onStart with reason', async () => { + expect(shellyPlatform).toBeDefined(); await shellyPlatform.onStart('Test reason'); - expect((mockLog.info as jest.Mock).mock.calls[0]).toEqual([`Starting platform ${idn}${mockConfig.name}${rs}${nf}: Test reason`]); + expect(mockLog.info).toHaveBeenCalledWith(`Starting platform ${idn}${mockConfig.name}${rs}${nf}: Test reason`); + expect(loggerLogSpy).toHaveBeenCalledWith(LogLevel.DEBUG, 'Started mDNS query service for shelly devices.'); expect((shellyPlatform as any).nodeStorageManager).toBeDefined(); + expect((shellyPlatform as any).shelly.mdnsScanner).toBeDefined(); + expect((shellyPlatform as any).shelly.mdnsScanner.isScanning).toBe(true); + expect((shellyPlatform as any).shelly.coapServer).toBeDefined(); + expect((shellyPlatform as any).shelly.coapServer.isListening).toBe(false); }); it('should load and save the stored devices', async () => { - await shellyPlatform.onStart('Test reason'); - expect((mockLog.info as jest.Mock).mock.calls[0]).toEqual([`Starting platform ${idn}${mockConfig.name}${rs}${nf}: Test reason`]); - expect((shellyPlatform as any).nodeStorageManager).toBeDefined(); - expect(await (shellyPlatform as any).loadStoredDevices()).toBeTruthy(); const originalSize = (shellyPlatform as any).storedDevices.size; expect(await (shellyPlatform as any).saveStoredDevices()).toBeTruthy(); @@ -93,23 +127,33 @@ describe('TestPlatform', () => { it('should call onConfigure', async () => { await shellyPlatform.onConfigure(); - expect((mockLog.info as jest.Mock).mock.calls[0]).toEqual([`Configuring platform ${idn}${mockConfig.name}${rs}${nf}`]); + expect(mockLog.info).toHaveBeenCalledWith(`Configuring platform ${idn}${mockConfig.name}${rs}${nf}`); }); it('should call onChangeLoggerLevel', async () => { await shellyPlatform.onChangeLoggerLevel(LogLevel.DEBUG); - expect((mockLog.debug as jest.Mock).mock.calls[0]).toEqual([`Changing logger level for platform ${idn}${mockConfig.name}${rs}${db}`]); + expect(mockLog.debug).toHaveBeenCalledWith(`Changing logger level for platform ${idn}${mockConfig.name}${rs}${db}`); }); it('should call onShutdown with reason', async () => { await shellyPlatform.onShutdown('Test reason'); - expect((mockLog.info as jest.Mock).mock.calls[0]).toEqual([`Shutting down platform ${idn}${mockConfig.name}${rs}${nf}: Test reason`]); - }); + expect(mockLog.info).toHaveBeenCalledWith(`Shutting down platform ${idn}${mockConfig.name}${rs}${nf}: Test reason`); + expect(mockMatterbridge.removeAllBridgedDevices).not.toHaveBeenCalled(); + expect(loggerLogSpy).toHaveBeenCalledWith(LogLevel.DEBUG, 'Stopped mDNS query service.'); + await wait(1000); + }, 60000); it('should call onShutdown and unregister', async () => { mockConfig.unregisterOnShutdown = true; await shellyPlatform.onShutdown('Test reason'); - expect((mockLog.info as jest.Mock).mock.calls[0]).toEqual([`Shutting down platform ${idn}${mockConfig.name}${rs}${nf}: Test reason`]); + expect(mockLog.info).toHaveBeenCalledWith(`Shutting down platform ${idn}${mockConfig.name}${rs}${nf}: Test reason`); expect(mockMatterbridge.removeAllBridgedDevices).toHaveBeenCalled(); - }); + await wait(1000); + }, 60000); + + it('should destroy shelly', async () => { + expect((shellyPlatform as any).shelly.mdnsScanner).toBeUndefined(); + expect((shellyPlatform as any).shelly.coapServer).toBeUndefined(); + await wait(1000); + }, 60000); }); diff --git a/src/platform.ts b/src/platform.ts index 02dc871..257a057 100644 --- a/src/platform.ts +++ b/src/platform.ts @@ -473,7 +473,7 @@ export class ShellyPlatform extends MatterbridgeDynamicPlatform { // start Shelly mDNS device discoverer if (this.config.enableMdnsDiscover === true) { - this.shelly.startMdns(60 * 10); + this.shelly.startMdns(10 * 60 * 1000); } // add all stored devices