diff --git a/lib/extension/homeassistant.ts b/lib/extension/homeassistant.ts index 74c69d758..4f160934a 100644 --- a/lib/extension/homeassistant.ts +++ b/lib/extension/homeassistant.ts @@ -141,7 +141,7 @@ const NUMERIC_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = { alarm_temperature_min: {device_class: "temperature", entity_category: "config", icon: "mdi:thermometer-low"}, angle: {icon: "angle-acute"}, angle_axis: {icon: "angle-acute"}, - apparent_temperature: {device_class: "temperature", icon: "mdi:thermometer-lines", preserve_name: true, state_class: "measurement"}, + apparent_temperature: {device_class: "temperature", icon: "mdi:thermometer-lines", state_class: "measurement"}, aqi: {device_class: "aqi", state_class: "measurement"}, auto_relock_time: {entity_category: "config", icon: "mdi:timer"}, away_preset_days: {entity_category: "config", icon: "mdi:timer"}, @@ -195,7 +195,7 @@ const NUMERIC_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = { entity_category: "diagnostic", state_class: "measurement", }, - dew_point: {device_class: "temperature", icon: "mdi:thermometer-water", preserve_name: true, state_class: "measurement"}, + dew_point: {device_class: "temperature", icon: "mdi:thermometer-water", state_class: "measurement"}, distance: {device_class: "distance", state_class: "measurement"}, duration: {entity_category: "config", icon: "mdi:timer"}, eco2: {device_class: "volatile_organic_compounds_parts", state_class: "measurement"}, @@ -213,12 +213,13 @@ const NUMERIC_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = { formaldehyd: {state_class: "measurement"}, formaldehyde: {state_class: "measurement"}, flow: {device_class: "volume_flow_rate", state_class: "measurement"}, + frequency: {device_class: "frequency", state_class: "measurement"}, gas: {device_class: "gas", state_class: "total_increasing", icon: "mdi:meter-gas"}, gas_density: {icon: "mdi:google-circles-communities", state_class: "measurement"}, - gust_speed: {device_class: "wind_speed", icon: "mdi:weather-windy-variant", preserve_name: true, state_class: "measurement"}, + gust_speed: {device_class: "wind_speed", icon: "mdi:weather-windy-variant", state_class: "measurement"}, hcho: {icon: "mdi:air-filter", state_class: "measurement"}, heat_stress: {icon: "mdi:weather-sunny-alert", state_class: "measurement"}, - humidex: {device_class: "temperature", icon: "mdi:thermometer-alert", preserve_name: true, state_class: "measurement"}, + humidex: {device_class: "temperature", icon: "mdi:thermometer-alert", state_class: "measurement"}, humidity: {device_class: "humidity", state_class: "measurement"}, humidity_calibration: {entity_category: "config", icon: "mdi:wrench-clock"}, humidity_max: {entity_category: "config", icon: "mdi:water-percent"}, @@ -318,7 +319,7 @@ const NUMERIC_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = { device_class: "water", state_class: "total_increasing", }, - wind_chill: {device_class: "temperature", icon: "mdi:snowflake-thermometer", preserve_name: true, state_class: "measurement"}, + wind_chill: {device_class: "temperature", icon: "mdi:snowflake-thermometer", state_class: "measurement"}, wind_direction: {icon: "mdi:compass-outline", state_class: "measurement"}, wind_speed: {device_class: "wind_speed", icon: "mdi:weather-windy", state_class: "measurement"}, x: {icon: "mdi:axis-x-arrow", state_class: "measurement"}, @@ -396,6 +397,8 @@ const featurePropertyWithoutEndpoint = (feature: zhc.Feature): string => { return feature.property; }; +const cleanName = (name: string): string => name.replace(/^(?:analog_in_|analog_out_)/, ""); + const applyHomeAssistantExposeMetadata = (payload: DiscoveryEntry, homeAssistant: zhc.Expose["homeassistant"]): void => { if (!homeAssistant) { return; @@ -1207,7 +1210,7 @@ export class HomeAssistant extends Extension { command_topic_postfix: firstExpose.property, ...(firstExpose.unit && {unit_of_measurement: firstExpose.unit}), ...(firstExpose.value_step && {step: firstExpose.value_step}), - ...NUMERIC_DISCOVERY_LOOKUP[firstExpose.name], + ...NUMERIC_DISCOVERY_LOOKUP[cleanName(firstExpose.name)], }, }; @@ -1239,7 +1242,7 @@ export class HomeAssistant extends Extension { Object.assign(extraAttrs, {device_class: "power", state_class: "measurement"}); } - let key = firstExpose.name; + let key = cleanName(firstExpose.name); // Home Assistant uses a different voc device_class for µg/m³ versus ppb or ppm. if (firstExpose.name === "voc" && firstExpose.unit && ["ppb", "ppm"].includes(firstExpose.unit)) { @@ -1487,12 +1490,11 @@ export class HomeAssistant extends Extension { } // Let Home Assistant generate entity name when device_class is present. - // preserve_name allows device_class and explicit name to coexist (e.g. derived sensors). - if ( - entry.discovery_payload.device_class && - entry.discovery_payload.name !== null && - !NUMERIC_DISCOVERY_LOOKUP[firstExpose.name]?.preserve_name - ) { + // homeassistant.name allows device_class and explicit name to coexist (e.g. derived sensors). + // If name = null Home Assistant will use the device name as entity name. + // This is intentionally an undefined-only check to distinguish it from an null value. + + if (entry.discovery_payload.device_class && firstExpose.homeassistant?.name === undefined) { delete entry.discovery_payload.name; } diff --git a/test/extensions/bridge.test.ts b/test/extensions/bridge.test.ts index 750af651f..8e8944176 100644 --- a/test/extensions/bridge.test.ts +++ b/test/extensions/bridge.test.ts @@ -1046,6 +1046,62 @@ describe("Extension: Bridge", () => { source: "generated", description: "Automatically generated definition", exposes: [ + { + access: 5, + description: "Analog Input my_sensor_name on endpoint 1", + endpoint: "1", + homeassistant: {name: "my_sensor_name"}, + label: "my_sensor_name", + name: "analog_in_temperature", + property: "analog_in_temperature_1", + type: "numeric", + unit: "°C", + }, + { + access: 5, + description: "Analog Input my_sensor2_name on endpoint 2", + endpoint: "2", + homeassistant: {name: "my_sensor2_name"}, + label: "my_sensor2_name", + name: "analog_in_temperature", + property: "analog_in_temperature_2", + type: "numeric", + unit: "°C", + }, + { + access: 7, + description: "Analog Output my_number_name on endpoint 2", + endpoint: "2", + homeassistant: {name: "my_number_name"}, + label: "my_number_name", + name: "analog_output", + property: "analog_output_2", + type: "numeric", + }, + { + access: 5, + description: "Binary Input my_binary_name on endpoint 2", + endpoint: "2", + homeassistant: {name: "my_binary_name"}, + label: "my_binary_name", + name: "binary_input", + property: "binary_input_2", + type: "binary", + value_off: "OFF", + value_on: "ON", + }, + { + access: 7, + description: "Binary Output my_switch_name on endpoint 2", + endpoint: "2", + homeassistant: {name: "my_switch_name"}, + label: "my_switch_name", + name: "binary_output", + property: "binary_output_2", + type: "binary", + value_off: "OFF", + value_on: "ON", + }, { access: 1, category: "diagnostic", @@ -1120,9 +1176,17 @@ describe("Extension: Bridge", () => { endpoints: { "1": { bindings: [], - clusters: {input: ["genBasic"], output: ["genBasic", "genOnOff", "genLevelCtrl", "genScenes"]}, + clusters: {input: ["genBasic", "genAnalogInput"], output: ["genBasic", "genOnOff", "genLevelCtrl", "genScenes"]}, configured_reportings: [], scenes: [], + name: "1", + }, + "2": { + bindings: [], + clusters: {input: ["genBasic", "genAnalogInput", "genAnalogOutput", "genBinaryInput", "genBinaryOutput"], output: []}, + configured_reportings: [], + scenes: [], + name: "2", }, }, friendly_name: "0x0017880104e45518", @@ -2762,6 +2826,62 @@ describe("Extension: Bridge", () => { source: "generated", description: "Automatically generated definition", exposes: [ + { + access: 5, + description: "Analog Input my_sensor_name on endpoint 1", + endpoint: "1", + homeassistant: {name: "my_sensor_name"}, + label: "my_sensor_name", + name: "analog_in_temperature", + property: "analog_in_temperature_1", + type: "numeric", + unit: "°C", + }, + { + access: 5, + description: "Analog Input my_sensor2_name on endpoint 2", + endpoint: "2", + homeassistant: {name: "my_sensor2_name"}, + label: "my_sensor2_name", + name: "analog_in_temperature", + property: "analog_in_temperature_2", + type: "numeric", + unit: "°C", + }, + { + access: 7, + description: "Analog Output my_number_name on endpoint 2", + endpoint: "2", + homeassistant: {name: "my_number_name"}, + label: "my_number_name", + name: "analog_output", + property: "analog_output_2", + type: "numeric", + }, + { + access: 5, + description: "Binary Input my_binary_name on endpoint 2", + endpoint: "2", + homeassistant: {name: "my_binary_name"}, + label: "my_binary_name", + name: "binary_input", + property: "binary_input_2", + type: "binary", + value_off: "OFF", + value_on: "ON", + }, + { + access: 7, + description: "Binary Output my_switch_name on endpoint 2", + endpoint: "2", + homeassistant: {name: "my_switch_name"}, + label: "my_switch_name", + name: "binary_output", + property: "binary_output_2", + type: "binary", + value_off: "OFF", + value_on: "ON", + }, { access: 1, category: "diagnostic", diff --git a/test/extensions/homeassistant.test.ts b/test/extensions/homeassistant.test.ts index fd8bb83bd..f97c7798f 100644 --- a/test/extensions/homeassistant.test.ts +++ b/test/extensions/homeassistant.test.ts @@ -1368,6 +1368,77 @@ describe("Extension: HomeAssistant", () => { }); }); + it("Should discover analog input on unsupported devices with device class and name", () => { + const payload = { + availability: [ + { + topic: "zigbee2mqtt/bridge/state", + value_template: "{{ value_json.state }}", + }, + ], + default_entity_id: "sensor.0x0017880104e45518_analog_in_temperature_1", + device: { + identifiers: ["zigbee2mqtt_0x0017880104e45518"], + manufacturer: "notSupportedMfg", + model: "Automatically generated definition", + model_id: "notSupportedModelID", + name: "0x0017880104e45518", + via_device: "zigbee2mqtt_bridge_0x00124b00120144ae", + }, + device_class: "temperature", + enabled_by_default: true, + name: "my_sensor_name", + object_id: "0x0017880104e45518_analog_in_temperature_1", + origin: origin, + state_class: "measurement", + state_topic: "zigbee2mqtt/0x0017880104e45518", + unique_id: "0x0017880104e45518_analog_in_temperature_1_zigbee2mqtt", + unit_of_measurement: "°C", + value_template: '{{ value_json["analog_in_temperature_1"] }}', + }; + + expect(mockMQTTPublishAsync).toHaveBeenCalledWith( + "homeassistant/sensor/0x0017880104e45518/analog_in_temperature_1/config", + stringify(payload), + { + retain: true, + qos: 1, + }, + ); + }); + + it("Should discover analog output on unsupported devices", () => { + const payload = { + availability: [ + { + topic: "zigbee2mqtt/bridge/state", + value_template: "{{ value_json.state }}", + }, + ], + command_topic: "zigbee2mqtt/0x0017880104e45518/2/set/analog_output_2", + default_entity_id: "number.0x0017880104e45518_analog_output_2", + device: { + identifiers: ["zigbee2mqtt_0x0017880104e45518"], + manufacturer: "notSupportedMfg", + model: "Automatically generated definition", + model_id: "notSupportedModelID", + name: "0x0017880104e45518", + via_device: "zigbee2mqtt_bridge_0x00124b00120144ae", + }, + name: "my_number_name", + object_id: "0x0017880104e45518_analog_output_2", + origin: origin, + state_topic: "zigbee2mqtt/0x0017880104e45518", + unique_id: "0x0017880104e45518_analog_output_2_zigbee2mqtt", + value_template: '{{ value_json["analog_output_2"] }}', + }; + + expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/number/0x0017880104e45518/analog_output_2/config", stringify(payload), { + qos: 1, + retain: true, + }); + }); + it("Should apply user configuration after converter compatibility mapping", async () => { settings.set(["devices", "0x18fc2600000d7ae2", "homeassistant", "climate"], { modes: ["off", "heat", "auto"], diff --git a/test/mocks/zigbeeHerdsman.ts b/test/mocks/zigbeeHerdsman.ts index 134e2dfda..f240da1b5 100644 --- a/test/mocks/zigbeeHerdsman.ts +++ b/test/mocks/zigbeeHerdsman.ts @@ -40,6 +40,10 @@ const CLUSTERS = { msRelativeHumidity: Zcl.Clusters.msRelativeHumidity.ID, msSoilMoisture: Zcl.Clusters.msSoilMoisture.ID, msCO2: Zcl.Clusters.msCO2.ID, + genAnalogInput: Zcl.Clusters.genAnalogInput.ID, + genAnalogOutput: Zcl.Clusters.genAnalogOutput.ID, + genBinaryInput: Zcl.Clusters.genBinaryInput.ID, + genBinaryOutput: Zcl.Clusters.genBinaryOutput.ID, }; export const CUSTOM_CLUSTERS = { @@ -626,7 +630,7 @@ export const devices = { {target: groups.group_1, cluster: {ID: 6, name: "genOnOff"}}, {target: groups.group_1, cluster: {ID: 6, name: "genLevelCtrl"}}, ]), - new Endpoint(2, [0, 1, 3, 15, 64512], [25, 6], "0x0017880104e45517"), + new Endpoint(2, [0, 1, 3, 64512], [25, 6], "0x0017880104e45517"), ], InterviewState.Successful, "Battery", @@ -637,7 +641,17 @@ export const devices = { "0x0017880104e45518", 6536, 0, - [new Endpoint(1, [0], [0, 3, 4, 6, 8, 5], "0x0017880104e45518")], + [ + new Endpoint(1, [0, 12], [0, 3, 4, 6, 8, 5], "0x0017880104e45518", [], { + genAnalogInput: {description: "my_sensor_name", applicationType: 0}, + }), + new Endpoint(2, [0, 12, 13, 15, 16], [], "0x0017880104e45518", [], { + genAnalogInput: {description: "my_sensor2_name", applicationType: 0}, + genAnalogOutput: {description: "my_number_name"}, + genBinaryInput: {description: "my_binary_name"}, + genBinaryOutput: {description: "my_switch_name"}, + }), + ], InterviewState.Successful, "Battery", "notSupportedModelID", @@ -928,7 +942,7 @@ export const devices = { 33901, 4476, [ - new Endpoint(1, [0, 4, 3, 5, 10, 258, 13, 19, 6, 1, 1030, 8, 768, 1027, 1029, 1026], [0, 3, 4, 6, 8, 5], "0x90fd9ffffe4b64ax", [], { + new Endpoint(1, [0, 4, 3, 5, 10, 258, 19, 6, 1, 1030, 8, 768, 1027, 1029, 1026], [0, 3, 4, 6, 8, 5], "0x90fd9ffffe4b64ax", [], { lightingColorCtrl: {colorCapabilities: 254}, }), ], @@ -942,7 +956,7 @@ export const devices = { 33901, 4476, [ - new Endpoint(1, [0, 4, 3, 5, 10, 258, 13, 19, 6, 1, 1030, 8, 768, 1027, 1029, 1026], [0, 3, 4, 6, 8, 5], "0x90fd9ffffe4b64aa", [], { + new Endpoint(1, [0, 4, 3, 5, 10, 258, 19, 6, 1, 1030, 8, 768, 1027, 1029, 1026], [0, 3, 4, 6, 8, 5], "0x90fd9ffffe4b64aa", [], { seMetering: {multiplier: 1, divisor: 10000}, }), ], @@ -958,7 +972,7 @@ export const devices = { 33901, 4476, [ - new Endpoint(1, [0, 4, 3, 5, 10, 258, 13, 19, 6, 1, 1030, 8, 768, 1027, 1029, 1026], [0, 3, 4, 6, 8, 5], "0x90fd9ffffe4b64aa", [], { + new Endpoint(1, [0, 4, 3, 5, 10, 258, 19, 6, 1, 1030, 8, 768, 1027, 1029, 1026], [0, 3, 4, 6, 8, 5], "0x90fd9ffffe4b64aa", [], { seMetering: {multiplier: 1, divisor: 10000}, }), ], @@ -973,7 +987,7 @@ export const devices = { "0x90fd9ffffe4b64ac", 33901, 4476, - [new Endpoint(1, [0, 4, 3, 5, 10, 258, 13, 19, 6, 1, 1030, 8, 768, 1027, 1029, 1026], [0, 3, 4, 6, 8, 5], "0x90fd9ffffe4b64aa", [], {})], + [new Endpoint(1, [0, 4, 3, 5, 10, 258, 19, 6, 1, 1030, 8, 768, 1027, 1029, 1026], [0, 3, 4, 6, 8, 5], "0x90fd9ffffe4b64aa", [], {})], InterviewState.Successful, "Mains (single phase)", "qnazj70",