matterbridge-hass
Version:
Matterbridge hass plugin
659 lines (658 loc) • 46.1 kB
JavaScript
import path from 'node:path';
import { promises as fs } from 'node:fs';
import { MatterbridgeDynamicPlatform, bridgedNode, colorTemperatureLight, extendedColorLight, onOffOutlet, smokeCoAlarm, waterLeakDetector, waterFreezeDetector, contactSensor, powerSource, electricalSensor, airQualitySensor, } from 'matterbridge';
import { dn, idn, ign, nf, rs, wr, db, or, debugStringify, YELLOW, CYAN, hk, er } from 'matterbridge/logger';
import { deepEqual, isValidArray, isValidBoolean, isValidNumber, isValidString, waiter } from 'matterbridge/utils';
import { OnOff, BridgedDeviceBasicInformation, SmokeCoAlarm, PowerSource, AirQuality } from 'matterbridge/matter/clusters';
import { ClusterRegistry } from 'matterbridge/matter/types';
import { HomeAssistant } from './homeAssistant.js';
import { MutableDevice } from './mutableDevice.js';
import { hassCommandConverter, hassDomainAttributeConverter, hassDomainBinarySensorsConverter, hassDomainConverter, hassDomainSensorsConverter, hassSubscribeConverter, hassUpdateAttributeConverter, hassUpdateStateConverter, } from './converters.js';
export class HomeAssistantPlatform extends MatterbridgeDynamicPlatform {
ha;
haSubscriptionId = null;
labelIdFilter = '';
matterbridgeDevices = new Map();
endpointNames = new Map();
airQualityRegex;
constructor(matterbridge, log, config) {
super(matterbridge, log, config);
if (this.verifyMatterbridgeVersion === undefined || typeof this.verifyMatterbridgeVersion !== 'function' || !this.verifyMatterbridgeVersion('3.0.6')) {
throw new Error(`This plugin requires Matterbridge version >= "3.0.6". 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)) {
throw new Error('Host and token must be defined in the configuration');
}
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.reconnectTimeout = isValidNumber(config.reconnectTimeout, 30) ? config.reconnectTimeout : undefined;
this.config.reconnectRetries = isValidNumber(config.reconnectRetries, 0) ? config.reconnectRetries : undefined;
this.config.certificatePath = isValidString(config.certificatePath, 1) ? config.certificatePath : undefined;
this.config.rejectUnauthorized = isValidBoolean(config.rejectUnauthorized) ? config.rejectUnauthorized : undefined;
if (config.individualEntityWhiteList)
delete config.individualEntityWhiteList;
if (config.individualEntityBlackList)
delete config.individualEntityBlackList;
this.airQualityRegex = this.createRegexFromConfig(config.airQualityRegex);
this.ha = new HomeAssistant(config.host, config.token, config.reconnectTimeout, config.reconnectRetries, config.certificatePath, config.rejectUnauthorized);
this.ha.on('connected', async (ha_version) => {
this.log.notice(`Connected to Home Assistant ${ha_version}`);
this.log.info(`Fetching data from Home Assistant...`);
try {
await this.ha.fetchData();
this.log.info(`Fetched data from Home Assistant successfully`);
}
catch (error) {
this.log.error(`Error fetching data from Home Assistant: ${error}`);
}
this.log.info(`Subscribing to Home Assistant events...`);
try {
this.haSubscriptionId = await this.ha.subscribe();
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}`);
}
});
this.ha.on('disconnected', () => {
this.log.warn('Disconnected from Home Assistant');
});
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: temperature unit '${config.unit_system.temperature}' pressure unit '${config.unit_system.pressure}'`);
});
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');
});
this.ha.on('labels', (labels) => {
this.log.info('Labels received from Home Assistant');
if (isValidString(this.config.filterByLabel, 1)) {
if (labels.find((label) => label.label_id === this.config.filterByLabel)) {
this.labelIdFilter = this.config.filterByLabel;
this.log.info(`Filtering by label_id: ${CYAN}${this.labelIdFilter}${nf}`);
return;
}
this.labelIdFilter = labels.find((label) => label.name === this.config.filterByLabel)?.label_id ?? '';
if (this.labelIdFilter) {
this.log.info(`Filtering by label_id: ${CYAN}${this.labelIdFilter}${nf}`);
return;
}
this.log.warn(`Label "${this.config.filterByLabel}" not found in Home Assistant. Filter by label is disabled.`);
}
});
this.ha.on('states', (_states) => {
this.log.info('States received from Home Assistant');
});
this.ha.on('event', this.updateHandler.bind(this));
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.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);
this.savePayload(path.join(this.matterbridge.matterbridgePluginDirectory, 'matterbridge-hass', 'homeassistant.json'));
await this.ready;
await this.clearSelect();
for (const entity of Array.from(this.ha.hassEntities.values())) {
const [domain, name] = entity.entity_id.split('.');
if (entity.platform === 'template') {
if (domain !== 'switch')
continue;
}
else {
if (!['automation', 'scene', 'script', 'input_boolean', 'input_button'].includes(domain))
continue;
}
if (entity.device_id !== null) {
this.log.debug(`Individual entity ${CYAN}${entity.entity_id}${db} is a device entity. Skipping...`);
continue;
}
const entityName = entity.name ?? entity.original_name;
if (!isValidString(entityName, 1)) {
this.log.debug(`Individual entity ${CYAN}${entity.entity_id}${db} has no valid name. Skipping...`);
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))
continue;
if (this.hasDeviceName(entityName)) {
this.log.warn(`Individual entity ${CYAN}${entityName}${wr} already exists as a registered device. Please change the name in Home Assistant`);
continue;
}
if (!this.isValidAreaLabel(entity.area_id, entity.labels)) {
this.log.debug(`Individual entity ${CYAN}${entityName}${db} is not in the area "${CYAN}${this.config.filterByArea}${db}" or doesn't have the label "${CYAN}${this.config.filterByLabel}${db}". Skipping...`);
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', domain);
mutableDevice.addDeviceTypes('', bridgedNode);
if (domain === 'automation')
mutableDevice.setComposedType(`Hass Automation`);
else if (domain === 'scene')
mutableDevice.setComposedType(`Hass Scene`);
else if (domain === 'script')
mutableDevice.setComposedType(`Hass Script`);
else if (domain === 'input_boolean')
mutableDevice.setComposedType(`Hass Boolean`);
else if (domain === 'input_button')
mutableDevice.setComposedType(`Hass Button`);
else if (domain === 'switch')
mutableDevice.setComposedType(`Hass Template`);
if (domain === 'automation')
mutableDevice.setConfigUrl(`${this.config.host?.replace('ws://', 'http://').replace('wss://', 'https://')}/config/automation/dashboard`);
else if (domain === 'scene')
mutableDevice.setConfigUrl(`${this.config.host?.replace('ws://', 'http://').replace('wss://', 'https://')}/config/scene/dashboard`);
else if (domain === 'script')
mutableDevice.setConfigUrl(`${this.config.host?.replace('ws://', 'http://').replace('wss://', 'https://')}/config/script/dashboard`);
else
mutableDevice.setConfigUrl(`${this.config.host?.replace('ws://', 'http://').replace('wss://', 'https://')}/config/helpers`);
mutableDevice.addDeviceTypes('', onOffOutlet);
mutableDevice.addCommandHandler('', 'on', async (data, _endpointName, _command) => {
if (domain === 'automation') {
await this.ha.callService(domain, 'trigger', entity.entity_id);
}
else if (domain === 'input_button') {
await this.ha.callService(domain, 'press', entity.entity_id);
}
else {
await this.ha.callService(domain, 'turn_on', entity.entity_id);
}
if (domain !== 'input_boolean' && domain !== 'switch') {
setTimeout(() => {
data.endpoint.setAttribute(OnOff.Cluster.id, 'onOff', false, data.endpoint.log);
}, 500).unref();
}
});
mutableDevice.addCommandHandler('', 'off', async (_data, _endpointName, _command) => {
if (domain === 'input_boolean' || domain === 'switch')
await this.ha.callService(domain, 'turn_off', entity.entity_id);
});
mutableDevice.addSubscribeHandler('', OnOff.Cluster.id, 'onOff', (_newValue, _oldValue, _context, _endpointName, _clusterId, _attribute) => {
});
await mutableDevice.create();
mutableDevice.logMutableDevice();
this.log.debug(`Registering device ${dn}${entityName}${db}...`);
await this.registerDevice(mutableDevice.getEndpoint());
this.matterbridgeDevices.set(entity.entity_id, mutableDevice.getEndpoint());
this.endpointNames.set(entity.entity_id, '');
}
for (const device of Array.from(this.ha.hassDevices.values())) {
const deviceName = device.name_by_user ?? device.name;
if (device.entry_type === 'service') {
this.log.debug(`Device ${CYAN}${deviceName}${db} is a service. Skipping...`);
continue;
}
if (!isValidString(deviceName, 1)) {
this.log.debug(`Device ${CYAN}${deviceName}${db} has not valid name. 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;
}
this.setSelectDevice(device.id, deviceName, undefined, 'hub');
if (!this.validateDevice([deviceName, device.id], true))
continue;
if (this.hasDeviceName(deviceName)) {
this.log.warn(`Device ${CYAN}${deviceName}${wr} already exists as a registered device. Please change the name in Home Assistant`);
continue;
}
if (!this.isValidAreaLabel(device.area_id, device.labels)) {
this.log.debug(`Device ${CYAN}${deviceName}${db} is not in the area "${CYAN}${this.config.filterByArea}${db}" or doesn't have the label "${CYAN}${this.config.filterByLabel}${db}". Skipping...`);
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}`);
}
}
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', device.model ?? 'Unknown');
mutableDevice.addDeviceTypes('', bridgedNode);
if (battery) {
mutableDevice.addDeviceTypes('', powerSource);
mutableDevice.addClusterServerPowerSource('', PowerSource.BatChargeLevel.Ok, 200);
}
mutableDevice.setComposedType('Hass Device');
mutableDevice.setConfigUrl(`${this.config.host?.replace('ws://', 'http://').replace('wss://', 'https://')}/config/devices/device/${device.id}`);
for (const entity of Array.from(this.ha.hassEntities.values()).filter((e) => e.device_id === device.id)) {
this.log.debug(`Lookup device ${CYAN}${device.name}${db} entity ${CYAN}${entity.entity_id}${db}`);
const domain = entity.entity_id.split('.')[0];
let endpointName = entity.entity_id;
const hassState = this.ha.hassStates.get(entity.entity_id);
if (!hassState) {
this.log.debug(`Lookup device ${CYAN}${device.name}${db} entity ${CYAN}${entity.entity_id}${db} disabled by ${entity.disabled_by}: state not found. Skipping...`);
continue;
}
if (this.config.applyFiltersToDeviceEntities && !this.isValidAreaLabel(entity.area_id, entity.labels)) {
this.log.debug(`Device ${CYAN}${deviceName}${db} entity ${CYAN}${entity.entity_id}${db} is not in the area "${CYAN}${this.config.filterByArea}${db}" or doesn't have the label "${CYAN}${this.config.filterByLabel}${db}". Skipping...`);
continue;
}
const hassDomains = hassDomainConverter.filter((d) => d.domain === domain);
if (hassDomains.length > 0) {
this.log.debug(`Lookup device ${CYAN}${device.name}${db} domain ${CYAN}${CYAN}${domain}${db} entity ${CYAN}${entity.entity_id}${db}`);
const entityName = entity.name ?? entity.original_name ?? deviceName;
this.setSelectDeviceEntity(device.id, entity.entity_id, entityName, 'component');
this.setSelectEntity(entityName, entity.entity_id, 'component');
if (!this.validateEntity(deviceName, entity.entity_id, true))
continue;
hassDomains.forEach((hassDomain) => {
if (hassDomain.deviceType)
mutableDevice.addDeviceTypes(entity.entity_id, hassDomain.deviceType);
if (hassDomain.clusterId)
mutableDevice.addClusterServerIds(entity.entity_id, hassDomain.clusterId);
if (hassDomain.deviceType && isValidString(hassState.attributes['friendly_name']))
mutableDevice.setFriendlyName(entity.entity_id, hassState.attributes['friendly_name']);
});
}
else {
this.log.debug(`Lookup device ${CYAN}${device.name}${db} domain ${CYAN}${CYAN}${domain}${db} entity ${CYAN}${entity.entity_id}${db}: domain not found`);
continue;
}
this.log.debug(`- state ${debugStringify(hassState)}`);
for (const [key, _value] of Object.entries(hassState.attributes)) {
hassDomainAttributeConverter
.filter((d) => d.domain === domain && d.withAttribute === key)
.forEach((hassDomainAttribute) => {
this.log.debug(`+ attribute device ${CYAN}${hassDomainAttribute.deviceType.name}${db} cluster ${CYAN}${ClusterRegistry.get(hassDomainAttribute.clusterId)?.name}${db}`);
mutableDevice.addDeviceTypes(entity.entity_id, hassDomainAttribute.deviceType);
mutableDevice.addClusterServerIds(entity.entity_id, hassDomainAttribute.clusterId);
});
}
if (this.airQualityRegex && this.airQualityRegex.test(entity.entity_id)) {
this.log.debug(`+ air_quality entity ${CYAN}${entity.entity_id}${db} found for device ${CYAN}${device.name}${db}`);
this.endpointNames.set(entity.entity_id, 'AirQuality');
mutableDevice.addDeviceTypes('AirQuality', airQualitySensor);
mutableDevice.addClusterServerIds('AirQuality', AirQuality.Cluster.id);
if (isValidString(hassState.attributes['friendly_name']))
mutableDevice.setFriendlyName('AirQuality', hassState.attributes['friendly_name']);
}
hassDomainSensorsConverter
.filter((d) => d.domain === domain)
.forEach((hassDomainSensor) => {
if (hassState.attributes['state_class'] === hassDomainSensor.withStateClass && hassState.attributes['device_class'] === hassDomainSensor.withDeviceClass) {
if (hassDomainSensor.deviceType === powerSource && hassState.attributes['state_class'] === 'measurement' && hassState.attributes['device_class'] === 'voltage' && !battery)
return;
if (hassDomainSensor.deviceType === electricalSensor && hassState.attributes['state_class'] === 'measurement' && hassState.attributes['device_class'] === 'voltage' && battery)
return;
if (hassDomainSensor.endpoint !== undefined) {
endpointName = hassDomainSensor.endpoint;
this.endpointNames.set(entity.entity_id, hassDomainSensor.endpoint);
this.log.debug(`***- sensor domain ${hassDomainSensor.domain} stateClass ${hassDomainSensor.withStateClass} deviceClass ${hassDomainSensor.withDeviceClass} endpoint '${CYAN}${endpointName}${db}' for entity ${CYAN}${entity.entity_id}${db}`);
}
this.log.debug(`+ sensor device ${CYAN}${hassDomainSensor.deviceType.name}${db} cluster ${CYAN}${ClusterRegistry.get(hassDomainSensor.clusterId)?.name}${db}`);
mutableDevice.addDeviceTypes(endpointName, hassDomainSensor.deviceType);
mutableDevice.addClusterServerIds(endpointName, hassDomainSensor.clusterId);
if (isValidString(hassState.attributes['friendly_name']))
mutableDevice.setFriendlyName(endpointName, hassState.attributes['friendly_name']);
}
});
hassDomainBinarySensorsConverter
.filter((d) => d.domain === domain)
.forEach((hassDomainBinarySensor) => {
if (hassState.attributes['device_class'] === hassDomainBinarySensor.withDeviceClass) {
if (hassDomainBinarySensor.endpoint !== undefined) {
endpointName = hassDomainBinarySensor.endpoint;
this.endpointNames.set(entity.entity_id, endpointName);
this.log.debug(`***- sensor domain ${hassDomainBinarySensor.domain} deviceClass ${hassDomainBinarySensor.withDeviceClass} endpoint '${CYAN}${endpointName}${db}' for entity ${CYAN}${entity.entity_id}${db}`);
}
this.log.debug(`+ binary_sensor device ${CYAN}${hassDomainBinarySensor.deviceType.name}${db} cluster ${CYAN}${ClusterRegistry.get(hassDomainBinarySensor.clusterId)?.name}${db}`);
mutableDevice.addDeviceTypes(endpointName, hassDomainBinarySensor.deviceType);
mutableDevice.addClusterServerIds(endpointName, hassDomainBinarySensor.clusterId);
if (isValidString(hassState.attributes['friendly_name']))
mutableDevice.setFriendlyName(endpointName, hassState.attributes['friendly_name']);
}
});
if (!mutableDevice.has(endpointName))
continue;
this.log.info(`Creating endpoint ${CYAN}${entity.entity_id}${nf} for device ${idn}${device.name}${rs}${nf} id ${CYAN}${device.id}${nf}`);
const deviceTypeCodes = mutableDevice.get(endpointName).deviceTypes.map((d) => d.code);
if (deviceTypeCodes.includes(powerSource.code)) {
this.log.debug(`= powerSource battery device ${CYAN}${entity.entity_id}${db} state ${CYAN}${hassState.state}${db}`);
mutableDevice.addClusterServerPowerSource(endpointName, PowerSource.BatChargeLevel.Ok, 200);
}
if (domain === 'binary_sensor' && deviceTypeCodes.includes(contactSensor.code)) {
this.log.debug(`= contactSensor device ${CYAN}${entity.entity_id}${db} state ${CYAN}${hassState.state}${db}`);
mutableDevice.addClusterServerBooleanState(entity.entity_id, hassState.state === 'on' ? false : true);
}
if (domain === 'binary_sensor' && (deviceTypeCodes.includes(waterLeakDetector.code) || deviceTypeCodes.includes(waterFreezeDetector.code))) {
this.log.debug(`= waterLeakDetector/waterFreezeDetector device ${CYAN}${entity.entity_id}${db} state ${CYAN}${hassState.state}${db}`);
mutableDevice.addClusterServerBooleanState(entity.entity_id, hassState.state === 'on' ? true : false);
}
if (domain === 'binary_sensor' && hassState.attributes.device_class === 'smoke' && mutableDevice.get(entity.entity_id).deviceTypes[0].code === smokeCoAlarm.code) {
this.log.debug(`= smokeCoAlarm SmokeAlarm device ${CYAN}${entity.entity_id}${db} state ${CYAN}${hassState.state}${db}`);
mutableDevice.addClusterServerSmokeAlarmSmokeCoAlarm(entity.entity_id, hassState.state === 'on' ? SmokeCoAlarm.AlarmState.Critical : SmokeCoAlarm.AlarmState.Normal);
}
if (domain === 'binary_sensor' && hassState.attributes.device_class === 'carbon_monoxide' && mutableDevice.get(entity.entity_id).deviceTypes[0].code === smokeCoAlarm.code) {
this.log.debug(`= smokeCoAlarm CoAlarm device ${CYAN}${entity.entity_id}${db} state ${CYAN}${hassState.state}${db}`);
mutableDevice.addClusterServerCoAlarmSmokeCoAlarm(entity.entity_id, hassState.state === 'on' ? SmokeCoAlarm.AlarmState.Critical : SmokeCoAlarm.AlarmState.Normal);
}
if (domain === 'light' && (deviceTypeCodes.includes(colorTemperatureLight.code) || deviceTypeCodes.includes(extendedColorLight.code))) {
this.log.debug(`= colorControl device ${CYAN}${entity.entity_id}${db} supported_color_modes: ${CYAN}${hassState.attributes['supported_color_modes']}${db} min_mireds: ${CYAN}${hassState.attributes['min_mireds']}${db} max_mireds: ${CYAN}${hassState.attributes['max_mireds']}${db}`);
if (isValidArray(hassState.attributes['supported_color_modes']) && !hassState.attributes['supported_color_modes'].includes('xy') && !hassState.attributes['supported_color_modes'].includes('hs') && !hassState.attributes['supported_color_modes'].includes('rgb') &&
!hassState.attributes['supported_color_modes'].includes('rgbw') && !hassState.attributes['supported_color_modes'].includes('rgbww') && hassState.attributes['supported_color_modes'].includes('color_temp')) {
mutableDevice.addClusterServerColorTemperatureColorControl(entity.entity_id, hassState.attributes['color_temp'] ?? 250, hassState.attributes['min_mireds'] ?? 147, hassState.attributes['max_mireds'] ?? 500);
}
else {
mutableDevice.addClusterServerColorControl(entity.entity_id, hassState.attributes['color_temp'] ?? 250, hassState.attributes['min_mireds'] ?? 147, hassState.attributes['max_mireds'] ?? 500);
}
}
if (domain === 'climate') {
if (isValidArray(hassState?.attributes['hvac_modes']) && hassState.attributes['hvac_modes'].includes('heat_cool')) {
this.log.debug(`= thermostat device ${CYAN}${entity.entity_id}${db} state ${CYAN}${hassState.attributes['hvac_modes']}${db}`);
mutableDevice.addClusterServerAutoModeThermostat(entity.entity_id, hassState.attributes['current_temperature'] ?? 23, hassState.attributes['target_temp_low'] ?? 21, hassState.attributes['target_temp_high'] ?? 25, hassState.attributes['min_temp'] ?? 0, hassState.attributes['max_temp'] ?? 50);
}
else if (isValidArray(hassState?.attributes['hvac_modes']) && hassState.attributes['hvac_modes'].includes('heat') && !hassState.attributes['hvac_modes'].includes('cool')) {
this.log.debug(`= thermostat device ${CYAN}${entity.entity_id}${db} state ${CYAN}${hassState.attributes['hvac_modes']}${db}`);
mutableDevice.addClusterServerHeatingThermostat(entity.entity_id, hassState.attributes['current_temperature'] ?? 23, hassState.attributes['temperature'] ?? 21, hassState.attributes['min_temp'] ?? 0, hassState.attributes['max_temp'] ?? 50);
}
else if (isValidArray(hassState?.attributes['hvac_modes']) && hassState.attributes['hvac_modes'].includes('cool') && !hassState.attributes['hvac_modes'].includes('heat')) {
this.log.debug(`= thermostat device ${CYAN}${entity.entity_id}${db} state ${CYAN}${hassState.attributes['hvac_modes']}${db}`);
mutableDevice.addClusterServerCoolingThermostat(entity.entity_id, hassState.attributes['current_temperature'] ?? 23, hassState.attributes['temperature'] ?? 21, hassState.attributes['min_temp'] ?? 0, hassState.attributes['max_temp'] ?? 50);
}
}
for (const hassCommand of hassCommandConverter.filter((c) => c.domain === domain)) {
this.log.debug(`- command: ${CYAN}${hassCommand.command}${db}`);
mutableDevice.addCommandHandler(entity.entity_id, hassCommand.command, async (data, endpointName, command) => {
this.commandHandler(data, endpointName, command);
});
}
for (const hassSubscribe of hassSubscribeConverter.filter((s) => s.domain === domain)) {
this.log.debug(`- subscribe: ${CYAN}${ClusterRegistry.get(hassSubscribe.clusterId)?.name}${db}:${CYAN}${hassSubscribe.attribute}${db}`);
mutableDevice.addSubscribeHandler(entity.entity_id, hassSubscribe.clusterId, hassSubscribe.attribute, (newValue, oldValue, context, _endpointName, _clusterId, _attribute) => {
this.subscribeHandler(entity, hassSubscribe, newValue, oldValue, context);
});
}
}
if (mutableDevice.size() > 1) {
this.log.debug(`Registering device ${dn}${device.name}${db}...`);
await mutableDevice.create();
mutableDevice.logMutableDevice();
await this.registerDevice(mutableDevice.getEndpoint());
this.matterbridgeDevices.set(device.id, mutableDevice.getEndpoint());
}
else {
this.log.debug(`Device ${CYAN}${device.name}${db} has no supported entities. Deleting device select...`);
this.clearDeviceSelect(device.id);
}
}
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);
const deviceId = entity?.device_id;
if (deviceId) {
this.log.debug(`Configuring state ${CYAN}${state.entity_id}${db} for device ${CYAN}${deviceId}${db}`);
await this.updateHandler(deviceId, state.entity_id, state, state);
}
else {
this.log.debug(`Configuring state on individual entity ${CYAN}${state.entity_id}${db}`);
await this.updateHandler(null, state.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}`);
}
}
async onChangeLoggerLevel(logLevel) {
this.log.info(`Logger level changed to ${logLevel}`);
for (const device of this.matterbridgeDevices.values()) {
device.log.logLevel = logLevel;
}
}
async onShutdown(reason) {
await super.onShutdown(reason);
this.log.info(`Shutting down platform ${idn}${this.config.name}${rs}${nf}: ${reason ?? ''}`);
try {
await this.ha.close();
this.log.info('Home Assistant connection closed');
}
catch (error) {
this.log.error(`Error closing Home Assistant connection: ${error}`);
}
this.ha.removeAllListeners();
if (this.config.unregisterOnShutdown === true)
await this.unregisterAllDevices();
this.matterbridgeDevices.clear();
this.endpointNames.clear();
}
async commandHandler(data, endpointName, command) {
const entityId = data.endpoint.uniqueStorageKey;
if (!entityId)
return;
data.endpoint.log.info(`${db}Received matter command ${ign}${command}${rs}${db} for endpoint ${or}${data.endpoint?.uniqueStorageKey}${db}:${or}${data.endpoint?.maybeNumber}${db}`);
const domain = entityId.split('.')[0];
const hassCommand = hassCommandConverter.find((cvt) => cvt.command === command && cvt.domain === domain);
if (hassCommand) {
const serviceAttributes = hassCommand.converter ? hassCommand.converter(data.request, data.attributes) : undefined;
await this.ha.callService(hassCommand.domain, hassCommand.service, entityId, serviceAttributes);
}
else {
data.endpoint.log.warn(`Command ${ign}${command}${rs}${wr} not supported for domain ${CYAN}${domain}${wr} entity ${CYAN}${entityId}${wr}`);
}
}
subscribeHandler(entity, hassSubscribe, newValue, oldValue, context) {
const matterbridgeDevice = entity.device_id ? this.matterbridgeDevices.get(entity.device_id) : undefined;
if (!matterbridgeDevice) {
this.log.debug(`Subscribe handler: Matterbridge device ${entity.device_id} for ${entity.entity_id} not found`);
return;
}
const child = matterbridgeDevice.getChildEndpointByName(entity.entity_id) || matterbridgeDevice.getChildEndpointByName(entity.entity_id.replaceAll('.', ''));
if (!child) {
this.log.debug(`Subscribe handler: Endpoint ${entity.entity_id} for device ${entity.device_id} not found`);
return;
}
if (context && context.offline === true) {
child.log.debug(`Subscribed attribute ${hk}${ClusterRegistry.get(hassSubscribe.clusterId)?.name}${db}:${hk}${hassSubscribe.attribute}${db} ` +
`on endpoint ${or}${child?.maybeId}${db}:${or}${child?.maybeNumber}${db} changed for an offline update`);
return;
}
if ((typeof newValue !== 'object' && newValue === oldValue) || (typeof newValue === 'object' && deepEqual(newValue, oldValue))) {
child.log.debug(`Subscribed attribute ${hk}${ClusterRegistry.get(hassSubscribe.clusterId)?.name}${db}:${hk}${hassSubscribe.attribute}${db} ` +
`on endpoint ${or}${child?.maybeId}${db}:${or}${child?.maybeNumber}${db} not changed`);
return;
}
child.log.info(`${db}Subscribed attribute ${hk}${ClusterRegistry.get(hassSubscribe.clusterId)?.name}${db}:${hk}${hassSubscribe.attribute}${db} on endpoint ${or}${child?.maybeId}${db}:${or}${child?.maybeNumber}${db} ` +
`changed from ${YELLOW}${typeof oldValue === 'object' ? debugStringify(oldValue) : oldValue}${db} to ${YELLOW}${typeof newValue === 'object' ? debugStringify(newValue) : newValue}${db}`);
const value = hassSubscribe.converter ? hassSubscribe.converter(newValue) : newValue;
if (hassSubscribe.converter)
child.log.debug(`Converter: ${typeof newValue === 'object' ? debugStringify(newValue) : newValue} => ${typeof value === 'object' ? debugStringify(value) : value}`);
const domain = entity.entity_id.split('.')[0];
if (value !== null)
this.ha.callService(domain, hassSubscribe.service, entity.entity_id, { [hassSubscribe.with]: value });
else
this.ha.callService(domain, 'turn_off', entity.entity_id);
}
async updateHandler(deviceId, entityId, old_state, new_state) {
const matterbridgeDevice = this.matterbridgeDevices.get(deviceId ?? entityId);
if (!matterbridgeDevice) {
this.log.debug(`Update handler: Matterbridge device ${deviceId} for ${entityId} not found`);
return;
}
let endpoint = matterbridgeDevice.getChildEndpointByName(entityId) || matterbridgeDevice.getChildEndpointByName(entityId.replaceAll('.', ''));
if (!endpoint) {
const mappedEndpoint = this.endpointNames.get(entityId);
if (mappedEndpoint === '') {
this.log.debug(`***Update handler: Endpoint ${entityId} for ${deviceId} mapped to endpoint '${mappedEndpoint}'`);
endpoint = matterbridgeDevice;
}
else if (mappedEndpoint) {
this.log.debug(`***Update handler: Endpoint ${entityId} for ${deviceId} mapped to endpoint '${mappedEndpoint}'`);
endpoint = matterbridgeDevice.getChildEndpointByName(mappedEndpoint);
}
}
if (!endpoint) {
this.log.debug(`Update handler: Endpoint ${entityId} for ${deviceId} not found`);
return;
}
if ((new_state.state === 'unavailable' && old_state.state !== 'unavailable') || (new_state.state === 'unavailable' && old_state.state === 'unavailable')) {
matterbridgeDevice.setAttribute(BridgedDeviceBasicInformation.Cluster.id, 'reachable', false, matterbridgeDevice.log);
return;
}
if (old_state.state === 'unavailable' && new_state.state !== 'unavailable') {
matterbridgeDevice.setAttribute(BridgedDeviceBasicInformation.Cluster.id, 'reachable', true, matterbridgeDevice.log);
}
matterbridgeDevice.log.info(`${db}Received update event from Home Assistant device ${idn}${matterbridgeDevice?.deviceName}${rs}${db} entity ${CYAN}${entityId}${db} ` +
`from ${YELLOW}${old_state.state}${db} with ${debugStringify(old_state.attributes)}${db} to ${YELLOW}${new_state.state}${db} with ${debugStringify(new_state.attributes)}`);
const domain = entityId.split('.')[0];
if (['automation', 'scene', 'script', 'input_button'].includes(domain)) {
return;
}
else if (domain === 'sensor') {
if (this.airQualityRegex && this.airQualityRegex.test(entityId)) {
new_state.attributes['state_class'] = 'measurement';
new_state.attributes['device_class'] = 'aqi';
this.log.debug(`***Converting entity ${CYAN}${entityId}${db} to air quality sensor`);
}
const hassSensorConverter = new_state.attributes['device_class'] === 'voltage' && new_state.attributes['unit_of_measurement'] === 'V'
? hassDomainSensorsConverter.find((s) => s.domain === domain &&
s.withStateClass === new_state.attributes['state_class'] &&
s.withDeviceClass === new_state.attributes['device_class'] &&
s.deviceType === electricalSensor)
: hassDomainSensorsConverter.find((s) => s.domain === domain && s.withStateClass === new_state.attributes['state_class'] && s.withDeviceClass === new_state.attributes['device_class']);
if (hassSensorConverter) {
const stateValue = /^-?\d+(\.\d+)?$/.test(new_state.state) ? parseFloat(new_state.state) : new_state.state;
const convertedValue = hassSensorConverter.converter(stateValue, new_state.attributes['unit_of_measurement']);
endpoint.log.debug(`Converting sensor ${new_state.attributes['state_class']}:${new_state.attributes['device_class']} value "${new_state.state}" to ${CYAN}${convertedValue}${db}`);
if (convertedValue !== null)
await endpoint.setAttribute(hassSensorConverter.clusterId, hassSensorConverter.attribute, convertedValue, endpoint.log);
}
else {
endpoint.log.warn(`Update sensor ${CYAN}${domain}${wr}:${CYAN}${new_state.attributes['state_class']}${wr}:${CYAN}${new_state.attributes['device_class']}${wr} not supported for entity ${entityId}`);
}
}
else if (domain === 'binary_sensor') {
const hassBinarySensorConverter = hassDomainBinarySensorsConverter.find((s) => s.domain === domain && s.withDeviceClass === new_state.attributes['device_class']);
if (hassBinarySensorConverter) {
const convertedValue = hassBinarySensorConverter.converter(new_state.state);
endpoint.log.debug(`Converting binary_sensor ${new_state.attributes['device_class']} value "${new_state.state}" to ${CYAN}${typeof convertedValue === 'object' ? debugStringify(convertedValue) : convertedValue}${db}`);
if (convertedValue !== null)
await endpoint.setAttribute(hassBinarySensorConverter.clusterId, hassBinarySensorConverter.attribute, convertedValue, endpoint.log);
}
else {
endpoint.log.warn(`Update binary_sensor ${CYAN}${domain}${wr}:${CYAN}${new_state.attributes['device_class']}${wr} not supported for entity ${entityId}`);
}
}
else {
const hassUpdateState = hassUpdateStateConverter.find((updateState) => updateState.domain === domain && updateState.state === new_state.state);
if (hassUpdateState) {
await endpoint.setAttribute(hassUpdateState.clusterId, hassUpdateState.attribute, hassUpdateState.value, matterbridgeDevice.log);
}
else {
endpoint.log.warn(`Update state ${CYAN}${domain}${wr}:${CYAN}${new_state.state}${wr} not supported for entity ${entityId}`);
}
if (new_state.state === 'off') {
endpoint.log.info(`State is off, skipping update of attributes for entity ${CYAN}${entityId}${nf}`);
return;
}
const hassUpdateAttributes = hassUpdateAttributeConverter.filter((updateAttribute) => updateAttribute.domain === domain);
if (hassUpdateAttributes.length > 0) {
for (const update of hassUpdateAttributes) {
const value = new_state.attributes[update.with];
if (value !== null) {
const convertedValue = update.converter(value, new_state);
endpoint.log.debug(`Converting attribute ${update.with} value ${value} to ${CYAN}${convertedValue}${db}`);
if (convertedValue !== null)
await endpoint.setAttribute(update.clusterId, update.attribute, convertedValue, endpoint.log);
}
}
}
}
}
savePayload(filename) {
const payload = {
devices: Array.from(this.ha.hassDevices.values()),
entities: Array.from(this.ha.hassEntities.values()),
areas: Array.from(this.ha.hassAreas.values()),
labels: Array.from(this.ha.hassLabels.values()),
states: Array.from(this.ha.hassStates.values()),
config: this.ha.hassConfig,
services: this.ha.hassServices,
};
fs.writeFile(filename, JSON.stringify(payload, null, 2))
.then(() => {
this.log.debug(`Payload successfully written to ${filename}`);
return;
})
.catch((error) => {
this.log.error(`Error writing payload to file ${filename}: ${error}`);
});
}
isValidAreaLabel(areaId, labels) {
let areaMatch = true;
let labelMatch = true;
if (isValidString(this.config.filterByArea, 1)) {
if (!areaId)
return false;
areaMatch = false;
const area = this.ha.hassAreas.get(areaId);
if (!area)
return false;
if (area.name === this.config.filterByArea)
areaMatch = true;
}
if (isValidString(this.labelIdFilter, 1)) {
if (labels.length === 0)
return false;
labelMatch = false;
if (labels.includes(this.labelIdFilter))
labelMatch = true;
}
return areaMatch && labelMatch;
}
createRegexFromConfig(regexString) {
if (!isValidString(regexString, 1)) {
this.log.debug(`No valid custom regex provided`);
return undefined;
}
try {
const customRegex = new RegExp(regexString);
this.log.info(`Using air quality regex: ${CYAN}${regexString}${nf}`);
return customRegex;
}
catch (error) {
this.log.warn(`Invalid regex pattern "${regexString}": ${error}`);
return undefined;
}
}
}