matterbridge-zigbee2mqtt
Version:
Matterbridge zigbee2mqtt plugin
667 lines • 114 kB
JavaScript
import EventEmitter from 'node:events';
import { airQualitySensor, colorTemperatureSwitch, dimmableSwitch, onOffSwitch, powerSource, bridgedNode, electricalSensor, onOffLight, dimmableLight, colorTemperatureLight, onOffOutlet, coverDevice, thermostatDevice, MatterbridgeEndpoint, dimmableOutlet, doorLockDevice, occupancySensor, lightSensor, contactSensor, temperatureSensor, humiditySensor, pressureSensor, genericSwitch, waterLeakDetector, rainSensor, smokeCoAlarm, extendedColorLight, } from 'matterbridge';
import { AnsiLogger, gn, dn, ign, idn, rs, db, debugStringify, hk, zb, or, nf, CYAN, er, YELLOW } from 'matterbridge/logger';
import { deepCopy, deepEqual, isValidArray, isValidNumber, isValidObject, kelvinToRGB, miredToKelvin } from 'matterbridge/utils';
import * as color from 'matterbridge/utils';
import { SwitchesTag, NumberTag } from 'matterbridge/matter';
import { getClusterNameById, ClusterId } from 'matterbridge/matter/types';
import { ElectricalEnergyMeasurement, ElectricalPowerMeasurement, WindowCoveringCluster, DoorLockCluster, BridgedDeviceBasicInformation, OnOff, Identify, LevelControl, ColorControl, ColorControlCluster, TemperatureMeasurement, BooleanState, RelativeHumidityMeasurement, PressureMeasurement, OccupancySensing, IlluminanceMeasurement, PowerSource, WindowCovering, DoorLock, ThermostatCluster, Thermostat, AirQuality, TotalVolatileOrganicCompoundsConcentrationMeasurement, CarbonDioxideConcentrationMeasurement, CarbonMonoxideConcentrationMeasurement, FormaldehydeConcentrationMeasurement, Pm1ConcentrationMeasurement, Pm25ConcentrationMeasurement, Pm10ConcentrationMeasurement, SmokeCoAlarm, } from 'matterbridge/matter/clusters';
export class ZigbeeEntity extends EventEmitter {
log;
serial = '';
platform;
device;
group;
entityName = '';
isDevice = false;
isGroup = false;
actions = [];
en = '';
ien = '';
bridgedDevice;
eidn = `${or}`;
lastPayload = {};
lastSeen = 0;
ignoreFeatures = [];
transition = false;
propertyMap = new Map();
mutableDevice = new Map();
cachePayload = {};
cachePublishTimeout = undefined;
cachePublishTimeoutTime = 100;
noUpdateTimeout = undefined;
noUpdateTimeoutTime = 2000;
thermostatTimeout = undefined;
thermostatTimeoutTime = 5000;
composedType = '';
hasEndpoints = false;
isRouter = false;
noUpdate = false;
thermostatSystemModeLookup = ['off', 'auto', '', 'cool', 'heat', '', '', 'fan_only'];
constructor(platform, entity) {
super();
this.platform = platform;
if (entity.ieee_address !== undefined) {
this.device = entity;
this.entityName = entity.friendly_name;
this.isDevice = true;
this.en = dn;
this.ien = idn;
}
if (entity.id !== undefined) {
this.group = entity;
this.entityName = entity.friendly_name;
this.isGroup = true;
this.en = gn;
this.ien = ign;
}
this.log = new AnsiLogger({
logName: this.entityName,
logTimestampFormat: 4,
logLevel: platform.debugEnabled ? "debug" : platform.log.logLevel,
});
this.log.debug(`Created MatterEntity: ${this.entityName}`);
this.platform.z2m.on('MESSAGE-' + this.entityName, (payload) => {
const now = Date.now();
if (now - this.lastSeen < 1000 * 60 &&
deepEqual(this.lastPayload, payload, ['linkquality', 'last_seen', ...this.ignoreFeatures]) &&
!Object.prototype.hasOwnProperty.call(this.lastPayload, 'action')) {
this.log.debug(`Skipping not changed ${platform.z2mDevicesRegistered ? 'MQTT message' : 'State update'} for accessory ${this.entityName}`);
return;
}
this.lastSeen = Date.now();
if (deepEqual(this.lastPayload, payload, this.ignoreFeatures))
return;
this.lastPayload = deepCopy(payload);
if (Object.prototype.hasOwnProperty.call(this.lastPayload, 'action'))
delete this.lastPayload.action;
for (const key of this.ignoreFeatures) {
if (Object.prototype.hasOwnProperty.call(payload, key)) {
delete payload[key];
this.log.debug(`Removed key ${CYAN}${key}${db} from payload`);
}
}
if (this.bridgedDevice === undefined) {
this.log.debug(`Skipping (no device) ${platform.z2mDevicesRegistered ? 'MQTT message' : 'State update'} for accessory ${this.entityName}`);
return;
}
if (this.noUpdate) {
this.log.debug(`Skipping (no update) ${platform.z2mDevicesRegistered ? 'MQTT message' : 'State update'} for accessory ${this.entityName}`);
return;
}
if ('state' in payload && payload.state === 'OFF') {
this.log.debug(`*Skipping color attributes update (state is OFF) ${platform.z2mDevicesRegistered ? 'MQTT message' : 'State update'} for accessory ${this.entityName}`);
for (const key of Object.keys(payload)) {
if (['brightness', 'color_temp', 'color', 'color_mode'].includes(key))
delete payload[key];
}
}
this.log.info(`${db}${platform.z2mDevicesRegistered ? 'MQTT message' : 'State update'} for device ${this.ien}${this.entityName}${rs}${db} payload: ${debugStringify(payload)}`);
Object.entries(payload).forEach(([key, value]) => {
if (value === undefined || value === null)
return;
if (this.bridgedDevice === undefined || this.noUpdate)
return;
if (key === 'voltage' && this.isDevice && this.device?.power_source === 'Battery')
key = 'battery_voltage';
if (key === 'battery' && !('battery_low' in payload) && isValidNumber(value, 0, 100) && this.isDevice && this.device?.power_source === 'Battery') {
if (value < 20) {
this.updateAttributeIfChanged(this.bridgedDevice, undefined, PowerSource.Cluster.id, 'batChargeLevel', PowerSource.BatChargeLevel.Critical);
}
else if (value < 40) {
this.updateAttributeIfChanged(this.bridgedDevice, undefined, PowerSource.Cluster.id, 'batChargeLevel', PowerSource.BatChargeLevel.Warning);
}
else {
this.updateAttributeIfChanged(this.bridgedDevice, undefined, PowerSource.Cluster.id, 'batChargeLevel', PowerSource.BatChargeLevel.Ok);
}
}
const propertyMap = this.propertyMap.get(key);
if (propertyMap) {
this.log.debug(`Payload entry ${CYAN}${key}${db} => name: ${CYAN}${propertyMap.name}${db} type: ${CYAN}${propertyMap.type === '' ? 'generic' : propertyMap.type}${db} ` +
`endpoint: ${CYAN}${propertyMap.endpoint === '' ? 'main' : propertyMap.endpoint}${db}`);
let z2m;
z2m = z2ms.find((z2m) => z2m.type === propertyMap?.type && z2m.property === propertyMap?.name);
if (!z2m)
z2m = z2ms.find((z2m) => z2m.property === propertyMap?.name);
if (z2m) {
if (z2m.valueLookup && propertyMap.values && propertyMap.values !== '' && typeof value === 'string' && !propertyMap.values.includes(value)) {
this.log.debug(`*Payload entry ${CYAN}${key}${db} value ${CYAN}${value}${db} not found in propertyMap values ${CYAN}${propertyMap.values}${db}`);
return;
}
if (z2m.converter || z2m.valueLookup) {
this.updateAttributeIfChanged(this.bridgedDevice, propertyMap === undefined || propertyMap.endpoint === '' ? undefined : propertyMap.endpoint, z2m.cluster, z2m.attribute, z2m.converter ? z2m.converter(value) : value, z2m.valueLookup);
return;
}
}
else
this.log.debug(`*Payload entry ${CYAN}${key}${db} not found in zigbeeToMatter converter`);
}
else
this.log.debug(`*Payload entry ${CYAN}${key}${db} not found in propertyMap`);
if (key === 'action' && value !== '') {
const propertyMap = this.propertyMap.get(('action_' + value));
if (propertyMap) {
const child = this.bridgedDevice.getChildEndpointByName(propertyMap.endpoint);
if (child && child.maybeNumber)
child.triggerSwitchEvent(propertyMap.action, this.log);
}
else
this.log.debug(`*Payload entry ${CYAN}${('action_' + value)}${db} not found in propertyMap`);
}
if (key === 'position' && this.isDevice && isValidNumber(value, 0, 100)) {
this.updateAttributeIfChanged(this.bridgedDevice, undefined, WindowCovering.Cluster.id, 'currentPositionLiftPercent100ths', value * 100);
}
if (key === 'moving' && this.isDevice) {
const reversed = this.lastPayload.motor_direction === 'reversed';
if (reversed && (value === 'UP' || value === 'DOWN')) {
value = reversed ? (value === 'UP' ? 'DOWN' : 'UP') : value;
}
if (value === 'UP') {
const status = WindowCovering.MovementStatus.Opening;
this.updateAttributeIfChanged(this.bridgedDevice, undefined, WindowCovering.Cluster.id, 'operationalStatus', { global: status, lift: status, tilt: status });
}
else if (value === 'DOWN') {
const status = WindowCovering.MovementStatus.Closing;
this.updateAttributeIfChanged(this.bridgedDevice, undefined, WindowCovering.Cluster.id, 'operationalStatus', { global: status, lift: status, tilt: status });
}
else if (value === 'STOP') {
const status = WindowCovering.MovementStatus.Stopped;
this.updateAttributeIfChanged(this.bridgedDevice, undefined, WindowCovering.Cluster.id, 'operationalStatus', { global: status, lift: status, tilt: status });
const position = this.bridgedDevice.getAttribute(WindowCovering.Cluster.id, 'currentPositionLiftPercent100ths', this.log);
this.updateAttributeIfChanged(this.bridgedDevice, undefined, WindowCovering.Cluster.id, 'currentPositionLiftPercent100ths', position);
this.updateAttributeIfChanged(this.bridgedDevice, undefined, WindowCovering.Cluster.id, 'targetPositionLiftPercent100ths', position);
}
}
if (key === 'current_heating_setpoint' && 'system_mode' in payload && payload['system_mode'] === 'heat' && isValidNumber(value)) {
this.updateAttributeIfChanged(this.bridgedDevice, undefined, Thermostat.Cluster.id, 'occupiedHeatingSetpoint', value * 100);
}
if (key === 'current_heating_setpoint' && 'system_mode' in payload && payload['system_mode'] === 'cool' && isValidNumber(value)) {
this.updateAttributeIfChanged(this.bridgedDevice, undefined, Thermostat.Cluster.id, 'occupiedCoolingSetpoint', value * 100);
}
if (key === 'color_temp' && 'color_mode' in payload && payload['color_mode'] === 'color_temp' && isValidNumber(value)) {
this.updateAttributeIfChanged(this.bridgedDevice, undefined, ColorControl.Cluster.id, 'colorMode', ColorControl.ColorMode.ColorTemperatureMireds);
const colorTemp = this.propertyMap.get('color_temp');
this.updateAttributeIfChanged(this.bridgedDevice, undefined, ColorControl.Cluster.id, 'colorTemperatureMireds', Math.max(colorTemp?.value_min ?? 147, Math.min(colorTemp?.value_max ?? 500, value)));
}
if (key === 'color' && 'color_mode' in payload && payload['color_mode'] === 'hs') {
const { hue, saturation } = value;
if (isValidNumber(hue, 0, 360) && isValidNumber(saturation, 0, 100)) {
this.updateAttributeIfChanged(this.bridgedDevice, undefined, ColorControl.Cluster.id, 'colorMode', ColorControl.ColorMode.CurrentHueAndCurrentSaturation);
this.updateAttributeIfChanged(this.bridgedDevice, undefined, ColorControl.Cluster.id, 'currentHue', Math.round(hue / 360 * 254));
this.updateAttributeIfChanged(this.bridgedDevice, undefined, ColorControl.Cluster.id, 'currentSaturation', Math.round(saturation / 100 * 254));
}
}
if (key === 'color' && 'color_mode' in payload && payload['color_mode'] === 'xy') {
const { x, y } = value;
if (isValidNumber(x, 0, 1) && isValidNumber(y, 0, 1)) {
const hsl = color.xyToHsl(x, y);
const rgb = color.xyColorToRgbColor(x, y);
this.log.debug(`ColorControl xyToHsl ${CYAN}${x}${db} ${CYAN}${y}${db} => h ${CYAN}${hsl.h}${db} s ${CYAN}${hsl.s}${db} l ${CYAN}${hsl.l}${db}`);
this.log.debug(`ColorControl xyToRgb ${CYAN}${x}${db} ${CYAN}${y}${db} => r ${CYAN}${rgb.r}${db} g ${CYAN}${rgb.g}${db} b ${CYAN}${rgb.b}${db}`);
this.updateAttributeIfChanged(this.bridgedDevice, undefined, ColorControl.Cluster.id, 'colorMode', ColorControl.ColorMode.CurrentHueAndCurrentSaturation);
this.updateAttributeIfChanged(this.bridgedDevice, undefined, ColorControl.Cluster.id, 'currentHue', Math.round(hsl.h / 360 * 254));
this.updateAttributeIfChanged(this.bridgedDevice, undefined, ColorControl.Cluster.id, 'currentSaturation', Math.round(hsl.s / 100 * 254));
}
}
});
});
this.platform.z2m.on('ONLINE-' + this.entityName, () => {
this.log.info(`ONLINE message for device ${this.ien}${this.entityName}${rs}`);
if (this.bridgedDevice?.maybeNumber !== undefined) {
this.bridgedDevice?.setAttribute(BridgedDeviceBasicInformation.Cluster.id, 'reachable', true, this.log);
this.bridgedDevice?.triggerEvent(BridgedDeviceBasicInformation.Cluster.id, 'reachableChanged', { reachableNewValue: true }, this.log);
}
});
this.platform.z2m.on('OFFLINE-' + this.entityName, () => {
this.log.warn(`OFFLINE message for device ${this.ien}${this.entityName}${rs}`);
if (this.bridgedDevice?.maybeNumber !== undefined) {
this.bridgedDevice?.setAttribute(BridgedDeviceBasicInformation.Cluster.id, 'reachable', false, this.log);
this.bridgedDevice?.triggerEvent(BridgedDeviceBasicInformation.Cluster.id, 'reachableChanged', { reachableNewValue: false }, this.log);
}
});
}
destroy() {
this.removeAllListeners();
if (this.cachePublishTimeout)
clearTimeout(this.cachePublishTimeout);
this.cachePublishTimeout = undefined;
if (this.thermostatTimeout)
clearTimeout(this.thermostatTimeout);
this.thermostatTimeout = undefined;
if (this.noUpdateTimeout)
clearTimeout(this.noUpdateTimeout);
this.noUpdateTimeout = undefined;
this.device = undefined;
this.group = undefined;
this.bridgedDevice = undefined;
this.mutableDevice.clear();
this.propertyMap.clear();
}
cachePublish(command = 'unknown', payload, transitionTime) {
if (payload)
this.cachePayload = { ...this.cachePayload, ...payload };
if (this.transition && transitionTime && transitionTime / 10 >= 1)
this.cachePayload['transition'] = Math.round(transitionTime / 10);
clearTimeout(this.cachePublishTimeout);
this.cachePublishTimeout = setTimeout(() => {
clearTimeout(this.cachePublishTimeout);
this.cachePublishTimeout = undefined;
if (isValidObject(this.cachePayload, 1))
this.publishCommand(command, (this.isGroup ? this.group?.friendly_name : this.device?.friendly_name), this.cachePayload);
this.cachePayload = {};
this.noUpdate = true;
this.log.debug(`****No update for 2 seconds to allow the device ${this.ien}${this.isGroup ? this.group?.friendly_name : this.device?.friendly_name}${rs}${db} to update its state`);
clearTimeout(this.noUpdateTimeout);
this.noUpdateTimeout = setTimeout(() => {
clearTimeout(this.noUpdateTimeout);
this.noUpdateTimeout = undefined;
this.log.debug(`****No update is now reset for the device ${this.ien}${this.isGroup ? this.group?.friendly_name : this.device?.friendly_name}${rs}${db}`);
this.noUpdate = false;
}, this.noUpdateTimeoutTime).unref();
}, this.cachePublishTimeoutTime).unref();
}
setCachePublishAttributes(endpoint, postfix = undefined) {
const brightness = endpoint.hasAttributeServer(LevelControl.Cluster.id, 'currentLevel')
? Math.round((endpoint.getAttribute(LevelControl.Cluster.id, 'currentLevel') / 254) * 255)
: undefined;
if (isValidNumber(brightness, 1, 255))
this.cachePayload['brightness' + (postfix || '')] = brightness;
const color_temp = endpoint.hasClusterServer(ColorControl.Cluster.id) &&
endpoint.hasAttributeServer(ColorControl.Cluster.id, 'colorTemperatureMireds') &&
endpoint.getAttribute(ColorControl.Cluster.id, 'colorMode') === ColorControl.ColorMode.ColorTemperatureMireds
? endpoint.getAttribute(ColorControl.Cluster.id, 'colorTemperatureMireds')
: undefined;
if (isValidNumber(color_temp))
this.cachePayload['color_temp' + (postfix || '')] = color_temp;
const hs_color = endpoint.hasClusterServer(ColorControl.Cluster.id) &&
endpoint.hasAttributeServer(ColorControl.Cluster.id, 'currentHue') &&
endpoint.hasAttributeServer(ColorControl.Cluster.id, 'currentSaturation') &&
endpoint.getAttribute(ColorControl.Cluster.id, 'colorMode') === ColorControl.ColorMode.CurrentHueAndCurrentSaturation
? [
Math.round((endpoint.getAttribute(ColorControl.Cluster.id, 'currentHue') / 254) * 360),
Math.round((endpoint.getAttribute(ColorControl.Cluster.id, 'currentSaturation') / 254) * 100),
]
: undefined;
if (isValidArray(hs_color, 2)) {
this.cachePayload['color' + (postfix || '')] = { h: hs_color[0], s: hs_color[1] };
}
const xy_color = endpoint.hasClusterServer(ColorControl.Cluster.id) &&
endpoint.hasAttributeServer(ColorControl.Cluster.id, 'currentX') &&
endpoint.hasAttributeServer(ColorControl.Cluster.id, 'currentY') &&
endpoint.getAttribute(ColorControl.Cluster.id, 'colorMode') === ColorControl.ColorMode.CurrentXAndCurrentY
? [endpoint.getAttribute(ColorControl.Cluster.id, 'currentX') / 65535, endpoint.getAttribute(ColorControl.Cluster.id, 'currentY') / 65535]
: undefined;
if (isValidArray(xy_color, 2))
this.cachePayload['color' + (postfix || '')] = { x: xy_color[0], y: xy_color[1] };
const lookupColorMode = [
'CurrentHueAndCurrentSaturation',
'CurrentXAndCurrentY',
'ColorTemperatureMireds',
'EnhancedCurrentHueAndCurrentSaturation',
'Brightness',
'OnOff',
'Unknown',
];
let colorMode = 6;
if (endpoint.hasClusterServer(ColorControl.Cluster.id)) {
colorMode = endpoint.getAttribute(ColorControl.Cluster.id, 'colorMode');
}
else if (endpoint.hasClusterServer(LevelControl.Cluster.id)) {
colorMode = 4;
}
else if (endpoint.hasClusterServer(OnOff.Cluster.id)) {
colorMode = 5;
}
this.log.debug(`***Set attributes called for ${this.ien}${this.isGroup ? this.group?.friendly_name : this.device?.friendly_name}${rs}${db} colorMode ${CYAN}${lookupColorMode[colorMode]}${db} payload ${debugStringify(this.cachePayload)}`);
}
async onCommandHandler(data) {
if (data.endpoint.getAttribute(OnOff.Cluster.id, 'onOff') === true) {
this.log.debug(`*Command on ignored for ${this.ien}${this.isGroup ? this.group?.friendly_name : this.device?.friendly_name}${rs}${db} endpoint: ${data.endpoint?.maybeId}:${data.endpoint?.maybeNumber} already ON`);
return;
}
this.log.debug(`Command on called for ${this.ien}${this.isGroup ? this.group?.friendly_name : this.device?.friendly_name}${rs}${db} endpoint: ${data.endpoint?.maybeId}:${data.endpoint?.maybeNumber}`);
this.setCachePublishAttributes(data.endpoint, this.hasEndpoints ? '_' + data.endpoint.uniqueStorageKey : undefined);
this.cachePublish('on', { ['state' + (this.hasEndpoints ? '_' + data.endpoint.uniqueStorageKey : '')]: 'ON' });
}
async offCommandHandler(data) {
if (data.endpoint.getAttribute(OnOff.Cluster.id, 'onOff') === false) {
this.log.debug(`*Command off ignored for ${this.ien}${this.isGroup ? this.group?.friendly_name : this.device?.friendly_name}${rs}${db} endpoint: ${data.endpoint?.maybeId}:${data.endpoint?.maybeNumber} already OFF`);
return;
}
this.log.debug(`Command off called for ${this.ien}${this.isGroup ? this.group?.friendly_name : this.device?.friendly_name}${rs}${db} endpoint: ${data.endpoint?.maybeId}:${data.endpoint?.maybeNumber}`);
this.cachePublish('off', { ['state' + (this.hasEndpoints ? '_' + data.endpoint.uniqueStorageKey : '')]: 'OFF' });
}
async toggleCommandHandler(data) {
this.log.debug(`Command toggle called for ${this.ien}${this.isGroup ? this.group?.friendly_name : this.device?.friendly_name}${rs}${db} endpoint: ${data.endpoint?.maybeId}:${data.endpoint?.maybeNumber}`);
if (data.endpoint.getAttribute(OnOff.Cluster.id, 'onOff') === false) {
this.setCachePublishAttributes(data.endpoint, this.hasEndpoints ? '_' + data.endpoint.uniqueStorageKey : undefined);
this.cachePublish('toggle', { ['state' + (this.hasEndpoints ? '_' + data.endpoint.uniqueStorageKey : '')]: 'ON' });
}
else {
this.cachePublish('toggle', { ['state' + (this.hasEndpoints ? '_' + data.endpoint.uniqueStorageKey : '')]: 'OFF' });
}
}
async moveToLevelCommandHandler(data) {
if (data.endpoint.getAttribute(OnOff.Cluster.id, 'onOff') === false || data.endpoint.getAttribute(LevelControl.Cluster.id, 'currentLevel') === data.request.level) {
this.log.debug(`*Command moveToLevel ignored for ${this.ien}${this.isGroup ? this.group?.friendly_name : this.device?.friendly_name}${rs}${db} endpoint: ${data.endpoint?.maybeId}:${data.endpoint?.maybeNumber} light OFF or level unchanged`);
return;
}
this.log.debug(`Command moveToLevel called for ${this.ien}${this.isGroup ? this.group?.friendly_name : this.device?.friendly_name}${rs}${db} endpoint: ${data.endpoint?.maybeId}:${data.endpoint?.maybeNumber} request: ${data.request.level} transition: ${data.request.transitionTime}`);
this.cachePublish('moveToLevel', { ['brightness' + (this.hasEndpoints ? '_' + data.endpoint.uniqueStorageKey : '')]: data.request.level }, data.request.transitionTime);
}
async moveToLevelWithOnOffCommandHandler(data) {
this.log.debug(`Command moveToLevelWithOnOff called for ${this.ien}${this.isGroup ? this.group?.friendly_name : this.device?.friendly_name}${rs}${db} endpoint: ${data.endpoint?.maybeId}:${data.endpoint?.maybeNumber} request: ${data.request.level} transition: ${data.request.transitionTime}`);
if (data.request['level'] <= (data.endpoint.getAttribute(LevelControl.Cluster.id, 'minLevel') ?? 1)) {
if (data.endpoint.getAttribute(OnOff.Cluster.id, 'onOff') === false) {
this.log.debug(`*Command moveToLevelWithOnOff ignored for ${this.ien}${this.isGroup ? this.group?.friendly_name : this.device?.friendly_name}${rs}${db} endpoint: ${data.endpoint?.maybeId}:${data.endpoint?.maybeNumber} light OFF`);
return;
}
data.endpoint.log.debug(`***Command moveToLevelWithOnOff received with level <= minLevel(${data.endpoint.getAttribute(LevelControl.Cluster.id, 'minLevel')}) => turn off the light`);
this.cachePublish('moveToLevelWithOnOff', { ['state' + (this.hasEndpoints ? '_' + data.endpoint.uniqueStorageKey : '')]: 'OFF' }, data.request.transitionTime);
}
else {
if (data.endpoint.getAttribute(OnOff.Cluster.id, 'onOff') === false) {
data.endpoint.log.debug(`***Command moveToLevelWithOnOff received with level > minLevel(${data.endpoint.getAttribute(LevelControl.Cluster.id, 'minLevel')}) and light is off => turn on the light with attributes`);
this.cachePayload['state' + (this.hasEndpoints ? '_' + data.endpoint.uniqueStorageKey : '')] = 'ON';
this.setCachePublishAttributes(data.endpoint, this.hasEndpoints ? '_' + data.endpoint.uniqueStorageKey : '');
}
this.cachePublish('moveToLevelWithOnOff', { ['brightness' + (this.hasEndpoints ? '_' + data.endpoint.uniqueStorageKey : '')]: data.request.level }, data.request.transitionTime);
}
}
async moveToColorTemperatureCommandHandler(data) {
if (data.endpoint.getAttribute(OnOff.Cluster.id, 'onOff') === false || data.endpoint.getAttribute(ColorControl.Cluster.id, 'colorTemperatureMireds') === data.request.colorTemperatureMireds) {
this.log.debug(`*Command moveToColorTemperature ignored for ${this.ien}${this.isGroup ? this.group?.friendly_name : this.device?.friendly_name}${rs}${db} endpoint: ${data.endpoint?.maybeId}:${data.endpoint?.maybeNumber} light OFF or colorTemperatureMireds unchanged`);
return;
}
this.log.debug(`Command moveToColorTemperature called for ${this.ien}${this.isGroup ? this.group?.friendly_name : this.device?.friendly_name}${rs}${db} endpoint: ${data.endpoint?.maybeId}:${data.endpoint?.maybeNumber} request: ${data.request.colorTemperatureMireds} transition: ${data.request.transitionTime}`);
if (this.propertyMap.get('color_temp')) {
this.cachePublish('moveToColorTemperature', { ['color_temp' + (this.hasEndpoints ? '_' + data.endpoint.uniqueStorageKey : '')]: data.request.colorTemperatureMireds }, data.request.transitionTime);
}
else {
const rgb = kelvinToRGB(miredToKelvin(data.request.colorTemperatureMireds));
this.cachePublish('moveToColorTemperature', { ['color' + (this.hasEndpoints ? '_' + data.endpoint.uniqueStorageKey : '')]: { r: rgb.r, g: rgb.g, b: rgb.b } }, data.request.transitionTime);
this.log.debug(`***Command moveToColorTemperature called for ${this.ien}${this.isGroup ? this.group?.friendly_name : this.device?.friendly_name}${rs}${db} but color_temp property is not available. Converting ${data.request.colorTemperatureMireds} to RGB ${debugStringify(rgb)}.`);
}
}
async moveToColorCommandHandler(data) {
if (data.endpoint.getAttribute(OnOff.Cluster.id, 'onOff') === false || (data.endpoint.getAttribute(ColorControl.Cluster.id, 'currentX') === data.request.colorX && data.endpoint.getAttribute(ColorControl.Cluster.id, 'currentY') === data.request.colorY)) {
this.log.debug(`*Command moveToColor ignored for ${this.ien}${this.isGroup ? this.group?.friendly_name : this.device?.friendly_name}${rs}${db} endpoint: ${data.endpoint?.maybeId}:${data.endpoint?.maybeNumber} light OFF or color unchanged`);
return;
}
this.log.debug(`Command moveToColor called for ${this.ien}${this.isGroup ? this.group?.friendly_name : this.device?.friendly_name}${rs}${db} endpoint: ${data.endpoint?.maybeId}:${data.endpoint?.maybeNumber} request: X: ${data.request.colorX} Y: ${data.request.colorY} transition: ${data.request.transitionTime}`);
this.cachePublish('moveToColor', { ['color' + (this.hasEndpoints ? '_' + data.endpoint.uniqueStorageKey : '')]: { x: Math.round(data.request.colorX / 65536 * 10000) / 10000, y: Math.round(data.request.colorY / 65536 * 10000) / 10000 } }, data.request.transitionTime);
}
async moveToHueCommandHandler(data) {
if (data.endpoint.getAttribute(OnOff.Cluster.id, 'onOff') === false || data.endpoint.getAttribute(ColorControl.Cluster.id, 'currentHue') === data.request.hue) {
this.log.debug(`*Command moveToHue ignored for ${this.ien}${this.isGroup ? this.group?.friendly_name : this.device?.friendly_name}${rs}${db} endpoint: ${data.endpoint?.maybeId}:${data.endpoint?.maybeNumber} light OFF or hue unchanged`);
return;
}
this.log.debug(`Command moveToHue called for ${this.ien}${this.isGroup ? this.group?.friendly_name : this.device?.friendly_name}${rs}${db} endpoint: ${data.endpoint?.maybeId}:${data.endpoint?.maybeNumber} request: ${data.request.hue} transition: ${data.request.transitionTime}`);
this.cachePublish('moveToHue', { ['color' + (this.hasEndpoints ? '_' + data.endpoint.uniqueStorageKey : '')]: { h: Math.round(data.request.hue / 254 * 360), s: Math.round(data.endpoint.getAttribute(ColorControlCluster.id, 'currentSaturation') / 254 * 100) } }, data.request.transitionTime);
}
async moveToSaturationCommandHandler(data) {
if (data.endpoint.getAttribute(OnOff.Cluster.id, 'onOff') === false || data.endpoint.getAttribute(ColorControl.Cluster.id, 'currentSaturation') === data.request.saturation) {
this.log.debug(`*Command moveToSaturation ignored for ${this.ien}${this.isGroup ? this.group?.friendly_name : this.device?.friendly_name}${rs}${db} endpoint: ${data.endpoint?.maybeId}:${data.endpoint?.maybeNumber} light OFF or saturation unchanged`);
return;
}
this.log.debug(`Command moveToSaturation called for ${this.ien}${this.isGroup ? this.group?.friendly_name : this.device?.friendly_name}${rs}${db} endpoint: ${data.endpoint?.maybeId}:${data.endpoint?.maybeNumber} request: ${data.request.saturation} transition: ${data.request.transitionTime}`);
this.cachePublish('moveToSaturation', { ['color' + (this.hasEndpoints ? '_' + data.endpoint.uniqueStorageKey : '')]: { h: Math.round(data.endpoint.getAttribute(ColorControlCluster.id, 'currentHue') / 254 * 360), s: Math.round(data.request.saturation / 254 * 100) } }, data.request.transitionTime);
}
async moveToHueAndSaturationCommandHandler(data) {
if (data.endpoint.getAttribute(OnOff.Cluster.id, 'onOff') === false || (data.endpoint.getAttribute(ColorControl.Cluster.id, 'currentHue') === data.request.hue && data.endpoint.getAttribute(ColorControl.Cluster.id, 'currentSaturation') === data.request.saturation)) {
this.log.debug(`*Command moveToHueAndSaturation ignored for ${this.ien}${this.isGroup ? this.group?.friendly_name : this.device?.friendly_name}${rs}${db} endpoint: ${data.endpoint?.maybeId}:${data.endpoint?.maybeNumber} light OFF or hue/saturation unchanged`);
return;
}
this.log.debug(`Command moveToHueAndSaturation called for ${this.ien}${this.isGroup ? this.group?.friendly_name : this.device?.friendly_name}${rs}${db} endpoint: ${data.endpoint?.maybeId}:${data.endpoint?.maybeNumber} request: ${data.request.hue} - ${data.request.saturation} transition: ${data.request.transitionTime}`);
this.cachePublish('moveToHueAndSaturation', { ['color' + (this.hasEndpoints ? '_' + data.endpoint.uniqueStorageKey : '')]: { h: Math.round(data.request.hue / 254 * 360), s: Math.round(data.request.saturation / 254 * 100) } }, data.request.transitionTime);
}
addBridgedDeviceBasicInformation() {
if (!this.bridgedDevice)
throw new Error('No bridged device');
const softwareVersion = parseInt(this.platform.z2mBridgeInfo?.version || '1');
const softwareVersionString = `${this.platform.z2mBridgeInfo?.version} (commit ${this.platform.z2mBridgeInfo?.commit})`;
const hardwareVersion = parseInt(this.platform.matterbridge.matterbridgeVersion || '1');
const hardwareVersionString = this.platform.matterbridge.matterbridgeVersion || 'unknown';
if (this.isDevice && this.device && this.device.friendly_name === 'Coordinator') {
this.bridgedDevice.createDefaultBridgedDeviceBasicInformationClusterServer(this.device.friendly_name, this.serial, 0xfff1, 'zigbee2MQTT', 'Coordinator', softwareVersion, softwareVersionString, hardwareVersion, hardwareVersionString);
}
else if (this.isDevice && this.device) {
this.bridgedDevice.createDefaultBridgedDeviceBasicInformationClusterServer(this.device.friendly_name, this.serial, 0xfff1, this.device.definition ? this.device.definition.vendor : this.device.manufacturer, this.device.definition ? this.device.definition.model : this.device.model_id, softwareVersion, softwareVersionString, hardwareVersion, hardwareVersionString);
}
else if (this.isGroup && this.group) {
this.bridgedDevice.createDefaultBridgedDeviceBasicInformationClusterServer(this.group.friendly_name, this.serial, 0xfff1, 'zigbee2MQTT', 'Group', softwareVersion, softwareVersionString, hardwareVersion, hardwareVersionString);
}
return this.bridgedDevice;
}
addPowerSource() {
if (!this.bridgedDevice)
throw new Error('No bridged device');
if (this.isDevice) {
if (this.device?.power_source === 'Battery') {
this.bridgedDevice.createDefaultPowerSourceReplaceableBatteryClusterServer(100, PowerSource.BatChargeLevel.Ok);
}
else {
this.bridgedDevice.createDefaultPowerSourceWiredClusterServer();
}
}
if (this.isGroup) {
this.bridgedDevice.createDefaultPowerSourceWiredClusterServer();
}
return this.bridgedDevice;
}
verifyMutableDevice(endpoint) {
if (!endpoint)
return false;
for (const deviceType of endpoint.getDeviceTypes()) {
for (const clusterId of deviceType.requiredServerClusters) {
if (!endpoint.hasClusterServer(clusterId)) {
endpoint.addClusterServers([clusterId]);
this.log.warn(`Endpoint with device type ${deviceType.name} (0x${deviceType.code.toString(16)}) requires cluster server ${getClusterNameById(clusterId)} (0x${clusterId.toString(16)}) but it is not present on endpoint`);
}
}
}
for (const childEndpoint of endpoint.getChildEndpoints()) {
for (const deviceType of childEndpoint.getDeviceTypes()) {
for (const clusterId of deviceType.requiredServerClusters) {
if (!childEndpoint.hasClusterServer(clusterId)) {
childEndpoint.addClusterServers([clusterId]);
this.log.warn(`Child endpoint with device type ${deviceType.name} (0x${deviceType.code.toString(16)}) requires cluster server ${getClusterNameById(clusterId)} (0x${clusterId.toString(16)}) but it is not present on child endpoint`);
}
}
}
}
return true;
}
async configure() {
if (this.bridgedDevice?.hasClusterServer(WindowCovering.Cluster.id)) {
this.log.info(`Configuring ${this.bridgedDevice?.deviceName} WindowCovering cluster`);
await this.bridgedDevice?.setWindowCoveringTargetAsCurrentAndStopped();
}
if (this.bridgedDevice?.hasClusterServer(DoorLock.Cluster.id)) {
this.log.info(`Configuring ${this.bridgedDevice?.deviceName} DoorLock cluster`);
const state = this.bridgedDevice?.getAttribute(DoorLock.Cluster.id, 'lockState', this.log);
if (this.bridgedDevice.maybeNumber) {
if (state === DoorLock.LockState.Locked)
this.bridgedDevice?.triggerEvent(DoorLock.Cluster.id, 'lockOperation', { lockOperationType: DoorLock.LockOperationType.Lock, operationSource: DoorLock.OperationSource.Manual, userIndex: null, fabricIndex: null, sourceNode: null }, this.log);
if (state === DoorLock.LockState.Unlocked)
this.bridgedDevice?.triggerEvent(DoorLock.Cluster.id, 'lockOperation', { lockOperationType: DoorLock.LockOperationType.Unlock, operationSource: DoorLock.OperationSource.Manual, userIndex: null, fabricIndex: null, sourceNode: null }, this.log);
}
}
if (this.bridgedDevice?.hasClusterServer(ColorControl.Cluster.id)) {
this.log.info(`Configuring ${this.bridgedDevice?.deviceName} ColorControl cluster`);
const colorTemp = this.propertyMap.get('color_temp');
this.bridgedDevice?.setAttribute(ColorControl.Cluster.id, 'colorTempPhysicalMinMireds', colorTemp?.value_min ?? 147, this.log);
this.bridgedDevice?.setAttribute(ColorControl.Cluster.id, 'colorTempPhysicalMaxMireds', colorTemp?.value_max ?? 500, this.log);
}
}
updateAttributeIfChanged(deviceEndpoint, childEndpointName, clusterId, attributeName, value, lookup) {
if (value === undefined)
return;
if (childEndpointName && childEndpointName !== '') {
deviceEndpoint = this.bridgedDevice?.getChildEndpointByName(childEndpointName) ?? deviceEndpoint;
}
if (!deviceEndpoint.hasClusterServer(ClusterId(clusterId))) {
this.log.debug(`Update endpoint ${this.eidn}${deviceEndpoint.name}:${deviceEndpoint.number}${db}${childEndpointName ? ' (' + zb + childEndpointName + db + ')' : ''} cluster ${hk}${clusterId}${db}-${hk}${getClusterNameById(ClusterId(clusterId))}${db} not found: is z2m converter exposing all features?`);
return;
}
if (!deviceEndpoint.hasAttributeServer(ClusterId(clusterId), attributeName)) {
this.log.debug(`Update endpoint ${this.eidn}${deviceEndpoint.name}:${deviceEndpoint.number}${db}${childEndpointName ? ' (' + zb + childEndpointName + db + ')' : ''} error attribute ${hk}${clusterId}${db}-${hk}${getClusterNameById(ClusterId(clusterId))}${db}.${hk}${attributeName}${db} not found`);
return;
}
if (lookup !== undefined) {
if (typeof value === 'string' && lookup.indexOf(value) !== -1) {
value = lookup.indexOf(value);
}
else {
this.log.debug(`Update endpoint ${this.eidn}${deviceEndpoint.name}:${deviceEndpoint.name}:${deviceEndpoint.number}${db}${childEndpointName ? ' (' + zb + childEndpointName + db + ')' : ''} ` +
`attribute ${hk}${getClusterNameById(ClusterId(clusterId))}${db}.${hk}${attributeName}${db} value ${zb}${typeof value === 'object' ? debugStringify(value) : value}${db} not found in lookup ${debugStringify(lookup)}`);
return;
}
}
const localValue = deviceEndpoint.getAttribute(ClusterId(clusterId), attributeName, undefined);
if (typeof value === 'object' ? deepEqual(value, localValue) : value === localValue) {
this.log.debug(`Skip update endpoint ${deviceEndpoint.name}:${deviceEndpoint.maybeNumber}${childEndpointName ? ' (' + childEndpointName + ')' : ''} ` +
`attribute ${getClusterNameById(ClusterId(clusterId))}.${attributeName} already ${typeof value === 'object' ? debugStringify(value) : value}`);
return;
}
this.log.info(`${db}Update endpoint ${this.eidn}${deviceEndpoint.name}:${deviceEndpoint.maybeNumber}${db}${childEndpointName ? ' (' + zb + childEndpointName + db + ')' : ''} ` +
`attribute ${hk}${getClusterNameById(ClusterId(clusterId))}${db}.${hk}${attributeName}${db} from ${YELLOW}${typeof localValue === 'object' ? debugStringify(localValue) : localValue}${db} to ${YELLOW}${typeof value === 'object' ? debugStringify(value) : value}${db}`);
try {
deviceEndpoint.setAttribute(ClusterId(clusterId), attributeName, value);
}
catch (error) {
this.log.error(`Error setting attribute ${hk}${getClusterNameById(ClusterId(clusterId))}${er}.${hk}${attributeName}${er} to ${value}: ${error}`);
}
}
publishCommand(command, entityName, payload) {
this.log.debug(`PublishCommand ${command} called for ${this.ien}${entityName}${rs}${db} payload: ${debugStringify(payload)}`);
if (entityName.startsWith('bridge/request')) {
this.platform.publish(entityName, '', JSON.stringify(payload));
}
else {
this.platform.publish(entityName, 'set', JSON.stringify(payload));
}
}
logPropertyMap() {
this.propertyMap.forEach((value, key) => {
this.log.debug(`Property ${CYAN}${key}${db} name ${CYAN}${value.name}${db} type ${CYAN}${value.type === '' ? 'generic' : value.type}${db} endpoint ${CYAN}${value.endpoint === '' ? 'main' : value.endpoint}${db} ` +
`category ${CYAN}${value.category}${db} description ${CYAN}${value.description}${db} label ${CYAN}${value.label}${db} unit ${CYAN}${value.unit}${db} ` +
`values ${CYAN}${value.values}${db} value_min ${CYAN}${value.value_min}${db} value_max ${CYAN}${value.value_max}${db}`);
});
}
}
export class ZigbeeGroup extends ZigbeeEntity {
constructor(platform, group) {
super(platform, group);
}
static async create(platform, group) {
const zigbeeGroup = new ZigbeeGroup(platform, group);
if (zigbeeGroup.platform.postfix !== '') {
zigbeeGroup.serial = `group-${group.id}-${zigbeeGroup.platform.postfix}`.slice(0, 32);
}
else {
zigbeeGroup.serial = `group-${group.id}`.slice(0, 32);
}
platform.setSelectDevice(`group-${group.id}`, group.friendly_name, 'wifi');
let useState = false;
let useBrightness = false;
let useColor = false;
let useColorTemperature = false;
let minColorTemperature = 140;
let maxColorTemperature = 500;
let isSwitch = false;
let isLight = false;
let isCover = false;
let isThermostat = false;
if (group.members.length === 0) {
zigbeeGroup.log.debug(`Group: ${gn}${group.friendly_name}${rs}${db} is a ${CYAN}virtual${db} group`);
zigbeeGroup.bridgedDevice = new MatterbridgeEndpoint([onOffSwitch, bridgedNode, powerSource], { uniqueStorageKey: group.friendly_name }, zigbeeGroup.log.logLevel === "debug");
isSwitch = true;
zigbeeGroup.propertyMap.set('state', { name: 'state', type: 'switch', endpoint: '' });
}
else {
group.members.forEach((member) => {
const device = zigbeeGroup.platform.z2mBridgeDevices?.find((device) => device.ieee_address === member.ieee_address);
if (!device)
return;
zigbeeGroup.log.debug(`Group ${gn}${group.friendly_name}${db}: member device ${dn}${device.friendly_name}${db}`);
device.definition?.exposes.forEach((expose) => {
if (expose.features) {
expose.features?.forEach((feature) => {
if (expose.type === 'lock' && feature.name === 'state' && feature.property === 'child_lock') {
expose.type = 'child_lock';
feature.name = 'child_lock';
}
zigbeeGroup.log.debug(`- specific type ${CYAN}${expose.type}${db}${feature.endpoint ? ' endpoint ' + CYAN + feature.endpoint + db : ''}${db} feature name ${CYAN}${feature.name}${db} property ${CYAN}${feature.property}${db} min ${CYAN}${feature.value_min}${db} max ${CYAN}${feature.value_max}${db}`);
if (expose.type === 'switch' || expose.type === 'light') {
if (expose.type === 'switch')
isSwitch = true;
if (expose.type === 'light')
isLight = true;
useState = useState === true || feature.name === 'state' ? true : false;
useBrightness = useBrightness === true || feature.name === 'brightness' ? true : false;
useColor = useColor === true || feature.property === 'color' ? true : false;
useColorTemperature = useColorTemperature === true || feature.name === 'color_temp' ? true : false;
if (feature.value_min)
minColorTemperature = Math.min(minColorTemperature, feature.value_min);
if (feature.value_max)
maxColorTemperature = Math.max(maxColorTemperature, feature.value_max);
}
else if (expose.type === 'cover') {
isCover = true;
}
else if (expose.type === 'climate') {
isThermostat = true;
}
});
}
else {
zigbeeGroup.log.debug(`- generic type ${CYAN}${expose.type}${db} expose name ${CYAN}${expose.name}${db} property ${CYAN}${expose.property}${db}`);
}
});
});
zigbeeGroup.log.debug(`Group ${gn}${group.friendly_name}${rs}${db} switch: ${CYAN}${isSwitch}${db} light: ${CYAN}${isLight}${db} cover: ${CYAN}${isCover}${db} thermostat: ${CYAN}${isThermostat}${db}`);
zigbeeGroup.log.debug(`Group ${gn}${group.friendly_name}${rs}${db} state: ${CYAN}${useState}${db} brightness: ${CYAN}${useBrightness}${db} color: ${CYAN}${useColor}${db} color_temp: ${CYAN}${useColorTemperature}${db} min: ${CYAN}${minColorTemperature}${db} max: ${CYAN}${maxColorTemperature}${db}`);
let deviceType;
if (useState) {
deviceType = onOffLight;
if (platform.switchList.includes(group.friendly_name))
deviceType = onOffSwitch;
else if (platform.lightList.includes(group.friendly_name))
deviceType = onOffLight;
else if (platform.outletList.includes(group.friendly_name))
deviceType = onOffOutlet;
zigbeeGroup.propertyMap.set('state', { name: 'state', type: isLight ? 'light' : 'switch', endpoint: '' });
}
if (useBrightness) {
deviceType = dimmableLight;
zigbeeGroup.propertyMap.set('brightness', { name: 'brightness', type: 'light', endpoint: '' });
}
if (useColorTemperature) {
deviceType = colorTemperatureLight;
zigbeeGroup.propertyMap.set('color_temp', { name: 'color_temp', type: 'light', endpoint: '' });
}
if (useColor) {
deviceType = extendedColorLight;
zigbeeGroup.propertyMap.set('color', { name: 'color', type: 'light', endpoint: '' });
}
if (isCover) {
deviceType = coverDevice;
zigbeeGroup.propertyMap.set('state', { name: 'state', type: 'cover', endpoint: '' });
zigbeeGroup.propertyMap.set('position', { name: 'position', type: 'cover', endpoint: '' });
zigbeeGroup.propertyMap.set('moving', { name: 'moving', type: 'cover', endpoint: '' });
}
if (isThermostat) {
deviceType = thermostatDevice;
zigbeeGroup.propertyMap.set('local_temperature', { name: 'local_temperature', type: 'climate', endpoint: '' });
zigbeeGroup.propertyMap.set('current_heating_setpoint', { name: 'current_heating_setpoint', type: 'climate', endpoint: '' });
zigbeeGroup.propertyMap.set('current_cooling_setpoint', { name: 'current_cooling_setpoint', type: 'climate', endpoint: '' });
zigbeeGroup.propertyMap.set('running_state', { name: 'running_state', type: 'climate', endpoint: '' });
zigbeeGroup.propertyMap.set('system_mode', { name: 'system_mode', type: 'climate', endpoint: '' });
}
if (!deviceType)
return zigbeeGroup;
zigbeeGroup.bridgedDevice = new MatterbridgeEndpoint([deviceType, bridgedNode, powerSource], { uniqueStorageKey: group.friendly_name }, zigbeeGroup.log.logLevel === "debug");
}
if (!platform.featureBlackList?.includes('scenes') && !platform.deviceFeatureBlackList[group.friendly_name]?.includes('scenes')) {
group.scenes.forEach((scene)