UNPKG

matterbridge-zigbee2mqtt

Version:
683 lines 116 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, 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, OnOffCluster, LevelControlCluster, WindowCoveringCluster, DoorLockCluster, BridgedDeviceBasicInformation, OnOff, 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(); lightTimeout = undefined; thermostatTimeout = undefined; thermostatSystemModeLookup = ['off', 'auto', '', 'cool', 'heat', '', '', 'fan_only']; composedType = ''; hasEndpoints = false; isRouter = false; noUpdate = false; 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; } 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') { 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, typeof value === 'number' ? value : 0))); } if (key === 'color' && 'color_mode' in payload && payload['color_mode'] === 'hs') { const { hue, saturation } = value; 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; 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.lightTimeout) clearTimeout(this.lightTimeout); this.lightTimeout = undefined; if (this.thermostatTimeout) clearTimeout(this.thermostatTimeout); this.thermostatTimeout = undefined; this.device = undefined; this.group = undefined; this.bridgedDevice = undefined; this.mutableDevice.clear(); this.propertyMap.clear(); } 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(`executeCommand ${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) => { zigbeeGroup.log.debug(`***Group ${gn}${group.friendly_name}${rs}${db} scene ${CYAN}${scene.name}${db} id ${CYAN}${scene.id}${db}`); platform.setSelectDeviceEntity(`group-${group.id}`, 'scenes', 'Scenes', 'component'); platform.registerVirtualDevice(`${platform.config.scenesPrefix ? group.friendly_name + ' ' : ''}${scene.name}`, async () => { zigbeeGroup.log.info(`Triggered scene "${scene.name}" id ${scene.id} from group ${group.friendly_name}`); zigbeeGroup.publishCommand('scene_recall', group.friendly_name, { scene_recall: scene.id }); }); }); } zigbeeGroup.addBridgedDeviceBasicInformation(); zigbeeGroup.addPowerSource(); zigbeeGroup.bridgedDevice.addRequiredClusterServers(); if (!zigbeeGroup.bridgedDevice || !zigbeeGroup.verifyMutableDevice(zigbeeGroup.bridgedDevice)) return zigbeeGroup; zigbeeGroup.mutableDevice.clear(); zigbeeGroup.logPropertyMap(); let lastRequestedHue = -1; let lastRequestedSaturation = -1; let nextPayload = {}; function cachePublishLight(command = 'cachedPublishLight') { clearTimeout(zigbeeGroup.lightTimeout); zigbeeGroup.lightTimeout = setTimeout(() => { clearTimeout(zigbeeGroup.lightTimeout); zigbeeGroup.lightTimeout = undefined; if (lastRequestedHue >= 0 && lastRequestedSaturation >= 0) { const rgb = color.hslColorToRgbColor((lastRequestedHue / 254) * 360, (lastRequestedSaturation / 254) * 100, 50); nextPayload['color'] = { r: rgb.r, g: rgb.g, b: rgb.b }; } if (isValidObject(nextPayload, 1)) zigbeeGroup.publishCommand(command, group.friendly_name, nextPayload); nextPayload = {}; lastRequestedHue = -1; lastRequestedSaturation = -1; }, 100).unref(); } if (isSwitch || isLight) { if (isSwitch && !isLight) await zigbeeGroup.bridgedDevice.addFixedLabel('type', 'switch'); if (isLight) await zigbeeGroup.bridgedDevice.addFixedLabel('type', 'light'); zigbeeGroup.bridgedDevice.addCommandHandler('identify', async ({ request: { identifyTime } }) => { zigbeeGroup.log.debug(`Command identify called for ${zigbeeGroup.ien}${group.friendly_name}${rs}${db} identifyTime:${identifyTime}`); }); zigbeeGroup.bridgedDevice.addCommandHandler('on', async () => { zigbeeGroup.log.debug(`Command on called for ${zigbeeGroup.ien}${group.friendly_name}${rs}${db}`); nextPayload['state'] = 'ON'; cachePublishLight(); }); zigbeeGroup.bridgedDevice.addCommandHandler('off', async () => { zigbeeGroup.log.debug(`Command off called for ${zigbeeGroup.ien}${group.friendly_name}${rs}${db}`); nextPayload['state'] = 'OFF'; cachePublishLight(); }); zigbeeGroup.bridgedDevice.addCommandHandler('toggle', async () => { zigbeeGroup.log.debug(`Command toggle called for ${zigbeeGroup.ien}${group.friendly_name}${rs}${db}`); nextPayload['state'] = 'TOGGLE'; cachePublishLight(); }); } if (isLight) { if (useBrightness) { zigbeeGroup.bridgedDevice.addCommandHandler('moveToLevel', async (data) => { zigbeeGroup.log.debug(`Command moveToLevel called for ${zigbeeGroup.ien}${group.friendly_name}${rs}${db} request: ${data.request.level}`); nextPayload['state'] = 'ON'; nextPayload['brightness'] = data.request.level; if (zigbeeGroup.transition && data.request.transitionTime && data.request.transitionTime / 10 >= 1) nextPayload['transition'] = Math.round(data.request.transitionTime / 10); cachePublishLight(); }); zigbeeGroup.bridgedDevice.addCommandHandler('moveToLevelWithOnOff', async (data) => { zigbeeGroup.log.debug(`Command moveToLevelWithOnOff called for ${zigbeeGroup.ien}${group.friendly_name}${rs}${db} request: ${data.request.level}`); nextPayload['state'] = 'ON'; nextPayload['brightness'] = data.request.level; if (zigbeeGroup.transition && data.request.transitionTime && data.request.transitionTime / 10 >= 1) nextPayload['transition'] = Math.round(data.request.transitionTime / 10); cachePublishLight(); }); } if (useColorTemperature) { zigbeeGroup.bridgedDevice.addCommandHandler('moveToColorTemperature', async (data) => { zigbeeGroup.log.debug(`Command moveToColorTemperature called for ${zigbeeGroup.ien}${group.friendly_name}${rs}${db} request: ${data.request.colorTemperatureMireds}`); await zigbeeGroup.bridgedDevice?.setAttribute(ColorControl.Cluster.id, 'colorMode', ColorControl.ColorMode.ColorTemperatureMireds); nextPayload['state'] = 'ON'; nextPayload['color_temp'] = data.request.colorTemperatureMireds; if (zigbeeGroup.transition && data.request.transitionTime && data.request.transitionTime / 10 >= 1) nextPayload['transition'] = Math.round(data.request.transitionTime / 10); cachePublishLight(); }); } if (useColor) { zigbeeGroup.bridgedDevice.addCommandHandler('moveToHue', async (data) => { zigbeeGroup.log.debug(`Command moveToHue called for ${zigbeeGroup.ien}${group.friendly_name}${rs}${db} request: ${data.request.hue}`); await zigbeeGroup.bridgedDevice?.setAttribute(ColorControl.Cluster.id, 'colorMode', ColorControl.ColorMode.CurrentHueAndCurrentSaturation); nextPayload['state'] = 'ON'; lastRequestedHue = data.request.hue; if (zigbeeGroup.transition && data.request.transitionTime && data.request.transitionTime / 10 >= 1) nextPayload['transition'] = Math.round(data.request.transitionTime / 10); cachePublishLight(); }); zigbeeGroup.bridgedDevice.addCommandHandler('moveToSaturation', async (data) => { zigbeeGroup.log.debug(`Command moveToSaturation called for ${zigbeeGroup.ien}${group.friendly_name}${rs}${db} request: ${data.request.saturation}`); await zigbeeGroup.bridgedDevice?.setAttribute(ColorControl.Cluster.id, 'colorMode', ColorControl.ColorMode.CurrentHueAndCurrentSaturation); nextPayload['state'] = 'ON'; lastRequestedSaturation = data.request.saturation; if (zigbeeGroup.transition && data.request.transitionTime && data.request.transitionTime / 10 >= 1) nextPayload['transition'] = Math.round(data.request.transitionTime / 10); cachePublishLight(); }); zigbeeGroup.bridgedDevice.addCommandHandler('moveToHueAndSaturation', async (data) => { zigbeeGroup.log.debug(`Command moveToHueAndSaturation called for ${zigbeeGroup.ien}${group.friendly_name}${rs}${db} request: ${data.request.hue}-${data.request.saturation}`); await zigbeeGroup.bridgedDevice?.setAttribute(ColorControl.Cluster.id, 'colorMode', ColorControl.ColorMode.CurrentHueAndCurrentSaturation); nextPayload['state'] = 'ON'; const rgb = color.hslColorToRgbColor((data.request.hue / 254) * 360, (data.request.saturation / 254) * 100, 50); nextPayload['color'] = { r: rgb.r, g: rgb.g, b: rgb.b }; if (zigbeeGroup.transition && data.request.transitionTime && data.request.transitionTime / 10 >= 1) nextPayload['transition'] = Math.round(data.request.transitionTime / 10); cachePublishLight(); }); zigbeeGroup.bridgedDevice.addCommandHandler('moveToColor', async (data) => { zigbeeGroup.log.debug(`Command moveToColor called for ${zigbeeGroup.ien}${group.friendly_name}${rs}${db} request: X: ${data.request.colorX} Y: ${data.request.colorY}`); await zigbeeGroup.bridgedDevice?.setAttribute(ColorControlCluster.id, 'colorMode', ColorControl.ColorMode.CurrentXAndCurrentY, zigbeeGroup.log); nextPayload['state'] = 'ON'; nextPayload['color'] = { x: data.request.colorX / 65536, y: data.request.colorY / 65536 }; if (zigbeeGroup.transition && data.request.transitionTime && data.request.transitionTime / 10 >= 1) nextPayload['transition'] = Math.round(data.request.transitionTime / 10); cachePublishLight(); }); } } if (isCover) { await zigbeeGroup.bridgedDevice.addFixedLabel('type', 'cover'); zigbeeGroup.bridgedDevice.addCommandHandler('upOrOpen', async () => { zigbeeGroup.log.debug(`Command upOrOpen called for ${zigbeeGroup.ien}${group.friendly_name}${rs}${db}`); await zigbeeGroup.bridgedDevice?.setWindowCoveringCurrentTargetStatus(0, 0, WindowCovering.MovementStatus.Stopped); zigbeeGroup.publishCommand('upOrOpen', group.friendly_name, { state: 'OPEN' }); }); zigbeeGroup.bridgedDevice.addCommandHandler('downOrClose', async () => { zigbeeGroup.log.debug(`Command downOrClose called for ${zigbeeGroup.ien}${group.friendly_name}${rs}${db}`); await zigbeeGroup.bridgedDevice?.setWindowCoveringCurrentTargetStatus(10000, 10000, WindowCovering.MovementStatus.Stopped); zigbeeGroup.publishCommand('downOrClose', group.friendly_name, { state: 'CLOSE' }); }); zigbeeGroup.bridgedDevice.addCommandHandler('stopMotion', async () => { zigbeeGroup.log.debug(`Command stopMotion called for ${zigbeeGroup.ien}${group.friendly_name}${rs}${db}`); await zigbeeGroup.bridgedDevice?.setWindowCoveringTargetAsCurrentAndStopped(); zigbeeGroup.publishCommand('stopMotion', group.friendly_name, { state: 'STOP' }); }); zigbeeGroup.bridgedDevice.addCommandHandler('goToLiftPercentage', async ({ request: { liftPercent100thsValue } }) => { zigbeeGroup.log.debug(`Command goToLiftPercentage called for ${zigbeeGroup.ien}${group.friendly_name}${rs}${db} liftPercent100thsValue: ${liftPercent100thsValue}`); await zigbeeGroup.bridgedDevice?.setWindowCoveringCurrentTargetStatus(liftPercent100thsValue, liftPercent100thsValue, WindowCovering.MovementStatus.Stopped); zigbeeGroup.publishCommand('goToLiftPercentage', group.friendly_name, { position: 100 - liftPercent100thsValue / 100 }); }); } if (isThermostat) { await zigbeeGroup.bridgedDevice.addFixedLabel('type', 'climate'); zigbeeGroup.bridgedDevice.subscribeAttribute(ThermostatCluster.id, 'systemMode', (newValue, oldValue) => { zigbeeGroup.bridgedDevice?.log.info(`Thermostat systemMode changed from ${oldValue} to ${newValue}`); if (oldValue !== newValue) { zigbeeGroup.bridgedDevice?.log.info(`Setting thermostat systemMode to ${newValue}`); if (newValue === Thermostat.SystemMode.Off) { zigbeeGroup.publishCommand('SystemMode', group.friendly_name, { system_mode: 'off' }); } else if (newValue === Thermostat.SystemMode.Heat) { zigbeeGroup.publishCommand('SystemMode', group.friendly_name, { system_mode: 'heat' }); } else if (newValue === Thermostat.SystemMode.Cool) { zigbeeGroup.publishCommand('SystemMode', group.friendly_name, { system_mode: 'cool' }); } zigbeeGroup.noUpdate = true; zigbeeGroup.thermostatTimeout = setTimeout(() => { zigbeeGroup.noUpdate = false; }, 2 * 1000); } }, zigbeeGroup.log); zigbeeGroup.bridgedDevice.subscribeAttribute(ThermostatCluster.id, 'occupiedHeatingSetpoint', (newValue, oldValue) => { zigbeeGroup.bridgedDevice?.log.info(`Thermostat occupiedHeatingSetpoint changed from ${oldValue / 100} to ${newValue / 100}`); zigbeeGroup.bridgedDevice?.log.info(`Setting thermostat occupiedHeatingSetpoint to ${newValue / 100}`); zigbeeGroup.publishCommand('CurrentHeatingSetpoint', group.friendly_name, { current_heating_setpoint: Math.round(newValue / 100) }); zigbeeGroup.publishCommand('OccupiedHeatingSetpoint', group.friendly_name, { occupied_heating_setpoint: Math.round(newValue / 100) }); zigbeeGroup.noUpdate = true; zigbeeGroup.thermostatTimeout = setTimeout(() => { zigbeeGroup.noUpdate = false; }, 2 * 1000); }, zigbeeGroup.log); zigbeeGroup.bridgedDevice.subscribeAttribute(ThermostatCluster.id, 'occupiedCoolingSetpoint', (newValue, oldValue) => { zigbeeGroup.bridgedDevice?.log.info(`Thermostat occupiedCoolingSetpoint changed from ${oldValue / 100} to ${newValue / 100}`); zigbeeGroup.bridgedDevice?.log.info(`Setting thermostat occupiedCoolingSetpoint to ${newValue / 100}`); zigbeeGroup.publishCommand('CurrentCoolingSetpoint', group.friendly_name, { current_heating_setpoint: Math.round(newValue / 100) }); zigbeeGroup.publishCommand('OccupiedCoolingSetpoint', group.friendly_name, { occupied_cooling_setpoint: Math.round(newValue / 100) }); zigbeeGroup.noUpdate = true; zigbeeGroup.thermostatTimeout = setTimeout(() => { zigbeeGroup.noUpdate = false; }, 2 * 1000); }, zigbeeGroup.log); } return zigbeeGroup; } } export const z2ms = [ { type: 'switch', name: 'state', property: 'state', deviceType: onOffSwitch, cluster: OnOff.Cluster.id, attribute: 'onOff', converter: (value) => { return value === 'ON' ? true : false; } }, { type: 'switch', name: 'brightness', property: 'brightness', deviceType: dimmableSwitch, cluster: LevelControl.Cluster.id, attribute: 'currentLevel', converter: (value) => { return Math.max(1, Math.min(254, value)); } }, { type: 'switch', name: 'color_hs', property: 'color_hs', deviceType: colorTemperatureSwitch, cluster: ColorControl.Cluster.id, attribute: 'colorMode' }, { type: 'switch', name: 'color_xy', property: 'color_xy', deviceType: colorTemperatureSwitch, cluster: ColorControl.Cluster.id, attribute: 'colorMode' }, { type: 'switch', name: 'color_temp', property: 'color_temp', deviceType: colorTemperatureSwitch, cluster: ColorControl.Cluster.id, attribute: 'colorMode' }, { type: 'outlet', name: 'state', property: 'state', deviceType: onOffOutlet, cluster: OnOff.Cluster.id, attribute: 'onOff', converter: (value) => { return value === 'ON' ? true : false; } }, { type: 'outlet', name: 'brightness', property: 'brightness', deviceType: dimmableOutlet, cluster: LevelControl.Cluster.id, attribute: 'currentLevel', converter: (value) => { return Math.max(1, Math.min(254, value)); } }, { type: 'light', name: 'state', property: 'state', deviceType: onOffLight, cluster: OnOff.Cluster.id, attribute: 'onOff', converter: (value) => { return value === 'ON' ? true : false; } }, { type: 'light', name: 'brightness', property: 'brightness', deviceType: dimmableLight, cluster: LevelControl.Cluster.id, attribute: 'currentLevel', converter: (value) => { return Math.max(1, Math.min(254, value)); } }, { type: 'light', name: 'color_hs', property: 'color_hs', deviceType: extendedColorLight, cluster: ColorControl.Cluster.id, attribute: 'colorMode' }, { type: 'light', name: 'color_xy', property: 'color_xy', deviceType: extendedColorLight, cluster: ColorControl.Cluster.id, attribute: 'colorMode' }, { type: 'light', name: 'color_temp', property: 'color_temp', deviceType: colorTemperatureLight, cluster: ColorControl.Cluster.id, attribute: 'colorMode' }, { type: 'cover', name: 'state', property: 'state', deviceType: coverDevice, cluster: WindowCovering.Cluster.id, attribute: 'targetPositionLiftPercent100ths' }, { type: 'cover', name: 'moving', property: 'moving', deviceType: coverDevice, cluster: WindowCovering.Cluster.id, attribute: 'operationalStatus' }, { type: 'cover', name: 'position', property: 'position', deviceType: coverDevice, cluster: WindowCovering.Cluster.id, attribute: 'currentPositionLiftPercent100ths' }, { type: 'lock', name: 'state', property: 'state', deviceType: doorLockDevice, cluster: DoorLock.Cluster.id, attribute: 'lockState', converter: (value) =