UNPKG

matterbridge

Version:
511 lines • 24.1 kB
/** * This file contains the class MatterbridgeAccessoryPlatform. * * @file matterbridgePlatform.ts * @author Luca Liguori * @date 2024-03-21 * @version 1.1.0 * * Copyright 2024, 2025, 2026 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. * */ import { checkNotLatinCharacters } from './matterbridgeEndpointHelpers.js'; import { isValidArray, isValidObject, isValidString } from './utils/export.js'; // AnsiLogger module import { CYAN, db, er, nf, wr } from './logger/export.js'; // Storage module import { NodeStorageManager } from './storage/export.js'; // Node.js module import path from 'node:path'; /** * Represents the base Matterbridge platform. It is extended by the MatterbridgeAccessoryPlatform and MatterbridgeServicePlatform classes. * */ export class MatterbridgePlatform { matterbridge; log; config = {}; name = ''; // Will be set by the loadPlugin() method using the package.json value. type = ''; // Will be set by the extending classes. version = '1.0.0'; // Will be set by the loadPlugin() method using the package.json value. // Platform storage storage; context; // Device and entity selection selectDevice = new Map(); selectEntity = new Map(); // Promises for storage _contextReady; _selectDeviceContextReady; _selectEntityContextReady; ready; // Registered devices _registeredEndpoints = new Map(); // uniqueId, MatterbridgeEndpoint _registeredEndpointsByName = new Map(); // deviceName, MatterbridgeEndpoint // Stored devices _storedDevices = new Map(); // serial, { serial, name } /** * Creates an instance of the base MatterbridgePlatform. It is extended by the MatterbridgeAccessoryPlatform and MatterbridgeServicePlatform classes. * @param {Matterbridge} matterbridge - The Matterbridge instance. * @param {AnsiLogger} log - The logger instance. * @param {PlatformConfig} config - The platform configuration. */ constructor(matterbridge, log, config) { this.matterbridge = matterbridge; this.log = log; this.config = config; // create the NodeStorageManager for the plugin platform if (!isValidString(this.config.name) || this.config.name === '') throw new Error('Platform: the plugin name is missing or invalid.'); this.log.debug(`Creating storage for plugin ${this.config.name} in ${path.join(this.matterbridge.matterbridgeDirectory, this.config.name)}`); this.storage = new NodeStorageManager({ dir: path.join(this.matterbridge.matterbridgeDirectory, this.config.name), writeQueue: false, expiredInterval: undefined, logging: false, forgiveParseErrors: true, }); // create the context storage for the plugin platform this.log.debug(`Creating context for plugin ${this.config.name}`); this._contextReady = this.storage.createStorage('context').then((context) => { this.context = context; this.context.remove('endpointMap'); // Remove the old endpointMap TODO: remove in future versions this.log.debug(`Created context for plugin ${this.config.name}`); }); // create the selectDevice storage for the plugin platform this.log.debug(`Loading selectDevice for plugin ${this.config.name}`); this._selectDeviceContextReady = this.storage.createStorage('selectDevice').then(async (context) => { const selectDevice = await context.get('selectDevice', []); for (const device of selectDevice) this.selectDevice.set(device.serial, device); this.log.debug(`Loaded ${this.selectDevice.size} selectDevice for plugin ${this.config.name}`); }); // create the selectEntity storage for the plugin platform this.log.debug(`Loading selectEntity for plugin ${this.config.name}`); this._selectEntityContextReady = this.storage.createStorage('selectEntity').then(async (context) => { const selectEntity = await context.get('selectEntity', []); for (const entity of selectEntity) this.selectEntity.set(entity.name, entity); this.log.debug(`Loaded ${this.selectEntity.size} selectEntity for plugin ${this.config.name}`); }); // Create the `ready` promise for the platform this.ready = Promise.all([this._contextReady, this._selectDeviceContextReady, this._selectEntityContextReady]).then(() => { this.log.debug(`MatterbridgePlatform for plugin ${this.config.name} is fully initialized`); }); } /** * This method must be overridden in the extended class. * It is called when the platform is started. * Use this method to create the MatterbridgeDevice and call this.registerDevice(). * @param {string} [reason] - The reason for starting. * @throws {Error} - Throws an error if the method is not overridden. */ async onStart(reason) { this.log.error('Plugins must override onStart.', reason); throw new Error('Plugins must override onStart.'); } /** * This method can be overridden in the extended class. Call super.onConfigure() to run checkEndpointNumbers(). * It is called after the platform has started. * Use this method to perform any configuration of your devices. */ async onConfigure() { this.log.debug(`Configuring platform ${this.name}`); // Save the selectDevice and selectEntity await this.saveSelects(); // Check and update the endpoint numbers await this.checkEndpointNumbers(); } /** * This method can be overridden in the extended class. In this case always call super.onShutdown() to save the selects, 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. */ async onShutdown(reason) { this.log.debug(`Shutting down platform ${this.name}`, reason); // Save the selectDevice and selectEntity await this.saveSelects(); // Check and update the endpoint numbers await this.checkEndpointNumbers(); // Cleanup memory this.selectDevice.clear(); this.selectEntity.clear(); this._registeredEndpoints.clear(); this._registeredEndpointsByName.clear(); this._storedDevices.clear(); // Close the storage await this.context?.close(); this.context = undefined; await this.storage?.close(); } /** * Sets the logger level and logs a debug message indicating that the plugin doesn't override this method. * @param {LogLevel} logLevel The new logger level. */ async onChangeLoggerLevel(logLevel) { this.log.debug(`The plugin doesn't override onChangeLoggerLevel. Logger level set to: ${logLevel}`); } /** * Called when a plugin config includes an action button or an action button with text field. * @param {string} action The action triggered by the button in plugin config. * @param {string} value The value of the field of the action button. * @param {string} id The id of the schema associated with the action. * * @remarks * This method can be overridden in the extended class. * * Use this method to handle the action defined in the plugin schema: * "addDevice": { * "description": "Manually add a device that has not been discovered with mdns:", * "type": "boolean", * "buttonText": "ADD", // The text on the button. * "buttonField": "ADD", // The text on the button. This is used when the action includes a text field. * "buttonClose": false, // optional, default is false. When true, the dialog will close after the action is sent. * "buttonSave": false, // optional, default is false. When true, the dialog will close and trigger the restart required after the action is sent. * "textPlaceholder": "Enter the device IP address", // optional: the placeholder text for the text field. * "default": false * }, */ async onAction(action, value, id) { this.log.debug(`The plugin ${CYAN}${this.name}${db} doesn't override onAction. Received action ${CYAN}${action}${db}${value ? ' with ' + CYAN + value + db : ''} ${id ? ' for schema ' + CYAN + id + db : ''}`); } /** * Called when the plugin config has been updated. * @param {PlatformConfig} config The new plugin config. */ async onConfigChanged(config) { this.log.debug(`The plugin ${CYAN}${config.name}${db} doesn't override onConfigChanged. Received new config.`); } /** * 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) { return this._registeredEndpointsByName.has(deviceName); } /** * Registers a device with the Matterbridge platform. * @param {MatterbridgeEndpoint} device - The device to register. */ async registerDevice(device) { 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); } /** * Unregisters a device registered with the Matterbridge platform. * @param {MatterbridgeEndpoint} device - The device to unregister. */ async unregisterDevice(device) { await this.matterbridge.removeBridgedEndpoint(this.name, device); if (device.uniqueId) this._registeredEndpoints.delete(device.uniqueId); if (device.deviceName) this._registeredEndpointsByName.delete(device.deviceName); } /** * Unregisters all devices registered with the Matterbridge platform. */ async unregisterAllDevices() { await this.matterbridge.removeAllBridgedEndpoints(this.name); this._registeredEndpoints.clear(); this._registeredEndpointsByName.clear(); } /** * Saves the select devices and entities to storage. * * This method saves the current state of `selectDevice` and `selectEntity` maps to their respective storage. * It logs the number of items being saved and ensures that the storage is properly closed after saving. * * @returns {Promise<void>} A promise that resolves when the save operation is complete. */ async saveSelects() { if (this.storage) { this.log.debug(`Saving ${this.selectDevice.size} selectDevice...`); const selectDevice = await this.storage.createStorage('selectDevice'); await selectDevice.set('selectDevice', Array.from(this.selectDevice.values())); await selectDevice.close(); this.log.debug(`Saving ${this.selectEntity.size} selectEntity...`); const selectEntity = await this.storage.createStorage('selectEntity'); await selectEntity.set('selectEntity', Array.from(this.selectEntity.values())); await selectEntity.close(); } } /** * Clears the select device and entity maps. * * @returns {void} */ async clearSelect() { this.selectDevice.clear(); this.selectEntity.clear(); await this.saveSelects(); } /** * Clears the select for a single device. * * @param {string} device - The serial of the device to clear. * @returns {void} */ async clearDeviceSelect(device) { this.selectDevice.delete(device); await this.saveSelects(); } /** * Set the select device in the platform map. * * @param {string} serial - The serial number of the device. * @param {string} name - The name of the device. * @param {string} [configUrl] - The configuration URL of the device. * @param {string} [icon] - The icon of the device: 'wifi', 'ble', 'hub' * @param {Array<{ name: string; description: string; icon?: string }>} [entities] - The entities associated with the device. * @returns {void} */ setSelectDevice(serial, name, configUrl, icon, entities) { const device = this.selectDevice.get(serial); if (device) { device.serial = serial; device.name = name; if (configUrl) device.configUrl = configUrl; if (icon) device.icon = icon; if (entities) device.entities = entities; } else { this.selectDevice.set(serial, { serial, name, configUrl, icon, entities }); } } /** * Set the select device entity in the platform map. * * @param {string} serial - The serial number of the device. * @param {string} entityName - The name of the entity. * @param {string} entityDescription - The description of the entity. * @param {string} [entityIcon] - The icon of the entity: 'wifi', 'ble', 'hub', 'component', 'matter' * @returns {void} */ setSelectDeviceEntity(serial, entityName, entityDescription, entityIcon) { const device = this.selectDevice.get(serial); if (device) { if (!device.entities) device.entities = []; if (!device.entities.find((entity) => entity.name === entityName)) device.entities.push({ name: entityName, description: entityDescription, icon: entityIcon }); } } /** * Retrieves the select devices from the platform map. * * @returns {{ pluginName: string; serial: string; name: string; configUrl?: string; icon?: string; entities?: { name: string; description: string; icon?: string }[] }[]} The selected devices array. */ getSelectDevices() { const selectDevices = []; for (const device of this.selectDevice.values()) { selectDevices.push({ pluginName: this.name, ...device }); } return selectDevices; } /** * Set the select entity in the platform map. * * @param {string} name - The entity name. * @param {string} description - The entity description. * @param {string} [icon] - The entity icon: 'wifi', 'ble', 'hub', 'component', 'matter' * @returns {void} */ setSelectEntity(name, description, icon) { this.selectEntity.set(name, { name, description, icon }); } /** * Retrieve the select entities. * * @returns {{ pluginName: string; name: string; description: string; icon?: string }[]} The select entities array. */ getSelectEntities() { const selectEntities = []; for (const entity of this.selectEntity.values()) { selectEntities.push({ pluginName: this.name, ...entity }); } return selectEntities; } /** * Verifies if the Matterbridge version meets the required version. * @param {string} requiredVersion - The required version to compare against. * @returns {boolean} True if the Matterbridge version meets or exceeds the required version, false otherwise. */ verifyMatterbridgeVersion(requiredVersion) { const compareVersions = (matterbridgeVersion, requiredVersion) => { const stripTag = (v) => { const parts = v.split('-'); return parts[0]; }; const v1Parts = stripTag(matterbridgeVersion).split('.').map(Number); const v2Parts = stripTag(requiredVersion).split('.').map(Number); for (let i = 0; i < Math.max(v1Parts.length, v2Parts.length); i++) { const v1Part = v1Parts[i] || 0; const v2Part = v2Parts[i] || 0; if (v1Part < v2Part) { return false; } else if (v1Part > v2Part) { return true; } } return true; }; if (!compareVersions(this.matterbridge.matterbridgeVersion, requiredVersion)) return false; return true; } /** * @deprecated This method is deprecated and will be removed in future versions. Use validateDevice instead. */ validateDeviceWhiteBlackList(device, log = true) { return this.validateDevice(device, log); } /** * Validates if a device is allowed based on the whitelist and blacklist configurations. * The blacklist has priority over the whitelist. * * @param {string | string[]} device - The device name(s) to validate. * @param {boolean} [log=true] - Whether to log the validation result. * @returns {boolean} - Returns true if the device is allowed, false otherwise. * */ validateDevice(device, log = true) { if (!Array.isArray(device)) device = [device]; let blackListBlocked = 0; if (isValidArray(this.config.blackList, 1)) { for (const d of device) if (this.config.blackList.includes(d)) blackListBlocked++; } if (blackListBlocked > 0) { if (log) this.log.info(`Skipping device ${CYAN}${device.join(', ')}${nf} because in blacklist`); return false; } let whiteListPassed = 0; if (isValidArray(this.config.whiteList, 1)) { for (const d of device) if (this.config.whiteList.includes(d)) whiteListPassed++; } else whiteListPassed++; if (whiteListPassed > 0) { return true; } if (log) this.log.info(`Skipping device ${CYAN}${device.join(', ')}${nf} because not in whitelist`); return false; } /** * @deprecated This method is deprecated and will be removed in future versions. Use validateEntity instead. */ validateEntityBlackList(device, entity, log = true) { return this.validateEntity(device, entity, log); } /** * Validates if an entity is allowed based on the entity blacklist and device-entity blacklist configurations. * * @param {string} device - The device to which the entity belongs. * @param {string} entity - The entity to validate. * @param {boolean} [log=true] - Whether to log the validation result. * @returns {boolean} - Returns true if the entity is allowed, false otherwise. * */ validateEntity(device, entity, log = true) { if (isValidArray(this.config.entityBlackList, 1) && this.config.entityBlackList.find((e) => e === entity)) { if (log) this.log.info(`Skipping entity ${CYAN}${entity}${nf} because in entityBlackList`); return false; } if (isValidArray(this.config.entityWhiteList, 1) && !this.config.entityWhiteList.find((e) => e === entity)) { if (log) this.log.info(`Skipping entity ${CYAN}${entity}${nf} because not in entityWhiteList`); return false; } if (isValidObject(this.config.deviceEntityBlackList, 1) && device in this.config.deviceEntityBlackList && this.config.deviceEntityBlackList[device].includes(entity)) { if (log) this.log.info(`Skipping entity ${CYAN}${entity}${nf} for device ${CYAN}${device}${nf} because in deviceEntityBlackList`); return false; } return true; } /** * Checks and updates the endpoint numbers for Matterbridge devices. * * This method retrieves the list of Matterbridge devices and their child endpoints, * compares their current endpoint numbers with the stored ones, and updates the storage * if there are any changes. It logs the changes and updates the endpoint numbers accordingly. * * @returns {Promise<number>} The size of the updated endpoint map, or -1 if storage is not available. */ async checkEndpointNumbers() { if (!this.storage) return -1; this.log.debug('Checking endpoint numbers...'); const context = await this.storage.createStorage('endpointNumbers'); const separator = '|.|'; const endpointMap = new Map(await context.get('endpointMap', [])); for (const device of this.matterbridge.getDevices().filter((d) => d.plugin === this.name)) { if (device.uniqueId === undefined || device.maybeNumber === undefined) { this.log.debug(`Not checking device ${device.deviceName} without uniqueId or maybeNumber`); continue; } if (endpointMap.has(device.uniqueId) && endpointMap.get(device.uniqueId) !== device.maybeNumber) { this.log.warn(`Endpoint number for device ${CYAN}${device.deviceName}${wr} changed from ${CYAN}${endpointMap.get(device.uniqueId)}${wr} to ${CYAN}${device.maybeNumber}${wr}`); endpointMap.set(device.uniqueId, device.maybeNumber); } if (!endpointMap.has(device.uniqueId)) { this.log.debug(`Setting endpoint number for device ${CYAN}${device.uniqueId}${db} to ${CYAN}${device.maybeNumber}${db}`); endpointMap.set(device.uniqueId, device.maybeNumber); } for (const child of device.getChildEndpoints()) { if (!child.maybeId || !child.maybeNumber) continue; if (endpointMap.has(device.uniqueId + separator + child.id) && endpointMap.get(device.uniqueId + separator + child.id) !== child.maybeNumber) { this.log.warn(`Child endpoint number for device ${CYAN}${device.deviceName}${wr}.${CYAN}${child.id}${wr} changed from ${CYAN}${endpointMap.get(device.uniqueId + separator + child.id)}${wr} to ${CYAN}${child.maybeNumber}${wr}`); endpointMap.set(device.uniqueId + separator + child.id, child.maybeNumber); } if (!endpointMap.has(device.uniqueId + separator + child.id)) { this.log.debug(`Setting child endpoint number for device ${CYAN}${device.uniqueId}${db}.${CYAN}${child.id}${db} to ${CYAN}${child.maybeNumber}${db}`); endpointMap.set(device.uniqueId + separator + child.id, child.maybeNumber); } } } this.log.debug('Saving endpointNumbers...'); await context.set('endpointMap', Array.from(endpointMap.entries())); await context.close(); this.log.debug('Endpoint numbers check completed.'); return endpointMap.size; } } //# sourceMappingURL=matterbridgePlatform.js.map