[zigbee] Add cluster: device_class to expose ESP32 sensors via measurement clusters instead of Analog/Binary Input (#17388)

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
This commit is contained in:
luar123
2026-09-29 21:48:08 -04:00
committed by GitHub
co-authored by Copilot Autofix powered by AI Jonathan Swoboda
parent 23afdc1d9b
commit cd0a0f3fca
13 changed files with 565 additions and 100 deletions
+20 -8
View File
@@ -18,6 +18,7 @@ from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.types import ConfigType
from .const import (
CONF_CLUSTER,
CONF_ENDPOINT,
CONF_MAX_EP_NUMBER,
CONF_ON_JOIN,
@@ -74,6 +75,11 @@ BASE_SCHEMA = cv.Schema(
_check_report_deprecation,
cv.enum(REPORT, lower=True),
),
cv.OnlyWith(CONF_CLUSTER, ["esp32", "zigbee"], default="generic"): cv.All(
cv.requires_component("zigbee"),
cv.requires_component("esp32"),
cv.one_of(*["generic", "device_class"], lower=True),
),
cv.Optional(CONF_ENDPOINT): cv.All(
cv.requires_component("zigbee"),
cv.requires_component("esp32"),
@@ -196,21 +202,27 @@ async def to_code(config: ConfigType) -> None:
async def setup_binary_sensor(entity: cg.MockObj, config: ConfigType) -> 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:
from .zigbee_zephyr import zephyr_setup_binary_sensor
await zephyr_setup_binary_sensor(entity, config)
if not config.get(CONF_ZIGBEE_ID):
return
from .zigbee_zephyr import add_binary_sensor
else:
from .zigbee_esp32 import add_component as add_binary_sensor
CORE.add_job(add_binary_sensor, entity, config)
async def setup_sensor(entity: cg.MockObj, config: ConfigType) -> 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:
from .zigbee_zephyr import zephyr_setup_sensor
await zephyr_setup_sensor(entity, config)
if not config.get(CONF_ZIGBEE_ID):
return
from .zigbee_zephyr import add_sensor
else:
from .zigbee_esp32 import add_component as add_sensor
CORE.add_job(add_sensor, entity, config)
async def setup_switch(entity: cg.MockObj, config: ConfigType) -> None:
+1
View File
@@ -64,6 +64,7 @@ REPORT = {
"default": report.ZIGBEE_REPORT_DEFAULT,
}
CONF_CLUSTER = "cluster"
CONF_ENDPOINT = "endpoint"
CONF_MAX_EP_NUMBER = 239
CONF_ON_JOIN = "on_join"
+23 -2
View File
@@ -1,12 +1,14 @@
import esphome.codegen as cg
ALLOWED_UNITS = "allowed_units"
DEVICE_TYPE = "device_type"
ROLE = "role"
CONF_CLUSTERS = "clusters"
CONF_ATTRIBUTES = "attributes"
CONF_CLUSTER = "cluster"
SCALE = "scale"
CONF_ATTRIBUTE_ID = "attribute_id"
KEY_ZIGBEE_ATTRIBUTES = "zigbee_attributes"
CONNECT = "connect"
SCALE = "scale"
KEY_ZIGBEE_EP = "zigbee_ep"
KEY_ZIGBEE_EP_NO_NUM = "zigbee_ep_no_num"
KEY_ZIGBEE_FIRST_EP_CL = "zigbee_first_ep_cl"
@@ -14,6 +16,11 @@ KEY_ZIGBEE_FIRST_EP_CL = "zigbee_first_ep_cl"
DEVICE_ID = {
"RANGE_EXTENDER": cg.RawExpression("EZB_ZHA_RANGE_EXTENDER_DEVICE_ID"),
"SIMPLE_SENSOR": cg.RawExpression("EZB_ZHA_SIMPLE_SENSOR_DEVICE_ID"),
"LIGHT_SENSOR": cg.RawExpression("EZB_ZHA_LIGHT_SENSOR_DEVICE_ID"),
"OCCUPANCY_SENSOR": cg.RawExpression("EZB_ZHA_OCCUPANCY_SENSOR_DEVICE_ID"),
"TEMPERATURE_SENSOR": cg.RawExpression("EZB_ZHA_TEMPERATURE_SENSOR_DEVICE_ID"),
"PRESSURE_SENSOR": cg.RawExpression("EZB_ZHA_PRESSURE_SENSOR_DEVICE_ID"),
"FLOW_SENSOR": cg.RawExpression("EZB_ZHA_FLOW_SENSOR_DEVICE_ID"),
"CUSTOM_ATTR": 0xFFF2,
}
cluster_id = cg.esphome_ns.enum("ezb_zcl_cluster_id_e")
@@ -22,6 +29,14 @@ CLUSTER_ID = {
"TIME": cluster_id.EZB_ZCL_CLUSTER_ID_TIME,
"BINARY_INPUT": cluster_id.EZB_ZCL_CLUSTER_ID_BINARY_INPUT,
"ANALOG_INPUT": cluster_id.EZB_ZCL_CLUSTER_ID_ANALOG_INPUT,
"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,
"FLOW_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_FLOW_MEASUREMENT,
"REL_HUMIDITY_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_REL_HUMIDITY_MEASUREMENT,
"OCCUPANCY_SENSING": cluster_id.EZB_ZCL_CLUSTER_ID_OCCUPANCY_SENSING,
"CARBON_DIOXIDE_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_CARBON_DIOXIDE_MEASUREMENT,
"PM2_5_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_PM2_5_MEASUREMENT,
}
CLUSTER_ROLE = {
"SERVER": cg.RawExpression("EZB_ZCL_CLUSTER_SERVER"),
@@ -31,6 +46,12 @@ attr_type = cg.esphome_ns.enum("ezb_zcl_attr_type_e")
ATTR_TYPE = {
"BOOL": attr_type.EZB_ZCL_ATTR_TYPE_BOOL,
"MAP8": attr_type.EZB_ZCL_ATTR_TYPE_MAP8,
"UINT8": attr_type.EZB_ZCL_ATTR_TYPE_UINT8,
"UINT16": attr_type.EZB_ZCL_ATTR_TYPE_UINT16,
"INT8": attr_type.EZB_ZCL_ATTR_TYPE_INT8,
"INT16": attr_type.EZB_ZCL_ATTR_TYPE_INT16,
"ENUM8": attr_type.EZB_ZCL_ATTR_TYPE_ENUM8,
"ENUM16": attr_type.EZB_ZCL_ATTR_TYPE_ENUM16,
"STRING": attr_type.EZB_ZCL_ATTR_TYPE_STRING,
"SINGLE": attr_type.EZB_ZCL_ATTR_TYPE_SINGLE,
"DOUBLE": attr_type.EZB_ZCL_ATTR_TYPE_DOUBLE,
@@ -23,7 +23,8 @@ void ZigbeeAttribute::set_attr_() {
}
// Check for error
if (state != EZB_ZCL_STATUS_SUCCESS) {
ESP_LOGE(TAG, "Setting attribute failed, ZCL status: %u", static_cast<unsigned>(state));
ESP_LOGE(TAG, "Setting attribute 0x%04X on cluster 0x%04X failed, ZCL status: %u", this->attr_id_,
this->cluster_id_, static_cast<unsigned>(state));
}
esp_zigbee_lock_release();
}
@@ -1,5 +1,7 @@
#pragma once
#include <cmath>
#include <limits>
#include <type_traits>
#include "esphome/core/automation.h"
@@ -47,6 +49,7 @@ class ZigbeeAttribute final : public Component {
void set_report(ZigbeeReportT report);
#ifdef USE_SENSOR
template<typename T> void connect(sensor::Sensor *sensor);
template<typename T, typename F> void connect(sensor::Sensor *sensor, F &&f);
#endif
#ifdef USE_BINARY_SENSOR
template<typename T> void connect(binary_sensor::BinarySensor *sensor);
@@ -68,6 +71,8 @@ class ZigbeeAttribute final : public Component {
bool set_attr_requested_{false};
bool report_requested_{false};
bool force_report_{false};
template<typename T> T scale_value_(float value);
template<typename T> T invalid_value_();
};
template<typename T> void ZigbeeAttribute::add_attr(T value) {
@@ -85,9 +90,44 @@ template<typename T> void ZigbeeAttribute::set_attr(const T &value) {
this->enable_loop();
}
template<typename T> T ZigbeeAttribute::scale_value_(float value) {
static_assert(sizeof(T) <= 2 || std::is_floating_point_v<T>);
if constexpr (std::is_integral<T>::value) {
const float scaled = this->scale_ * value;
if (std::isnan(value) || scaled < static_cast<float>(std::numeric_limits<T>::lowest()) ||
scaled > static_cast<float>(std::numeric_limits<T>::max())) {
return this->invalid_value_<T>(); // 0x8000 / 0xFFFF / 0 for bitmaps
}
return static_cast<T>(lroundf(scaled));
}
return static_cast<T>(this->scale_ * value);
}
template<typename T> T ZigbeeAttribute::invalid_value_() {
if constexpr (std::is_integral_v<T>) {
if constexpr (std::is_signed_v<T>) {
// For signed integer types, NaN is represented by the minimum value
return static_cast<T>(std::numeric_limits<T>::min());
}
if (this->attr_type_ >= EZB_ZCL_ATTR_TYPE_UINT8 && this->attr_type_ <= EZB_ZCL_ATTR_TYPE_ENUM16) {
// For unsigned integer types and enum, NaN is represented by the maximum value
return static_cast<T>(std::numeric_limits<T>::max());
}
// For other integer types, return 0 as a fallback
return static_cast<T>(0);
}
return std::numeric_limits<T>::quiet_NaN(); // For floating-point types, return NaN
}
#ifdef USE_SENSOR
template<typename T> void ZigbeeAttribute::connect(sensor::Sensor *sensor) {
sensor->add_on_state_callback([this](float value) { this->set_attr((T) (this->scale_ * value)); });
sensor->add_on_state_callback([this](float value) { this->set_attr(this->scale_value_<T>(value)); });
}
template<typename T, typename F> void ZigbeeAttribute::connect(sensor::Sensor *sensor, F &&f) {
sensor->add_on_state_callback([f = std::forward<F>(f), this](float value) { this->set_attr((T) f(value)); });
}
#endif
#ifdef USE_BINARY_SENSOR
+245 -28
View File
@@ -1,8 +1,31 @@
from typing import Any
import esphome.config_validation as cv
from esphome.const import CONF_DEVICE, CONF_ID, CONF_TYPE
from esphome.core import CORE
from esphome.const import (
CONF_ID,
CONF_LAMBDA,
CONF_TYPE,
CONF_VALUE,
DEVICE_CLASS_ATMOSPHERIC_PRESSURE,
DEVICE_CLASS_CARBON_DIOXIDE,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_ILLUMINANCE,
DEVICE_CLASS_OCCUPANCY,
DEVICE_CLASS_PM25,
DEVICE_CLASS_PRESSURE,
DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_VOLUME_FLOW_RATE,
UNIT_CELSIUS,
UNIT_CUBIC_METER_PER_HOUR,
UNIT_HECTOPASCAL,
UNIT_LITRE_PER_HOUR,
UNIT_LUX,
UNIT_MICROGRAMS_PER_CUBIC_METER,
UNIT_PARTS_PER_MILLION,
UNIT_PASCAL,
UNIT_PERCENT,
)
from esphome.core import CORE, Lambda
from .const import (
CONF_MAX_EP_NUMBER,
@@ -12,70 +35,264 @@ from .const import (
REPORT,
)
from .const_esp32 import (
ALLOWED_UNITS,
CONF_ATTRIBUTE_ID,
CONF_ATTRIBUTES,
CONF_CLUSTERS,
CONNECT,
DEVICE_TYPE,
KEY_ZIGBEE_EP,
KEY_ZIGBEE_EP_NO_NUM,
KEY_ZIGBEE_FIRST_EP_CL,
ROLE,
SCALE,
)
# endpoint configs:
ep_configs: dict[str, dict[str, Any]] = {
"binary_input": {
DEVICE_TYPE: "SIMPLE_SENSOR",
ANALOG_INPUT_EP = {
CONF_CLUSTERS: [
{
CONF_ID: "ANALOG_INPUT",
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: [
{
CONF_ID: "BINARY_INPUT",
ROLE: "SERVER",
CONF_ATTRIBUTES: [
{
CONF_ATTRIBUTE_ID: 0x55,
CONF_TYPE: "BOOL",
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",
},
],
},
],
}
def _pressure_ep(device_type: bool = False) -> dict[str, Any]:
ep = {
ALLOWED_UNITS: [UNIT_HECTOPASCAL, UNIT_PASCAL],
CONF_CLUSTERS: [
{
CONF_ID: "BINARY_INPUT",
CONF_ID: "PRESSURE_MEASUREMENT",
ROLE: "SERVER",
CONF_ATTRIBUTES: [
{
CONF_ATTRIBUTE_ID: 0x55,
CONF_TYPE: "BOOL",
CONF_ATTRIBUTE_ID: 0x0,
CONF_TYPE: "INT16",
CONF_REPORT: cv.enum(REPORT, lower=True)("default"),
CONF_DEVICE: None,
CONNECT: True,
SCALE: {
UNIT_HECTOPASCAL: 1,
UNIT_PASCAL: 0.01,
},
},
],
},
],
}
if device_type:
ep[DEVICE_TYPE] = (
"PRESSURE_SENSOR" # Sensor that measures pressure of liquids like water
)
return ep
SENSOR_EP_CONFIGS: dict[str, dict[str, Any]] = {
DEVICE_CLASS_TEMPERATURE: {
ALLOWED_UNITS: [UNIT_CELSIUS],
DEVICE_TYPE: "TEMPERATURE_SENSOR",
CONF_CLUSTERS: [
{
CONF_ID: "TEMPERATURE_MEASUREMENT",
ROLE: "SERVER",
CONF_ATTRIBUTES: [
{
CONF_ATTRIBUTE_ID: 0x51,
CONF_TYPE: "BOOL",
},
{
CONF_ATTRIBUTE_ID: 0x6F,
CONF_TYPE: "MAP8",
},
{
CONF_ATTRIBUTE_ID: 0x1C,
CONF_TYPE: "STRING",
CONF_ATTRIBUTE_ID: 0x0,
CONF_TYPE: "INT16",
CONF_REPORT: cv.enum(REPORT, lower=True)("default"),
SCALE: 100,
CONNECT: True,
},
],
},
],
},
"analog_input": {
DEVICE_CLASS_HUMIDITY: {
ALLOWED_UNITS: [UNIT_PERCENT],
CONF_CLUSTERS: [
{
CONF_ID: "ANALOG_INPUT",
CONF_ID: "REL_HUMIDITY_MEASUREMENT",
ROLE: "SERVER",
CONF_ATTRIBUTES: [
{
CONF_ATTRIBUTE_ID: 0x55,
CONF_ATTRIBUTE_ID: 0x0,
CONF_TYPE: "UINT16",
CONF_REPORT: cv.enum(REPORT, lower=True)("default"),
SCALE: 100,
CONNECT: True,
},
],
},
],
},
DEVICE_CLASS_ATMOSPHERIC_PRESSURE: _pressure_ep(),
DEVICE_CLASS_PRESSURE: _pressure_ep(device_type=True),
DEVICE_CLASS_VOLUME_FLOW_RATE: {
ALLOWED_UNITS: [UNIT_LITRE_PER_HOUR, UNIT_CUBIC_METER_PER_HOUR],
DEVICE_TYPE: "FLOW_SENSOR",
CONF_CLUSTERS: [
{
CONF_ID: "FLOW_MEASUREMENT",
ROLE: "SERVER",
CONF_ATTRIBUTES: [
{
CONF_ATTRIBUTE_ID: 0x0,
CONF_TYPE: "UINT16",
CONF_REPORT: cv.enum(REPORT, lower=True)("default"),
CONNECT: True,
SCALE: {
UNIT_LITRE_PER_HOUR: 0.01,
UNIT_CUBIC_METER_PER_HOUR: 10,
},
},
],
},
],
},
DEVICE_CLASS_ILLUMINANCE: {
ALLOWED_UNITS: [UNIT_LUX],
DEVICE_TYPE: "LIGHT_SENSOR",
CONF_CLUSTERS: [
{
CONF_ID: "ILLUMINANCE_MEASUREMENT",
ROLE: "SERVER",
CONF_ATTRIBUTES: [
{
CONF_ATTRIBUTE_ID: 0x0,
CONF_TYPE: "UINT16",
CONF_REPORT: cv.enum(REPORT, lower=True)("default"),
CONF_LAMBDA: cv.lambda_(
Lambda(
"if (x < 0.0f || std::isnan(x)) return 0xFFFF;" # NaN
" if (x < 1.0f) return 0;" # too small to measure
" const float v = log10(x)*10000 + 1;"
" return v > 65534.0f ? 0xFFFE : (uint16_t) lroundf(v);" # clamp to 0xFFFE if too large
)
),
CONNECT: True,
},
],
},
],
},
DEVICE_CLASS_PM25: {
ALLOWED_UNITS: [UNIT_MICROGRAMS_PER_CUBIC_METER],
CONF_CLUSTERS: [
{
CONF_ID: "PM2_5_MEASUREMENT",
ROLE: "SERVER",
CONF_ATTRIBUTES: [
{
CONF_ATTRIBUTE_ID: 0x0,
CONF_TYPE: "SINGLE",
CONF_REPORT: cv.enum(REPORT, lower=True)("default"),
CONF_DEVICE: None,
CONNECT: True,
},
{
CONF_ATTRIBUTE_ID: 0x51,
CONF_TYPE: "BOOL",
CONF_ATTRIBUTE_ID: 0x2,
CONF_TYPE: "SINGLE",
CONF_VALUE: 9999, # overwrite default 1.0
},
],
},
],
},
DEVICE_CLASS_CARBON_DIOXIDE: {
ALLOWED_UNITS: [UNIT_PARTS_PER_MILLION],
CONF_CLUSTERS: [
{
CONF_ID: "CARBON_DIOXIDE_MEASUREMENT",
ROLE: "SERVER",
CONF_ATTRIBUTES: [
{
CONF_ATTRIBUTE_ID: 0x6F,
CONF_ATTRIBUTE_ID: 0x0000,
CONF_TYPE: "SINGLE",
CONF_REPORT: cv.enum(REPORT, lower=True)("default"),
CONNECT: True,
SCALE: 0.000001,
},
{CONF_ATTRIBUTE_ID: 0x0001, CONF_TYPE: "SINGLE", CONF_VALUE: 0.0},
{CONF_ATTRIBUTE_ID: 0x0002, CONF_TYPE: "SINGLE", CONF_VALUE: 0.1},
],
},
],
},
}
BINARY_SENSOR_EP_CONFIGS: dict[str, dict[str, Any]] = {
DEVICE_CLASS_OCCUPANCY: {
DEVICE_TYPE: "OCCUPANCY_SENSOR",
CONF_CLUSTERS: [
{
CONF_ID: "OCCUPANCY_SENSING",
ROLE: "SERVER",
CONF_ATTRIBUTES: [
{
CONF_ATTRIBUTE_ID: 0x0,
CONF_TYPE: "MAP8",
CONF_REPORT: cv.enum(REPORT, lower=True)("default"),
CONNECT: True,
},
{
CONF_ATTRIBUTE_ID: 0x1C,
CONF_TYPE: "STRING",
CONF_ATTRIBUTE_ID: 0x1,
CONF_TYPE: "ENUM8",
CONF_VALUE: 0, # hardcode PIR for now as ultrasonic or physical contact is unlikely
},
{
CONF_ATTRIBUTE_ID: 0x2,
CONF_TYPE: "MAP8",
CONF_VALUE: 0b00000001, # hardcode PIR for now as ultrasonic or physical contact is unlikely
},
],
},
+3 -8
View File
@@ -181,13 +181,8 @@ static void zb_action_handler(ezb_zcl_core_action_callback_id_t callback_id, voi
}
void ZigbeeComponent::create_default_cluster(uint8_t endpoint_id, uint16_t device_id) {
ezb_af_ep_config_t config = {
.ep_id = endpoint_id,
.app_profile_id = EZB_AF_HA_PROFILE_ID,
.app_device_id = device_id,
.app_device_version = 0,
};
ezb_af_ep_desc_t ep_desc = ezb_af_create_endpoint_desc(&config);
ezb_af_ep_desc_t ep_desc =
esphome_zb_zha_default_ep_desc_create(endpoint_id, device_id, this->basic_cluster_data_.power_source);
if (ezb_af_device_add_endpoint_desc(this->dev_desc_, ep_desc) != EZB_ERR_NONE) {
ESP_LOGE(TAG, "Could not create endpoint %u", endpoint_id);
}
@@ -232,13 +227,13 @@ void ZigbeeComponent::update_basic_cluster_(ezb_af_ep_desc_t ep_desc) {
.power_source = this->basic_cluster_data_.power_source,
};
cluster_desc = ezb_zcl_basic_create_cluster_desc(&basic_cluster_cfg, EZB_ZCL_CLUSTER_SERVER);
ezb_af_endpoint_add_cluster_desc(ep_desc, cluster_desc);
}
ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_MANUFACTURER_NAME_ID,
this->basic_cluster_data_.manufacturer);
ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_MODEL_IDENTIFIER_ID,
this->basic_cluster_data_.model);
ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_DATE_CODE_ID, this->basic_cluster_data_.date);
ezb_af_endpoint_add_cluster_desc(ep_desc, cluster_desc);
}
bool ZigbeeComponent::register_device() {
+102 -40
View File
@@ -17,9 +17,9 @@ import esphome.config_validation as cv
from esphome.const import (
CONF_ACCURACY_DECIMALS,
CONF_AP,
CONF_DEVICE,
CONF_DEVICE_CLASS,
CONF_ID,
CONF_LAMBDA,
CONF_MAX_LENGTH,
CONF_MODEL,
CONF_NAME,
@@ -38,6 +38,7 @@ from .const import (
ANALOG_INPUT_APPTYPE,
BACNET_UNIT_NO_UNITS,
BACNET_UNITS,
CONF_CLUSTER,
CONF_ENDPOINT,
CONF_POWER_SOURCE,
CONF_REPORT,
@@ -48,19 +49,29 @@ from .const import (
ZigbeeAttribute,
)
from .const_esp32 import (
ALLOWED_UNITS,
ATTR_TYPE,
CLUSTER_ID,
CLUSTER_ROLE,
CONF_ATTRIBUTE_ID,
CONF_ATTRIBUTES,
CONF_CLUSTERS,
CONNECT,
DEVICE_ID,
DEVICE_TYPE,
KEY_ZIGBEE_ATTRIBUTES,
KEY_ZIGBEE_EP,
ROLE,
SCALE,
)
from .zigbee_ep_esp32 import add_ep, create_ep, ep_configs
from .zigbee_ep_esp32 import (
ANALOG_INPUT_EP,
BINARY_INPUT_EP,
BINARY_SENSOR_EP_CONFIGS,
SENSOR_EP_CONFIGS,
add_ep,
create_ep,
)
_LOGGER = logging.getLogger(__name__)
@@ -79,8 +90,11 @@ def get_c_type(attr_type: str) -> Any | None:
if "STRING" in attr_type:
return cg.std_string
test = re.match(r"^(DATA|UINT|MAP|ENUM)(\d{1,2})$", attr_type)
if test and test.group(2):
if test:
return getattr(cg, "uint" + get_c_size(test.group(2), [8, 16, 32, 64]))
test = re.match(r"^INT(\d{1,2})$", attr_type)
if test:
return getattr(cg, "int" + get_c_size(test.group(1), [8, 16, 32, 64]))
return None
@@ -92,8 +106,11 @@ def get_cv_by_type(attr_type: str) -> Any | None:
if "STRING" in attr_type:
return cv.string
test = re.match(r"^(DATA|UINT|MAP|ENUM)(\d{1,2})$", attr_type)
if test and test.group(2):
if test:
return cv.positive_int
test = re.match(r"^INT(\d{1,2})$", attr_type)
if test:
return cv.int_
raise cv.Invalid(f"Zigbee: type {attr_type} not supported or implemented")
@@ -104,6 +121,14 @@ def get_default_by_type(attr_type: str) -> str | bool | int | float:
return False
if attr_type in ["SINGLE", "DOUBLE"]:
return float("nan")
test = re.match(r"^(UINT|ENUM)(\d{1,2})$", attr_type)
if test:
# ZCL "invalid value" sentinel for unsigned ints is the maximum (0xFFFF for UINT16)
return 2 ** (int(test.group(2))) - 1
test = re.match(r"^INT(\d{1,2})$", attr_type)
if test:
# ZCL "invalid value" sentinel for signed ints is the minimum (0x8000 for INT16)
return -(1 << (int(test.group(1)) - 1))
return 0
@@ -167,59 +192,84 @@ def setup_attributes(config: ConfigType, clusters: list[dict[str, Any]]) -> None
) # or use unidecode
attr[CONF_VALUE] = str(name)
attr[CONF_MAX_LENGTH] = len(str(name))
if CONF_DEVICE in attr: # connect device
attr[CONF_DEVICE] = config[CONF_ID]
if attr.get(CONNECT): # connect device
if CONF_REPORT in config:
attr[CONF_REPORT] = config[CONF_REPORT]
attr[CONF_ID] = cv.declare_id(ZigbeeAttribute)(None)
if "zb_attr_ids" not in config:
config["zb_attr_ids"] = []
config["zb_attr_ids"].append(attr[CONF_ID])
attr_list = config.setdefault(KEY_ZIGBEE_ATTRIBUTES, [])
attr_list.append(attr)
else:
attr[CONF_ID] = None
validate_attributes(attr)
def validate_sensor_esp32(config: ConfigType) -> ConfigType:
ep = copy.deepcopy(ep_configs["analog_input"])
# 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)
apptype = ANALOG_INPUT_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:
if config[CONF_CLUSTER] == "device_class":
if dev_class not in SENSOR_EP_CONFIGS:
raise cv.Invalid(
"'cluster: device_class' requires a supported 'device_class'. "
f"Supported: {', '.join(SENSOR_EP_CONFIGS)}. Use 'cluster: generic' otherwise."
)
ep = copy.deepcopy(SENSOR_EP_CONFIGS[dev_class])
if unit not in ep[ALLOWED_UNITS]:
raise cv.Invalid(
f"Device class '{dev_class}' requires one of units {', '.join(ep[ALLOWED_UNITS])}."
)
for attr in ep[CONF_CLUSTERS][0][CONF_ATTRIBUTES]:
if isinstance(attr.get(SCALE), dict):
attr[SCALE] = attr[SCALE][unit]
if isinstance(attr.get(CONF_LAMBDA), dict):
attr[CONF_LAMBDA] = attr[CONF_LAMBDA][unit]
else:
ep = copy.deepcopy(ANALOG_INPUT_EP)
apptype = ANALOG_INPUT_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:
ep[CONF_CLUSTERS][0][CONF_ATTRIBUTES].append(
{
CONF_ATTRIBUTE_ID: 0x100,
CONF_VALUE: (apptype << 16) | 0xFFFF,
CONF_TYPE: "UINT32",
},
)
ep[CONF_CLUSTERS][0][CONF_ATTRIBUTES].append(
{
CONF_ATTRIBUTE_ID: 0x100,
CONF_VALUE: (apptype << 16) | 0xFFFF,
CONF_TYPE: "UINT32",
},
)
ep[CONF_CLUSTERS][0][CONF_ATTRIBUTES].append(
{
CONF_ATTRIBUTE_ID: 0x75,
CONF_VALUE: bacunit,
CONF_TYPE: "ENUM16",
},
)
if accuracy is not None:
# Analog Input Resolution (0x006A): smallest reportable change
ep[CONF_CLUSTERS][0][CONF_ATTRIBUTES].append(
{
CONF_ATTRIBUTE_ID: 0x6A,
CONF_VALUE: 10**-accuracy,
CONF_TYPE: "SINGLE",
CONF_ATTRIBUTE_ID: 0x75,
CONF_VALUE: bacunit,
CONF_TYPE: "ENUM16",
},
)
if accuracy is not None:
# Analog Input Resolution (0x006A): smallest reportable change
ep[CONF_CLUSTERS][0][CONF_ATTRIBUTES].append(
{
CONF_ATTRIBUTE_ID: 0x6A,
CONF_VALUE: 10**-accuracy,
CONF_TYPE: "SINGLE",
},
)
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:
ep = copy.deepcopy(ep_configs["binary_input"])
dev_class = config.get(CONF_DEVICE_CLASS)
if config[CONF_CLUSTER] == "device_class":
if dev_class in BINARY_SENSOR_EP_CONFIGS:
ep = copy.deepcopy(BINARY_SENSOR_EP_CONFIGS[dev_class])
else:
raise cv.Invalid(
"'cluster: device_class' requires a supported 'device_class'. "
f"Supported: {', '.join(BINARY_SENSOR_EP_CONFIGS)}. Use 'cluster: generic' otherwise."
)
else:
ep = copy.deepcopy(BINARY_INPUT_EP)
setup_attributes(config, ep[CONF_CLUSTERS])
add_ep(ep, config.get(CONF_ENDPOINT), config.get(CONF_USE_DEVICE_TYPE))
return config
@@ -274,15 +324,11 @@ async def attributes_to_code(
)
await cg.register_component(attr_var, attr)
cg.add(attr_var.add_attr(attr[CONF_VALUE]))
template_arg = cg.TemplateArguments(get_c_type(attr[CONF_TYPE]))
cg.add(attr_var.add_attr(template_arg, attr[CONF_VALUE]))
if CONF_REPORT in attr:
cg.add(attr_var.set_report(attr[CONF_REPORT]))
if CONF_DEVICE in attr:
device = await cg.get_variable(attr[CONF_DEVICE])
template_arg = cg.TemplateArguments(get_c_type(attr[CONF_TYPE]))
cg.add(attr_var.connect(template_arg, device))
async def esp32_to_code(config: ConfigType) -> "MockObj":
add_idf_component(
@@ -332,3 +378,19 @@ async def esp32_to_code(config: ConfigType) -> "MockObj":
)
await attributes_to_code(var, ep_num, cl)
return var
async def add_component(entity: cg.MockObj, config: ConfigType) -> None:
attrs = config.get(KEY_ZIGBEE_ATTRIBUTES, [])
for attr in attrs:
zb_attr = await cg.get_variable(attr[CONF_ID])
template_arg = cg.TemplateArguments(get_c_type(attr[CONF_TYPE]))
if attr.get(CONF_LAMBDA) is not None:
lambda_ = await cg.process_lambda(
attr[CONF_LAMBDA],
[(cg.float_, "x")],
return_type=get_c_type(attr[CONF_TYPE]),
)
cg.add(zb_attr.connect(template_arg, entity, lambda_))
else:
cg.add(zb_attr.connect(template_arg, entity))
@@ -24,6 +24,36 @@ ezb_err_t esphome_zb_add_or_update_cluster(uint16_t cluster_id, ezb_af_ep_desc_t
return ezb_af_endpoint_add_cluster_desc(ep_desc, cluster_desc);
}
ezb_af_ep_desc_t esphome_zb_zha_default_ep_desc_create(uint8_t ep_id, uint16_t device_id, uint8_t power_source) {
ezb_af_ep_desc_t ep_desc;
switch (device_id) {
case EZB_ZHA_LIGHT_SENSOR_DEVICE_ID: {
ezb_zha_light_sensor_config_t config = EZB_ZHA_LIGHT_SENSOR_CONFIG();
config.basic_cfg.power_source = power_source;
ep_desc = ezb_zha_create_light_sensor(ep_id, &config);
break;
}
case EZB_ZHA_TEMPERATURE_SENSOR_DEVICE_ID: {
ezb_zha_temperature_sensor_config_t config = EZB_ZHA_TEMPERATURE_SENSOR_CONFIG();
config.basic_cfg.power_source = power_source;
ep_desc = ezb_zha_create_temperature_sensor(ep_id, &config);
break;
}
default: {
// For other device IDs no macro exists, so we create an empty endpoint descriptor
ezb_af_ep_config_t config = {
.ep_id = ep_id,
.app_profile_id = EZB_AF_HA_PROFILE_ID,
.app_device_id = device_id,
.app_device_version = 0,
};
ep_desc = ezb_af_create_endpoint_desc(&config);
break;
}
}
return ep_desc;
}
ezb_zcl_cluster_desc_t esphome_zb_default_cluster_dscr_create(uint16_t cluster_id, uint8_t role_mask) {
switch (cluster_id) {
case EZB_ZCL_CLUSTER_ID_BASIC:
@@ -36,6 +66,22 @@ ezb_zcl_cluster_desc_t esphome_zb_default_cluster_dscr_create(uint16_t cluster_i
return ezb_zcl_analog_input_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_ILLUMINANCE_MEASUREMENT:
return ezb_zcl_illuminance_measurement_create_cluster_desc(NULL, role_mask);
case EZB_ZCL_CLUSTER_ID_TEMPERATURE_MEASUREMENT:
return ezb_zcl_temperature_measurement_create_cluster_desc(NULL, role_mask);
case EZB_ZCL_CLUSTER_ID_PRESSURE_MEASUREMENT:
return ezb_zcl_pressure_measurement_create_cluster_desc(NULL, role_mask);
case EZB_ZCL_CLUSTER_ID_FLOW_MEASUREMENT:
return ezb_zcl_flow_measurement_create_cluster_desc(NULL, role_mask);
case EZB_ZCL_CLUSTER_ID_REL_HUMIDITY_MEASUREMENT:
return ezb_zcl_rel_humidity_measurement_create_cluster_desc(NULL, role_mask);
case EZB_ZCL_CLUSTER_ID_OCCUPANCY_SENSING:
return ezb_zcl_occupancy_sensing_create_cluster_desc(NULL, role_mask);
case EZB_ZCL_CLUSTER_ID_CARBON_DIOXIDE_MEASUREMENT:
return ezb_zcl_carbon_dioxide_measurement_create_cluster_desc(NULL, role_mask);
case EZB_ZCL_CLUSTER_ID_PM2_5_MEASUREMENT:
return ezb_zcl_pm2_5_measurement_create_cluster_desc(NULL, role_mask);
default: {
ezb_zcl_custom_cluster_config_t config = {0};
config.cluster_id = cluster_id;
@@ -57,6 +103,22 @@ ezb_err_t esphome_zb_cluster_add_attr(uint16_t cluster_id, ezb_zcl_cluster_desc_
return ezb_zcl_analog_input_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_ILLUMINANCE_MEASUREMENT:
return ezb_zcl_illuminance_measurement_cluster_desc_add_attr(cluster_desc, attr_id, value_p);
case EZB_ZCL_CLUSTER_ID_TEMPERATURE_MEASUREMENT:
return ezb_zcl_temperature_measurement_cluster_desc_add_attr(cluster_desc, attr_id, value_p);
case EZB_ZCL_CLUSTER_ID_PRESSURE_MEASUREMENT:
return ezb_zcl_pressure_measurement_cluster_desc_add_attr(cluster_desc, attr_id, value_p);
case EZB_ZCL_CLUSTER_ID_FLOW_MEASUREMENT:
return ezb_zcl_flow_measurement_cluster_desc_add_attr(cluster_desc, attr_id, value_p);
case EZB_ZCL_CLUSTER_ID_REL_HUMIDITY_MEASUREMENT:
return ezb_zcl_rel_humidity_measurement_cluster_desc_add_attr(cluster_desc, attr_id, value_p);
case EZB_ZCL_CLUSTER_ID_OCCUPANCY_SENSING:
return ezb_zcl_occupancy_sensing_cluster_desc_add_attr(cluster_desc, attr_id, value_p);
case EZB_ZCL_CLUSTER_ID_CARBON_DIOXIDE_MEASUREMENT:
return ezb_zcl_carbon_dioxide_measurement_cluster_desc_add_attr(cluster_desc, attr_id, value_p);
case EZB_ZCL_CLUSTER_ID_PM2_5_MEASUREMENT:
return ezb_zcl_pm2_5_measurement_cluster_desc_add_attr(cluster_desc, attr_id, value_p);
default:
return EZB_ERR_NOT_FOUND;
}
@@ -10,6 +10,7 @@ extern "C" {
#include "esp_zigbee.h"
ezb_af_ep_desc_t esphome_zb_zha_default_ep_desc_create(uint8_t ep_id, uint16_t device_id, uint8_t power_source);
ezb_err_t esphome_zb_cluster_add_or_update_attr(uint16_t cluster_id, ezb_zcl_cluster_desc_t cluster_desc,
uint16_t attr_id, void *value_p);
ezb_err_t esphome_zb_add_or_update_cluster(uint16_t cluster_id, ezb_af_ep_desc_t ep_desc, uint8_t role_mask);
+2 -10
View File
@@ -314,14 +314,6 @@ async def _ctx_to_code(config: ConfigType) -> None:
cg.add(cg.RawExpression("ZB_AF_REGISTER_DEVICE_CTX(&zb_device_ctx)"))
async def zephyr_setup_binary_sensor(entity: cg.MockObj, config: ConfigType) -> None:
CORE.add_job(_add_binary_sensor, entity, config)
async def zephyr_setup_sensor(entity: cg.MockObj, config: ConfigType) -> None:
CORE.add_job(_add_sensor, entity, config)
async def zephyr_setup_switch(entity: cg.MockObj, config: ConfigType) -> None:
CORE.add_job(_add_switch, entity, config)
@@ -404,7 +396,7 @@ async def _add_zigbee_ep(
cg.add(var.set_parent(hub))
async def _add_binary_sensor(entity: cg.MockObj, config: ConfigType) -> None:
async def add_binary_sensor(entity: cg.MockObj, config: ConfigType) -> None:
await _add_zigbee_ep(
entity,
config,
@@ -416,7 +408,7 @@ async def _add_binary_sensor(entity: cg.MockObj, config: ConfigType) -> None:
)
async def _add_sensor(entity: cg.MockObj, config: ConfigType) -> None:
async def add_sensor(entity: cg.MockObj, config: ConfigType) -> None:
# Get BACnet engineering unit from unit_of_measurement
unit = config.get(CONF_UNIT_OF_MEASUREMENT, "")
bacnet_unit = BACNET_UNITS.get(unit, BACNET_UNIT_NO_UNITS)
+27 -2
View File
@@ -3,13 +3,19 @@ packages:
binary_sensor:
- platform: template
name: "Garage Door Open 10"
name: "Occupancy Sensor"
report: "default"
use_device_type: false
device_class: occupancy
cluster: device_class
endpoint: 3
- platform: template
name: "Garage Door Open 12"
report: "force"
endpoint: 1
- platform: template
name: "Garage Door Open 13"
endpoint: 2
use_device_type: false
sensor:
- platform: template
@@ -19,6 +25,25 @@ sensor:
unit_of_measurement: "°C"
endpoint: 1
use_device_type: true
cluster: device_class
- platform: template
name: "Light Sensor"
lambda: return 100.0;
device_class: illuminance
unit_of_measurement: "lx"
endpoint: 2
use_device_type: true
cluster: device_class
- platform: template
name: "pressure_sensor"
unit_of_measurement: Pa
device_class: pressure
cluster: device_class
- platform: template
name: "flow_sensor"
unit_of_measurement: L/h
device_class: volume_flow_rate
cluster: device_class
zigbee:
model: zigbee_test
@@ -0,0 +1,36 @@
packages:
zigbee: !include common_esp32.yaml
sensor:
- platform: template
name: "humidity_sensor"
unit_of_measurement: "%"
device_class: humidity
cluster: device_class
endpoint: 2
use_device_type: false
- platform: template
name: "pm25_sensor"
unit_of_measurement: 'µg/m³'
device_class: pm25
cluster: device_class
- platform: template
name: "co2_sensor"
unit_of_measurement: ppm
device_class: carbon_dioxide
cluster: device_class
- platform: template
name: "pressure_sensor2"
unit_of_measurement: hPa
device_class: pressure
cluster: device_class
- platform: template
name: "flow_sensor2"
unit_of_measurement: m³/h
device_class: volume_flow_rate
cluster: device_class
- platform: template
name: "atmospheric_pressure_sensor"
unit_of_measurement: hPa
device_class: atmospheric_pressure
cluster: device_class