matterbridge-hass
Version:
Matterbridge hass plugin
755 lines • 68.1 kB
JavaScript
import fs from 'node:fs';
import path from 'node:path';
import { bridgedNode, electricalSensor, MatterbridgeDynamicPlatform, powerSource } from 'matterbridge';
import { CYAN, db, debugStringify, dn, er, hk, idn, ign, nf, or, rs, wr, YELLOW } from 'matterbridge/logger';
import { BridgedDeviceBasicInformation, ColorControl, LevelControl, ModeSelect, OnOff, PowerSource } from 'matterbridge/matter/clusters';
import { getClusterNameById } from 'matterbridge/matter/types';
import { deepEqual, inspectError, isValidArray, isValidBoolean, isValidNumber, isValidObject, isValidString, waiter } from 'matterbridge/utils';
import { addBinarySensorEntity } from './binary_sensor.entity.js';
import { addButtonEntity } from './button.entity.js';
import { addControlEntity } from './control.entity.js';
import { clamp, convertMatterXYToHA, hassCommandConverter, hassDomainBinarySensorsConverter, hassDomainEventConverter, hassDomainSensorsConverter, hassUpdateAttributeConverter, hassUpdateStateConverter, miredsToKelvin, } from './converters.js';
import { addEventEntity } from './event.entity.js';
import { addHelperEntity } from './helper.entity.js';
import { getDomain, getEntityName, isDeviceEntity, isDisabled, isHidden, isIndividualEntity, isSplitEntity, satisfiesAreaFilter, satisfiesLabelFilter } from './helpers.js';
import { HomeAssistant, } from './homeAssistant.js';
import { MutableDevice } from './mutableDevice.js';
import { savePayload } from './payload.js';
import { writeReport } from './report.js';
import { addSensorEntity } from './sensor.entity.js';
import { StateCache } from './stateCache.js';
export default function initializePlugin(matterbridge, log, config) {
return new HomeAssistantPlatform(matterbridge, log, config);
}
export class HomeAssistantPlatform extends MatterbridgeDynamicPlatform {
config;
ha;
haSubscriptionId = null;
stateCache = new StateCache();
matterbridgeDevices = new Map();
updatingEntities = new Map();
offUpdatedEntities = new Set();
endpointNames = new Map();
batteryVoltageEntities = new Set();
airQualityRegex;
supportedHelpersDomains = ['automation', 'scene', 'script', 'input_boolean', 'input_button'];
supportedCoreDomains = ['switch', 'light', 'lock', 'fan', 'cover', 'climate', 'valve', 'vacuum', 'remote', 'input_select', 'select', 'media_player'];
supportedOtherDomains = ['sensor', 'binary_sensor', 'event', 'button'];
supportedDomains = [...this.supportedHelpersDomains, ...this.supportedCoreDomains, ...this.supportedOtherDomains];
filterMessages = [];
filteredDevices = 0;
filteredEntities = 0;
unselectedDevices = 0;
unselectedEntities = 0;
duplicatedDevices = 0;
duplicatedEntities = 0;
longNameDevices = 0;
longNameEntities = 0;
failedDevices = 0;
failedEntities = 0;
dryRun = false;
constructor(matterbridge, log, config) {
super(matterbridge, log, config);
this.config = config;
if (typeof this.verifyMatterbridgeVersion !== 'function' || !this.verifyMatterbridgeVersion('3.7.0')) {
throw new Error(`This plugin requires Matterbridge version >= "3.7.0". Please update Matterbridge from ${this.matterbridge.matterbridgeVersion} to the latest version in the frontend."`);
}
this.log.info(`Initializing platform: ${CYAN}${this.config.name}${nf} version: ${CYAN}${this.config.version}${rs}`);
if (!isValidString(config.host, 1) || !isValidString(config.token, 1)) {
setImmediate(() => {
void this.onShutdown('Invalid configuration').catch(() => { });
});
this.wssSendSnackbarMessage('Home Assistant Plugin: configure Host and Token', 0, 'error');
throw new Error('Host and token must be defined in the configuration');
}
{
this.config.certificatePath = isValidString(config.certificatePath, 1) ? config.certificatePath : '';
this.config.rejectUnauthorized = isValidBoolean(config.rejectUnauthorized) ? config.rejectUnauthorized : true;
this.config.reconnectTimeout = isValidNumber(config.reconnectTimeout, 30) ? config.reconnectTimeout : 60;
this.config.reconnectRetries = isValidNumber(config.reconnectRetries, 0) ? config.reconnectRetries : 10;
this.config.filterByArea = isValidString(this.config.filterByArea, 1) ? this.config.filterByArea : '';
this.config.filterByLabel = isValidString(this.config.filterByLabel, 1) ? this.config.filterByLabel : '';
this.config.whiteList = isValidArray(this.config.whiteList, 1) ? this.config.whiteList : [];
this.config.blackList = isValidArray(this.config.blackList, 1) ? this.config.blackList : [];
this.config.entityWhiteList = isValidArray(this.config.entityWhiteList, 1) ? this.config.entityWhiteList : [];
this.config.entityBlackList = isValidArray(this.config.entityBlackList, 1) ? this.config.entityBlackList : [];
this.config.deviceEntityBlackList = isValidObject(this.config.deviceEntityBlackList, 1) ? this.config.deviceEntityBlackList : {};
this.config.splitEntities = isValidArray(this.config.splitEntities, 1) ? this.config.splitEntities : [];
this.config.splitByLabel = isValidString(this.config.splitByLabel) ? this.config.splitByLabel : '';
this.config.splitNameStrategy =
isValidString(this.config.splitNameStrategy, 10) && ['Entity name', 'Friendly name'].includes(this.config.splitNameStrategy)
? this.config.splitNameStrategy
: 'Entity name';
this.config.controllerStrategy =
isValidString(this.config.controllerStrategy, 5) && ['Merge', 'Matter'].includes(this.config.controllerStrategy) ? this.config.controllerStrategy : 'Merge';
this.config.namePostfix = isValidString(this.config.namePostfix, 1, 3) ? this.config.namePostfix : '';
this.config.postfix = isValidString(this.config.postfix, 1, 3) ? this.config.postfix : '';
this.config.airQualityRegex = isValidString(this.config.airQualityRegex, 1) ? this.config.airQualityRegex : '';
this.config.enableServerRvc = isValidBoolean(this.config.enableServerRvc) ? this.config.enableServerRvc : true;
this.config.discardHiddenEntities = isValidBoolean(this.config.discardHiddenEntities) ? this.config.discardHiddenEntities : false;
this.config.virtualControlLabel = isValidString(this.config.virtualControlLabel, 1) ? this.config.virtualControlLabel : '';
}
this.airQualityRegex = this.createRegexFromConfig(config.airQualityRegex);
this.stateCache.log.logLevel = this.log.logLevel;
this.ha = new HomeAssistant(config.host, config.token, config.reconnectTimeout, config.reconnectRetries, config.certificatePath, config.rejectUnauthorized);
this.ha.log.logLevel = this.log.logLevel;
this.ha.on('connected', (ha_version) => {
this.log.notice(`Connected to Home Assistant ${ha_version}`);
this.log.info(`Fetching data from Home Assistant...`);
void this.ha
.fetchData()
.then(() => {
this.log.info(`Fetched data from Home Assistant successfully`);
this.log.info(`Subscribing to Home Assistant events...`);
void this.ha
.subscribe()
.then((id) => {
this.haSubscriptionId = id;
this.log.info(`Subscribed to Home Assistant events successfully with id ${this.haSubscriptionId}`);
})
.catch((error) => {
this.log.error(`Error subscribing to Home Assistant events: ${error}`);
});
if (this.isConfigured)
this.wssSendSnackbarMessage('Reconnected to Home Assistant', 5, 'success');
if (this.isConfigured)
this.wssSendRestartRequired();
})
.catch((error) => {
this.log.error(`Error fetching data from Home Assistant: ${error}`);
});
});
this.ha.on('disconnected', () => {
this.log.warn('Disconnected from Home Assistant');
this.haSubscriptionId = null;
if (this.isReady)
this.wssSendSnackbarMessage('Disconnected from Home Assistant', 5, 'warning');
});
this.ha.on('error', (error) => {
this.log.error(`Error from Home Assistant: ${error}`);
});
this.ha.on('subscribed', () => {
this.log.info(`Subscribed to Home Assistant events`);
});
this.ha.on('config', (config) => {
this.log.info(`Configuration received from Home Assistant: state ${CYAN}${config.state}${nf} temperature unit ${CYAN}${config.unit_system.temperature}${nf} pressure unit ${CYAN}${config.unit_system.pressure}${nf}`);
});
this.ha.on('services', (_services) => {
this.log.info('Services received from Home Assistant');
});
this.ha.on('devices', (_devices) => {
this.log.info('Devices received from Home Assistant');
});
this.ha.on('entities', (_entities) => {
this.log.info('Entities received from Home Assistant');
});
this.ha.on('areas', (areas) => {
this.log.info('Areas received from Home Assistant');
if (isValidString(this.config.filterByArea, 1)) {
const area = areas.find((a) => a.name === this.config.filterByArea);
if (area) {
this.log.notice(`Filtering by area: ${CYAN}${area.name}${nf}`);
this.filterMessages.push({ message: `Home Assistant: filtering by area "${this.config.filterByArea}"`, timeout: 60, severity: 'success' });
}
else {
this.log.warn(`Area "${this.config.filterByArea}" not found in Home Assistant. Filter by area will discard all devices and entities.`);
this.filterMessages.push({
message: `Home Assistant: area "${this.config.filterByArea}" set in filterByArea not found. Filter by area will discard all devices and entities.`,
timeout: 0,
severity: 'warning',
});
}
}
});
this.ha.on('labels', (labels) => {
this.log.info('Labels received from Home Assistant');
if (isValidString(this.config.filterByLabel, 1)) {
const label = labels.find((l) => l.name === this.config.filterByLabel);
if (label) {
this.log.notice(`Filtering by label: ${CYAN}${this.config.filterByLabel}${nf}`);
this.filterMessages.push({ message: `Home Assistant: filtering by label: ${this.config.filterByLabel}`, timeout: 60, severity: 'success' });
}
else {
this.log.warn(`Label "${this.config.filterByLabel}" not found in Home Assistant. Filter by label will discard all devices and entities.`);
this.filterMessages.push({
message: `Home Assistant: label "${this.config.filterByLabel}" set in filterByLabel not found. Filter by label will discard all devices and entities.`,
timeout: 0,
severity: 'warning',
});
}
}
});
this.ha.on('states', (_states) => {
this.log.info('States received from Home Assistant');
});
this.ha.on('event', (deviceId, entityId, old_state, new_state) => {
void this.updateHandler(deviceId, entityId, old_state, new_state).catch(() => { });
});
this.log.info(`Initialized platform: ${CYAN}${this.config.name}${nf} version: ${CYAN}${this.config.version}${rs}`);
}
async onStart(reason) {
this.log.info(`Starting platform ${idn}${this.config.name}${rs}${nf}: ${reason ?? ''}`);
await fs.promises.mkdir(path.join(this.matterbridge.matterbridgePluginDirectory, 'matterbridge-hass'), { recursive: true });
this.log.info(`Connecting to Home Assistant at ${CYAN}${this.config.host}${nf}...`);
try {
await this.ha.connect();
this.log.info(`Connected to Home Assistant at ${CYAN}${this.config.host}${nf}`);
}
catch (error) {
this.log.error(`Error connecting to Home Assistant at ${CYAN}${this.config.host}${nf}: ${error}`);
}
const check = () => {
this.log.debug(`Checking Home Assistant connection: connected ${CYAN}${this.ha.connected}${db} config ${CYAN}${this.ha.hassConfig !== null}${db} services ${CYAN}${this.ha.hassServices !== null}${db} subscription ${CYAN}${this.haSubscriptionId !== null}${db}`);
return this.ha.connected && this.ha.hassConfig !== null && this.ha.hassServices !== null && this.haSubscriptionId !== null;
};
await waiter('Home Assistant connected', check, true, 110000, 1000);
void savePayload(this).catch(() => { });
void writeReport(this).catch(() => { });
await this.ready;
await this.clearSelect();
if (this.context)
await this.stateCache.load(this.context);
for (const entityId of this.config.splitEntities) {
if (!this.ha.hassEntities.has(entityId))
this.log.warn(`Split entity "${CYAN}${entityId}${wr}" set in splitEntities not found in Home Assistant. Please check your configuration.`);
if (this.ha.hassEntities.has(entityId) && this.ha.hassEntities.get(entityId)?.device_id === null)
this.log.warn(`Split entity "${CYAN}${entityId}${wr}" set in splitEntities is an individual entity. Please check your configuration.`);
}
for (const entity of Array.from(this.ha.hassEntities.values()).filter((entity) => isIndividualEntity(entity) && !isDisabled(entity) && (!isHidden(entity) || !this.config.discardHiddenEntities))) {
const [domain, name] = entity.entity_id.split('.');
if (!this.supportedDomains.includes(domain)) {
this.log.debug(`Individual entity ${CYAN}${entity.entity_id}${db} has unsupported domain ${CYAN}${domain}${db}. Skipping...`);
continue;
}
const hassState = this.ha.hassStates.get(entity.entity_id);
if (!hassState) {
this.log.debug(`Individual entity ${CYAN}${entity.entity_id}${db}: state not found. Skipping...`);
continue;
}
if (hassState.state === 'unavailable' && hassState.attributes?.['restored'] === true) {
this.log.debug(`Individual entity ${CYAN}${entity.entity_id}${db}: state unavailable and restored. Skipping...`);
continue;
}
const entityName = getEntityName(this, entity);
if (!isValidString(entityName, 1)) {
this.log.debug(`Individual entity ${CYAN}${entity.entity_id}${db} has no valid name. Skipping...`);
continue;
}
if (entityName.length > 32) {
this.longNameEntities++;
this.log.warn(`Individual entity "${CYAN}${entityName}${wr}" has a name that exceeds Matter’s 32-character limit (${entityName.length}). Matterbridge will truncate the name, but it's recommended to change it in Home Assistant to avoid issues.`);
}
if (this.hasDeviceName(entityName)) {
this.duplicatedEntities++;
this.log.warn(`Individual entity "${CYAN}${entityName}${wr}" already exists as a registered device. Please change the name in Home Assistant`);
continue;
}
if (!satisfiesAreaFilter(this, entity)) {
this.filteredEntities++;
this.log.info(`Individual entity ${CYAN}${entity.entity_id}${nf} name ${CYAN}${entityName}${nf} is not in the area "${CYAN}${this.config.filterByArea}${nf}". Skipping...`);
continue;
}
if (!satisfiesLabelFilter(this, entity)) {
this.filteredEntities++;
this.log.info(`Individual entity ${CYAN}${entity.entity_id}${nf} name ${CYAN}${entityName}${nf} doesn't have the label "${CYAN}${this.config.filterByLabel}${nf}". Skipping...`);
continue;
}
if (!this.validateEntity('', entity.entity_id, true)) {
this.unselectedEntities++;
continue;
}
this.setSelectDevice(entity.id, entityName, undefined, 'hub');
this.setSelectEntity(entityName, entity.entity_id, 'hub');
if (!this.validateDevice([entityName, entity.entity_id, entity.id], true)) {
this.unselectedEntities++;
continue;
}
this.log.info(`Creating device for individual entity ${idn}${entityName}${rs}${nf} domain ${CYAN}${domain}${nf} name ${CYAN}${name}${nf}`);
const mutableDevice = new MutableDevice(this.matterbridge, entityName + (isValidString(this.config.namePostfix, 1, 3) ? ' ' + this.config.namePostfix : ''), isValidString(this.config.postfix, 1, 3) ? entity.id.slice(0, 32 - this.config.postfix.length) + this.config.postfix : entity.id.slice(0, 32), 0xfff1, 'HomeAssistant', 0x8000, domain);
mutableDevice.setLogLevel(this.log.logLevel);
mutableDevice.addDeviceTypes('', bridgedNode);
if (this.supportedHelpersDomains.includes(domain))
addHelperEntity(this, mutableDevice, entity, hassState, true);
if (domain === 'vacuum' && this.config.enableServerRvc)
mutableDevice.setMode('server');
if (this.supportedCoreDomains.includes(domain))
addControlEntity(this, mutableDevice, entity, hassState, this.commandHandler.bind(this), this.subscribeHandler.bind(this));
if (domain === 'sensor')
addSensorEntity(this, mutableDevice, entity, hassState, this.airQualityRegex, name.includes('battery'));
if (domain === 'binary_sensor')
addBinarySensorEntity(this, mutableDevice, entity, hassState);
if (domain === 'event')
addEventEntity(this, mutableDevice, entity, hassState);
if (domain === 'button')
addButtonEntity(this, mutableDevice, entity, hassState);
if (mutableDevice.get().deviceTypes.includes(powerSource)) {
mutableDevice.addClusterServerBatteryPowerSource('', PowerSource.BatChargeLevel.Ok, 200);
}
if (entity.platform === 'template' || entity.platform === 'group') {
mutableDevice.setComposedType(`Hass Template`);
mutableDevice.setConfigUrl(`${this.config.host?.replace('ws://', 'http://').replace('wss://', 'https://')}/config/helpers`);
}
if (mutableDevice.get().deviceTypes.length > 1 || mutableDevice.size() > 1) {
try {
mutableDevice.create(this.config.controllerStrategy === 'Merge');
mutableDevice.logMutableDevice();
this.log.debug(`Registering device ${dn}${entityName}${db}...`);
await this.registerDevice(mutableDevice.getEndpoint());
if (!this.dryRun && !mutableDevice.getEndpoint().owner)
throw new Error(`Endpoint not created`);
this.matterbridgeDevices.set(entity.entity_id, mutableDevice.getEndpoint());
this.endpointNames.set(entity.entity_id, this.config.controllerStrategy === 'Merge' ? '' : entity.entity_id);
}
catch (error) {
this.failedEntities++;
inspectError(this.log, `Failed to register device ${dn}${entityName}${er}`, error);
await this.clearDeviceSelect(entity.id);
await this.clearEntitySelect(entityName);
}
}
else {
this.log.debug(`Removing device ${dn}${entityName}${db}...`);
await this.clearDeviceSelect(entity.id);
await this.clearEntitySelect(entityName);
}
mutableDevice.destroy();
}
this.log.debug(`Individual entities endpoint map(${this.matterbridgeDevices.size}/${this.endpointNames.size}):`);
for (const [entity, endpoint] of this.endpointNames) {
this.log.debug(`- individual entity ${CYAN}${entity}${db} mapped to endpoint ${CYAN}${endpoint === '' ? 'main' : endpoint}${db}`);
}
for (const device of Array.from(this.ha.hassDevices.values()).filter((device) => !isDisabled(device))) {
const deviceName = device.name_by_user ?? device.name;
if (!isValidString(deviceName, 1)) {
this.log.debug(`Device ${CYAN}${deviceName}${db} has not valid name. Skipping...`);
continue;
}
if (device.entry_type === 'service') {
this.log.debug(`Device ${CYAN}${deviceName}${db} is a service. Skipping...`);
continue;
}
if (Array.from(this.ha.hassEntities.values()).filter((e) => e.device_id === device.id).length === 0) {
this.log.debug(`Device ${CYAN}${deviceName}${db} has no entities. Skipping...`);
continue;
}
if (deviceName.length > 32) {
this.longNameDevices++;
this.log.warn(`Device "${CYAN}${deviceName}${wr}" has a name that exceeds Matter’s 32-character limit (${deviceName.length}). Matterbridge will truncate the name, but it's recommended to change it in Home Assistant to avoid issues.`);
}
if (this.hasDeviceName(deviceName)) {
this.duplicatedDevices++;
this.log.warn(`Device "${CYAN}${deviceName}${wr}" already exists as a registered device. Please change the name in Home Assistant`);
continue;
}
if (!satisfiesAreaFilter(this, device)) {
this.filteredDevices++;
this.log.info(`Device ${CYAN}${deviceName}${nf} is not in the area "${CYAN}${this.config.filterByArea}${nf}". Skipping...`);
continue;
}
const deviceHasValidLabelFilterEntities = Array.from(this.ha.hassEntities.values()).some((e) => e.device_id === device.id && !isDisabled(e) && satisfiesLabelFilter(this, e));
if (!satisfiesLabelFilter(this, device) && !deviceHasValidLabelFilterEntities) {
this.filteredDevices++;
this.log.info(`Device ${CYAN}${deviceName}${nf} doesn't have the label "${CYAN}${this.config.filterByLabel}${nf}". Skipping...`);
continue;
}
this.setSelectDevice(device.id, deviceName, undefined, 'hub');
if (!this.validateDevice([deviceName, device.id], true)) {
this.unselectedDevices++;
continue;
}
this.log.info(`Creating device ${idn}${device.name}${rs}${nf} id ${CYAN}${device.id}${nf}...`);
let battery = false;
for (const entity of Array.from(this.ha.hassEntities.values()).filter((e) => e.device_id === device.id)) {
const state = this.ha.hassStates.get(entity.entity_id);
if (state && state.attributes['device_class'] === 'battery') {
this.log.debug(`Device ${CYAN}${device.name}${db} has a battery entity: ${CYAN}${entity.entity_id}${db}`);
battery = true;
}
if (battery && state && state.attributes['state_class'] === 'measurement' && state.attributes['device_class'] === 'voltage') {
this.log.debug(`Device ${CYAN}${device.name}${db} has a battery voltage entity: ${CYAN}${entity.entity_id}${db}`);
this.batteryVoltageEntities.add(entity.entity_id);
}
}
const mutableDevice = new MutableDevice(this.matterbridge, deviceName + (isValidString(this.config.namePostfix, 1, 3) ? ' ' + this.config.namePostfix : ''), isValidString(this.config.postfix, 1, 3) ? device.id.slice(0, 32 - this.config.postfix.length) + this.config.postfix : device.id.slice(0, 32), 0xfff1, 'HomeAssistant', 0x8000, device.model ?? 'Unknown');
mutableDevice.setLogLevel(this.log.logLevel);
mutableDevice.addDeviceTypes('', bridgedNode);
if (battery) {
mutableDevice.addDeviceTypes('', powerSource);
mutableDevice.addClusterServerBatteryPowerSource('', PowerSource.BatChargeLevel.Ok, 200);
}
mutableDevice.setComposedType('Hass Device');
mutableDevice.setConfigUrl(`${this.config.host?.replace('ws://', 'http://').replace('wss://', 'https://')}/config/devices/device/${device.id}`);
let hasRvc = false;
for (const entity of Array.from(this.ha.hassEntities.values()).filter((entity) => entity.device_id === device.id && !isDisabled(entity) && (!isHidden(entity) || !this.config.discardHiddenEntities))) {
this.log.debug(`Lookup device ${CYAN}${device.name}${db} entity ${CYAN}${entity.entity_id}${db} labels ${CYAN}${entity.labels?.join(', ') ?? ''}${db}...`);
const [domain, _name] = entity.entity_id.split('.');
const entityName = entity.name ?? entity.original_name ?? deviceName;
let endpointName = entity.entity_id;
if (!this.supportedDomains.includes(domain)) {
this.log.debug(`Lookup device ${CYAN}${device.name}${db} entity ${CYAN}${entity.entity_id}${db} has unsupported domain ${CYAN}${domain}${db}. Skipping...`);
continue;
}
const hassState = this.ha.hassStates.get(entity.entity_id);
if (!hassState) {
this.log.debug(`Device ${CYAN}${device.name}${db} entity ${CYAN}${entity.entity_id}${db}: state not found. Skipping...`);
continue;
}
if (hassState.state === 'unavailable' && hassState.attributes?.['restored'] === true) {
this.log.debug(`Device ${CYAN}${device.name}${db} entity ${CYAN}${entity.entity_id}${db}: state unavailable and restored. Skipping...`);
continue;
}
if (deviceHasValidLabelFilterEntities && !satisfiesLabelFilter(this, entity)) {
this.filteredEntities++;
this.log.info(`Device ${CYAN}${deviceName}${nf} entity ${CYAN}${entity.entity_id}${nf} doesn't have the label "${CYAN}${this.config.filterByLabel}${nf}". Skipping...`);
continue;
}
this.setSelectDeviceEntity(device.id, entity.entity_id, entityName, 'component');
this.setSelectEntity(entityName, entity.entity_id, 'component');
if (isSplitEntity(this, entity)) {
this.log.debug(`Lookup device ${CYAN}${device.name}${db} entity ${CYAN}${entity.entity_id}${db} name ${CYAN}${entityName}${db} is a splitEntity. Skipping...`);
continue;
}
if (!this.validateEntity(deviceName, entity.entity_id, true)) {
this.unselectedEntities++;
continue;
}
if (domain === 'vacuum' && this.config.enableServerRvc) {
hasRvc = true;
mutableDevice.setMode('server');
if (!battery)
mutableDevice.addDeviceTypes('', powerSource);
}
const eHelper = addHelperEntity(this, mutableDevice, entity, hassState, false);
if (eHelper !== undefined) {
endpointName = eHelper;
this.endpointNames.set(entity.entity_id, endpointName);
}
const eControl = addControlEntity(this, mutableDevice, entity, hassState, this.commandHandler.bind(this), this.subscribeHandler.bind(this));
if (eControl !== undefined) {
endpointName = eControl;
this.endpointNames.set(entity.entity_id, endpointName);
}
const eSensor = addSensorEntity(this, mutableDevice, entity, hassState, this.airQualityRegex, battery);
if (eSensor !== undefined) {
endpointName = eSensor;
this.endpointNames.set(entity.entity_id, endpointName);
}
const eBinarySensor = addBinarySensorEntity(this, mutableDevice, entity, hassState);
if (eBinarySensor !== undefined) {
endpointName = eBinarySensor;
this.endpointNames.set(entity.entity_id, endpointName);
}
const eEvent = addEventEntity(this, mutableDevice, entity, hassState);
if (eEvent !== undefined) {
endpointName = eEvent;
this.endpointNames.set(entity.entity_id, endpointName);
}
const eButton = addButtonEntity(this, mutableDevice, entity, hassState);
if (eButton !== undefined) {
endpointName = eButton;
this.endpointNames.set(entity.entity_id, endpointName);
}
if (mutableDevice.has(endpointName))
this.log.debug(`Creating endpoint ${CYAN}${entity.entity_id}${db} for device ${idn}${device.name}${rs}${db} id ${CYAN}${device.id}${db}...`);
else {
await this.clearEntitySelect(entityName);
this.log.debug(`Deleting endpoint ${CYAN}${entity.entity_id}${db} for device ${idn}${device.name}${rs}${db} id ${CYAN}${device.id}${db}...`);
}
}
if (mutableDevice.size() > 1) {
try {
if (this.config.enableServerRvc && hasRvc) {
this.log.debug(`Checking server RVC for device ${dn}${device.name}${db} with enabled server RVC...`);
for (const entity of Array.from(this.ha.hassEntities.values()).filter((e) => e.device_id === device.id)) {
const domain = entity.entity_id.split('.')[0];
if (domain !== 'vacuum' && mutableDevice.has(entity.entity_id)) {
this.log.warn(`Device ${dn}${device.name}${wr} has more entities with enabled server RVC. Please filter out or unselect all other entities.`);
}
}
}
this.log.debug(`Registering device ${dn}${device.name}${db}...`);
mutableDevice.create(this.config.controllerStrategy === 'Merge');
mutableDevice.logMutableDevice();
await this.registerDevice(mutableDevice.getEndpoint());
if (!this.dryRun && !mutableDevice.getEndpoint().owner)
throw new Error(`Endpoint not created`);
this.matterbridgeDevices.set(device.id, mutableDevice.getEndpoint());
}
catch (error) {
this.failedDevices++;
inspectError(this.log, `Failed to register device ${dn}${device.name}${er}`, error);
await this.clearDeviceSelect(device.id);
}
for (const remappedEndpoint of mutableDevice.getRemappedEndpoints()) {
this.log.debug(`- Device ${CYAN}${device.name}${db} remapped endpoint ${CYAN}${remappedEndpoint}${db}`);
}
for (const splitEndpoint of mutableDevice.getSplitEndpoints()) {
this.log.debug(`- Device ${CYAN}${device.name}${db} split endpoint ${CYAN}${splitEndpoint}${db}`);
}
for (const entity of Array.from(this.ha.hassEntities.values()).filter((e) => e.device_id === device.id)) {
const endpoint = this.endpointNames.get(entity.entity_id);
if (endpoint && mutableDevice.getRemappedEndpoints().has(endpoint)) {
this.log.debug(`- Device ${CYAN}${device.name}${db} entity ${CYAN}${entity.entity_id}${db} remapped to endpoint ${CYAN}${'main'}${db}`);
this.endpointNames.set(entity.entity_id, '');
}
else if (endpoint && !mutableDevice.getRemappedEndpoints().has(endpoint)) {
this.log.debug(`- Device ${CYAN}${device.name}${db} entity ${CYAN}${entity.entity_id}${db} mapped to endpoint ${CYAN}${endpoint}${db}`);
}
}
}
else {
this.log.debug(`Device ${CYAN}${device.name}${db} has no supported entities. Deleting device select...`);
await this.clearDeviceSelect(device.id);
}
mutableDevice.destroy();
}
for (const entity of Array.from(this.ha.hassEntities.values()).filter((entity) => isDeviceEntity(entity) && !isDisabled(entity) && (!isHidden(entity) || !this.config.discardHiddenEntities) && isSplitEntity(this, entity))) {
const [domain, name] = entity.entity_id.split('.');
if (!this.supportedDomains.includes(domain)) {
this.log.debug(`Split entity ${CYAN}${entity.entity_id}${db} has unsupported domain ${CYAN}${domain}${db}. Skipping...`);
continue;
}
const hassState = this.ha.hassStates.get(entity.entity_id);
if (!hassState) {
this.log.debug(`Split entity ${CYAN}${entity.entity_id}${db} state not found. Skipping...`);
continue;
}
if (hassState.state === 'unavailable' && hassState.attributes?.['restored'] === true) {
this.log.debug(`Split entity ${CYAN}${entity.entity_id}${db}: state unavailable and restored. Skipping...`);
continue;
}
const entityName = getEntityName(this, entity);
if (!isValidString(entityName, 1)) {
this.log.debug(`Split entity ${CYAN}${entity.entity_id}${db} has no valid name. Skipping...`);
continue;
}
if (entityName.length > 32) {
this.longNameEntities++;
this.log.warn(`Split entity "${CYAN}${entityName}${wr}" has a name that exceeds Matter’s 32-character limit (${entityName.length}). Matterbridge will truncate the name, but it's recommended to change it in Home Assistant to avoid issues.`);
}
if (this.hasDeviceName(entityName)) {
this.duplicatedEntities++;
this.log.warn(`Split entity ${CYAN}${entity.entity_id}${wr} name "${CYAN}${entityName}${wr}" already exists as a registered device. Please change the name in Home Assistant.`);
continue;
}
const device = entity.device_id && this.ha.hassDevices.get(entity.device_id);
if (!device) {
this.log.info(`Split entity ${CYAN}${entity.entity_id}${nf} name ${CYAN}${getEntityName(this, entity)}${nf} device not found. Skipping...`);
continue;
}
if (!satisfiesAreaFilter(this, device)) {
this.log.info(`Split entity ${CYAN}${entity.entity_id}${nf} name ${CYAN}${getEntityName(this, entity)}${nf} is not in the area "${CYAN}${this.config.filterByArea}${nf}". Skipping...`);
this.filteredEntities++;
continue;
}
if (!satisfiesLabelFilter(this, device) && !satisfiesLabelFilter(this, entity)) {
this.log.info(`Split entity ${CYAN}${entity.entity_id}${nf} name ${CYAN}${getEntityName(this, entity)}${nf} doesn't have the label "${CYAN}${this.config.filterByLabel}${nf}". Skipping...`);
this.filteredEntities++;
continue;
}
if (!this.validateEntity('', entity.entity_id, true)) {
this.unselectedEntities++;
continue;
}
this.setSelectDevice(entity.id, entityName, undefined, 'hub');
this.setSelectEntity(entityName, entity.entity_id, 'hub');
if (!this.validateDevice([entityName, entity.entity_id, entity.id], true)) {
this.unselectedEntities++;
continue;
}
this.log.info(`Creating device for split entity ${idn}${entityName}${rs}${nf} domain ${CYAN}${domain}${nf} name ${CYAN}${name}${nf}`);
const mutableDevice = new MutableDevice(this.matterbridge, entityName + (isValidString(this.config.namePostfix, 1, 3) ? ' ' + this.config.namePostfix : ''), isValidString(this.config.postfix, 1, 3) ? entity.id.slice(0, 32 - this.config.postfix.length) + this.config.postfix : entity.id.slice(0, 32), 0xfff1, 'HomeAssistant', 0x8000, domain);
mutableDevice.setLogLevel(this.log.logLevel);
mutableDevice.addDeviceTypes('', bridgedNode);
if (this.supportedHelpersDomains.includes(domain))
addHelperEntity(this, mutableDevice, entity, hassState, true);
if (domain === 'vacuum' && this.config.enableServerRvc)
mutableDevice.setMode('server');
if (this.supportedCoreDomains.includes(domain))
addControlEntity(this, mutableDevice, entity, hassState, this.commandHandler.bind(this), this.subscribeHandler.bind(this));
if (domain === 'sensor')
addSensorEntity(this, mutableDevice, entity, hassState, this.airQualityRegex, name.includes('battery'));
if (domain === 'binary_sensor')
addBinarySensorEntity(this, mutableDevice, entity, hassState);
if (domain === 'event')
addEventEntity(this, mutableDevice, entity, hassState);
if (domain === 'button')
addButtonEntity(this, mutableDevice, entity, hassState);
if (mutableDevice.get().deviceTypes.includes(powerSource)) {
mutableDevice.addClusterServerBatteryPowerSource('', PowerSource.BatChargeLevel.Ok, 200);
}
mutableDevice.setComposedType('Hass Split');
mutableDevice.setConfigUrl(`${this.config.host?.replace('ws://', 'http://').replace('wss://', 'https://')}/config/devices/device/${entity.device_id}`);
if (mutableDevice.get().deviceTypes.length > 1 || mutableDevice.size() > 1) {
try {
mutableDevice.create(this.config.controllerStrategy === 'Merge');
mutableDevice.logMutableDevice();
this.log.debug(`Registering device ${dn}${entityName}${db}...`);
await this.registerDevice(mutableDevice.getEndpoint());
if (!this.dryRun && !mutableDevice.getEndpoint().owner)
throw new Error(`Endpoint not created`);
this.matterbridgeDevices.set(entity.entity_id, mutableDevice.getEndpoint());
this.endpointNames.set(entity.entity_id, this.config.controllerStrategy === 'Merge' ? '' : entity.entity_id);
}
catch (error) {
this.failedEntities++;
inspectError(this.log, `Failed to register device ${dn}${entityName}${er}`, error);
await this.clearDeviceSelect(entity.id);
await this.clearEntitySelect(entityName);
}
}
else {
this.log.debug(`Removing device ${dn}${entityName}${db}...`);
await this.clearDeviceSelect(entity.id);
await this.clearEntitySelect(entityName);
}
mutableDevice.destroy();
}
this.log.debug(`All entities endpoint map(${this.endpointNames.size}):`);
for (const [entity, endpoint] of this.endpointNames) {
this.log.debug(`- ${this.matterbridgeDevices.has(entity) ? 'individual' : 'device'} entity ${CYAN}${entity}${db} mapped to endpoint ${CYAN}${endpoint === '' ? 'main' : endpoint}${db}`);
}
this.log.info(`Started platform ${idn}${this.config.name}${rs}${nf}: ${reason ?? ''}`);
}
async onConfigure() {
await super.onConfigure();
this.log.info(`Configuring platform ${idn}${this.config.name}${rs}${nf}...`);
try {
for (const state of Array.from(this.ha.hassStates.values())) {
const entity = this.ha.hassEntities.get(state.entity_id);
if (!entity)
continue;
if (this.endpointNames.get(entity.entity_id) === undefined)
continue;
const [domain, _name] = entity.entity_id.split('.');
if (!this.supportedHelpersDomains.includes(domain) && !this.supportedCoreDomains.includes(domain) && domain !== 'sensor' && domain !== 'binary_sensor')
continue;
this.log.debug(`Configuring state of entity ${CYAN}${state.entity_id}${db}...`);
await this.updateHandler(entity.device_id, entity.entity_id, state, state);
}
this.log.info(`Configured platform ${idn}${this.config.name}${rs}${nf}`);
}
catch (error) {
this.log.error(`Error configuring platform ${idn}${this.config.name}${rs}${er}: ${error}`);
}
for (const msg of this.filterMessages) {
this.wssSendSnackbarMessage(msg.message, msg.timeout, msg.severity);
}
this.log.notice(`Filtered devices: ${this.filteredDevices}`);
if (this.filteredDevices)
this.wssSendSnackbarMessage(`Home Assistant: ${this.filteredDevices} devices have been discarded by filters`, 60, 'success');
this.log.notice(`Filtered entities: ${this.filteredEntities}`);
if (this.filteredEntities)
this.wssSendSnackbarMessage(`Home Assistant: ${this.filteredEntities} entities have been discarded by filters`, 60, 'success');
this.log.notice(`Unselected devices: ${this.unselectedDevices}`);
if (this.unselectedDevices)
this.wssSendSnackbarMessage(`Home Assistant: ${this.unselectedDevices} devices have been discarded by select`, 60, 'success');
this.log.notice(`Unselected entities: ${this.unselectedEntities}`);
if (this.unselectedEntities)
this.wssSendSnackbarMessage(`Home Assistant: ${this.unselectedEntities} entities have been discarded by select`, 60, 'success');
if (this.longNameDevices)
this.log.warn(`Devices with long names: ${this.longNameDevices}`);
if (this.longNameDevices)
this.wssSendSnackbarMessage(`Home Assistant: ${this.longNameDevices} devices have names that exceed Matter’s 32-character limit`, 60, 'warning');
if (this.longNameEntities)
this.log.warn(`Entities with long names: ${this.longNameEntities}`);
if (this.longNameEntities)
this.wssSendSnackbarMessage(`Home Assistant: ${this.longNameEntities} entities have names that exceed Matter’s 32-character limit`, 60, 'warning');
if (this.duplicatedDevices)
this.log.warn(`Duplicated device names: ${this.duplicatedDevices}`);
if (this.duplicatedDevices)
this.wssSendSnackbarMessage(`Home Assistant: ${this.duplicatedDevices} devices have been discarded due to duplicate names`, 60, 'warning');
if (this.duplicatedEntities)
this.log.warn(`Duplicated entity names: ${this.duplicatedEntities}`);
if (this.duplicatedEntities)
this.wssSendSnackbarMessage(`Home Assistant: ${this.duplicatedEntities} entities have been discarded due to duplicate names`, 60, 'warning');
if (this.failedDevices)
this.log.error(`Failed device creation: ${this.failedDevices}`);
if (this.failedDevices)
this.wssSendSnackbarMessage(`Home Assistant: ${this.failedDevices} devices failed to be created`, 60, 'error');
if (this.failedEntities)
this.log.error(`Failed entity creation: ${this.failedEntities}`);
if (this.failedEntities)
this.wssSendSnackbarMessage(`Home Assistant: ${this.failedEntities} entities failed to be created`, 60, 'error');
}
async onChangeLoggerLevel(logLevel) {
this.log.info(`Logger level changed to ${logLevel}`);
this.ha.log.logLevel = logLevel;
this.stateCache.log.logLevel = logLevel;
for (const device of this.matterbridgeDevices.values()) {
device.log.logLevel = logLevel;
}
}
async onShutdown(reason) {
if (this.context)
await this.stateCache.save(this.context);
await super.onShutdown(reason);
this.log.info(`Shutting down platform ${idn}${this.config.name}${rs}${nf}: ${reason}`);
try {
await this.ha?.close();
this.ha?.removeAllListeners();
this.log.info('Home Assistant connection closed');
}
catch (error) {
this.log.error(`Error closing Home Assistant connection: ${error}`);
}
if (this.config.unregisterOnShutdown === true)
await this.unregisterAllDevices();
this.stateCache.clear();
this.matterbridgeDevices.clear();
this.updatingEntities.clear();
this.offUpdatedEntities.clear();
this.endpointNames.clear();
this.batteryVoltageEntities.clear();
this.log.info(`Shut down platform ${idn}${this.config.name}${rs}${nf} completed`);
}
async commandHandler(data, endpointName, command) {
const entityId = endpointName;
if (!entityId)
return;
data.endpoint.log.info(`${db}Received matter command ${ign}${command}${rs}${db} for endpoint ${or}${endpointName}${db}:${or}${data.endpoint?.maybeNumber}${db}`);
const state = this.ha.hassStates.get(entityId);
const domain = entityId.split('.')[0];
const hassCommand = hassCommandConverter.find((cvt) => cvt.command === command && cvt.domain === domain);
if (hassCommand) {
if (domain === 'cover') {
if (command === 'goToLiftPercentage' && data.request.liftPercent100thsValue === 10000) {
await this.ha.callService(hassCommand.domain, 'close_cover', entityId);
return;
}
else if (command === 'goToLiftPercentage' && data.request.liftPercent100thsValue === 0) {
await this.ha.callService(hassCommand.domain, 'open_cover', entityId);
return;
}
}
if (domain === 'light') {
const onOff = data.endpoint.getAttribute(OnOff.Cluster.id, 'onOff', data.endpoint.log);
if (onOff === false && ['moveToLevel', 'moveToColorTemperature', 'moveToColor', 'moveToHue', 'moveToSaturation', 'moveToHueAndSaturation'].includes(command)) {
data.endpoint.log.debug(`Command ${ign}${command}${rs}${db} for domain ${CYAN}${domain}${db} entity ${CYAN}${entityId}${db} received while the light is off => skipping it`);
this.offUpdatedEntities.add(entityId);
return;
}
if (command === 'moveToLevelWithOnOff' && data.request['level'] <= (data.endpoint.getAttribute(LevelControl.Cluster.id, 'minLevel') ?? 1)) {
data.endpoint.log.debug(`Command ${ign}${command}${rs}${db} for domain ${CYAN}${domain}${db} entity ${CYAN}${entityId}${db} received with level = minLevel => turn off the light`);
await this.ha.callService('light', 'turn_off', entityId);
return;
}
if (onOff === false &&
(command === 'on' ||
command === 'toggle' ||
(command === 'moveToLevelWithOnOff' && data.request['level'] > (data.endpoint.getAttribute(LevelControl.Cluster.id, 'minLevel') ?? 1)))) {
const serviceAttributes = {};
const brightness = data.endpoint.hasAttributeServer(LevelControl.Cluster.id, 'currentLevel')
? Math.round((data.endpoint.getAttribute(LevelControl.Cluster.id, 'currentLevel') / 254) * 255)
: undefined;
if (isValidNumber(brightness, 1, 255) && this.offUpdatedEntities.has(entityId))
serviceAttributes['brightness'] = brightness;
if (command === 'moveToLevelWithOnOff' && isValidNumber(data.request['level'], 2, 254))
serviceAttributes['brightness'] = Math.round((data.request['level'] / 254) * 255);
const colorMode = data.endpoint.hasClusterServer(ColorControl.Cluster.id) && data.endpoint.hasAttributeServer(ColorControl.Cluster.id, 'colorMode')
? data.endpoint.getAttribute(ColorControl.Cluster.id, 'colorMode')
: undefined;
if (colorMode === ColorControl.ColorMode.ColorTemperatureMireds &&
data.endpoint.hasAttributeServer(ColorControl.Cluster.id, 'colorTemperatureMireds') &&
this.offUpdatedEntities.has(entityId)) {
const color_temp = data.endp