From a774b43d6376fd0bc45e516d3a90dc983ab9136c Mon Sep 17 00:00:00 2001 From: Koen Kanters Date: Sat, 9 Oct 2021 19:41:00 +0200 Subject: [PATCH] Fix Home Assistant template variable warnings (#9088) * Fix more template warnings. https://github.com/Koenkk/zigbee2mqtt/issues/8769 * Update * Updates --- lib/controller.ts | 6 ++-- lib/extension/homeassistant.ts | 65 ++++++++++++++++++++-------------- test/frontend.test.js | 4 +-- test/homeassistant.test.js | 20 +++++------ 4 files changed, 53 insertions(+), 42 deletions(-) diff --git a/lib/controller.ts b/lib/controller.ts index 7500a9b64..cd9e62d34 100644 --- a/lib/controller.ts +++ b/lib/controller.ts @@ -145,6 +145,9 @@ class Controller { this.exitCallback(1); } + // Call extensions + await this.callExtensions('start', this.extensions); + // Send all cached states. if (settings.get().advanced.cache_state_send_on_startup && settings.get().advanced.cache_state) { for (const device of devices) { @@ -154,9 +157,6 @@ class Controller { } } - // Call extensions - await this.callExtensions('start', this.extensions); - if (settings.get().advanced.last_seen && settings.get().advanced.last_seen !== 'disable') { this.eventBus.onLastSeenChanged(this, (data) => this.publishEntityState(data.device, {}, 'lastSeenChanged')); diff --git a/lib/extension/homeassistant.ts b/lib/extension/homeassistant.ts index faf0dbd9f..ac91fa866 100644 --- a/lib/extension/homeassistant.ts +++ b/lib/extension/homeassistant.ts @@ -8,11 +8,12 @@ import Extension from './extension'; import bind from 'bind-decorator'; // eslint-disable-next-line camelcase -interface DiscoveryEntry {type: string, object_id: string, discovery_payload: KeyValue} +interface DiscoveryEntry {mockProperties: string[], type: string, object_id: string, discovery_payload: KeyValue} const sensorClick = { type: 'sensor', object_id: 'click', + mockProperties: ['click'], discovery_payload: { icon: 'mdi:toggle-switch', value_template: '{{ value_json.click }}', @@ -36,7 +37,7 @@ const featurePropertyWithoutEndpoint = (feature: zhc.DefinitionExposeFeature): s * This extensions handles integration with HomeAssistant */ export default class HomeAssistant extends Extension { - private discovered: {[s: string]: string[]} = {}; + private discovered: {[s: string]: {topics: Set, mockProperties: Set}} = {}; private mapping: {[s: string]: DiscoveryEntry[]} = {}; private discoveredTriggers : {[s: string]: Set}= {}; private legacyApi = settings.get().advanced.legacy_api; @@ -91,7 +92,7 @@ export default class HomeAssistant extends Extension { assert(entityType === 'device' || groupSupportedTypes.includes(firstExpose.type), `Unsupported expose type ${firstExpose.type} for group`); - const discoveryEntries = []; + const discoveryEntries: DiscoveryEntry[] = []; const endpoint = entityType === 'device' ? exposes[0].endpoint : undefined; const getProperty = (feature: zhc.DefinitionExposeFeature): string => entityType === 'group' ? featurePropertyWithoutEndpoint(feature) : feature.property; @@ -102,10 +103,12 @@ export default class HomeAssistant extends Extension { const hasColorHS = exposes.find((expose) => expose.features.find((e) => e.name === 'color_hs')); const hasBrightness = exposes.find((expose) => expose.features.find((e) => e.name === 'brightness')); const hasColorTemp = exposes.find((expose) => expose.features.find((e) => e.name === 'color_temp')); + const state = firstExpose.features.find((f) => f.name === 'state'); const discoveryEntry: DiscoveryEntry = { type: 'light', object_id: endpoint ? `light_${endpoint}` : 'light', + mockProperties: [state.property], discovery_payload: { brightness: !!hasBrightness, schema: 'json', @@ -149,6 +152,7 @@ export default class HomeAssistant extends Extension { const discoveryEntry: DiscoveryEntry = { type: 'switch', object_id: endpoint ? `switch_${endpoint}` : 'switch', + mockProperties: [property], discovery_payload: { payload_off: state.value_off, payload_on: state.value_on, @@ -181,6 +185,7 @@ export default class HomeAssistant extends Extension { const discoveryEntry: DiscoveryEntry = { type: 'climate', object_id: endpoint ? `climate_${endpoint}` : 'climate', + mockProperties: [], discovery_payload: { // Static state_topic: false, @@ -268,6 +273,7 @@ export default class HomeAssistant extends Extension { const discoveryEntry: DiscoveryEntry = { type: 'lock', object_id: 'lock', + mockProperties: [state.property], discovery_payload: { command_topic: true, value_template: `{{ value_json.${state.property} }}`, @@ -304,6 +310,7 @@ export default class HomeAssistant extends Extension { const discoveryEntry: DiscoveryEntry = { type: 'cover', + mockProperties: [], object_id: endpoint ? `cover_${endpoint}` : 'cover', discovery_payload: {}, }; @@ -347,6 +354,7 @@ export default class HomeAssistant extends Extension { const discoveryEntry: DiscoveryEntry = { type: 'fan', object_id: 'fan', + mockProperties: ['fan_state'], discovery_payload: { state_topic: true, state_value_template: '{{ value_json.fan_state }}', @@ -427,6 +435,7 @@ export default class HomeAssistant extends Extension { if (firstExpose.access & ACCESS_SET) { const discoveryEntry: DiscoveryEntry = { type: 'switch', + mockProperties: [firstExpose.property], object_id: endpoint ? `switch_${firstExpose.name}_${endpoint}` : `switch_${firstExpose.name}`, @@ -447,6 +456,7 @@ export default class HomeAssistant extends Extension { const discoveryEntry = { type: 'binary_sensor', object_id: endpoint ? `${firstExpose.name}_${endpoint}` : `${firstExpose.name}`, + mockProperties: [firstExpose.property], discovery_payload: { value_template: `{{ value_json.${firstExpose.property} }}`, payload_on: firstExpose.value_on, @@ -506,6 +516,7 @@ export default class HomeAssistant extends Extension { const discoveryEntry = { type: 'sensor', object_id: endpoint ? `${firstExpose.name}_${endpoint}` : `${firstExpose.name}`, + mockProperties: [firstExpose.property], discovery_payload: { value_template: `{{ value_json.${firstExpose.property} }}`, enabled_by_default: !allowsSet, @@ -524,6 +535,7 @@ export default class HomeAssistant extends Extension { const discoveryEntry = { type: 'number', object_id: endpoint ? `${firstExpose.name}_${endpoint}` : `${firstExpose.name}`, + mockProperties: [firstExpose.property], discovery_payload: { value_template: `{{ value_json.${firstExpose.property} }}`, command_topic: true, @@ -561,6 +573,7 @@ export default class HomeAssistant extends Extension { discoveryEntries.push({ type: 'sensor', object_id: firstExpose.property, + mockProperties: [firstExpose.property], discovery_payload: { value_template: `{{ value_json.${firstExpose.property} }}`, enabled_by_default: !(firstExpose.access & ACCESS_SET), @@ -577,6 +590,7 @@ export default class HomeAssistant extends Extension { discoveryEntries.push({ type: 'select', object_id: firstExpose.property, + mockProperties: [firstExpose.property], discovery_payload: { value_template: `{{ value_json.${firstExpose.property} }}`, state_topic: true, @@ -598,6 +612,7 @@ export default class HomeAssistant extends Extension { const discoveryEntry = { type: 'sensor', object_id: firstExpose.property, + mockProperties: [firstExpose.property], discovery_payload: { value_template: `{{ value_json.${firstExpose.property} }}`, ...lookup[firstExpose.name], @@ -629,6 +644,7 @@ export default class HomeAssistant extends Extension { // deprecated this.mapping[def.model].push({ type: 'sensor', + mockProperties: ['brightness'], object_id: 'brightness', discovery_payload: { unit_of_measurement: 'brightness', @@ -653,7 +669,7 @@ export default class HomeAssistant extends Extension { @bind onDeviceRemoved(data: eventdata.DeviceRemoved): void { logger.debug(`Clearing Home Assistant discovery topic for '${data.name}'`); - this.discovered[data.ieeeAddr]?.forEach((topic) => { + this.discovered[data.ieeeAddr]?.topics.forEach((topic) => { this.mqtt.publish(topic, null, {retain: true, qos: 0}, this.discoveryTopic, false, false); }); @@ -781,6 +797,7 @@ export default class HomeAssistant extends Extension { configs.push({ type: 'sensor', object_id: 'last_seen', + mockProperties: ['last_seen'], discovery_payload: { icon: 'mdi:clock', value_template: '{{ value_json.last_seen }}', @@ -790,9 +807,10 @@ export default class HomeAssistant extends Extension { } if (isDevice && entity.definition.hasOwnProperty('ota')) { - const updateStateSensor = { + const updateStateSensor: DiscoveryEntry = { type: 'sensor', object_id: 'update_state', + mockProperties: [], discovery_payload: { icon: 'mdi:update', value_template: `{{ value_json['update']['state'] }}`, @@ -805,6 +823,7 @@ export default class HomeAssistant extends Extension { const updateAvailableSensor = { type: 'binary_sensor', object_id: 'update_available', + mockProperties: ['update_available'], discovery_payload: { payload_on: true, payload_off: false, @@ -842,9 +861,13 @@ export default class HomeAssistant extends Extension { return configs; } + private getDiscoverKey(entity: Device | Group): string | number { + return entity.isDevice() ? entity.ieeeAddr : entity.ID; + } + private discover(entity: Device | Group, force=false): void { // Check if already discoverd and check if there are configs. - const discoverKey = entity.isDevice() ? entity.ieeeAddr : entity.ID; + const discoverKey = this.getDiscoverKey(entity); const discover = force || !this.discovered[discoverKey]; if (entity.isGroup()) { @@ -855,7 +878,7 @@ export default class HomeAssistant extends Extension { return; } - this.discovered[discoverKey] = []; + this.discovered[discoverKey] = {topics: new Set(), mockProperties: new Set()}; this.getConfigs(entity).forEach((config) => { const payload = {...config.discovery_payload}; let stateTopic = `${settings.get().mqtt.base_topic}/${entity.name}`; @@ -1044,7 +1067,8 @@ export default class HomeAssistant extends Extension { const topic = this.getDiscoveryTopic(config, entity); this.mqtt.publish(topic, stringify(payload), {retain: true, qos: 0}, this.discoveryTopic, false, false); - this.discovered[discoverKey].push(topic); + this.discovered[discoverKey].topics.add(topic); + config.mockProperties?.forEach((property) => this.discovered[discoverKey].mockProperties.add(property)); }); } @@ -1139,26 +1163,12 @@ export default class HomeAssistant extends Extension { } override adjustMessageBeforePublish(entity: Device | Group, message: KeyValue): void { - // Set missing values of state to 'null': https://github.com/Koenkk/zigbee2mqtt/issues/6987 - if (!entity.isDevice() || !entity.definition) return null; - - const add = (expose: zhc.DefinitionExpose | zhc.DefinitionExposeFeature): void => { - if (!message.hasOwnProperty(expose.property) && expose.access & ACCESS_STATE) { - message[expose.property] = null; + const discoverKey = this.getDiscoverKey(entity); + this.discovered[discoverKey]?.mockProperties?.forEach((property) => { + if (!message.hasOwnProperty(property)) { + message[property] = null; } - }; - - for (const expose of entity.definition.exposes) { - if (expose.hasOwnProperty('features')) { - for (const feature of expose.features) { - if (feature.name === 'state') { - add(feature); - } - } - } else { - add(expose); - } - } + }); // Copy hue -> h, saturation -> s to make homeassitant happy if (message.hasOwnProperty('color')) { @@ -1200,6 +1210,7 @@ export default class HomeAssistant extends Extension { const config: DiscoveryEntry = { type: 'device_automation', object_id: `${key}_${value}`, + mockProperties: [], discovery_payload: { automation_type: 'trigger', type: key, diff --git a/test/frontend.test.js b/test/frontend.test.js index 653413c93..3d940b5c3 100644 --- a/test/frontend.test.js +++ b/test/frontend.test.js @@ -140,7 +140,7 @@ describe('Frontend', () => { expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledWith( 'zigbee2mqtt/bulb_color', - stringify({state: 'ON', linkquality: null}), + stringify({state: 'ON', linkquality: null, update_available: null}), { retain: false, qos: 0 }, expect.any(Function) ); @@ -151,7 +151,7 @@ describe('Frontend', () => { // Received message on socket expect(mockWSClient.implementation.send).toHaveBeenCalledTimes(1); - expect(mockWSClient.implementation.send).toHaveBeenCalledWith(stringify({topic: 'bulb_color', payload: {state: 'ON', linkquality: null}})); + expect(mockWSClient.implementation.send).toHaveBeenCalledWith(stringify({topic: 'bulb_color', payload: {state: 'ON', linkquality: null, update_available: null}})); // Shouldnt set when not ready mockWSClient.implementation.send.mockClear(); diff --git a/test/homeassistant.test.js b/test/homeassistant.test.js index 16ae418f7..eac8943ad 100644 --- a/test/homeassistant.test.js +++ b/test/homeassistant.test.js @@ -825,7 +825,7 @@ describe('HomeAssistant extension', () => { expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledWith( 'zigbee2mqtt/bulb_color', - stringify({"color":{"hue": 0, "saturation": 100, "h": 0, "s": 100}, "color_mode": "hs", "linkquality": null, "state": null}), + stringify({"color":{"hue": 0, "saturation": 100, "h": 0, "s": 100}, "color_mode": "hs", "linkquality": null, "state": null, "update_available": null}), { retain: false, qos: 0 }, expect.any(Function), ); @@ -841,7 +841,7 @@ describe('HomeAssistant extension', () => { expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledWith( 'zigbee2mqtt/bulb_color', - stringify({"color": {"x": 0.4576,"y": 0.41}, "color_mode": "xy", "linkquality": null,"state": null}), + stringify({"color": {"x": 0.4576,"y": 0.41}, "color_mode": "xy", "linkquality": null,"state": null, "update_available": null}), { retain: false, qos: 0 }, expect.any(Function), ); @@ -857,7 +857,7 @@ describe('HomeAssistant extension', () => { expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledWith( 'zigbee2mqtt/bulb_color', - stringify({"linkquality": null,"state": "ON"}), + stringify({"linkquality": null,"state": "ON", "update_available": null}), { retain: false, qos: 0 }, expect.any(Function), ); @@ -932,13 +932,13 @@ describe('HomeAssistant extension', () => { await flushPromises(); expect(MQTT.publish).toHaveBeenCalledWith( 'zigbee2mqtt/bulb', - stringify({"state":"ON","brightness":50,"color_temp":370,"linkquality":99,"power_on_behavior":null}), + stringify({"state":"ON","brightness":50,"color_temp":370,"linkquality":99,"power_on_behavior":null, "update_available": null}), { retain: true, qos: 0 }, expect.any(Function) ); expect(MQTT.publish).toHaveBeenCalledWith( 'zigbee2mqtt/remote', - stringify({"action":null,"battery":null,"brightness":255,"linkquality":null}), + stringify({"action":null,"battery":null,"brightness":255,"linkquality":null, "update_available": null}), { retain: true, qos: 0 }, expect.any(Function) ); @@ -956,13 +956,13 @@ describe('HomeAssistant extension', () => { await flushPromises(); expect(MQTT.publish).toHaveBeenCalledWith( 'zigbee2mqtt/bulb', - stringify({"state":"ON","brightness":50,"color_temp":370,"linkquality":99,"power_on_behavior":null}), + stringify({"state":"ON","brightness":50,"color_temp":370,"linkquality":99,"power_on_behavior":null, "update_available": null}), { retain: true, qos: 0 }, expect.any(Function) ); expect(MQTT.publish).toHaveBeenCalledWith( 'zigbee2mqtt/remote', - stringify({"action":null,"battery":null,"brightness":255,"linkquality":null}), + stringify({"action":null,"battery":null,"brightness":255,"linkquality":null, "update_available": null}), { retain: true, qos: 0 }, expect.any(Function) ); @@ -1290,7 +1290,7 @@ describe('HomeAssistant extension', () => { expect(MQTT.publish).toHaveBeenCalledWith( 'zigbee2mqtt/button', - stringify({action: "", battery: null, linkquality: null, voltage: null}), + stringify({action: "", battery: null, linkquality: null, voltage: null, click: null}), { retain: false, qos: 0 }, expect.any(Function), ); @@ -1477,10 +1477,10 @@ describe('HomeAssistant extension', () => { await flushPromises(); expect(MQTT.publish).toHaveBeenCalledTimes(4); expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/button'); - expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({action: 'single', battery: null, linkquality: null, voltage: null}); + expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({action: 'single', click: null, battery: null, linkquality: null, voltage: null}); expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({"qos": 0, "retain": false}); expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/button'); - expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({action: '', battery: null, linkquality: null, voltage: null}); + expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({action: '', click: null, battery: null, linkquality: null, voltage: null}); expect(MQTT.publish.mock.calls[1][2]).toStrictEqual({"qos": 0, "retain": false}); expect(MQTT.publish.mock.calls[2][0]).toStrictEqual('homeassistant/device_automation/0x0017880104e45520/action_single/config'); expect(MQTT.publish.mock.calls[3][0]).toStrictEqual('zigbee2mqtt/button/action');