import bind from "bind-decorator"; import type * as zhc from "zigbee-herdsman-converters"; import Device from "../model/device"; import Group from "../model/group"; import logger from "../util/logger"; import * as settings from "../util/settings"; import {stringify} from "../util/stringify"; import utils from "../util/utils"; import Extension from "./extension"; // TODO: get rid of this, use class member let topicGetSetRegex: RegExp; // Used by `publish.test.ts` to reload regex when changing `mqtt.base_topic`. export const loadTopicGetSetRegex = (): void => { topicGetSetRegex = new RegExp(`^${settings.get().mqtt.base_topic}/(?!bridge)(.+?)/(get|set)(?:/(.+))?$`); }; const STATE_VALUES: ReadonlyArray = ["on", "off", "toggle", "open", "close", "stop", "lock", "unlock"]; const SCENE_CONVERTER_KEYS: ReadonlyArray = ["scene_store", "scene_add", "scene_remove", "scene_remove_all", "scene_rename"]; interface ParsedTopic { ID: string; endpoint: string | undefined; attribute: string; type: "get" | "set"; } export default class Publish extends Extension { // biome-ignore lint/suspicious/useAwait: API override async start(): Promise { loadTopicGetSetRegex(); this.eventBus.onMQTTMessage(this, this.onMQTTMessage); } parseTopic(topic: string): ParsedTopic | undefined { // The function supports the following topic formats (below are for 'set'. 'get' will look the same): // - /device_name/set (auto-matches endpoint and attribute is defined in the payload) // - /device_name/set/attribute (default endpoint used) // - /device_name/endpoint/set (attribute is defined in the payload) // - /device_name/endpoint/set/attribute (payload is the value) // Make the rough split on get/set keyword. // Before the get/set is the device name and optional endpoint name. // After it there will be an optional attribute name. const match = topic.match(topicGetSetRegex); if (!match) { return undefined; } const deviceNameAndEndpoint = match[1]; const attribute = match[3]; // Now parse the device/group name, and endpoint name const entity = this.zigbee.resolveEntityAndEndpoint(deviceNameAndEndpoint); return {ID: entity.ID, endpoint: entity.endpointID, type: match[2] as "get" | "set", attribute: attribute}; } parseMessage(parsedTopic: ParsedTopic, data: eventdata.MQTTMessage): KeyValue | undefined { if (parsedTopic.attribute) { try { return {[parsedTopic.attribute]: JSON.parse(data.message)}; } catch { return {[parsedTopic.attribute]: data.message}; } } else { try { return JSON.parse(data.message); } catch { return STATE_VALUES.includes(data.message.toLowerCase()) ? {state: data.message} : undefined; } } } updateMessageHomeAssistant(message: KeyValue, entityState: KeyValue): void { /** * Home Assistant always publishes 'state', even when e.g. only setting * the color temperature. This would lead to 2 zigbee publishes, where the first one * (state) is probably unnecessary. */ if (settings.get().homeassistant.enabled) { const hasColorTemp = message.color_temp !== undefined; const hasColor = message.color !== undefined; const hasBrightness = message.brightness !== undefined; if (entityState.state === "ON" && (hasColorTemp || hasColor) && !hasBrightness) { delete message.state; logger.debug("Skipping state because of Home Assistant"); } } } @bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise { const parsedTopic = this.parseTopic(data.topic); if (!parsedTopic) { return; } const re = this.zigbee.resolveEntity(parsedTopic.ID); if (!re) { logger.error(`Entity '${parsedTopic.ID}' is unknown`); return; } // Get entity details let definition: zhc.Definition | zhc.Definition[]; if (re instanceof Device) { if (!re.definition) { logger.error(`Cannot publish to unsupported device '${re.name}'`); return; } definition = re.definition; } else { definition = re.membersDefinitions(); } const target = re instanceof Group ? re.zh : re.endpoint(parsedTopic.endpoint); if (!target) { logger.error(`Device '${re.name}' has no endpoint '${parsedTopic.endpoint}'`); return; } // Convert the MQTT message to a Zigbee message. const message = this.parseMessage(parsedTopic, data); if (!message) { logger.error(`Invalid message '${message}', skipping...`); return; } const device = re instanceof Device ? re.zh : undefined; const entitySettings = re.options; const entityState = this.state.get(re); const membersState = re instanceof Group ? // biome-ignore lint/style/noNonNullAssertion: TODO: biome migration: might be a bit much assumed here? Object.fromEntries(re.zh.members.map((e) => [e.deviceIeeeAddress, this.state.get(this.zigbee.resolveEntity(e.deviceIeeeAddress)!)])) : undefined; const converters = this.getDefinitionConverters(definition); this.updateMessageHomeAssistant(message, entityState); /** * Order state & brightness based on current bulb state * * Not all bulbs support setting the color/color_temp while it is off * this results in inconsistent behavior between different vendors. * * bulb on => move state & brightness to the back * bulb off => move state & brightness to the front */ const entries = Object.entries(message); const sorter = typeof message.state === "string" && message.state.toLowerCase() === "off" ? 1 : -1; entries.sort((a) => (["state", "brightness", "brightness_percent"].includes(a[0]) ? sorter : sorter * -1)); // For each attribute call the corresponding converter const usedConverters: {[s: number]: zhc.Tz.Converter[]} = {}; const toPublish: {[s: number | string]: KeyValue} = {}; const toPublishEntity: {[s: number | string]: Device | Group} = {}; const addToToPublish = (entity: Device | Group, payload: KeyValue): void => { const ID = entity.ID; if (!(ID in toPublish)) { toPublish[ID] = {}; toPublishEntity[ID] = entity; } toPublish[ID] = {...toPublish[ID], ...payload}; }; const endpointNames = re instanceof Device ? re.getEndpointNames() : []; const propertyEndpointRegex = new RegExp(`^(.*?)_(${endpointNames.join("|")})$`); let scenesChanged = false; for (const entry of entries) { let key = entry[0]; const value = entry[1]; let endpointName = parsedTopic.endpoint; let localTarget = target; let endpointOrGroupID = utils.isZHEndpoint(target) ? target.ID : target.groupID; // When the key has a endpointName included (e.g. state_right), this will override the target. const propertyEndpointMatch = key.match(propertyEndpointRegex); if (re instanceof Device && propertyEndpointMatch) { endpointName = propertyEndpointMatch[2]; key = propertyEndpointMatch[1]; // biome-ignore lint/style/noNonNullAssertion: endpointName is always matched to an existing endpoint of the device since `propertyEndpointRegex` only contains valid endpoints for this device localTarget = re.endpoint(endpointName)!; endpointOrGroupID = localTarget.ID; } if (usedConverters[endpointOrGroupID] === undefined) usedConverters[endpointOrGroupID] = []; // Match any key if the toZigbee converter defines no key. const converter = converters.find( (c) => (!c.key || c.key.includes(key)) && (re instanceof Group || !c.endpoints || (endpointName && c.endpoints.includes(endpointName))), ); if (parsedTopic.type === "set" && converter && usedConverters[endpointOrGroupID].includes(converter)) { // Use a converter for set only once // (e.g. light_onoff_brightness converters can convert state and brightness) continue; } if (!converter) { logger.error(`No converter available for '${key}' on '${re.name}': (${stringify(message[key])})`); continue; } // If the endpoint_name name is a number, try to map it to a friendlyName if (!Number.isNaN(Number(endpointName)) && re.isDevice() && utils.isZHEndpoint(localTarget) && re.endpointName(localTarget)) { endpointName = re.endpointName(localTarget); } // Converter didn't return a result, skip const entitySettingsKeyValue: KeyValue = entitySettings; const meta: zhc.Tz.Meta = { endpoint_name: endpointName, options: entitySettingsKeyValue, message: {...message}, device, state: entityState, membersState, mapped: definition, /* v8 ignore start */ deviceExposesChanged: (): void => { if (re instanceof Device) this.eventBus.emitExposesAndDevicesChanged(re); }, /* v8 ignore stop */ /* v8 ignore next */ publish: (payload: KeyValue) => this.publishEntityState(re, payload), }; // Strip endpoint name from meta.message properties. if (endpointName) { for (const [key, value] of Object.entries(meta.message)) { if (key.endsWith(endpointName)) { delete meta.message[key]; const keyWithoutEndpoint = key.substring(0, key.length - endpointName.length - 1); meta.message[keyWithoutEndpoint] = value; } } } try { if (parsedTopic.type === "set" && converter.convertSet) { logger.debug(`Publishing '${parsedTopic.type}' '${key}' to '${re.name}'`); const result = await converter.convertSet(localTarget, key, value, meta); const optimistic = entitySettings.optimistic === undefined || entitySettings.optimistic; if (result?.state && optimistic) { const msg = result.state; if (endpointName) { for (const key of Object.keys(msg)) { msg[`${key}_${endpointName}`] = msg[key]; delete msg[key]; } } // filter out attribute listed in filtered_optimistic utils.filterProperties(entitySettings.filtered_optimistic, msg); addToToPublish(re, msg); } if (result?.membersState && optimistic) { for (const [ieeeAddr, state] of Object.entries(result.membersState)) { // biome-ignore lint/style/noNonNullAssertion: might be a bit much assumed here? addToToPublish(this.zigbee.resolveEntity(ieeeAddr)!, state); } } } else if (parsedTopic.type === "get" && converter.convertGet) { logger.debug(`Publishing get '${parsedTopic.type}' '${key}' to '${re.name}'`); await converter.convertGet(localTarget, key, meta); } else { logger.error(`No converter available for '${parsedTopic.type}' '${key}' (${message[key]})`); continue; } } catch (error) { const message = `Publish '${parsedTopic.type}' '${key}' to '${re.name}' failed: '${error}'`; logger.error(message); // biome-ignore lint/style/noNonNullAssertion: always Error logger.debug((error as Error).stack!); } usedConverters[endpointOrGroupID].push(converter); if (!scenesChanged && converter.key) { scenesChanged = converter.key.some((k) => SCENE_CONVERTER_KEYS.includes(k)); } } for (const [ID, payload] of Object.entries(toPublish)) { if (!utils.objectIsEmpty(payload)) { await this.publishEntityState(toPublishEntity[ID], payload); } } if (scenesChanged) { this.eventBus.emitScenesChanged({entity: re}); } } private getDefinitionConverters(definition: zhc.Definition | zhc.Definition[]): ReadonlyArray { if (Array.isArray(definition)) { return definition.length ? Array.from(new Set(definition.flatMap((d) => d.toZigbee))) : []; } return definition?.toZigbee; } }