diff --git a/lib/extension/homeassistant.ts b/lib/extension/homeassistant.ts index 27bfb3a56..376435401 100644 --- a/lib/extension/homeassistant.ts +++ b/lib/extension/homeassistant.ts @@ -8,13 +8,7 @@ import {stringify} from "../util/stringify"; import utils, {assertBinaryExpose, assertEnumExpose, assertNumericExpose, isBinaryExpose, isEnumExpose, isNumericExpose} from "../util/utils"; import Extension from "./extension"; -interface MockProperty { - property: string; - value: KeyValue | string | null; -} - interface DiscoveryEntry { - mockProperties: MockProperty[]; type: string; object_id: string; discovery_payload: KeyValue; @@ -22,7 +16,6 @@ interface DiscoveryEntry { } interface Discovered { - mockProperties: Set; messages: {[s: string]: {payload: string; published: boolean}}; triggers: Set; discovered: boolean; @@ -399,6 +392,16 @@ const featurePropertyWithoutEndpoint = (feature: zhc.Feature): string => { const cleanName = (name: string): string => name.replace(/^(?:analog_in_|analog_out_)/, ""); +/** + * Reads `property` from the state payload, guarded against it being absent, which otherwise logs + * `Template variable warning: 'dict object' has no attribute ''`. Renders an empty string + * when absent, which the MQTT platforms ignore. + * Check presence, not truthiness: `0`, `false` and `null` are falsy in Jinja. + * https://github.com/Koenkk/zigbee2mqtt/issues/30086 + */ +const stateValueTemplate = (property: string, body = `{{ value_json["${property}"] }}`): string => + `{% if "${property}" in value_json %}${body}{% endif %}`; + const applyHomeAssistantExposeMetadata = (payload: DiscoveryEntry, homeAssistant: zhc.Expose["homeassistant"]): void => { if (!homeAssistant) { return; @@ -611,7 +614,7 @@ export class HomeAssistant extends Extension { private getDiscovered(entity: Device | Group | Bridge | string | number): Discovered { const ID = typeof entity === "string" || typeof entity === "number" ? entity : entity.ID; if (!(ID in this.discovered)) { - this.discovered[ID] = {messages: {}, triggers: new Set(), mockProperties: new Set(), discovered: false}; + this.discovered[ID] = {messages: {}, triggers: new Set(), discovered: false}; } return this.discovered[ID]; } @@ -645,7 +648,6 @@ export class HomeAssistant extends Extension { const discoveryEntry: DiscoveryEntry = { type: "light", object_id: endpointName ? `light_${endpointName}` : "light", - mockProperties: [{property: state.property, value: null}], discovery_payload: { name: endpointName ? utils.capitalize(endpointName) : null, brightness: !!hasBrightness, @@ -707,12 +709,11 @@ export class HomeAssistant extends Extension { const discoveryEntry: DiscoveryEntry = { type: "switch", object_id: endpointName ? `switch_${endpointName}` : "switch", - mockProperties: [{property: property, value: null}], discovery_payload: { name: endpointName ? utils.capitalize(endpointName) : null, payload_off: state.value_off, payload_on: state.value_on, - value_template: `{{ value_json["${property}"] }}`, + value_template: stateValueTemplate(property), command_topic: true, command_topic_prefix: endpointName, }, @@ -747,7 +748,6 @@ export class HomeAssistant extends Extension { const discoveryEntry: DiscoveryEntry = { type: "climate", object_id: endpointName ? `climate_${endpointName}` : "climate", - mockProperties: [], discovery_payload: { name: endpointName ? utils.capitalize(endpointName) : null, // Static @@ -759,7 +759,7 @@ export class HomeAssistant extends Extension { max_temp: primarySetpoint.value_max.toString(), // Temperature current_temperature_topic: true, - current_temperature_template: `{{ value_json["${temperature.property}"] }}`, + current_temperature_template: stateValueTemplate(temperature.property), command_topic_prefix: endpointName, }, }; @@ -773,28 +773,30 @@ export class HomeAssistant extends Extension { mode.values.splice(mode.values.indexOf("sleep"), 1); } discoveryEntry.discovery_payload.mode_state_topic = true; - discoveryEntry.discovery_payload.mode_state_template = `{{ value_json["${mode.property}"] }}`; + discoveryEntry.discovery_payload.mode_state_template = stateValueTemplate(mode.property); discoveryEntry.discovery_payload.modes = mode.values; discoveryEntry.discovery_payload.mode_command_topic = true; } const state = (firstExpose as zhc.Climate).features.find((f) => f.name === "running_state"); if (state) { - discoveryEntry.mockProperties.push({property: state.property, value: null}); discoveryEntry.discovery_payload.action_topic = true; - discoveryEntry.discovery_payload.action_template = `{% set values = {None:None,'idle':'idle','heat':'heating','cool':'cooling','fan_only':'fan'} %}{{ values[value_json["${state.property}"]] }}`; + discoveryEntry.discovery_payload.action_template = stateValueTemplate( + state.property, + `{% set values = {None:None,'idle':'idle','heat':'heating','cool':'cooling','fan_only':'fan'} %}{{ values[value_json["${state.property}"]] }}`, + ); } if (heatingSetpoint && coolingSetpoint) { discoveryEntry.discovery_payload.temperature_low_command_topic = heatingSetpoint.name; - discoveryEntry.discovery_payload.temperature_low_state_template = `{{ value_json["${heatingSetpoint.property}"] }}`; + discoveryEntry.discovery_payload.temperature_low_state_template = stateValueTemplate(heatingSetpoint.property); discoveryEntry.discovery_payload.temperature_low_state_topic = true; discoveryEntry.discovery_payload.temperature_high_command_topic = coolingSetpoint.name; - discoveryEntry.discovery_payload.temperature_high_state_template = `{{ value_json["${coolingSetpoint.property}"] }}`; + discoveryEntry.discovery_payload.temperature_high_state_template = stateValueTemplate(coolingSetpoint.property); discoveryEntry.discovery_payload.temperature_high_state_topic = true; } else { discoveryEntry.discovery_payload.temperature_command_topic = primarySetpoint.name; - discoveryEntry.discovery_payload.temperature_state_template = `{{ value_json["${primarySetpoint.property}"] }}`; + discoveryEntry.discovery_payload.temperature_state_template = stateValueTemplate(primarySetpoint.property); discoveryEntry.discovery_payload.temperature_state_topic = true; } @@ -802,7 +804,7 @@ export class HomeAssistant extends Extension { if (fanMode) { discoveryEntry.discovery_payload.fan_modes = fanMode.values; discoveryEntry.discovery_payload.fan_mode_command_topic = true; - discoveryEntry.discovery_payload.fan_mode_state_template = `{{ value_json["${fanMode.property}"] }}`; + discoveryEntry.discovery_payload.fan_mode_state_template = stateValueTemplate(fanMode.property); discoveryEntry.discovery_payload.fan_mode_state_topic = true; } @@ -810,7 +812,7 @@ export class HomeAssistant extends Extension { if (swingMode) { discoveryEntry.discovery_payload.swing_modes = swingMode.values; discoveryEntry.discovery_payload.swing_mode_command_topic = true; - discoveryEntry.discovery_payload.swing_mode_state_template = `{{ value_json["${swingMode.property}"] }}`; + discoveryEntry.discovery_payload.swing_mode_state_template = stateValueTemplate(swingMode.property); discoveryEntry.discovery_payload.swing_mode_state_topic = true; } @@ -818,7 +820,7 @@ export class HomeAssistant extends Extension { if (preset) { discoveryEntry.discovery_payload.preset_modes = preset.values; discoveryEntry.discovery_payload.preset_mode_command_topic = "preset"; - discoveryEntry.discovery_payload.preset_mode_value_template = `{{ value_json["${preset.property}"] }}`; + discoveryEntry.discovery_payload.preset_mode_value_template = stateValueTemplate(preset.property); discoveryEntry.discovery_payload.preset_mode_state_topic = true; } @@ -829,10 +831,9 @@ export class HomeAssistant extends Extension { const discoveryEntry: DiscoveryEntry = { type: "number", object_id: endpointName ? `${tempCalibration.name}_${endpointName}` : `${tempCalibration.name}`, - mockProperties: [{property: tempCalibration.property, value: null}], discovery_payload: { name: endpointName ? `${tempCalibration.label} ${endpointName}` : tempCalibration.label, - value_template: `{{ value_json["${tempCalibration.property}"] }}`, + value_template: stateValueTemplate(tempCalibration.property), command_topic: true, command_topic_prefix: endpointName, command_topic_postfix: tempCalibration.property, @@ -856,10 +857,9 @@ export class HomeAssistant extends Extension { if (piHeatingDemand) { const discoveryEntry: Partial = { object_id: endpointName ? `${piHeatingDemand.name}_${endpointName}` : `${piHeatingDemand.name}`, - mockProperties: [{property: piHeatingDemand.property, value: null}], discovery_payload: { name: endpointName ? `${piHeatingDemand.label} ${endpointName}` : piHeatingDemand.label, - value_template: `{{ value_json["${piHeatingDemand.property}"] }}`, + value_template: stateValueTemplate(piHeatingDemand.property), ...(piHeatingDemand.unit && {unit_of_measurement: piHeatingDemand.unit}), icon: "mdi:radiator", }, @@ -887,10 +887,9 @@ export class HomeAssistant extends Extension { const discoveryEntry: DiscoveryEntry = { type: "sensor", object_id: endpointName ? /* v8 ignore next */ `${piCoolingDemand.name}_${endpointName}` : `${piCoolingDemand.name}`, - mockProperties: [{property: piCoolingDemand.property, value: null}], discovery_payload: { name: endpointName ? /* v8 ignore next */ `${piCoolingDemand.label} ${endpointName}` : piCoolingDemand.label, - value_template: `{{ value_json["${piCoolingDemand.property}"] }}`, + value_template: stateValueTemplate(piCoolingDemand.property), ...(piCoolingDemand.unit && {unit_of_measurement: piCoolingDemand.unit}), entity_category: "diagnostic", icon: "mdi:air-conditioner", @@ -909,10 +908,9 @@ export class HomeAssistant extends Extension { const discoveryEntry: DiscoveryEntry = { type: "sensor", object_id: endpointName ? `${localTemperature.name}_${endpointName}` : `${localTemperature.name}`, - mockProperties: [{property: localTemperature.property, value: null}], discovery_payload: { name: endpointName ? `${localTemperature.label} ${endpointName}` : localTemperature.label, - value_template: `{{ value_json["${localTemperature.property}"] }}`, + value_template: stateValueTemplate(localTemperature.property), ...(localTemperature.unit && {unit_of_measurement: localTemperature.unit}), device_class: "temperature", state_class: "measurement", @@ -927,7 +925,7 @@ export class HomeAssistant extends Extension { const humidities = allExposes.filter(isNumericExpose).filter((e) => e.name === "humidity" && e.access & ACCESS_STATE); const currentHumidity = humidities.find((e) => e.endpoint === endpointName) ?? humidities.find((e) => e.endpoint === undefined); if (currentHumidity) { - discoveryEntry.discovery_payload.current_humidity_template = `{{ value_json["${currentHumidity.property}"] }}`; + discoveryEntry.discovery_payload.current_humidity_template = stateValueTemplate(currentHumidity.property); discoveryEntry.discovery_payload.current_humidity_topic = true; } @@ -941,13 +939,12 @@ export class HomeAssistant extends Extension { type: "lock", /* v8 ignore next */ object_id: endpointName ? `lock_${endpointName}` : "lock", - mockProperties: [{property: state.property, value: null}], discovery_payload: { /* v8 ignore next */ name: endpointName ? utils.capitalize(endpointName) : null, command_topic_prefix: endpointName, command_topic: true, - value_template: `{{ value_json["${state.property}"] }}`, + value_template: stateValueTemplate(state.property), state_locked: state.value_on, state_unlocked: state.value_off, /* v8 ignore next */ @@ -975,7 +972,6 @@ export class HomeAssistant extends Extension { const discoveryEntry: DiscoveryEntry = { type: "cover", - mockProperties: [{property: state.property, value: null}], object_id: endpointName ? `cover_${endpointName}` : "cover", discovery_payload: { name: endpointName ? utils.capitalize(endpointName) : null, @@ -990,7 +986,9 @@ export class HomeAssistant extends Extension { // The movement direction is calculated (assumed) in this case. if (running) { assert(position, `Cover must have 'position' when it has 'running'`); - discoveryEntry.discovery_payload.value_template = `{% if "${featurePropertyWithoutEndpoint(running)}" in value_json and value_json["${featurePropertyWithoutEndpoint(running)}"] %} {% if value_json["${featurePropertyWithoutEndpoint(position)}"] > 0 %} closing {% else %} opening {% endif %} {% else %} stopped {% endif %}`; + const runningProperty = featurePropertyWithoutEndpoint(running); + const positionProperty = featurePropertyWithoutEndpoint(position); + discoveryEntry.discovery_payload.value_template = `{% if "${runningProperty}" in value_json and value_json["${runningProperty}"] and "${positionProperty}" in value_json %} {% if value_json["${positionProperty}"] > 0 %} closing {% else %} opening {% endif %} {% else %} stopped {% endif %}`; } // If curtains have `motor_state` or `moving` property, lookup for possible @@ -1029,7 +1027,7 @@ export class HomeAssistant extends Extension { // If curtains do not have `running`, `motor_state` or `moving` properties. if (!discoveryEntry.discovery_payload.value_template) { - discoveryEntry.discovery_payload.value_template = `{{ value_json["${featurePropertyWithoutEndpoint(state)}"] }}`; + discoveryEntry.discovery_payload.value_template = stateValueTemplate(featurePropertyWithoutEndpoint(state)); discoveryEntry.discovery_payload.state_open = "OPEN"; discoveryEntry.discovery_payload.state_closed = "CLOSE"; discoveryEntry.discovery_payload.state_stopped = "STOP"; @@ -1044,7 +1042,7 @@ export class HomeAssistant extends Extension { if (position) { discoveryEntry.discovery_payload = { ...discoveryEntry.discovery_payload, - position_template: `{{ value_json["${featurePropertyWithoutEndpoint(position)}"] }}`, + position_template: stateValueTemplate(featurePropertyWithoutEndpoint(position)), set_position_template: `{ "${getProperty(position)}": {{ position }} }`, set_position_topic: true, position_topic: true, @@ -1056,7 +1054,7 @@ export class HomeAssistant extends Extension { ...discoveryEntry.discovery_payload, tilt_command_topic: true, tilt_status_topic: true, - tilt_status_template: `{{ value_json["${featurePropertyWithoutEndpoint(tilt)}"] }}`, + tilt_status_template: stateValueTemplate(featurePropertyWithoutEndpoint(tilt)), }; } @@ -1068,7 +1066,6 @@ export class HomeAssistant extends Extension { const discoveryEntry: DiscoveryEntry = { type: "fan", object_id: "fan", - mockProperties: [{property: "fan_state", value: null}], discovery_payload: { name: null, state_topic: true, @@ -1121,31 +1118,37 @@ export class HomeAssistant extends Extension { discoveryEntry.discovery_payload.percentage_state_topic = true; discoveryEntry.discovery_payload.percentage_command_topic = "fan_mode"; - discoveryEntry.discovery_payload.percentage_value_template = `{{ {${percentValues}}[value_json["${modeEmulatedSpeed.property}"]] | default('None') }}`; + discoveryEntry.discovery_payload.percentage_value_template = stateValueTemplate( + modeEmulatedSpeed.property, + `{{ {${percentValues}}[value_json["${modeEmulatedSpeed.property}"]] | default('None') }}`, + ); discoveryEntry.discovery_payload.percentage_command_template = `{{ {${percentCommands}}[value] | default('') }}`; discoveryEntry.discovery_payload.speed_range_min = 1; discoveryEntry.discovery_payload.speed_range_max = speeds.length - 1; assert(presets.length !== 0); discoveryEntry.discovery_payload.preset_mode_state_topic = true; discoveryEntry.discovery_payload.preset_mode_command_topic = "fan_mode"; - discoveryEntry.discovery_payload.preset_mode_value_template = `{{ value_json["${modeEmulatedSpeed.property}"] if value_json["${modeEmulatedSpeed.property}"] in [${presetList}] else 'None' | default('None') }}`; + discoveryEntry.discovery_payload.preset_mode_value_template = stateValueTemplate( + modeEmulatedSpeed.property, + `{{ value_json["${modeEmulatedSpeed.property}"] if value_json["${modeEmulatedSpeed.property}"] in [${presetList}] else 'None' }}`, + ); discoveryEntry.discovery_payload.preset_modes = presets; // Emulate state based on mode - discoveryEntry.discovery_payload.state_value_template = "{{ value_json.fan_state }}"; + discoveryEntry.discovery_payload.state_value_template = stateValueTemplate("fan_state"); discoveryEntry.discovery_payload.command_topic_postfix = "fan_state"; /* v8 ignore start */ } else if (nativeSpeed) { /* v8 ignore stop */ discoveryEntry.discovery_payload.percentage_state_topic = true; discoveryEntry.discovery_payload.percentage_command_topic = "speed"; - discoveryEntry.discovery_payload.percentage_value_template = `{{ value_json["${nativeSpeed.property}"] | default('None') }}`; + discoveryEntry.discovery_payload.percentage_value_template = stateValueTemplate(nativeSpeed.property); discoveryEntry.discovery_payload.percentage_command_template = `{{ value | default('') }}`; discoveryEntry.discovery_payload.speed_range_min = nativeSpeed.value_min; discoveryEntry.discovery_payload.speed_range_max = nativeSpeed.value_max; // Speed-controlled fans generally have an onOff cluster, use that for state - discoveryEntry.discovery_payload.state_value_template = "{{ value_json.state }}"; + discoveryEntry.discovery_payload.state_value_template = stateValueTemplate("state"); discoveryEntry.discovery_payload.command_topic_postfix = "state"; } @@ -1163,14 +1166,15 @@ export class HomeAssistant extends Extension { if (firstExpose.access & ACCESS_SET) { const discoveryEntry: DiscoveryEntry = { type: "switch", - mockProperties: [{property: firstExpose.property, value: null}], object_id: endpointName ? `switch_${firstExpose.name}_${endpointName}` : `switch_${firstExpose.name}`, discovery_payload: { name: endpointName ? /* v8 ignore next */ `${firstExpose.label} ${endpointName}` : firstExpose.label, - value_template: + value_template: stateValueTemplate( + firstExpose.property, typeof firstExpose.value_on === "boolean" ? `{% if value_json["${firstExpose.property}"] %}true{% else %}false{% endif %}` - : `{{ value_json["${firstExpose.property}"] }}`, + : undefined, + ), payload_on: firstExpose.value_on.toString(), payload_off: firstExpose.value_off.toString(), command_topic: true, @@ -1185,10 +1189,9 @@ export class HomeAssistant extends Extension { const discoveryEntry: DiscoveryEntry = { type: "binary_sensor", object_id: endpointName ? `${firstExpose.name}_${endpointName}` : `${firstExpose.name}`, - mockProperties: [{property: firstExpose.property, value: null}], discovery_payload: { name: endpointName ? /* v8 ignore next */ `${firstExpose.label} ${endpointName}` : firstExpose.label, - value_template: `{{ value_json["${firstExpose.property}"] }}`, + value_template: stateValueTemplate(firstExpose.property), payload_on: firstExpose.value_on, payload_off: firstExpose.value_off, ...(BINARY_DISCOVERY_LOOKUP[firstExpose.name] || {}), @@ -1210,10 +1213,9 @@ export class HomeAssistant extends Extension { const discoveryEntry: DiscoveryEntry = { type: "number", object_id: endpointName ? `${firstExpose.name}_${endpointName}` : `${firstExpose.name}`, - mockProperties: [{property: firstExpose.property, value: null}], discovery_payload: { name: endpointName ? `${firstExpose.label} ${endpointName}` : firstExpose.label, - value_template: `{{ value_json["${firstExpose.property}"] }}`, + value_template: stateValueTemplate(firstExpose.property), command_topic: true, command_topic_prefix: endpointName, command_topic_postfix: firstExpose.property, @@ -1261,10 +1263,9 @@ export class HomeAssistant extends Extension { const discoveryEntry: DiscoveryEntry = { type: "sensor", object_id: endpointName ? `${firstExpose.name}_${endpointName}` : `${firstExpose.name}`, - mockProperties: [{property: firstExpose.property, value: null}], discovery_payload: { name: endpointName ? `${firstExpose.label} ${endpointName}` : firstExpose.label, - value_template: `{{ value_json["${firstExpose.property}"] }}`, + value_template: stateValueTemplate(firstExpose.property), enabled_by_default: !allowsSet, ...(firstExpose.unit && {unit_of_measurement: firstExpose.unit}), ...NUMERIC_DISCOVERY_LOOKUP[key], @@ -1303,7 +1304,6 @@ export class HomeAssistant extends Extension { discoveryEntries.push({ type: "event", object_id: firstExpose.property, - mockProperties: [], discovery_payload: { name: endpointName ? /* v8 ignore next */ `${firstExpose.label} ${endpointName}` : firstExpose.label, state_topic: true, @@ -1318,7 +1318,7 @@ export class HomeAssistant extends Extension { } } - const valueTemplate = firstExpose.access & ACCESS_STATE ? `{{ value_json["${firstExpose.property}"] }}` : undefined; + const valueTemplate = firstExpose.access & ACCESS_STATE ? stateValueTemplate(firstExpose.property) : undefined; /** * If enum has only one item and has SET access then expose as BUTTON entity. @@ -1327,7 +1327,6 @@ export class HomeAssistant extends Extension { discoveryEntries.push({ type: "button", object_id: firstExpose.property, - mockProperties: [{property: firstExpose.property, value: null}], discovery_payload: { name: endpointName ? /* v8 ignore next */ `${firstExpose.label} ${endpointName}` : firstExpose.label, state_topic: false, @@ -1348,7 +1347,6 @@ export class HomeAssistant extends Extension { discoveryEntries.push({ type: "select", object_id: firstExpose.property, - mockProperties: [{property: firstExpose.property, value: null}], discovery_payload: { name: endpointName ? `${firstExpose.label} ${endpointName}` : firstExpose.label, value_template: valueTemplate, @@ -1372,7 +1370,6 @@ export class HomeAssistant extends Extension { discoveryEntries.push({ type: "sensor", object_id: firstExpose.property, - mockProperties: [{property: firstExpose.property, value: null}], discovery_payload: { name: endpointName ? `${firstExpose.label} ${endpointName}` : firstExpose.label, value_template: valueTemplate, @@ -1398,7 +1395,6 @@ export class HomeAssistant extends Extension { const discoveryEntry: DiscoveryEntry = { type: "siren", object_id: endpointName ? /* v8 ignore next */ `siren_${endpointName}` : "siren", - mockProperties: [{property: warningExpose.property, value: null}], discovery_payload: { name: endpointName ? /* v8 ignore next */ utils.capitalize(endpointName) : null, command_topic: true, @@ -1450,11 +1446,10 @@ export class HomeAssistant extends Extension { discoveryEntries.push({ type: "text", object_id: firstExposeTyped.property, - mockProperties: [{property: firstExposeTyped.property, value: null}], discovery_payload: { name: endpointName ? `${firstExposeTyped.label} ${endpointName}` : firstExposeTyped.label, state_topic: firstExposeTyped.access & ACCESS_STATE, - value_template: `{{ value_json["${firstExposeTyped.property}"] }}`, + value_template: stateValueTemplate(firstExposeTyped.property), command_topic_prefix: endpointName, command_topic: true, command_topic_postfix: firstExposeTyped.property, @@ -1467,12 +1462,14 @@ export class HomeAssistant extends Extension { discoveryEntries.push({ type: "sensor", object_id: firstExposeTyped.property, - mockProperties: [{property: firstExposeTyped.property, value: null}], discovery_payload: { name: endpointName ? `${firstExposeTyped.label} ${endpointName}` : firstExposeTyped.label, // Truncate text if it's too long // https://github.com/Koenkk/zigbee2mqtt/issues/23199 - value_template: `{{ value_json["${firstExposeTyped.property}"] | default('',True) | string | truncate(254, True, '', 0) }}`, + value_template: stateValueTemplate( + firstExposeTyped.property, + `{{ value_json["${firstExposeTyped.property}"] | default('',True) | string | truncate(254, True, '', 0) }}`, + ), ...LIST_DISCOVERY_LOOKUP[firstExposeTyped.name], }, }); @@ -1573,7 +1570,10 @@ export class HomeAssistant extends Extension { } } - await this.mqtt.publish(`${data.entity.name}/${endpoint}`, stringify(payload), {}); + // Nothing to republish when this payload carries no attribute of the endpoint. + if (!utils.objectIsEmpty(payload)) { + await this.mqtt.publish(`${data.entity.name}/${endpoint}`, stringify(payload), {}); + } } } } @@ -1684,10 +1684,9 @@ export class HomeAssistant extends Extension { const config: DiscoveryEntry = { type: "sensor", object_id: "last_seen", - mockProperties: [{property: "last_seen", value: null}], discovery_payload: { name: "Last seen", - value_template: "{{ value_json.last_seen }}", + value_template: stateValueTemplate("last_seen"), icon: "mdi:clock", enabled_by_default: false, entity_category: "diagnostic", @@ -1707,7 +1706,6 @@ export class HomeAssistant extends Extension { const updateSensor: DiscoveryEntry = { type: "update", object_id: "update", - mockProperties: [{property: "update", value: {state: null}}], discovery_payload: { name: null, entity_picture: "https://github.com/Koenkk/zigbee2mqtt/raw/master/images/logo.png", @@ -1716,7 +1714,10 @@ export class HomeAssistant extends Extension { entity_category: "config", command_topic: `${settings.get().mqtt.base_topic}/bridge/request/device/ota_update/update`, payload_install: `{"id": "${entity.ieeeAddr}"}`, - value_template: `{"latest_version":"{{ value_json['update']['latest_version'] }}","installed_version":"{{ value_json['update']['installed_version'] }}","update_percentage":{{ value_json['update'].get('progress', 'null') }},"in_progress":{{ (value_json['update']['state'] == 'updating')|lower }}}`, + value_template: stateValueTemplate( + "update", + `{"latest_version":"{{ value_json['update']['latest_version'] }}","installed_version":"{{ value_json['update']['installed_version'] }}","update_percentage":{{ value_json['update'].get('progress', 'null') }},"in_progress":{{ (value_json['update'].get('state') == 'updating')|lower }}}`, + ), }, }; configs.push(updateSensor); @@ -1728,7 +1729,6 @@ export class HomeAssistant extends Extension { const sceneEntry: DiscoveryEntry = { type: "scene", object_id: `scene_${scene.id}`, - mockProperties: [], discovery_payload: { name: `${scene.name}`, state_topic: false, @@ -2042,14 +2042,6 @@ export class HomeAssistant extends Extension { } else { logger.debug(`Skipping discovery of '${topic}', already discovered`); } - - /* v8 ignore start */ - if (config.mockProperties) { - /* v8 ignore stop */ - for (const mockProperty of config.mockProperties) { - discovered.mockProperties.add(mockProperty); - } - } } for (const topic of lastDiscoveredTopics) { @@ -2211,12 +2203,6 @@ export class HomeAssistant extends Extension { } override adjustMessageBeforePublish(entity: Device | Group | Bridge, message: KeyValue): void { - for (const mockProperty of this.getDiscovered(entity).mockProperties) { - if (message[mockProperty.property] === undefined) { - message[mockProperty.property] = mockProperty.value; - } - } - // Copy hue -> h, saturation -> s to make homeassistant happy if (message.color !== undefined) { if (message.color.hue !== undefined) { @@ -2263,7 +2249,6 @@ export class HomeAssistant extends Extension { const config: DiscoveryEntry = { type: "device_automation", object_id: `${key}_${value}`, - mockProperties: [], discovery_payload: { automation_type: "trigger", type: key, @@ -2299,7 +2284,6 @@ export class HomeAssistant extends Extension { { type: "binary_sensor", object_id: "connection_state", - mockProperties: [], discovery_payload: { name: "Connection state", device_class: "connectivity", @@ -2315,7 +2299,6 @@ export class HomeAssistant extends Extension { { type: "binary_sensor", object_id: "restart_required", - mockProperties: [], discovery_payload: { name: "Restart required", device_class: "problem", @@ -2333,7 +2316,6 @@ export class HomeAssistant extends Extension { { type: "button", object_id: "restart", - mockProperties: [], discovery_payload: { name: "Restart", device_class: "restart", @@ -2347,7 +2329,6 @@ export class HomeAssistant extends Extension { { type: "select", object_id: "log_level", - mockProperties: [], discovery_payload: { name: "Log level", entity_category: "config", @@ -2363,7 +2344,6 @@ export class HomeAssistant extends Extension { { type: "sensor", object_id: "version", - mockProperties: [], discovery_payload: { name: "Version", icon: "mdi:zigbee", @@ -2376,7 +2356,6 @@ export class HomeAssistant extends Extension { { type: "sensor", object_id: "coordinator_version", - mockProperties: [], discovery_payload: { name: "Coordinator version", icon: "mdi:chip", @@ -2390,7 +2369,6 @@ export class HomeAssistant extends Extension { { type: "sensor", object_id: "network_map", - mockProperties: [], discovery_payload: { name: "Network map", entity_category: "diagnostic", @@ -2407,7 +2385,6 @@ export class HomeAssistant extends Extension { { type: "switch", object_id: "permit_join", - mockProperties: [], discovery_payload: { name: "Permit join", icon: "mdi:human-greeting-proximity", diff --git a/test/extensions/frontend.test.ts b/test/extensions/frontend.test.ts index e21673d66..f7eca5338 100644 --- a/test/extensions/frontend.test.ts +++ b/test/extensions/frontend.test.ts @@ -247,13 +247,8 @@ describe("Extension: Frontend", () => { "zigbee2mqtt/bulb_color", stringify({ state: "ON", - effect: null, - effect_color: null, - effect_speed: null, - identify: null, - power_on_behavior: null, linkquality: 20, - update: {state: null, installed_version: -1, latest_version: -1}, + update: {installed_version: -1, latest_version: -1}, }), {retain: false, qos: 0}, ); @@ -279,13 +274,8 @@ describe("Extension: Frontend", () => { topic: "bulb_color", payload: { state: "ON", - power_on_behavior: null, - effect: null, - effect_color: null, - effect_speed: null, - identify: null, linkquality: 20, - update: {state: null, installed_version: -1, latest_version: -1}, + update: {installed_version: -1, latest_version: -1}, }, }), ); @@ -309,13 +299,8 @@ describe("Extension: Frontend", () => { payload: { state: "ON", brightness: 90, - power_on_behavior: null, - effect: null, - effect_color: null, - effect_speed: null, - identify: null, linkquality: 20, - update: {state: null, installed_version: -1, latest_version: -1}, + update: {installed_version: -1, latest_version: -1}, }, }), ); diff --git a/test/extensions/homeassistant.test.ts b/test/extensions/homeassistant.test.ts index 8d185f5b7..a96a20a3d 100644 --- a/test/extensions/homeassistant.test.ts +++ b/test/extensions/homeassistant.test.ts @@ -59,6 +59,17 @@ describe("Extension: HomeAssistant", () => { return controller.zigbee.resolveEntity(zhDeviceOrGroup)!; }; + const createDevice = (exposes: zhc.Expose[], definition: object = {}): Device => + ({ + definition, + isDevice: (): boolean => true, + isGroup: (): boolean => false, + endpoint: () => undefined, + options: {}, + exposes: (): zhc.Expose[] => exposes, + zh: {endpoints: []}, + }) as Device; + beforeAll(async () => { const {getZigbee2MQTTVersion} = await import("../../lib/util/utils.js"); z2m_version = (await getZigbee2MQTTVersion()).version; @@ -128,6 +139,115 @@ describe("Extension: HomeAssistant", () => { expect(duplicated).toStrictEqual([]); }); + /** + * Home Assistant logs `Template variable warning: 'dict object' has no attribute ''` + * when a `value_template` reads a property which isn't in the payload, reported since 2021 in + * #30086, #20949, #20494, #16571, #24386, #15116, #12825, #11848, #7361, #7139, #7073 and #6987. + * Guards must check presence, not truthiness: `0`, `false` and `null` are falsy in Jinja, so + * `{% if value_json["x"] %}` and `default('', True)` would drop a legitimate `0`. + */ + describe("value_json guards", () => { + const findUnguardedReads = (template: string): string[] => { + const reads = /value_json(?:\[["']([^"'\]]+)["']\]|\.([A-Za-z_]\w*))/g; + const unguarded = new Set(); + + for (const match of template.matchAll(reads)) { + const property = match[1] ?? match[2]; + // `default(...)` also guards: Jinja's filter type-checks the `Undefined` instead of + // stringifying it, so it doesn't log either. Only counts for the expression it's in, + // so a second unguarded read elsewhere in the same template is still reported. + const expression = template.slice(template.lastIndexOf("{", match.index), template.indexOf("}", match.index)); + + if (!template.includes(`"${property}" in value_json`) && !expression.includes("default(")) { + unguarded.add(property); + } + } + + return [...unguarded]; + }; + + // One walk over every zigbee-herdsman-converters definition, shared by the checks below. + type GeneratedConfig = {type: string; object_id: string; discovery_payload: KeyValueAny}; + let generatedConfigs: GeneratedConfig[] | undefined; + + const getGeneratedConfigs = async (): Promise => { + if (!generatedConfigs) { + settings.set(["advanced", "last_seen"], "ISO_8601"); + generatedConfigs = []; + + for (const baseDefinition of await getZhcBaseDefinitions()) { + const d = zhc.prepareDefinition(baseDefinition); + const exposes = typeof d.exposes === "function" ? d.exposes({isDummyDevice: true}, {}) : d.exposes; + // @ts-expect-error private + generatedConfigs.push(...(extension.getConfigs(createDevice(exposes, d)) as GeneratedConfig[])); + } + } + + return generatedConfigs; + }; + + const templatesOf = function* (config: GeneratedConfig): Generator<[string, string]> { + for (const [key, value] of Object.entries(config.discovery_payload)) { + if (key.endsWith("_template") && typeof value === "string") yield [key, value]; + } + }; + + it("Should guard every generated state template against a missing property", async () => { + const offenders = new Set(); + + for (const config of await getGeneratedConfigs()) { + for (const [key, value] of templatesOf(config)) { + for (const property of findUnguardedReads(value)) { + // Carried on the expose by zigbee-herdsman-converters (`lib/zosung.js`), not built here. + if (property === "learned_ir_timings") continue; + + offenders.add(`${config.type}/${config.object_id} ${key} reads '${property}': ${value}`); + } + } + } + + expect([...offenders]).toStrictEqual([]); + }); + + it("Should guard the number entity built from a numeric expose with SET access", () => { + const exposes = [new zhc.Numeric("effect_speed", zhc.access.ALL).withLabel("Effect speed").withValueMin(0).withValueMax(255)]; + // @ts-expect-error private + const configs = extension.getConfigs(createDevice(exposes)); + const number = configs.find((config: {type: string}) => config.type === "number"); + + assert(number); + expect(number.discovery_payload.value_template).toStrictEqual( + '{% if "effect_speed" in value_json %}{{ value_json["effect_speed"] }}{% endif %}', + ); + }); + + it("Should not guard with a form that drops a legitimate 0, false or null", async () => { + const falsySensitive = new Set(); + + for (const config of await getGeneratedConfigs()) { + for (const [key, value] of templatesOf(config)) { + // `default(x, True)` is falsy-sensitive, except on the truncating + // text/list/composite template, which only carries strings. + // https://github.com/Koenkk/zigbee2mqtt/issues/23199 + if (/default\([^)]*,\s*True\)/.test(value) && !value.includes("truncate(")) { + falsySensitive.add(`${config.type}/${config.object_id} ${key}: ${value}`); + } + + // A bare `{% if value_json["x"] %}` tests truthiness; a comparison such as + // `{% if value_json["position"] == 0 %}` doesn't, hence the closing `%}`. + // Intended for a binary expose, but only once presence is established. + for (const match of value.matchAll(/\{%-?\s*if\s+value_json\["([^"\]]+)"\]\s*-?%\}/g)) { + if (!value.includes(`"${match[1]}" in value_json`)) { + falsySensitive.add(`${config.type}/${config.object_id} ${key}: ${value}`); + } + } + } + } + + expect([...falsySensitive]).toStrictEqual([]); + }); + }); + it("Should mark thermostat configuration toggles as config entities", () => { const switchExposes = [ new zhc.Switch().withLabel("Auto lock").withState("auto_lock", false, "Enable/disable auto lock", zhc.access.STATE_SET, "AUTO", "MANUAL"), @@ -248,17 +368,6 @@ describe("Extension: HomeAssistant", () => { }); it("Should apply expose-level Home Assistant discovery metadata", () => { - const createDevice = (exposes: zhc.Expose[]): Device => - ({ - definition: {}, - isDevice: (): boolean => true, - isGroup: (): boolean => false, - endpoint: () => undefined, - options: {}, - exposes: (): zhc.Expose[] => exposes, - zh: {endpoints: []}, - }) as Device; - const voltageExpose = new zhc.Numeric("voltage", zhc.access.STATE).withUnit("V"); Object.assign(voltageExpose, { homeassistant: { @@ -282,17 +391,6 @@ describe("Extension: HomeAssistant", () => { }); it("Should set discovery name to null when expose specifies homeassistant name null", () => { - const createDevice = (exposes: zhc.Expose[]): Device => - ({ - definition: {}, - isDevice: (): boolean => true, - isGroup: (): boolean => false, - endpoint: () => undefined, - options: {}, - exposes: (): zhc.Expose[] => exposes, - zh: {endpoints: []}, - }) as Device; - const contactExpose = new zhc.Binary("contact", zhc.access.STATE, false, true).withHomeAssistant({name: null}); // @ts-expect-error private @@ -411,7 +509,7 @@ describe("Extension: HomeAssistant", () => { unique_id: "9_switch_zigbee2mqtt", group: ["0x0017880104e45542_switch_right_zigbee2mqtt"], origin: origin, - value_template: '{{ value_json["state"] }}', + value_template: '{% if "state" in value_json %}{{ value_json["state"] }}{% endif %}', }; expect(mockMQTTPublishAsync).toHaveBeenCalledWith( @@ -424,7 +522,7 @@ describe("Extension: HomeAssistant", () => { unit_of_measurement: "°C", device_class: "temperature", state_class: "measurement", - value_template: '{{ value_json["temperature"] }}', + value_template: '{% if "temperature" in value_json %}{{ value_json["temperature"] }}{% endif %}', state_topic: "zigbee2mqtt/weather_sensor", object_id: "weather_sensor_temperature", default_entity_id: "sensor.weather_sensor_temperature", @@ -451,7 +549,7 @@ describe("Extension: HomeAssistant", () => { unit_of_measurement: "%", device_class: "humidity", state_class: "measurement", - value_template: '{{ value_json["humidity"] }}', + value_template: '{% if "humidity" in value_json %}{{ value_json["humidity"] }}{% endif %}', state_topic: "zigbee2mqtt/weather_sensor", object_id: "weather_sensor_humidity", default_entity_id: "sensor.weather_sensor_humidity", @@ -478,7 +576,7 @@ describe("Extension: HomeAssistant", () => { unit_of_measurement: "hPa", device_class: "atmospheric_pressure", state_class: "measurement", - value_template: '{{ value_json["pressure"] }}', + value_template: '{% if "pressure" in value_json %}{{ value_json["pressure"] }}{% endif %}', state_topic: "zigbee2mqtt/weather_sensor", object_id: "weather_sensor_pressure", default_entity_id: "sensor.weather_sensor_pressure", @@ -505,7 +603,7 @@ describe("Extension: HomeAssistant", () => { unit_of_measurement: "%", device_class: "battery", state_class: "measurement", - value_template: '{{ value_json["battery"] }}', + value_template: '{% if "battery" in value_json %}{{ value_json["battery"] }}{% endif %}', state_topic: "zigbee2mqtt/weather_sensor", object_id: "weather_sensor_battery", default_entity_id: "sensor.weather_sensor_battery", @@ -535,7 +633,7 @@ describe("Extension: HomeAssistant", () => { entity_category: "diagnostic", unit_of_measurement: "lqi", state_class: "measurement", - value_template: '{{ value_json["linkquality"] }}', + value_template: '{% if "linkquality" in value_json %}{{ value_json["linkquality"] }}{% endif %}', state_topic: "zigbee2mqtt/weather_sensor", name: "Linkquality", object_id: "weather_sensor_linkquality", @@ -577,7 +675,7 @@ describe("Extension: HomeAssistant", () => { default_entity_id: "switch.wall_switch_double_left", unique_id: "0x0017880104e45542_switch_left_zigbee2mqtt", origin: origin, - value_template: '{{ value_json["state_left"] }}', + value_template: '{% if "state_left" in value_json %}{{ value_json["state_left"] }}{% endif %}', }; expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/switch/0x0017880104e45542/switch_left/config", stringify(payload), { @@ -604,7 +702,7 @@ describe("Extension: HomeAssistant", () => { default_entity_id: "switch.wall_switch_double_right", unique_id: "0x0017880104e45542_switch_right_zigbee2mqtt", origin: origin, - value_template: '{{ value_json["state_right"] }}', + value_template: '{% if "state_right" in value_json %}{{ value_json["state_right"] }}{% endif %}', }; expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/switch/0x0017880104e45542/switch_right/config", stringify(payload), { @@ -717,7 +815,7 @@ describe("Extension: HomeAssistant", () => { unit_of_measurement: "%", device_class: "humidity", state_class: "measurement", - value_template: '{{ value_json["humidity"] }}', + value_template: '{% if "humidity" in value_json %}{{ value_json["humidity"] }}{% endif %}', state_topic: "zigbee2mqtt/weather_sensor", object_id: "weather_sensor_humidity", default_entity_id: "sensor.weather_sensor_humidity", @@ -789,7 +887,7 @@ describe("Extension: HomeAssistant", () => { device_class: "temperature", state_class: "measurement", enabled_by_default: true, - value_template: '{{ value_json["temperature"] }}', + value_template: '{% if "temperature" in value_json %}{{ value_json["temperature"] }}{% endif %}', state_topic: "zigbee2mqtt/weather_sensor", object_id: "weather_sensor_temperature", default_entity_id: "sensor.weather_sensor_temperature", @@ -815,7 +913,7 @@ describe("Extension: HomeAssistant", () => { unit_of_measurement: "%", device_class: "humidity", state_class: "measurement", - value_template: '{{ value_json["humidity"] }}', + value_template: '{% if "humidity" in value_json %}{{ value_json["humidity"] }}{% endif %}', state_topic: "zigbee2mqtt/weather_sensor", object_id: "weather_sensor_humidity", default_entity_id: "sensor.weather_sensor_humidity", @@ -842,7 +940,7 @@ describe("Extension: HomeAssistant", () => { unit_of_measurement: "hPa", device_class: "atmospheric_pressure", state_class: "measurement", - value_template: '{{ value_json["pressure"] }}', + value_template: '{% if "pressure" in value_json %}{{ value_json["pressure"] }}{% endif %}', state_topic: "zigbee2mqtt/weather_sensor", enabled_by_default: true, object_id: "weather_sensor_pressure", @@ -900,7 +998,7 @@ describe("Extension: HomeAssistant", () => { unit_of_measurement: "°C", device_class: "temperature", state_class: "measurement", - value_template: '{{ value_json["temperature"] }}', + value_template: '{% if "temperature" in value_json %}{{ value_json["temperature"] }}{% endif %}', state_topic: "zigbee2mqtt/weather_sensor", enabled_by_default: true, object_id: "weather_sensor_temperature", @@ -930,7 +1028,7 @@ describe("Extension: HomeAssistant", () => { unit_of_measurement: "%", device_class: "humidity", state_class: "measurement", - value_template: '{{ value_json["humidity"] }}', + value_template: '{% if "humidity" in value_json %}{{ value_json["humidity"] }}{% endif %}', state_topic: "zigbee2mqtt/weather_sensor", enabled_by_default: true, device: { @@ -973,7 +1071,7 @@ describe("Extension: HomeAssistant", () => { unit_of_measurement: "°C", device_class: "temperature", state_class: "measurement", - value_template: '{{ value_json["temperature"] }}', + value_template: '{% if "temperature" in value_json %}{{ value_json["temperature"] }}{% endif %}', state_topic: "zigbee2mqtt/weather_sensor", object_id: "weather_sensor_temperature", default_entity_id: "sensor.weather_sensor_temperature", @@ -1000,7 +1098,7 @@ describe("Extension: HomeAssistant", () => { unit_of_measurement: "%", device_class: "humidity", state_class: "measurement", - value_template: '{{ value_json["humidity"] }}', + value_template: '{% if "humidity" in value_json %}{{ value_json["humidity"] }}{% endif %}', state_topic: "zigbee2mqtt/weather_sensor", object_id: "weather_sensor_humidity", default_entity_id: "sensor.weather_sensor_humidity", @@ -1063,7 +1161,7 @@ describe("Extension: HomeAssistant", () => { default_entity_id: "light.my_switch", unique_id: "0x0017880104e45541_light_zigbee2mqtt", origin: origin, - value_template: '{{ value_json["state"] }}', + value_template: '{% if "state" in value_json %}{{ value_json["state"] }}{% endif %}', }; expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/light/0x0017880104e45541/light/config", stringify(payload), { @@ -1105,15 +1203,17 @@ describe("Extension: HomeAssistant", () => { it("Should discover devices with fan", () => { const payload = { state_topic: "zigbee2mqtt/fan", - state_value_template: "{{ value_json.fan_state }}", + state_value_template: '{% if "fan_state" in value_json %}{{ value_json["fan_state"] }}{% endif %}', command_topic: "zigbee2mqtt/fan/set/fan_state", percentage_state_topic: "zigbee2mqtt/fan", percentage_command_topic: "zigbee2mqtt/fan/set/fan_mode", - percentage_value_template: "{{ {'off':0, 'low':1, 'medium':2, 'high':3, 'on':4}[value_json[\"fan_mode\"]] | default('None') }}", + percentage_value_template: + "{% if \"fan_mode\" in value_json %}{{ {'off':0, 'low':1, 'medium':2, 'high':3, 'on':4}[value_json[\"fan_mode\"]] | default('None') }}{% endif %}", percentage_command_template: "{{ {0:'off', 1:'low', 2:'medium', 3:'high', 4:'on'}[value] | default('') }}", preset_mode_state_topic: "zigbee2mqtt/fan", preset_mode_command_topic: "zigbee2mqtt/fan/set/fan_mode", - preset_mode_value_template: "{{ value_json[\"fan_mode\"] if value_json[\"fan_mode\"] in ['smart'] else 'None' | default('None') }}", + preset_mode_value_template: + '{% if "fan_mode" in value_json %}{{ value_json["fan_mode"] if value_json["fan_mode"] in [\'smart\'] else \'None\' }}{% endif %}', preset_modes: ["smart"], speed_range_min: 1, speed_range_max: 4, @@ -1181,11 +1281,11 @@ describe("Extension: HomeAssistant", () => { it("Should discover devices with speed-controlled fan", () => { const payload = { state_topic: "zigbee2mqtt/fanbee", - state_value_template: "{{ value_json.state }}", + state_value_template: '{% if "state" in value_json %}{{ value_json["state"] }}{% endif %}', command_topic: "zigbee2mqtt/fanbee/set/state", percentage_state_topic: "zigbee2mqtt/fanbee", percentage_command_topic: "zigbee2mqtt/fanbee/set/speed", - percentage_value_template: "{{ value_json[\"speed\"] | default('None') }}", + percentage_value_template: '{% if "speed" in value_json %}{{ value_json["speed"] }}{% endif %}', percentage_command_template: "{{ value | default('') }}", speed_range_min: 1, speed_range_max: 254, @@ -1218,7 +1318,7 @@ describe("Extension: HomeAssistant", () => { it("Should discover thermostat devices", () => { const payload = { action_template: - "{% set values = {None:None,'idle':'idle','heat':'heating','cool':'cooling','fan_only':'fan'} %}{{ values[value_json[\"running_state\"]] }}", + "{% if \"running_state\" in value_json %}{% set values = {None:None,'idle':'idle','heat':'heating','cool':'cooling','fan_only':'fan'} %}{{ values[value_json[\"running_state\"]] }}{% endif %}", action_topic: "zigbee2mqtt/TS0601_thermostat", availability: [ { @@ -1226,7 +1326,7 @@ describe("Extension: HomeAssistant", () => { value_template: "{{ value_json.state }}", }, ], - current_temperature_template: '{{ value_json["local_temperature"] }}', + current_temperature_template: '{% if "local_temperature" in value_json %}{{ value_json["local_temperature"] }}{% endif %}', current_temperature_topic: "zigbee2mqtt/TS0601_thermostat", device: { identifiers: ["zigbee2mqtt_0x0017882104a44559"], @@ -1238,18 +1338,18 @@ describe("Extension: HomeAssistant", () => { }, preset_mode_command_topic: "zigbee2mqtt/TS0601_thermostat/set/preset", preset_modes: ["schedule", "manual", "boost", "complex", "comfort", "eco", "away"], - preset_mode_value_template: '{{ value_json["preset"] }}', + preset_mode_value_template: '{% if "preset" in value_json %}{{ value_json["preset"] }}{% endif %}', preset_mode_state_topic: "zigbee2mqtt/TS0601_thermostat", max_temp: "35", min_temp: "5", mode_command_topic: "zigbee2mqtt/TS0601_thermostat/set/system_mode", - mode_state_template: '{{ value_json["system_mode"] }}', + mode_state_template: '{% if "system_mode" in value_json %}{{ value_json["system_mode"] }}{% endif %}', mode_state_topic: "zigbee2mqtt/TS0601_thermostat", modes: ["heat", "auto", "off"], name: null, temp_step: 0.5, temperature_command_topic: "zigbee2mqtt/TS0601_thermostat/set/current_heating_setpoint", - temperature_state_template: '{{ value_json["current_heating_setpoint"] }}', + temperature_state_template: '{% if "current_heating_setpoint" in value_json %}{{ value_json["current_heating_setpoint"] }}{% endif %}', temperature_state_topic: "zigbee2mqtt/TS0601_thermostat", temperature_unit: "C", object_id: "ts0601_thermostat", @@ -1289,7 +1389,7 @@ describe("Extension: HomeAssistant", () => { state_topic: "zigbee2mqtt/thermostat", unique_id: "0x0017880104e45550_pi_heating_demand_zigbee2mqtt", unit_of_measurement: "%", - value_template: '{{ value_json["pi_heating_demand"] }}', + value_template: '{% if "pi_heating_demand" in value_json %}{{ value_json["pi_heating_demand"] }}{% endif %}', }; expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/sensor/0x0017880104e45550/pi_heating_demand/config", stringify(payload), { @@ -1326,7 +1426,7 @@ describe("Extension: HomeAssistant", () => { state_topic: "zigbee2mqtt/bosch_radiator", unique_id: "0x18fc2600000d7ae2_pi_heating_demand_zigbee2mqtt", unit_of_measurement: "%", - value_template: '{{ value_json["pi_heating_demand"] }}', + value_template: '{% if "pi_heating_demand" in value_json %}{{ value_json["pi_heating_demand"] }}{% endif %}', }; expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/number/0x18fc2600000d7ae2/pi_heating_demand/config", stringify(payload), { @@ -1338,10 +1438,10 @@ describe("Extension: HomeAssistant", () => { it("Should discover Bosch BTH-RA with a compatibility mapping", () => { const payload = { action_template: - "{% set values = {None:None,'idle':'idle','heat':'heating','cool':'cooling','fan_only':'fan'} %}{{ values[value_json[\"running_state\"]] }}", + "{% if \"running_state\" in value_json %}{% set values = {None:None,'idle':'idle','heat':'heating','cool':'cooling','fan_only':'fan'} %}{{ values[value_json[\"running_state\"]] }}{% endif %}", action_topic: "zigbee2mqtt/bosch_radiator", availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}], - current_temperature_template: '{{ value_json["local_temperature"] }}', + current_temperature_template: '{% if "local_temperature" in value_json %}{{ value_json["local_temperature"] }}{% endif %}', current_temperature_topic: "zigbee2mqtt/bosch_radiator", device: { identifiers: ["zigbee2mqtt_0x18fc2600000d7ae2"], @@ -1366,7 +1466,7 @@ describe("Extension: HomeAssistant", () => { origin: origin, temp_step: 0.5, temperature_command_topic: "zigbee2mqtt/bosch_radiator/set/occupied_heating_setpoint", - temperature_state_template: '{{ value_json["occupied_heating_setpoint"] }}', + temperature_state_template: '{% if "occupied_heating_setpoint" in value_json %}{{ value_json["occupied_heating_setpoint"] }}{% endif %}', temperature_state_topic: "zigbee2mqtt/bosch_radiator", temperature_unit: "C", unique_id: "0x18fc2600000d7ae2_climate_zigbee2mqtt", @@ -1404,7 +1504,7 @@ describe("Extension: HomeAssistant", () => { state_topic: "zigbee2mqtt/0x0017880104e45518", unique_id: "0x0017880104e45518_analog_in_temperature_1_zigbee2mqtt", unit_of_measurement: "°C", - value_template: '{{ value_json["analog_in_temperature_1"] }}', + value_template: '{% if "analog_in_temperature_1" in value_json %}{{ value_json["analog_in_temperature_1"] }}{% endif %}', }; expect(mockMQTTPublishAsync).toHaveBeenCalledWith( @@ -1440,7 +1540,7 @@ describe("Extension: HomeAssistant", () => { origin: origin, state_topic: "zigbee2mqtt/0x0017880104e45518", unique_id: "0x0017880104e45518_analog_output_2_zigbee2mqtt", - value_template: '{{ value_json["analog_output_2"] }}', + value_template: '{% if "analog_output_2" in value_json %}{{ value_json["analog_output_2"] }}{% endif %}', }; expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/number/0x0017880104e45518/analog_output_2/config", stringify(payload), { @@ -1482,10 +1582,10 @@ describe("Extension: HomeAssistant", () => { const payload = { action_template: - "{% set values = {None:None,'idle':'idle','heat':'heating','cool':'cooling','fan_only':'fan'} %}{{ values[value_json[\"running_state\"]] }}", + "{% if \"running_state\" in value_json %}{% set values = {None:None,'idle':'idle','heat':'heating','cool':'cooling','fan_only':'fan'} %}{{ values[value_json[\"running_state\"]] }}{% endif %}", action_topic: "zigbee2mqtt/bosch_radiator", availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}], - current_temperature_template: '{{ value_json["local_temperature"] }}', + current_temperature_template: '{% if "local_temperature" in value_json %}{{ value_json["local_temperature"] }}{% endif %}', current_temperature_topic: "zigbee2mqtt/bosch_radiator", device: { identifiers: ["zigbee2mqtt_0x18fc2600000d7ae2"], @@ -1499,7 +1599,7 @@ describe("Extension: HomeAssistant", () => { max_temp: "30", min_temp: "5", mode_command_topic: "zigbee2mqtt/bosch_radiator/set/system_mode", - mode_state_template: '{{ value_json["system_mode"] }}', + mode_state_template: '{% if "system_mode" in value_json %}{{ value_json["system_mode"] }}{% endif %}', mode_state_topic: "zigbee2mqtt/bosch_radiator", modes: ["heat"], name: null, @@ -1508,7 +1608,7 @@ describe("Extension: HomeAssistant", () => { origin: origin, temp_step: 0.5, temperature_command_topic: "zigbee2mqtt/bosch_radiator/set/occupied_heating_setpoint", - temperature_state_template: '{{ value_json["occupied_heating_setpoint"] }}', + temperature_state_template: '{% if "occupied_heating_setpoint" in value_json %}{{ value_json["occupied_heating_setpoint"] }}{% endif %}', temperature_state_topic: "zigbee2mqtt/bosch_radiator", temperature_unit: "C", unique_id: "0x18fc2600000d7ae2_climate_zigbee2mqtt", @@ -1550,12 +1650,12 @@ describe("Extension: HomeAssistant", () => { it("Should discover Bosch BTH-RM230Z with a current_humidity attribute", () => { const payload = { action_template: - "{% set values = {None:None,'idle':'idle','heat':'heating','cool':'cooling','fan_only':'fan'} %}{{ values[value_json[\"running_state\"]] }}", + "{% if \"running_state\" in value_json %}{% set values = {None:None,'idle':'idle','heat':'heating','cool':'cooling','fan_only':'fan'} %}{{ values[value_json[\"running_state\"]] }}{% endif %}", action_topic: "zigbee2mqtt/bosch_rm230z", availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}], - current_humidity_template: '{{ value_json["humidity"] }}', + current_humidity_template: '{% if "humidity" in value_json %}{{ value_json["humidity"] }}{% endif %}', current_humidity_topic: "zigbee2mqtt/bosch_rm230z", - current_temperature_template: '{{ value_json["local_temperature"] }}', + current_temperature_template: '{% if "local_temperature" in value_json %}{{ value_json["local_temperature"] }}{% endif %}', current_temperature_topic: "zigbee2mqtt/bosch_rm230z", default_entity_id: "climate.bosch_rm230z", device: { @@ -1580,10 +1680,12 @@ describe("Extension: HomeAssistant", () => { origin, temp_step: 0.5, temperature_high_command_topic: "zigbee2mqtt/bosch_rm230z/set/occupied_cooling_setpoint", - temperature_high_state_template: '{{ value_json["occupied_cooling_setpoint"] }}', + temperature_high_state_template: + '{% if "occupied_cooling_setpoint" in value_json %}{{ value_json["occupied_cooling_setpoint"] }}{% endif %}', temperature_high_state_topic: "zigbee2mqtt/bosch_rm230z", temperature_low_command_topic: "zigbee2mqtt/bosch_rm230z/set/occupied_heating_setpoint", - temperature_low_state_template: '{{ value_json["occupied_heating_setpoint"] }}', + temperature_low_state_template: + '{% if "occupied_heating_setpoint" in value_json %}{{ value_json["occupied_heating_setpoint"] }}{% endif %}', temperature_low_state_topic: "zigbee2mqtt/bosch_rm230z", temperature_unit: "C", unique_id: "0x18fc2600000d7ae3_climate_zigbee2mqtt", @@ -1614,7 +1716,7 @@ describe("Extension: HomeAssistant", () => { state_topic: "zigbee2mqtt/bosch_rm230z", unique_id: "0x18fc2600000d7ae3_local_temperature_zigbee2mqtt", unit_of_measurement: "°C", - value_template: '{{ value_json["local_temperature"] }}', + value_template: '{% if "local_temperature" in value_json %}{{ value_json["local_temperature"] }}{% endif %}', }; expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/sensor/0x18fc2600000d7ae3/local_temperature/config", stringify(payload), { @@ -1669,7 +1771,7 @@ describe("Extension: HomeAssistant", () => { expect(climate).toBeDefined(); expect(climate!.discovery_payload).toMatchObject({ temperature_command_topic: "occupied_cooling_setpoint", - temperature_state_template: '{{ value_json["occupied_cooling_setpoint"] }}', + temperature_state_template: '{% if "occupied_cooling_setpoint" in value_json %}{{ value_json["occupied_cooling_setpoint"] }}{% endif %}', temperature_state_topic: true, min_temp: "16", max_temp: "32", @@ -1687,9 +1789,9 @@ describe("Extension: HomeAssistant", () => { position_topic: "zigbee2mqtt/smart vent", set_position_topic: "zigbee2mqtt/smart vent/set", set_position_template: '{ "position": {{ position }} }', - position_template: '{{ value_json["position"] }}', + position_template: '{% if "position" in value_json %}{{ value_json["position"] }}{% endif %}', state_topic: "zigbee2mqtt/smart vent", - value_template: '{{ value_json["state"] }}', + value_template: '{% if "state" in value_json %}{{ value_json["state"] }}{% endif %}', state_open: "OPEN", state_closed: "CLOSE", state_stopped: "STOP", @@ -1726,7 +1828,7 @@ describe("Extension: HomeAssistant", () => { via_device: "zigbee2mqtt_bridge_0x00124b00120144ae", }, name: "L6", - position_template: '{{ value_json["position"] }}', + position_template: '{% if "position" in value_json %}{{ value_json["position"] }}{% endif %}', position_topic: "zigbee2mqtt/zigfred_plus/l6", set_position_template: '{ "position_l6": {{ position }} }', set_position_topic: "zigbee2mqtt/zigfred_plus/l6/set", @@ -1735,13 +1837,13 @@ describe("Extension: HomeAssistant", () => { state_open: "OPEN", state_topic: "zigbee2mqtt/zigfred_plus/l6", tilt_command_topic: "zigbee2mqtt/zigfred_plus/l6/set/tilt", - tilt_status_template: '{{ value_json["tilt"] }}', + tilt_status_template: '{% if "tilt" in value_json %}{{ value_json["tilt"] }}{% endif %}', tilt_status_topic: "zigbee2mqtt/zigfred_plus/l6", object_id: "zigfred_plus_l6", default_entity_id: "cover.zigfred_plus_l6", unique_id: "0xf4ce368a38be56a1_cover_l6_zigbee2mqtt", origin: origin, - value_template: '{{ value_json["state"] }}', + value_template: '{% if "state" in value_json %}{{ value_json["state"] }}{% endif %}', }; expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/cover/0xf4ce368a38be56a1/cover_l6/config", stringify(payload), { @@ -1773,7 +1875,7 @@ describe("Extension: HomeAssistant", () => { object_id: "0xa4c138018cf95021_left", default_entity_id: "cover.0xa4c138018cf95021_left", origin: origin, - position_template: '{{ value_json["position"] }}', + position_template: '{% if "position" in value_json %}{{ value_json["position"] }}{% endif %}', position_topic: "zigbee2mqtt/0xa4c138018cf95021/left", set_position_template: '{ "position_left": {{ position }} }', set_position_topic: "zigbee2mqtt/0xa4c138018cf95021/left/set", @@ -1806,7 +1908,7 @@ describe("Extension: HomeAssistant", () => { object_id: "0xa4c138018cf95021_right", default_entity_id: "cover.0xa4c138018cf95021_right", origin: origin, - position_template: '{{ value_json["position"] }}', + position_template: '{% if "position" in value_json %}{{ value_json["position"] }}{% endif %}', position_topic: "zigbee2mqtt/0xa4c138018cf95021/right", set_position_template: '{ "position_right": {{ position }} }', set_position_topic: "zigbee2mqtt/0xa4c138018cf95021/right/set", @@ -1958,7 +2060,7 @@ describe("Extension: HomeAssistant", () => { unit_of_measurement: "°C", device_class: "temperature", state_class: "measurement", - value_template: '{{ value_json["temperature"] }}', + value_template: '{% if "temperature" in value_json %}{{ value_json["temperature"] }}{% endif %}', state_topic: "zigbee2mqtt/weather_sensor", enabled_by_default: true, object_id: "weather_sensor_temperature", @@ -2011,7 +2113,8 @@ describe("Extension: HomeAssistant", () => { ); }); - it("Should set missing values to null", async () => { + it("Should not add missing values to the payload", async () => { + // Missing properties are guarded in the discovery value templates instead. // https://github.com/Koenkk/zigbee2mqtt/issues/6987 const device = devices.WSDCGQ11LM; const data = {measuredValue: -85}; @@ -2027,11 +2130,7 @@ describe("Extension: HomeAssistant", () => { await mockZHEvents.message(payload); await flushPromises(); expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1); - expect(mockMQTTPublishAsync).toHaveBeenCalledWith( - "zigbee2mqtt/weather_sensor", - stringify({battery: null, humidity: null, linkquality: null, pressure: null, temperature: -0.85, voltage: null}), - {retain: false, qos: 1}, - ); + expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/weather_sensor", stringify({temperature: -0.85}), {retain: false, qos: 1}); }); it("Should copy hue/saturtion to h/s if present", async () => { @@ -2047,14 +2146,7 @@ describe("Extension: HomeAssistant", () => { stringify({ color: {hue: 0, saturation: 100, h: 0, s: 100}, color_mode: "hs", - effect: null, - effect_color: null, - effect_speed: null, - identify: null, - linkquality: null, - state: null, - power_on_behavior: null, - update: {state: null, installed_version: -1, latest_version: -1}, + update: {installed_version: -1, latest_version: -1}, }), {retain: false, qos: 0}, ); @@ -2073,14 +2165,7 @@ describe("Extension: HomeAssistant", () => { stringify({ color: {x: 0.4576, y: 0.41}, color_mode: "xy", - effect: null, - effect_color: null, - effect_speed: null, - identify: null, - linkquality: null, - state: null, - power_on_behavior: null, - update: {state: null, installed_version: -1, latest_version: -1}, + update: {installed_version: -1, latest_version: -1}, }), {retain: false, qos: 0}, ); @@ -2097,14 +2182,8 @@ describe("Extension: HomeAssistant", () => { expect(mockMQTTPublishAsync).toHaveBeenCalledWith( "zigbee2mqtt/bulb_color", stringify({ - linkquality: null, - effect: null, - effect_color: null, - effect_speed: null, - identify: null, state: "ON", - power_on_behavior: null, - update: {state: null, installed_version: -1, latest_version: -1}, + update: {installed_version: -1, latest_version: -1}, }), {retain: false, qos: 0}, ); @@ -2149,7 +2228,7 @@ describe("Extension: HomeAssistant", () => { device_class: "temperature", enabled_by_default: true, state_class: "measurement", - value_template: '{{ value_json["temperature"] }}', + value_template: '{% if "temperature" in value_json %}{{ value_json["temperature"] }}{% endif %}', state_topic: "zigbee2mqtt/weather_sensor", object_id: "weather_sensor_temperature", default_entity_id: "sensor.weather_sensor_temperature", @@ -2209,25 +2288,18 @@ describe("Extension: HomeAssistant", () => { "zigbee2mqtt/bulb", stringify({ state: "ON", - color_options: null, brightness: 50, color_temp: 370, - effect: null, - identify: null, linkquality: 99, - power_on_behavior: null, - update: {state: null, installed_version: -1, latest_version: -1}, + update: {installed_version: -1, latest_version: -1}, }), {retain: true, qos: 0}, ); expect(mockMQTTPublishAsync).toHaveBeenCalledWith( "zigbee2mqtt/remote", stringify({ - action_duration: null, - battery: null, brightness: 255, - linkquality: null, - update: {state: null, installed_version: -1, latest_version: -1}, + update: {installed_version: -1, latest_version: -1}, }), {retain: true, qos: 0}, ); @@ -2250,30 +2322,41 @@ describe("Extension: HomeAssistant", () => { "zigbee2mqtt/bulb", stringify({ state: "ON", - color_options: null, brightness: 50, color_temp: 370, - effect: null, - identify: null, linkquality: 99, - power_on_behavior: null, - update: {state: null, installed_version: -1, latest_version: -1}, + update: {installed_version: -1, latest_version: -1}, }), {retain: true, qos: 0}, ); expect(mockMQTTPublishAsync).toHaveBeenCalledWith( "zigbee2mqtt/remote", stringify({ - action_duration: null, - battery: null, brightness: 255, - linkquality: null, - update: {state: null, installed_version: -1, latest_version: -1}, + update: {installed_version: -1, latest_version: -1}, }), {retain: true, qos: 0}, ); }); + it("Should keep the versions of an update payload that already has them", async () => { + data.writeDefaultState({"0x000b57fffec6a5b2": {state: "ON", update: {state: "idle", installed_version: 1, latest_version: 2}}}); + // @ts-expect-error private + extension.state.load(); + await resetExtension(); + await flushPromises(); + mockMQTTPublishAsync.mockClear(); + await mockMQTTEvents.message("homeassistant/status", "online"); + await flushPromises(); + await vi.runOnlyPendingTimersAsync(); + await flushPromises(); + expect(mockMQTTPublishAsync).toHaveBeenCalledWith( + "zigbee2mqtt/bulb", + stringify({state: "ON", update: {state: "idle", installed_version: 1, latest_version: 2}}), + {retain: true, qos: 0}, + ); + }); + it("Shouldnt send all status when home assistant comes offline", async () => { data.writeDefaultState(); // @ts-expect-error private @@ -2312,7 +2395,7 @@ describe("Extension: HomeAssistant", () => { device_class: "temperature", enabled_by_default: true, state_class: "measurement", - value_template: '{{ value_json["temperature"] }}', + value_template: '{% if "temperature" in value_json %}{{ value_json["temperature"] }}{% endif %}', state_topic: "zigbee2mqtt/weather_sensor", object_id: "weather_sensor_temperature", default_entity_id: "sensor.weather_sensor_temperature", @@ -2382,7 +2465,7 @@ describe("Extension: HomeAssistant", () => { device_class: "temperature", state_class: "measurement", enabled_by_default: true, - value_template: '{{ value_json["temperature"] }}', + value_template: '{% if "temperature" in value_json %}{{ value_json["temperature"] }}{% endif %}', state_topic: "zigbee2mqtt/weather_sensor_renamed", object_id: "weather_sensor_renamed_temperature", default_entity_id: "sensor.weather_sensor_renamed_temperature", @@ -2516,7 +2599,7 @@ describe("Extension: HomeAssistant", () => { device_class: "temperature", state_class: "measurement", enabled_by_default: true, - value_template: '{{ value_json["temperature"] }}', + value_template: '{% if "temperature" in value_json %}{{ value_json["temperature"] }}{% endif %}', state_topic: "zigbee2mqtt/weather_sensor_renamed", object_id: "weather_sensor_renamed_temperature", default_entity_id: "sensor.weather_sensor_renamed_temperature", @@ -2562,7 +2645,7 @@ describe("Extension: HomeAssistant", () => { state_topic: "zigbee2mqtt/bulb", unique_id: "0x000b57fffec6a5b2_update_zigbee2mqtt", value_template: - "{\"latest_version\":\"{{ value_json['update']['latest_version'] }}\",\"installed_version\":\"{{ value_json['update']['installed_version'] }}\",\"update_percentage\":{{ value_json['update'].get('progress', 'null') }},\"in_progress\":{{ (value_json['update']['state'] == 'updating')|lower }}}", + "{% if \"update\" in value_json %}{\"latest_version\":\"{{ value_json['update']['latest_version'] }}\",\"installed_version\":\"{{ value_json['update']['installed_version'] }}\",\"update_percentage\":{{ value_json['update'].get('progress', 'null') }},\"in_progress\":{{ (value_json['update'].get('state') == 'updating')|lower }}}{% endif %}", }; expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/update/0x000b57fffec6a5b2/update/config", stringify(payload), { @@ -2605,19 +2688,7 @@ describe("Extension: HomeAssistant", () => { {retain: true, qos: 1}, ); - expect(mockMQTTPublishAsync).toHaveBeenCalledWith( - "zigbee2mqtt/button", - stringify({ - action: "single", - battery: null, - identify: null, - linkquality: null, - voltage: null, - power_outage_count: null, - device_temperature: null, - }), - {retain: false, qos: 0}, - ); + expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/button", stringify({action: "single"}), {retain: false, qos: 0}); // Should only discover it once mockMQTTPublishAsync.mockClear(); @@ -2741,30 +2812,13 @@ describe("Extension: HomeAssistant", () => { await mockMQTTEvents.message("zigbee2mqtt/U202DST600ZB/l2/set", stringify({state: "ON", brightness: 20})); await flushPromises(); - expect(mockMQTTPublishAsync).toHaveBeenCalledWith( - "zigbee2mqtt/U202DST600ZB", - stringify({ - state_l2: "ON", - brightness_l2: 20, - linkquality: null, - state_l1: null, - effect_l1: null, - effect_l2: null, - power_on_behavior_l1: null, - power_on_behavior_l2: null, - }), - {qos: 0, retain: false}, - ); - expect(mockMQTTPublishAsync).toHaveBeenCalledWith( - "zigbee2mqtt/U202DST600ZB/l2", - stringify({state: "ON", brightness: 20, effect: null, power_on_behavior: null}), - {}, - ); - expect(mockMQTTPublishAsync).toHaveBeenCalledWith( - "zigbee2mqtt/U202DST600ZB/l1", - stringify({state: null, effect: null, power_on_behavior: null}), - {}, - ); + expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/U202DST600ZB", stringify({state_l2: "ON", brightness_l2: 20}), { + qos: 0, + retain: false, + }); + expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/U202DST600ZB/l2", stringify({state: "ON", brightness: 20}), {}); + // `l1` has no attributes in this payload, so nothing is republished for it. + expect(mockMQTTPublishAsync).not.toHaveBeenCalledWith("zigbee2mqtt/U202DST600ZB/l1", expect.any(String), expect.any(Object)); }); it("Shouldnt crash in onPublishEntityState on group publish", async () => { @@ -3100,7 +3154,7 @@ describe("Extension: HomeAssistant", () => { default_entity_id: "sensor.bulb_last_seen", unique_id: "0x000b57fffec6a5b2_last_seen_zigbee2mqtt", origin: origin, - value_template: "{{ value_json.last_seen }}", + value_template: '{% if "last_seen" in value_json %}{{ value_json["last_seen"] }}{% endif %}', device_class: "timestamp", entity_category: "diagnostic", }; @@ -3127,7 +3181,7 @@ describe("Extension: HomeAssistant", () => { device_class: "temperature", state_class: "measurement", enabled_by_default: true, - value_template: '{{ value_json["temperature"] }}', + value_template: '{% if "temperature" in value_json %}{{ value_json["temperature"] }}{% endif %}', state_topic: "zigbee2mqtt/weather_sensor", object_id: "weather_sensor_temperature", default_entity_id: "sensor.weather_sensor_temperature", @@ -3494,7 +3548,7 @@ describe("Extension: HomeAssistant", () => { origin: origin, state_topic: "zigbee2mqtt/0x18fc26000000cafe", unique_id: "0x18fc26000000cafe_device_mode_zigbee2mqtt", - value_template: '{{ value_json["device_mode"] }}', + value_template: '{% if "device_mode" in value_json %}{{ value_json["device_mode"] }}{% endif %}', }; expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/select/0x18fc26000000cafe/device_mode/config", stringify(payload), { retain: true, @@ -3525,23 +3579,11 @@ describe("Extension: HomeAssistant", () => { expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/button"); expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({ action: "single", - battery: null, - identify: null, - linkquality: null, - voltage: null, - power_outage_count: null, - device_temperature: null, }); expect(mockMQTTPublishAsync.mock.calls[0][2]).toStrictEqual({qos: 0, retain: false}); expect(mockMQTTPublishAsync.mock.calls[1][0]).toStrictEqual("zigbee2mqtt/button"); expect(JSON.parse(mockMQTTPublishAsync.mock.calls[1][1])).toStrictEqual({ action: "", - battery: null, - identify: null, - linkquality: null, - voltage: null, - power_outage_count: null, - device_temperature: null, }); expect(mockMQTTPublishAsync.mock.calls[1][2]).toStrictEqual({qos: 0, retain: false}); expect(mockMQTTPublishAsync.mock.calls[2][0]).toStrictEqual("homeassistant/device_automation/0x0017880104e45520/action_single/config");