UNPKG

matterbridge-hass

Version:
755 lines 68.1 kB
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