import assert from "node:assert"; import bind from "bind-decorator"; import debounce from "debounce"; import stringify from "json-stable-stringify-without-jsonify"; import {Zcl} from "zigbee-herdsman"; import type {TClusterAttributeKeys} from "zigbee-herdsman/dist/zspec/zcl/definition/clusters-types"; import type {ClusterName} from "zigbee-herdsman/dist/zspec/zcl/definition/tstype"; import Device from "../model/device"; import Group from "../model/group"; import type {Zigbee2MQTTAPI, Zigbee2MQTTResponseEndpoints} from "../types/api"; import logger from "../util/logger"; import * as settings from "../util/settings"; import utils, {DEFAULT_BIND_GROUP_ID} from "../util/utils"; import Extension from "./extension"; const ALL_CLUSTER_CANDIDATES: readonly ClusterName[] = [ "genScenes", "genOnOff", "genLevelCtrl", "lightingColorCtrl", "closuresWindowCovering", "hvacThermostat", "msIlluminanceMeasurement", "msTemperatureMeasurement", "msRelativeHumidity", "msSoilMoisture", "msCO2", ]; // See zigbee-herdsman-converters const DEFAULT_BIND_GROUP = {type: "group_number", ID: DEFAULT_BIND_GROUP_ID, name: "default_bind_group"}; const DEFAULT_REPORT_CONFIG = {minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1}; const getColorCapabilities = async (endpoint: zh.Endpoint): Promise<{colorTemperature: boolean; colorXY: boolean}> => { if (endpoint.getClusterAttributeValue("lightingColorCtrl", "colorCapabilities") == null) { await endpoint.read("lightingColorCtrl", ["colorCapabilities"]); } const value = endpoint.getClusterAttributeValue("lightingColorCtrl", "colorCapabilities") as number; return { colorTemperature: (value & (1 << 4)) > 0, colorXY: (value & (1 << 3)) > 0, }; }; const REPORT_CLUSTERS = { genOnOff: [{attribute: "onOff" as const, ...DEFAULT_REPORT_CONFIG, minimumReportInterval: 0, reportableChange: 0}], genLevelCtrl: [{attribute: "currentLevel" as const, ...DEFAULT_REPORT_CONFIG}], lightingColorCtrl: [ { attribute: "colorTemperature" as const, ...DEFAULT_REPORT_CONFIG, condition: async (endpoint: zh.Endpoint): Promise => (await getColorCapabilities(endpoint)).colorTemperature, }, { attribute: "currentX" as const, ...DEFAULT_REPORT_CONFIG, condition: async (endpoint: zh.Endpoint): Promise => (await getColorCapabilities(endpoint)).colorXY, }, { attribute: "currentY" as const, ...DEFAULT_REPORT_CONFIG, condition: async (endpoint: zh.Endpoint): Promise => (await getColorCapabilities(endpoint)).colorXY, }, ], closuresWindowCovering: [ {attribute: "currentPositionLiftPercentage" as const, ...DEFAULT_REPORT_CONFIG}, {attribute: "currentPositionTiltPercentage" as const, ...DEFAULT_REPORT_CONFIG}, ], }; const POLL_ON_MESSAGE = [ { // On messages that have the cluster and type of below cluster: { manuSpecificPhilips: [ {type: "commandHueNotification", data: {button: 2}}, {type: "commandHueNotification", data: {button: 3}}, ], genLevelCtrl: [ {type: "commandStep", data: {}}, {type: "commandStepWithOnOff", data: {}}, {type: "commandStop", data: {}}, {type: "commandMoveWithOnOff", data: {}}, {type: "commandStopWithOnOff", data: {}}, {type: "commandMove", data: {}}, {type: "commandMoveToLevelWithOnOff", data: {}}, ], genScenes: [{type: "commandRecall", data: {}}], }, // Read the following attributes read: {cluster: "genLevelCtrl" as const, attributes: ["currentLevel"] as TClusterAttributeKeys<"genLevelCtrl">}, // When the bound devices/members of group have the following manufacturerIDs manufacturerIDs: [ Zcl.ManufacturerCode.SIGNIFY_NETHERLANDS_B_V, Zcl.ManufacturerCode.ATMEL, Zcl.ManufacturerCode.GLEDOPTO_CO_LTD, Zcl.ManufacturerCode.MUELLER_LICHT_INTERNATIONAL_INC, Zcl.ManufacturerCode.TELINK_MICRO, Zcl.ManufacturerCode.BUSCH_JAEGER_ELEKTRO, ], manufacturerNames: ["GLEDOPTO", "Trust International B.V.\u0000"], }, { cluster: { genLevelCtrl: [ {type: "commandStepWithOnOff", data: {}}, {type: "commandMoveWithOnOff", data: {}}, {type: "commandStopWithOnOff", data: {}}, {type: "commandMoveToLevelWithOnOff", data: {}}, ], genOnOff: [ {type: "commandOn", data: {}}, {type: "commandOff", data: {}}, {type: "commandOffWithEffect", data: {}}, {type: "commandToggle", data: {}}, ], genScenes: [{type: "commandRecall", data: {}}], manuSpecificPhilips: [ {type: "commandHueNotification", data: {button: 1}}, {type: "commandHueNotification", data: {button: 4}}, ], }, read: {cluster: "genOnOff" as const, attributes: ["onOff"] as TClusterAttributeKeys<"genOnOff">}, manufacturerIDs: [ Zcl.ManufacturerCode.SIGNIFY_NETHERLANDS_B_V, Zcl.ManufacturerCode.ATMEL, Zcl.ManufacturerCode.GLEDOPTO_CO_LTD, Zcl.ManufacturerCode.MUELLER_LICHT_INTERNATIONAL_INC, Zcl.ManufacturerCode.TELINK_MICRO, Zcl.ManufacturerCode.BUSCH_JAEGER_ELEKTRO, ], manufacturerNames: ["GLEDOPTO", "Trust International B.V.\u0000"], }, { cluster: { genScenes: [{type: "commandRecall", data: {}}], }, read: { cluster: "lightingColorCtrl" as const, attributes: [] as TClusterAttributeKeys<"lightingColorCtrl">, // Since not all devices support the same attributes they need to be calculated dynamically // depending on the capabilities of the endpoint. attributesForEndpoint: async (endpoint: zh.Endpoint): Promise> => { const supportedAttrs = await getColorCapabilities(endpoint); const readAttrs: TClusterAttributeKeys<"lightingColorCtrl"> = []; if (supportedAttrs.colorXY) { readAttrs.push("currentX", "currentY"); } if (supportedAttrs.colorTemperature) { readAttrs.push("colorTemperature"); } return readAttrs; }, }, manufacturerIDs: [ Zcl.ManufacturerCode.SIGNIFY_NETHERLANDS_B_V, Zcl.ManufacturerCode.ATMEL, Zcl.ManufacturerCode.GLEDOPTO_CO_LTD, Zcl.ManufacturerCode.MUELLER_LICHT_INTERNATIONAL_INC, Zcl.ManufacturerCode.TELINK_MICRO, // Note: ManufacturerCode.BUSCH_JAEGER is left out intentionally here as their devices don't support colors ], manufacturerNames: ["GLEDOPTO", "Trust International B.V.\u0000"], }, ]; interface ParsedMQTTMessage { type: "bind" | "unbind"; sourceKey?: string; sourceEndpointKey?: string | number; targetKey?: string | number; targetEndpointKey?: string | number; clusters?: string[]; skipDisableReporting: boolean; resolvedSource?: Device; resolvedTarget?: Device | Group | typeof DEFAULT_BIND_GROUP; resolvedSourceEndpoint?: zh.Endpoint; resolvedBindTarget?: number | zh.Endpoint | zh.Group; } export default class Bind extends Extension { #topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/(bind|unbind|binds/clear)`); private pollDebouncers: {[s: string]: () => void} = {}; // biome-ignore lint/suspicious/useAwait: API override async start(): Promise { this.eventBus.onDeviceMessage(this, this.poll); this.eventBus.onMQTTMessage(this, this.onMQTTMessage); this.eventBus.onGroupMembersChanged(this, this.onGroupMembersChanged); } private parseMQTTMessage( data: eventdata.MQTTMessage, ): [raw: KeyValue | undefined, parsed: ParsedMQTTMessage | undefined, error: string | undefined] { if (data.topic.match(this.#topicRegex)) { const type = data.topic.endsWith("unbind") ? "unbind" : "bind"; let skipDisableReporting = false; const message = JSON.parse(data.message) as Zigbee2MQTTAPI["bridge/request/device/bind"]; if (typeof message !== "object" || message.from == null || message.to == null) { return [message, {type, skipDisableReporting}, "Invalid payload"]; } const sourceKey = message.from; const sourceEndpointKey = message.from_endpoint ?? "default"; const targetKey = message.to; const targetEndpointKey = message.to_endpoint; const clusters = message.clusters; skipDisableReporting = message.skip_disable_reporting != null ? message.skip_disable_reporting : false; const resolvedSource = this.zigbee.resolveEntity(message.from) as Device; if (!resolvedSource || !(resolvedSource instanceof Device)) { return [message, {type, skipDisableReporting}, `Source device '${message.from}' does not exist`]; } const resolvedTarget = message.to === DEFAULT_BIND_GROUP.name || message.to === DEFAULT_BIND_GROUP.ID ? DEFAULT_BIND_GROUP : this.zigbee.resolveEntity(message.to); if (!resolvedTarget) { return [message, {type, skipDisableReporting}, `Target device or group '${message.to}' does not exist`]; } const resolvedSourceEndpoint = resolvedSource.endpoint(sourceEndpointKey); if (!resolvedSourceEndpoint) { return [ message, {type, skipDisableReporting}, `Source device '${resolvedSource.name}' does not have endpoint '${sourceEndpointKey}'`, ]; } // resolves to 'default' endpoint if targetEndpointKey is invalid (used by frontend for 'Coordinator') const resolvedBindTarget = resolvedTarget instanceof Device ? resolvedTarget.endpoint(targetEndpointKey) : resolvedTarget instanceof Group ? resolvedTarget.zh : Number(resolvedTarget.ID); if (resolvedTarget instanceof Device && !resolvedBindTarget) { return [ message, {type, skipDisableReporting}, `Target device '${resolvedTarget.name}' does not have endpoint '${targetEndpointKey}'`, ]; } return [ message, { type, sourceKey, sourceEndpointKey, targetKey, targetEndpointKey, clusters, skipDisableReporting, resolvedSource, resolvedTarget, resolvedSourceEndpoint, resolvedBindTarget, }, undefined, ]; } return [undefined, undefined, undefined]; } @bind private async onMQTTMessage(data: eventdata.MQTTMessage): Promise { if (data.topic.endsWith("binds/clear")) { const message = JSON.parse(data.message) as Zigbee2MQTTAPI["bridge/request/device/binds/clear"]; if (typeof message !== "object" || typeof message.target !== "string") { await this.publishResponse("binds/clear", message, {}, "Invalid payload"); return; } const target = this.zigbee.resolveEntity(message.target); if (!(target instanceof Device)) { await this.publishResponse("binds/clear", message, {}, "Invalid target"); return; } // this list is raw (not resolved) to allow clearing any specific target (not only currently known) const eui64List = message.ieee_list ?? ["0xffffffffffffffff"]; await target.zh.clearAllBindings(eui64List); const responseData: Zigbee2MQTTAPI["bridge/response/device/binds/clear"] = { target: message.target, ieee_list: eui64List, }; await this.publishResponse("binds/clear", message, responseData); this.eventBus.emitDevicesChanged(); return; } const [raw, parsed, error] = this.parseMQTTMessage(data); if (!raw || !parsed) { return; } if (error) { await this.publishResponse(parsed.type, raw, {}, error); return; } const { type, sourceKey, sourceEndpointKey, targetKey, targetEndpointKey, clusters, skipDisableReporting, resolvedSource, resolvedTarget, resolvedSourceEndpoint, resolvedBindTarget, } = parsed; assert(resolvedSource, "`resolvedSource` is missing"); assert(resolvedTarget, "`resolvedTarget` is missing"); assert(resolvedSourceEndpoint, "`resolvedSourceEndpoint` is missing"); assert(resolvedBindTarget !== undefined, "`resolvedBindTarget` is missing"); const successfulClusters: string[] = []; const failedClusters = []; const attemptedClusters = []; // Find which clusters are supported by both the source and target. // Groups are assumed to support all clusters. const clusterCandidates = clusters ?? ALL_CLUSTER_CANDIDATES; for (const cluster of clusterCandidates) { let matchingClusters = false; const anyClusterValid = utils.isZHGroup(resolvedBindTarget) || typeof resolvedBindTarget === "number" || (resolvedTarget instanceof Device && resolvedTarget.zh.type === "Coordinator"); if (!anyClusterValid && utils.isZHEndpoint(resolvedBindTarget)) { matchingClusters = (resolvedBindTarget.supportsInputCluster(cluster) && resolvedSourceEndpoint.supportsOutputCluster(cluster)) || (resolvedSourceEndpoint.supportsInputCluster(cluster) && resolvedBindTarget.supportsOutputCluster(cluster)); } const sourceValid = resolvedSourceEndpoint.supportsInputCluster(cluster) || resolvedSourceEndpoint.supportsOutputCluster(cluster); if (sourceValid && (anyClusterValid || matchingClusters)) { logger.debug(`${type}ing cluster '${cluster}' from '${resolvedSource.name}' to '${resolvedTarget.name}'`); attemptedClusters.push(cluster); try { if (type === "bind") { await resolvedSourceEndpoint.bind(cluster, resolvedBindTarget); } else { await resolvedSourceEndpoint.unbind(cluster, resolvedBindTarget); } successfulClusters.push(cluster); logger.info( `Successfully ${type === "bind" ? "bound" : "unbound"} cluster '${cluster}' from '${resolvedSource.name}' to '${resolvedTarget.name}'`, ); } catch (error) { failedClusters.push(cluster); logger.error(`Failed to ${type} cluster '${cluster}' from '${resolvedSource.name}' to '${resolvedTarget.name}' (${error})`); } } } if (attemptedClusters.length === 0) { logger.error(`Nothing to ${type} from '${resolvedSource.name}' to '${resolvedTarget.name}'`); await this.publishResponse(parsed.type, raw, {}, `Nothing to ${type}`); return; } if (failedClusters.length === attemptedClusters.length) { await this.publishResponse(parsed.type, raw, {}, `Failed to ${type}`); return; } const responseData: Zigbee2MQTTAPI["bridge/response/device/bind"] | Zigbee2MQTTAPI["bridge/response/device/unbind"] = { // biome-ignore lint/style/noNonNullAssertion: valid with assert above on `resolvedSource` from: sourceKey!, // biome-ignore lint/style/noNonNullAssertion: valid with assert above on `resolvedSourceEndpoint` from_endpoint: sourceEndpointKey!, // biome-ignore lint/style/noNonNullAssertion: valid with assert above on `resolvedTarget` to: targetKey!, to_endpoint: targetEndpointKey, clusters: successfulClusters, failed: failedClusters, }; if (successfulClusters.length !== 0) { if (type === "bind") { await this.setupReporting( resolvedSourceEndpoint.binds.filter((b) => successfulClusters.includes(b.cluster.name) && b.target === resolvedBindTarget), ); } else if (typeof resolvedBindTarget !== "number" && !skipDisableReporting) { await this.disableUnnecessaryReportings(resolvedBindTarget); } } await this.publishResponse(parsed.type, raw, responseData); this.eventBus.emitDevicesChanged(); } private async publishResponse( type: ParsedMQTTMessage["type"] | "binds/clear", request: KeyValue, data: Zigbee2MQTTAPI[T], error?: string, ): Promise { const response = utils.getResponse(request, data, error); await this.mqtt.publish(`bridge/response/device/${type}`, stringify(response)); if (error) { logger.error(error); } } @bind async onGroupMembersChanged(data: eventdata.GroupMembersChanged): Promise { if (data.action === "add") { const bindsToGroup: zh.Bind[] = []; for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) { for (const endpoint of device.zh.endpoints) { for (const bind of endpoint.binds) { if (bind.target === data.group.zh) { bindsToGroup.push(bind); } } } } await this.setupReporting(bindsToGroup); } else { // action === remove/remove_all if (!data.skipDisableReporting) { await this.disableUnnecessaryReportings(data.endpoint); } } } getSetupReportingEndpoints(bind: zh.Bind, coordinatorEp: zh.Endpoint): zh.Endpoint[] { const endpoints = utils.isZHEndpoint(bind.target) ? [bind.target] : bind.target.members; return endpoints.filter((e) => { if (!e.supportsInputCluster(bind.cluster.name)) { return false; } const hasConfiguredReporting = e.configuredReportings.some((c) => c.cluster.name === bind.cluster.name); if (!hasConfiguredReporting) { return true; } const hasBind = e.binds.some((b) => b.cluster.name === bind.cluster.name && b.target === coordinatorEp); return !hasBind; }); } async setupReporting(binds: zh.Bind[]): Promise { const coordinatorEndpoint = this.zigbee.firstCoordinatorEndpoint(); for (const bind of binds) { if (bind.cluster.name in REPORT_CLUSTERS) { for (const endpoint of this.getSetupReportingEndpoints(bind, coordinatorEndpoint)) { // biome-ignore lint/style/noNonNullAssertion: TODO: biome migration: ??? const resolvedDevice = this.zigbee.resolveEntity(endpoint.getDevice())!; const entity = `${resolvedDevice.name}/${endpoint.ID}`; try { await endpoint.bind(bind.cluster.name, coordinatorEndpoint); const items = []; // biome-ignore lint/style/noNonNullAssertion: valid from outer `if` for (const c of REPORT_CLUSTERS[bind.cluster.name as keyof typeof REPORT_CLUSTERS]!) { if (!("condition" in c) || !c.condition || (await c.condition(endpoint))) { const {attribute, minimumReportInterval, maximumReportInterval, reportableChange} = c; items.push({attribute, minimumReportInterval, maximumReportInterval, reportableChange}); } } await endpoint.configureReporting(bind.cluster.name as keyof typeof REPORT_CLUSTERS, items); logger.info(`Successfully setup reporting for '${entity}' cluster '${bind.cluster.name}'`); } catch (error) { logger.warning(`Failed to setup reporting for '${entity}' cluster '${bind.cluster.name}' (${(error as Error).message})`); } } } } this.eventBus.emitDevicesChanged(); } async disableUnnecessaryReportings(target: zh.Group | zh.Endpoint): Promise { const coordinator = this.zigbee.firstCoordinatorEndpoint(); const endpoints = utils.isZHEndpoint(target) ? [target] : target.members; const allBinds: zh.Bind[] = []; for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) { for (const endpoint of device.zh.endpoints) { allBinds.push(...endpoint.binds); } } for (const endpoint of endpoints) { const device = this.zigbee.resolveEntity(endpoint.getDevice()) as Device; const entity = `${device.name}/${endpoint.ID}`; const requiredClusters: string[] = []; const boundClusters: string[] = []; for (const bind of allBinds) { if (utils.isZHEndpoint(bind.target) ? bind.target === endpoint : bind.target.members.includes(endpoint)) { requiredClusters.push(bind.cluster.name); } } for (const b of endpoint.binds) { if (b.target === coordinator && !requiredClusters.includes(b.cluster.name) && b.cluster.name in REPORT_CLUSTERS) { boundClusters.push(b.cluster.name); } } for (const cluster of boundClusters) { try { await endpoint.unbind(cluster, coordinator); const items = []; // biome-ignore lint/style/noNonNullAssertion: valid from loop (pushed to array only if in) for (const item of REPORT_CLUSTERS[cluster as keyof typeof REPORT_CLUSTERS]!) { if (!("condition" in item) || !item.condition || (await item.condition(endpoint))) { const {attribute, minimumReportInterval, reportableChange} = item; items.push({attribute, minimumReportInterval, maximumReportInterval: 0xffff, reportableChange}); } } await endpoint.configureReporting(cluster as keyof typeof REPORT_CLUSTERS, items); logger.info(`Successfully disabled reporting for '${entity}' cluster '${cluster}'`); } catch (error) { logger.warning(`Failed to disable reporting for '${entity}' cluster '${cluster}' (${(error as Error).message})`); } } this.eventBus.emitReconfigure({device}); } } @bind async poll(data: eventdata.DeviceMessage): Promise { /** * This method poll bound endpoints and group members for state changes. * * A use case is e.g. a Hue Dimmer switch bound to a Hue bulb. * Hue bulbs only report their on/off state. * When dimming the bulb via the dimmer switch the state is therefore not reported. * When we receive a message from a Hue dimmer we read the brightness from the bulb (if bound). */ const polls = POLL_ON_MESSAGE.filter((p) => p.cluster[data.cluster as keyof (typeof p)["cluster"]]?.some((c) => c.type === data.type && utils.equalsPartial(data.data, c.data)), ); if (polls.length) { const toPoll = new Set(); // Add bound devices for (const endpoint of data.device.zh.endpoints) { for (const bind of endpoint.binds) { if (utils.isZHEndpoint(bind.target) && bind.target.getDevice().type !== "Coordinator") { toPoll.add(bind.target); } } } if (data.groupID && data.groupID !== 0) { // If message is published to a group, add members of the group const group = this.zigbee.groupByID(data.groupID); if (group) { for (const member of group.zh.members) { toPoll.add(member); } } } for (const endpoint of toPoll) { const device = endpoint.getDevice(); for (const poll of polls) { if ( // biome-ignore lint/style/noNonNullAssertion: manufacturerID/manufacturerName can be undefined and won't match `includes`, but TS enforces same-type (!poll.manufacturerIDs.includes(device.manufacturerID!) && !poll.manufacturerNames.includes(device.manufacturerName!)) || !endpoint.supportsInputCluster(poll.read.cluster) ) { continue; } let readAttrs = poll.read.attributes; if (poll.read.attributesForEndpoint) { const attrsForEndpoint = await poll.read.attributesForEndpoint(endpoint); readAttrs = [...poll.read.attributes, ...attrsForEndpoint]; } const key = `${device.ieeeAddr}_${endpoint.ID}_${POLL_ON_MESSAGE.indexOf(poll)}`; if (!this.pollDebouncers[key]) { this.pollDebouncers[key] = debounce(async () => { try { await endpoint.read(poll.read.cluster, readAttrs); } catch (error) { // biome-ignore lint/style/noNonNullAssertion: TODO: biome migration: ??? const resolvedDevice = this.zigbee.resolveEntity(device)!; logger.error(`Failed to poll ${readAttrs} from ${resolvedDevice.name} (${(error as Error).message})`); } }, 1000); } this.pollDebouncers[key](); } } } } }