fix: Home Assistant: guard value templates against missing properties (#33071)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-08 20:29:43 +02:00
1 parent 972f0be125
commit 2dbb566a8b
3 files changed
+293 -289

No files matched your search

+69 -92
View File
@@ -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<MockProperty>;
messages: {[s: string]: {payload: string; published: boolean}};
triggers: Set<string>;
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 '<property>'`. 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<DiscoveryEntry> = {
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",
+3 -18
View File
@@ -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},
},
}),
);
+221 -179
View File
@@ -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 '<property>'`
* 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<string>();
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<GeneratedConfig[]> => {
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<string>();
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<string>();
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");