From e4d8913680bdc2abe4fbfc43cf3954c6c2c75605 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:21:17 +0200 Subject: [PATCH] [zigbee] Add zigbee support for number on esp32 (#20243) --- esphome/components/zigbee/__init__.py | 15 +++++++-- esphome/components/zigbee/const.py | 3 +- esphome/components/zigbee/const_esp32.py | 1 + .../zigbee/zigbee_attribute_esp32.h | 25 ++++++++++++++ esphome/components/zigbee/zigbee_ep_esp32.py | 29 ++++++++++++++++ esphome/components/zigbee/zigbee_esp32.py | 33 +++++++++++++++++-- .../components/zigbee/zigbee_helpers_esp32.c | 4 +++ tests/components/zigbee/common.yaml | 1 + 8 files changed, 105 insertions(+), 6 deletions(-) diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 02f05ef8a2..0a99db8958 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -42,6 +42,7 @@ from .const_zephyr import ( from .zigbee_esp32 import ( final_validate_esp32, validate_binary_sensor_esp32, + validate_number_esp32, validate_sensor_esp32, validate_switch_esp32, zigbee_require_vfs_select, @@ -120,7 +121,9 @@ SENSOR_SCHEMA = ( SWITCH_SCHEMA = ( cv.Schema({}).extend(_get_base_schema(["generic", "on_off"])).extend(zephyr_switch) ) -NUMBER_SCHEMA = cv.Schema({}).extend(zephyr_number) +NUMBER_SCHEMA = ( + cv.Schema({}).extend(_get_base_schema(["generic"])).extend(zephyr_number) +) def _validate_router_sleepy(config: ConfigType) -> ConfigType: @@ -269,12 +272,18 @@ async def setup_number( max_value: float, step: float, ) -> None: - if not config.get(CONF_ZIGBEE_ID) or config.get(CONF_INTERNAL): + if "zigbee" not in CORE.loaded_integrations or config.get(CONF_INTERNAL): return if CORE.using_zephyr: + if not config.get(CONF_ZIGBEE_ID): + return from .zigbee_zephyr import zephyr_setup_number await zephyr_setup_number(entity, config, min_value, max_value, step) + else: + from .zigbee_esp32 import add_component as add_number + + CORE.add_job(add_number, entity, config) def consume_endpoint(config: ConfigType) -> ConfigType: @@ -320,7 +329,7 @@ def validate_number(config: ConfigType) -> ConfigType: if "zigbee" not in CORE.loaded_integrations or config.get(CONF_INTERNAL): return config if CORE.is_esp32: - return config + return validate_number_esp32(config) return consume_endpoint(config) diff --git a/esphome/components/zigbee/const.py b/esphome/components/zigbee/const.py index d6abd9d6c0..86cac665b4 100644 --- a/esphome/components/zigbee/const.py +++ b/esphome/components/zigbee/const.py @@ -145,7 +145,8 @@ class AnalogInputType(IntEnum): TIME_SECONDS = 0x0E -ANALOG_INPUT_APPTYPE = { +# Same for input and output +ANALOG_APPTYPE = { (DEVICE_CLASS_TEMPERATURE, UNIT_CELSIUS): AnalogInputType.TEMP_DEGREES_C, (DEVICE_CLASS_HUMIDITY, UNIT_PERCENT): AnalogInputType.RELATIVE_HUMIDITY_PERCENT, (DEVICE_CLASS_PRESSURE, UNIT_PASCAL): AnalogInputType.PRESSURE_PASCAL, diff --git a/esphome/components/zigbee/const_esp32.py b/esphome/components/zigbee/const_esp32.py index b8122d1441..bb6c153752 100644 --- a/esphome/components/zigbee/const_esp32.py +++ b/esphome/components/zigbee/const_esp32.py @@ -33,6 +33,7 @@ CLUSTER_ID = { "BINARY_INPUT": cluster_id.EZB_ZCL_CLUSTER_ID_BINARY_INPUT, "BINARY_OUTPUT": cluster_id.EZB_ZCL_CLUSTER_ID_BINARY_OUTPUT, "ANALOG_INPUT": cluster_id.EZB_ZCL_CLUSTER_ID_ANALOG_INPUT, + "ANALOG_OUTPUT": cluster_id.EZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT, "ILLUMINANCE_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_ILLUMINANCE_MEASUREMENT, "TEMPERATURE_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_TEMPERATURE_MEASUREMENT, "PRESSURE_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_PRESSURE_MEASUREMENT, diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.h b/esphome/components/zigbee/zigbee_attribute_esp32.h index 2680fc2203..305febf171 100644 --- a/esphome/components/zigbee/zigbee_attribute_esp32.h +++ b/esphome/components/zigbee/zigbee_attribute_esp32.h @@ -23,6 +23,9 @@ #ifdef USE_SWITCH #include "esphome/components/switch/switch.h" #endif +#ifdef USE_NUMBER +#include "esphome/components/number/number.h" +#endif namespace esphome::zigbee { @@ -67,6 +70,9 @@ class ZigbeeAttribute final : public Component { #endif #ifdef USE_SWITCH template void connect(switch_::Switch *device); +#endif +#ifdef USE_NUMBER + template void connect(number::Number *device); #endif bool report_enabled = false; @@ -157,6 +163,25 @@ template void ZigbeeAttribute::connect(switch_::Switch *device) { device->add_on_state_callback([this](bool value) { this->set_attr((T) (this->scale_ * value)); }); } #endif +#ifdef USE_NUMBER +template void ZigbeeAttribute::connect(number::Number *device) { + // Add min, max and step attributes to the analog output cluster + if (this->cluster_id_ == EZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT) { + this->zb_->add_attr(this->endpoint_id_, this->cluster_id_, this->role_, 0x0045, this->max_size_, + device->traits.get_min_value()); + this->zb_->add_attr(this->endpoint_id_, this->cluster_id_, this->role_, 0x0041, this->max_size_, + device->traits.get_max_value()); + this->zb_->add_attr(this->endpoint_id_, this->cluster_id_, this->role_, 0x006A, this->max_size_, + device->traits.get_step()); + } + this->add_on_value_callback([this, device](ezb_zcl_attribute_t attribute) { + auto call = device->make_call(); + call.set_value(static_cast(*(T *) attribute.data.value) / this->scale_); + call.perform(); + }); + device->add_on_state_callback([this](float value) { this->set_attr(this->scale_value_(value)); }); +} +#endif } // namespace esphome::zigbee diff --git a/esphome/components/zigbee/zigbee_ep_esp32.py b/esphome/components/zigbee/zigbee_ep_esp32.py index b0f3d17bd6..88f49ef3c1 100644 --- a/esphome/components/zigbee/zigbee_ep_esp32.py +++ b/esphome/components/zigbee/zigbee_ep_esp32.py @@ -78,6 +78,35 @@ ANALOG_INPUT_EP = { ], } +ANALOG_OUTPUT_EP = { + CONF_CLUSTERS: [ + { + CONF_ID: "ANALOG_OUTPUT", + ROLE: "SERVER", + CONF_ATTRIBUTES: [ + { + CONF_ATTRIBUTE_ID: 0x55, + CONF_TYPE: "SINGLE", + CONF_REPORT: cv.enum(REPORT, lower=True)("default"), + CONNECT: True, + }, + { + CONF_ATTRIBUTE_ID: 0x51, + CONF_TYPE: "BOOL", + }, + { + CONF_ATTRIBUTE_ID: 0x6F, + CONF_TYPE: "MAP8", + }, + { + CONF_ATTRIBUTE_ID: 0x1C, + CONF_TYPE: "STRING", + }, + ], + }, + ], +} + BINARY_INPUT_EP = { DEVICE_TYPE: "SIMPLE_SENSOR", CONF_CLUSTERS: [ diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index f48bf9ef0b..463a398033 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -36,7 +36,7 @@ import esphome.final_validate as fv from esphome.types import ConfigType from .const import ( - ANALOG_INPUT_APPTYPE, + ANALOG_APPTYPE, BACNET_UNIT_NO_UNITS, BACNET_UNITS, CONF_CLUSTER, @@ -67,6 +67,7 @@ from .const_esp32 import ( ) from .zigbee_ep_esp32 import ( ANALOG_INPUT_EP, + ANALOG_OUTPUT_EP, BINARY_INPUT_EP, BINARY_OUTPUT_EP, BINARY_SENSOR_EP_CONFIGS, @@ -229,7 +230,7 @@ def validate_sensor_esp32(config: ConfigType) -> ConfigType: attr[CONF_LAMBDA] = attr[CONF_LAMBDA][unit] else: ep = copy.deepcopy(ANALOG_INPUT_EP) - apptype = ANALOG_INPUT_APPTYPE.get((dev_class, unit)) + apptype = ANALOG_APPTYPE.get((dev_class, unit)) bacunit = BACNET_UNITS.get(unit, BACNET_UNIT_NO_UNITS) accuracy = config.get(CONF_ACCURACY_DECIMALS) if apptype is not None: @@ -261,6 +262,34 @@ def validate_sensor_esp32(config: ConfigType) -> ConfigType: return config +def validate_number_esp32(config: ConfigType) -> ConfigType: + # get application type from device class and meas unit + # if none get BACNET unit from meas unit + dev_class = config.get(CONF_DEVICE_CLASS) + unit = config.get(CONF_UNIT_OF_MEASUREMENT) + ep = copy.deepcopy(ANALOG_OUTPUT_EP) + apptype = ANALOG_APPTYPE.get((dev_class, unit)) + bacunit = BACNET_UNITS.get(unit, BACNET_UNIT_NO_UNITS) + if apptype is not None: + ep[CONF_CLUSTERS][0][CONF_ATTRIBUTES].append( + { + CONF_ATTRIBUTE_ID: 0x100, + CONF_VALUE: (apptype << 16) | 0x0100FFFF, + CONF_TYPE: "UINT32", + }, + ) + ep[CONF_CLUSTERS][0][CONF_ATTRIBUTES].append( + { + CONF_ATTRIBUTE_ID: 0x75, + CONF_VALUE: bacunit, + CONF_TYPE: "ENUM16", + }, + ) + setup_attributes(config, ep[CONF_CLUSTERS]) + add_ep(ep, config.get(CONF_ENDPOINT), config.get(CONF_USE_DEVICE_TYPE)) + return config + + def validate_binary_sensor_esp32(config: ConfigType) -> ConfigType: dev_class = config.get(CONF_DEVICE_CLASS) if config[CONF_CLUSTER] == "device_class": diff --git a/esphome/components/zigbee/zigbee_helpers_esp32.c b/esphome/components/zigbee/zigbee_helpers_esp32.c index 22f014f3ef..e52513b740 100644 --- a/esphome/components/zigbee/zigbee_helpers_esp32.c +++ b/esphome/components/zigbee/zigbee_helpers_esp32.c @@ -72,6 +72,8 @@ ezb_zcl_cluster_desc_t esphome_zb_default_cluster_dscr_create(uint16_t cluster_i return ezb_zcl_time_create_cluster_desc(NULL, role_mask); case EZB_ZCL_CLUSTER_ID_ANALOG_INPUT: return ezb_zcl_analog_input_create_cluster_desc(NULL, role_mask); + case EZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT: + return ezb_zcl_analog_output_create_cluster_desc(NULL, role_mask); case EZB_ZCL_CLUSTER_ID_BINARY_INPUT: return ezb_zcl_binary_input_create_cluster_desc(NULL, role_mask); case EZB_ZCL_CLUSTER_ID_BINARY_OUTPUT: @@ -113,6 +115,8 @@ ezb_err_t esphome_zb_cluster_add_attr(uint16_t cluster_id, ezb_zcl_cluster_desc_ return ezb_zcl_time_cluster_desc_add_attr(cluster_desc, attr_id, value_p); case EZB_ZCL_CLUSTER_ID_ANALOG_INPUT: return ezb_zcl_analog_input_cluster_desc_add_attr(cluster_desc, attr_id, value_p); + case EZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT: + return ezb_zcl_analog_output_cluster_desc_add_attr(cluster_desc, attr_id, value_p); case EZB_ZCL_CLUSTER_ID_BINARY_INPUT: return ezb_zcl_binary_input_cluster_desc_add_attr(cluster_desc, attr_id, value_p); case EZB_ZCL_CLUSTER_ID_BINARY_OUTPUT: diff --git a/tests/components/zigbee/common.yaml b/tests/components/zigbee/common.yaml index ce59f4a6ae..8ef4957072 100644 --- a/tests/components/zigbee/common.yaml +++ b/tests/components/zigbee/common.yaml @@ -45,6 +45,7 @@ number: max_value: 100 step: 1 unit_of_measurement: "°C" + device_class: temperature time: - platform: zigbee