UNPKG

matterbridge-zigbee2mqtt

Version:
667 lines 114 kB
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)