matterbridge
Version:
Matterbridge plugin manager for Matter
511 lines • 24.1 kB
JavaScript
/**
* 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