From 25b5a6c4ae3f5a951f324206a4c3fd6e818dd54d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Vikstr=C3=B6m?= Date: Fri, 5 Apr 2024 11:53:43 +0200 Subject: [PATCH 001/183] Add device_id to entity_base --- esphome/components/api/api.proto | 2 ++ esphome/components/api/api_connection.cpp | 2 ++ esphome/components/api/api_pb2.cpp | 18 ++++++++++++++++++ esphome/components/api/api_pb2.h | 2 ++ esphome/config_validation.py | 15 +++++++++++++++ esphome/const.py | 2 ++ esphome/core/entity_base.cpp | 9 +++++++++ esphome/core/entity_base.h | 5 +++++ esphome/cpp_helpers.py | 4 ++++ 9 files changed, 59 insertions(+) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index d59b5e0d3e..e90586a42b 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -273,6 +273,7 @@ message ListEntitiesBinarySensorResponse { bool disabled_by_default = 7; string icon = 8; EntityCategory entity_category = 9; + string device_name = 10; } message BinarySensorStateResponse { option (id) = 21; @@ -306,6 +307,7 @@ message ListEntitiesCoverResponse { string icon = 10; EntityCategory entity_category = 11; bool supports_stop = 12; + string device_name = 13; } enum LegacyCoverState { diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 9d7b8c1780..2dddc3b4e0 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -280,6 +280,7 @@ bool APIConnection::try_send_binary_sensor_info(APIConnection *api, void *v_bina msg.disabled_by_default = binary_sensor->is_disabled_by_default(); msg.icon = binary_sensor->get_icon(); msg.entity_category = static_cast(binary_sensor->get_entity_category()); + msg.device_name = binary_sensor->get_device_name(); return api->send_list_entities_binary_sensor_response(msg); } #endif @@ -330,6 +331,7 @@ bool APIConnection::try_send_cover_info(APIConnection *api, void *v_cover) { msg.disabled_by_default = cover->is_disabled_by_default(); msg.icon = cover->get_icon(); msg.entity_category = static_cast(cover->get_entity_category()); + msg.device_name = cover->get_device_name(); return api->send_list_entities_cover_response(msg); } void APIConnection::cover_command(const CoverCommandRequest &msg) { diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 8001a74b6d..f386924d5e 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -1001,6 +1001,10 @@ bool ListEntitiesBinarySensorResponse::decode_length(uint32_t field_id, ProtoLen this->icon = value.as_string(); return true; } + case 10: { + this->device_name = value.as_string(); + return true; + } default: return false; } @@ -1025,6 +1029,7 @@ void ListEntitiesBinarySensorResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(7, this->disabled_by_default); buffer.encode_string(8, this->icon); buffer.encode_enum(9, this->entity_category); + buffer.encode_string(10, this->device_name); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesBinarySensorResponse::dump_to(std::string &out) const { @@ -1066,6 +1071,10 @@ void ListEntitiesBinarySensorResponse::dump_to(std::string &out) const { out.append(" entity_category: "); out.append(proto_enum_to_string(this->entity_category)); out.append("\n"); + + out.append(" device_name: "); + out.append("'").append(this->device_name).append("'"); + out.append("\n"); out.append("}"); } #endif @@ -1169,6 +1178,10 @@ bool ListEntitiesCoverResponse::decode_length(uint32_t field_id, ProtoLengthDeli this->icon = value.as_string(); return true; } + case 13: { + this->device_name = value.as_string(); + return true; + } default: return false; } @@ -1196,6 +1209,7 @@ void ListEntitiesCoverResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(10, this->icon); buffer.encode_enum(11, this->entity_category); buffer.encode_bool(12, this->supports_stop); + buffer.encode_string(13, this->device_name); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesCoverResponse::dump_to(std::string &out) const { @@ -1249,6 +1263,10 @@ void ListEntitiesCoverResponse::dump_to(std::string &out) const { out.append(" supports_stop: "); out.append(YESNO(this->supports_stop)); out.append("\n"); + + out.append(" device_name: "); + out.append("'").append(this->device_name).append("'"); + out.append("\n"); out.append("}"); } #endif diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 455e3ff6cf..247ec0d65a 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -402,6 +402,7 @@ class ListEntitiesBinarySensorResponse : public ProtoMessage { bool disabled_by_default{false}; std::string icon{}; enums::EntityCategory entity_category{}; + std::string device_name{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -440,6 +441,7 @@ class ListEntitiesCoverResponse : public ProtoMessage { std::string icon{}; enums::EntityCategory entity_category{}; bool supports_stop{false}; + std::string device_name{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 858c6e197c..0abbfc1aff 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -21,6 +21,7 @@ from esphome.const import ( CONF_COMMAND_RETAIN, CONF_COMMAND_TOPIC, CONF_DAY, + CONF_DEVICE_ID, CONF_DISABLED_BY_DEFAULT, CONF_DISCOVERY, CONF_ENTITY_CATEGORY, @@ -348,6 +349,18 @@ def icon(value): ) +def device_name(value): + """Validate that a given config value is a valid device name.""" + value = string_strict(value) + if not value: + return value + # if re.match("^[\\w\\-]+:[\\w\\-]+$", value): + # return value + raise Invalid( + 'device name must be string that matches a defined device in "deviced:" section' + ) + + def boolean(value): """Validate the given config option to be a boolean. @@ -1867,6 +1880,8 @@ ENTITY_BASE_SCHEMA = Schema( Optional(CONF_DISABLED_BY_DEFAULT, default=False): boolean, Optional(CONF_ICON): icon, Optional(CONF_ENTITY_CATEGORY): entity_category, + Optional(CONF_DEVICE_ID): device_name, + } ) diff --git a/esphome/const.py b/esphome/const.py index f6f9b7df80..361d8147bd 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -210,8 +210,10 @@ CONF_DELIMITER = "delimiter" CONF_DELTA = "delta" CONF_DEST = "dest" CONF_DEVICE = "device" +CONF_DEVICES = "devices" CONF_DEVICE_CLASS = "device_class" CONF_DEVICE_FACTOR = "device_factor" +CONF_DEVICE_ID = "device_id" CONF_DIELECTRIC_CONSTANT = "dielectric_constant" CONF_DIMENSIONS = "dimensions" CONF_DIO_PIN = "dio_pin" diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index 725a8569a3..883c23e9f3 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -35,6 +35,15 @@ std::string EntityBase::get_icon() const { } void EntityBase::set_icon(const char *icon) { this->icon_c_str_ = icon; } +// Entity Device Name +std::string EntityBase::get_device_name() const { + if (this->device_name_c_str_ == nullptr) { + return ""; + } + return this->device_name_c_str_; +} +void EntityBase::set_device_name(const char *device_name) { this->device_name_c_str_ = device_name; } + // Entity Category EntityCategory EntityBase::get_entity_category() const { return this->entity_category_; } void EntityBase::set_entity_category(EntityCategory entity_category) { this->entity_category_ = entity_category; } diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 4ca21f9ee5..342a1fc042 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -47,6 +47,10 @@ class EntityBase { std::string get_icon() const; void set_icon(const char *icon); + // Get/set this entity's device name + std::string get_device_name() const; + void set_device_name(const char *icon); + protected: /// The hash_base() function has been deprecated. It is kept in this /// class for now, to prevent external components from not compiling. @@ -61,6 +65,7 @@ class EntityBase { bool internal_{false}; bool disabled_by_default_{false}; EntityCategory entity_category_{ENTITY_CATEGORY_NONE}; + const char *device_name_c_str_{nullptr}; }; class EntityBase_DeviceClass { // NOLINT(readability-identifier-naming) diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index 9a775bad33..c1b1828d1c 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -1,6 +1,7 @@ import logging from esphome.const import ( + CONF_DEVICE_ID, CONF_DISABLED_BY_DEFAULT, CONF_ENTITY_CATEGORY, CONF_ICON, @@ -110,6 +111,9 @@ async def setup_entity(var, config): add(var.set_icon(config[CONF_ICON])) if CONF_ENTITY_CATEGORY in config: add(var.set_entity_category(config[CONF_ENTITY_CATEGORY])) + if CONF_DEVICE_ID in config: + # TODO: lookup the device from devices: section and get the real name + add(var.set_device_name(config[CONF_DEVICE_ID])) def extract_registry_entry_config( From 1bd8985dff9e0027dcea6320e735ed828208d5e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Vikstr=C3=B6m?= Date: Fri, 5 Apr 2024 13:50:21 +0200 Subject: [PATCH 002/183] Add a device component --- esphome/components/device/__init__.py | 35 +++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 esphome/components/device/__init__.py diff --git a/esphome/components/device/__init__.py b/esphome/components/device/__init__.py new file mode 100644 index 0000000000..7e45eb9c75 --- /dev/null +++ b/esphome/components/device/__init__.py @@ -0,0 +1,35 @@ +from esphome import config_validation as cv +from esphome import codegen as cg +from esphome.const import CONF_ID, CONF_NAME + +DeviceStruct = cg.esphome_ns.struct("Device") + +MULTI_CONF = True + + +CONFIG_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.declare_id(DeviceStruct), + cv.Required(CONF_NAME): cv.string, + # cv.Exclusive(CONF_RED, "red"): cv.percentage, + # cv.Exclusive(CONF_RED_INT, "red"): cv.uint8_t, + # cv.Exclusive(CONF_GREEN, "green"): cv.percentage, + # cv.Exclusive(CONF_GREEN_INT, "green"): cv.uint8_t, + # cv.Exclusive(CONF_BLUE, "blue"): cv.percentage, + # cv.Exclusive(CONF_BLUE_INT, "blue"): cv.uint8_t, + # cv.Exclusive(CONF_WHITE, "white"): cv.percentage, + # cv.Exclusive(CONF_WHITE_INT, "white"): cv.uint8_t, + }).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config): + # paren = await cg.get_variable(config[CONF_WEB_SERVER_BASE_ID]) + # var = cg.new_Pvariable(config[CONF_ID], paren) + # await cg.register_component(var, config) + # cg.add_define("USE_CAPTIVE_PORTAL") + + cg.new_variable( + config[CONF_ID], + cg.new_Pvariable(config[CONF_NAME]), + ) + # cg.add_define("USE_DEVICE_ID") From a8b76c617c09af7fb73ed1873e6c62c0d61d5acd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Vikstr=C3=B6m?= Date: Sat, 6 Apr 2024 00:03:26 +0200 Subject: [PATCH 003/183] Some basic chain working --- esphome/components/device/__init__.py | 32 ++++++++++++--------------- esphome/components/device/device.h | 14 ++++++++++++ esphome/config_validation.py | 18 +++++---------- esphome/const.py | 1 - esphome/core/entity_base.cpp | 10 ++++----- esphome/core/entity_base.h | 6 ++--- esphome/cpp_helpers.py | 4 ++-- 7 files changed, 44 insertions(+), 41 deletions(-) create mode 100644 esphome/components/device/device.h diff --git a/esphome/components/device/__init__.py b/esphome/components/device/__init__.py index 7e45eb9c75..4d1be53a0b 100644 --- a/esphome/components/device/__init__.py +++ b/esphome/components/device/__init__.py @@ -2,34 +2,30 @@ from esphome import config_validation as cv from esphome import codegen as cg from esphome.const import CONF_ID, CONF_NAME -DeviceStruct = cg.esphome_ns.struct("Device") +# DeviceStruct = cg.esphome_ns.struct("Device") +# StringVar = cg.std_ns.struct("string") +StringRef = cg.esphome_ns.struct("StringRef") MULTI_CONF = True CONFIG_SCHEMA = cv.Schema( { - cv.Required(CONF_ID): cv.declare_id(DeviceStruct), + # cv.Required(CONF_ID): cv.declare_id(DeviceStruct), + # cv.Required(CONF_ID): cv.declare_id(StringVar), + cv.Required(CONF_ID): cv.declare_id(StringRef), cv.Required(CONF_NAME): cv.string, - # cv.Exclusive(CONF_RED, "red"): cv.percentage, - # cv.Exclusive(CONF_RED_INT, "red"): cv.uint8_t, - # cv.Exclusive(CONF_GREEN, "green"): cv.percentage, - # cv.Exclusive(CONF_GREEN_INT, "green"): cv.uint8_t, - # cv.Exclusive(CONF_BLUE, "blue"): cv.percentage, - # cv.Exclusive(CONF_BLUE_INT, "blue"): cv.uint8_t, - # cv.Exclusive(CONF_WHITE, "white"): cv.percentage, - # cv.Exclusive(CONF_WHITE_INT, "white"): cv.uint8_t, - }).extend(cv.COMPONENT_SCHEMA) + } +).extend(cv.COMPONENT_SCHEMA) async def to_code(config): - # paren = await cg.get_variable(config[CONF_WEB_SERVER_BASE_ID]) - # var = cg.new_Pvariable(config[CONF_ID], paren) - # await cg.register_component(var, config) - # cg.add_define("USE_CAPTIVE_PORTAL") - - cg.new_variable( + # cg.new_variable( + # config[CONF_ID], + # config[CONF_NAME], + # ) + cg.new_Pvariable( config[CONF_ID], - cg.new_Pvariable(config[CONF_NAME]), + config[CONF_NAME], ) # cg.add_define("USE_DEVICE_ID") diff --git a/esphome/components/device/device.h b/esphome/components/device/device.h new file mode 100644 index 0000000000..936c48b0da --- /dev/null +++ b/esphome/components/device/device.h @@ -0,0 +1,14 @@ +#pragma once + +namespace esphome { + +class Device { + public: + void set_name(std::string name) { name_ = name; } + std::string get_name(void) {return name_;} + + protected: + std::string name_ = ""; +}; + +} // namespace esphome diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 0abbfc1aff..14a64d2277 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -349,16 +349,10 @@ def icon(value): ) -def device_name(value): - """Validate that a given config value is a valid device name.""" - value = string_strict(value) - if not value: - return value - # if re.match("^[\\w\\-]+:[\\w\\-]+$", value): - # return value - raise Invalid( - 'device name must be string that matches a defined device in "deviced:" section' - ) +def device_id(value): + StringRef = cg.esphome_ns.struct("StringRef") + validator = use_id(StringRef) + return validator(value) def boolean(value): @@ -1880,8 +1874,8 @@ ENTITY_BASE_SCHEMA = Schema( Optional(CONF_DISABLED_BY_DEFAULT, default=False): boolean, Optional(CONF_ICON): icon, Optional(CONF_ENTITY_CATEGORY): entity_category, - Optional(CONF_DEVICE_ID): device_name, - + # Optional(CONF_DEVICE_ID): use_id(StringRef), + Optional(CONF_DEVICE_ID): device_id, } ) diff --git a/esphome/const.py b/esphome/const.py index 361d8147bd..55580e5bcd 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -210,7 +210,6 @@ CONF_DELIMITER = "delimiter" CONF_DELTA = "delta" CONF_DEST = "dest" CONF_DEVICE = "device" -CONF_DEVICES = "devices" CONF_DEVICE_CLASS = "device_class" CONF_DEVICE_FACTOR = "device_factor" CONF_DEVICE_ID = "device_id" diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index 883c23e9f3..15864e793c 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -36,13 +36,13 @@ std::string EntityBase::get_icon() const { void EntityBase::set_icon(const char *icon) { this->icon_c_str_ = icon; } // Entity Device Name -std::string EntityBase::get_device_name() const { - if (this->device_name_c_str_ == nullptr) { - return ""; +StringRef EntityBase::get_device_name() const { + if (this->device_name_.empty()) { + return StringRef(""); } - return this->device_name_c_str_; + return this->device_name_; } -void EntityBase::set_device_name(const char *device_name) { this->device_name_c_str_ = device_name; } +void EntityBase::set_device_name(const StringRef *device_name) { this->device_name_ = *device_name; } // Entity Category EntityCategory EntityBase::get_entity_category() const { return this->entity_category_; } diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 342a1fc042..0f6b222efd 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -48,8 +48,8 @@ class EntityBase { void set_icon(const char *icon); // Get/set this entity's device name - std::string get_device_name() const; - void set_device_name(const char *icon); + StringRef get_device_name() const; + void set_device_name(const StringRef *device_name); protected: /// The hash_base() function has been deprecated. It is kept in this @@ -65,7 +65,7 @@ class EntityBase { bool internal_{false}; bool disabled_by_default_{false}; EntityCategory entity_category_{ENTITY_CATEGORY_NONE}; - const char *device_name_c_str_{nullptr}; + StringRef device_name_; }; class EntityBase_DeviceClass { // NOLINT(readability-identifier-naming) diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index c1b1828d1c..afd951b504 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -112,8 +112,8 @@ async def setup_entity(var, config): if CONF_ENTITY_CATEGORY in config: add(var.set_entity_category(config[CONF_ENTITY_CATEGORY])) if CONF_DEVICE_ID in config: - # TODO: lookup the device from devices: section and get the real name - add(var.set_device_name(config[CONF_DEVICE_ID])) + parent = await get_variable(config[CONF_DEVICE_ID]) + add(var.set_device_name(parent)) def extract_registry_entry_config( From 7b647c3faeedf263b67ec10efffec961f1515969 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Vikstr=C3=B6m?= Date: Sat, 6 Apr 2024 00:08:43 +0200 Subject: [PATCH 004/183] Add a single test --- tests/components/device/common.yaml | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 tests/components/device/common.yaml diff --git a/tests/components/device/common.yaml b/tests/components/device/common.yaml new file mode 100644 index 0000000000..0f24038167 --- /dev/null +++ b/tests/components/device/common.yaml @@ -0,0 +1,11 @@ +device: + - id: other_device + name: Another device + +binary_sensor: + - platform: template + name: Basic sensor + + - platform: template + name: Other device sensor + device_id: other_device From 583e5ea47f4a654a5815ec384b2f206193de601b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Vikstr=C3=B6m?= Date: Sat, 6 Apr 2024 00:13:14 +0200 Subject: [PATCH 005/183] Add code-owner tag --- esphome/components/device/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/device/__init__.py b/esphome/components/device/__init__.py index 4d1be53a0b..b21ab7ec23 100644 --- a/esphome/components/device/__init__.py +++ b/esphome/components/device/__init__.py @@ -8,6 +8,7 @@ StringRef = cg.esphome_ns.struct("StringRef") MULTI_CONF = True +CODEOWNERS = ["@dala318"] CONFIG_SCHEMA = cv.Schema( { From 3b5fbc359f6fb119a6a820870f36dc4e7a4fd569 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Vikstr=C3=B6m?= Date: Sat, 6 Apr 2024 00:29:08 +0200 Subject: [PATCH 006/183] Formating updates --- esphome/components/device/__init__.py | 11 +++-------- esphome/components/device/device.h | 4 +++- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/esphome/components/device/__init__.py b/esphome/components/device/__init__.py index b21ab7ec23..c7ecbb31f1 100644 --- a/esphome/components/device/__init__.py +++ b/esphome/components/device/__init__.py @@ -2,8 +2,8 @@ from esphome import config_validation as cv from esphome import codegen as cg from esphome.const import CONF_ID, CONF_NAME -# DeviceStruct = cg.esphome_ns.struct("Device") -# StringVar = cg.std_ns.struct("string") +# ns = cg.esphome_ns.namespace("device") +# DeviceClass = ns.Class("Device") StringRef = cg.esphome_ns.struct("StringRef") MULTI_CONF = True @@ -12,8 +12,7 @@ CODEOWNERS = ["@dala318"] CONFIG_SCHEMA = cv.Schema( { - # cv.Required(CONF_ID): cv.declare_id(DeviceStruct), - # cv.Required(CONF_ID): cv.declare_id(StringVar), + # cv.Required(CONF_ID): cv.declare_id(DeviceClass), cv.Required(CONF_ID): cv.declare_id(StringRef), cv.Required(CONF_NAME): cv.string, } @@ -21,10 +20,6 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config): - # cg.new_variable( - # config[CONF_ID], - # config[CONF_NAME], - # ) cg.new_Pvariable( config[CONF_ID], config[CONF_NAME], diff --git a/esphome/components/device/device.h b/esphome/components/device/device.h index 936c48b0da..49a7b88704 100644 --- a/esphome/components/device/device.h +++ b/esphome/components/device/device.h @@ -1,14 +1,16 @@ #pragma once namespace esphome { +namespace device { class Device { public: void set_name(std::string name) { name_ = name; } - std::string get_name(void) {return name_;} + std::string get_name(void) { return name_; } protected: std::string name_ = ""; }; +} // namespace device } // namespace esphome From 68ecc0811149a2a7b3719bea99883c6770e5494c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Vikstr=C3=B6m?= Date: Wed, 9 Apr 2025 00:11:05 +0200 Subject: [PATCH 007/183] Register device_id to entity and separate struct for all device info --- esphome/components/api/api.proto | 12 +++++++++-- esphome/components/api/api_connection.cpp | 4 ++-- esphome/components/api/api_pb2.cpp | 16 +++++++-------- esphome/components/api/api_pb2.h | 4 ++-- .../{device => devices}/__init__.py | 9 ++++----- .../{device/device.h => devices/devices.h} | 8 +++++--- esphome/core/application.h | 20 +++++++++++++++++++ esphome/core/defines.h | 1 + esphome/core/entity_base.cpp | 8 ++++---- esphome/core/entity_base.h | 6 +++--- esphome/cpp_helpers.py | 6 +++++- tests/components/device/common.yaml | 2 +- 12 files changed, 65 insertions(+), 31 deletions(-) rename esphome/components/{device => devices}/__init__.py (71%) rename esphome/components/{device/device.h => devices/devices.h} (62%) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index e90586a42b..10f5aace5e 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -185,6 +185,12 @@ message DeviceInfoRequest { // Empty } +message SubDeviceInfo { + string id = 1; + string name = 2; + string suggested_area = 3; +} + message DeviceInfoResponse { option (id) = 10; option (source) = SOURCE_SERVER; @@ -230,6 +236,8 @@ message DeviceInfoResponse { // The Bluetooth mac address of the device. For example "AC:BC:32:89:0E:AA" string bluetooth_mac_address = 18; + + repeated SubDeviceInfo sub_devices = 19; } message ListEntitiesRequest { @@ -273,7 +281,7 @@ message ListEntitiesBinarySensorResponse { bool disabled_by_default = 7; string icon = 8; EntityCategory entity_category = 9; - string device_name = 10; + string device_id = 10; } message BinarySensorStateResponse { option (id) = 21; @@ -307,7 +315,7 @@ message ListEntitiesCoverResponse { string icon = 10; EntityCategory entity_category = 11; bool supports_stop = 12; - string device_name = 13; + string device_id = 13; } enum LegacyCoverState { diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 2dddc3b4e0..2fdf95192b 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -280,7 +280,7 @@ bool APIConnection::try_send_binary_sensor_info(APIConnection *api, void *v_bina msg.disabled_by_default = binary_sensor->is_disabled_by_default(); msg.icon = binary_sensor->get_icon(); msg.entity_category = static_cast(binary_sensor->get_entity_category()); - msg.device_name = binary_sensor->get_device_name(); + msg.device_id = binary_sensor->get_device_id(); return api->send_list_entities_binary_sensor_response(msg); } #endif @@ -331,7 +331,7 @@ bool APIConnection::try_send_cover_info(APIConnection *api, void *v_cover) { msg.disabled_by_default = cover->is_disabled_by_default(); msg.icon = cover->get_icon(); msg.entity_category = static_cast(cover->get_entity_category()); - msg.device_name = cover->get_device_name(); + msg.device_id = cover->get_device_id(); return api->send_list_entities_cover_response(msg); } void APIConnection::cover_command(const CoverCommandRequest &msg) { diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index f386924d5e..61a53e4a0c 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -1002,7 +1002,7 @@ bool ListEntitiesBinarySensorResponse::decode_length(uint32_t field_id, ProtoLen return true; } case 10: { - this->device_name = value.as_string(); + this->device_id = value.as_string(); return true; } default: @@ -1029,7 +1029,7 @@ void ListEntitiesBinarySensorResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(7, this->disabled_by_default); buffer.encode_string(8, this->icon); buffer.encode_enum(9, this->entity_category); - buffer.encode_string(10, this->device_name); + buffer.encode_string(10, this->device_id); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesBinarySensorResponse::dump_to(std::string &out) const { @@ -1072,8 +1072,8 @@ void ListEntitiesBinarySensorResponse::dump_to(std::string &out) const { out.append(proto_enum_to_string(this->entity_category)); out.append("\n"); - out.append(" device_name: "); - out.append("'").append(this->device_name).append("'"); + out.append(" device_id: "); + out.append("'").append(this->device_id).append("'"); out.append("\n"); out.append("}"); } @@ -1179,7 +1179,7 @@ bool ListEntitiesCoverResponse::decode_length(uint32_t field_id, ProtoLengthDeli return true; } case 13: { - this->device_name = value.as_string(); + this->device_id = value.as_string(); return true; } default: @@ -1209,7 +1209,7 @@ void ListEntitiesCoverResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(10, this->icon); buffer.encode_enum(11, this->entity_category); buffer.encode_bool(12, this->supports_stop); - buffer.encode_string(13, this->device_name); + buffer.encode_string(13, this->device_id); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesCoverResponse::dump_to(std::string &out) const { @@ -1264,8 +1264,8 @@ void ListEntitiesCoverResponse::dump_to(std::string &out) const { out.append(YESNO(this->supports_stop)); out.append("\n"); - out.append(" device_name: "); - out.append("'").append(this->device_name).append("'"); + out.append(" device_id: "); + out.append("'").append(this->device_id).append("'"); out.append("\n"); out.append("}"); } diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 247ec0d65a..fc1b71e8ee 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -402,7 +402,7 @@ class ListEntitiesBinarySensorResponse : public ProtoMessage { bool disabled_by_default{false}; std::string icon{}; enums::EntityCategory entity_category{}; - std::string device_name{}; + std::string device_id{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -441,7 +441,7 @@ class ListEntitiesCoverResponse : public ProtoMessage { std::string icon{}; enums::EntityCategory entity_category{}; bool supports_stop{false}; - std::string device_name{}; + std::string device_id{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; diff --git a/esphome/components/device/__init__.py b/esphome/components/devices/__init__.py similarity index 71% rename from esphome/components/device/__init__.py rename to esphome/components/devices/__init__.py index c7ecbb31f1..c8249f6f91 100644 --- a/esphome/components/device/__init__.py +++ b/esphome/components/devices/__init__.py @@ -1,9 +1,8 @@ -from esphome import config_validation as cv -from esphome import codegen as cg +from esphome import codegen as cg, config_validation as cv from esphome.const import CONF_ID, CONF_NAME -# ns = cg.esphome_ns.namespace("device") -# DeviceClass = ns.Class("Device") +# ns = cg.esphome_ns.namespace("devices") +# DeviceClass = ns.Class("SubDevice") StringRef = cg.esphome_ns.struct("StringRef") MULTI_CONF = True @@ -24,4 +23,4 @@ async def to_code(config): config[CONF_ID], config[CONF_NAME], ) - # cg.add_define("USE_DEVICE_ID") + cg.add_define("USE_SUB_DEVICE") diff --git a/esphome/components/device/device.h b/esphome/components/devices/devices.h similarity index 62% rename from esphome/components/device/device.h rename to esphome/components/devices/devices.h index 49a7b88704..80d7d9923c 100644 --- a/esphome/components/device/device.h +++ b/esphome/components/devices/devices.h @@ -1,16 +1,18 @@ #pragma once namespace esphome { -namespace device { +namespace devices { -class Device { +class SubDevice { public: void set_name(std::string name) { name_ = name; } std::string get_name(void) { return name_; } protected: + // std::string id_ = ""; std::string name_ = ""; + std::string suggested_area_ = ""; }; -} // namespace device +} // namespace devices } // namespace esphome diff --git a/esphome/core/application.h b/esphome/core/application.h index 462beb1f25..4336ea43d5 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -9,6 +9,9 @@ #include "esphome/core/preferences.h" #include "esphome/core/scheduler.h" +#ifdef USE_SUB_DEVICE +#include "esphome/components/devices/devices.h" +#endif #ifdef USE_BINARY_SENSOR #include "esphome/components/binary_sensor/binary_sensor.h" #endif @@ -97,6 +100,10 @@ class Application { this->compilation_time_ = compilation_time; } +#ifdef USE_SUB_DEVICE + void register_sub_device(devices::SubDevice *sub_device) { this->sub_devices_.push_back(sub_device); } +#endif + #ifdef USE_BINARY_SENSOR void register_binary_sensor(binary_sensor::BinarySensor *binary_sensor) { this->binary_sensors_.push_back(binary_sensor); @@ -243,6 +250,16 @@ class Application { uint32_t get_app_state() const { return this->app_state_; } +#ifdef USE_SUB_DEVICE + const std::vector &get_sub_devices() { return this->sub_devices_; } + // devices::SubDevice *get_sub_device_by_key(uint32_t key, bool include_internal = false) { + // for (auto *obj : this->sub_devices_) { + // if (obj->get_object_id_hash() == key && (include_internal || !obj->is_internal())) + // return obj; + // } + // return nullptr; + // } +#endif #ifdef USE_BINARY_SENSOR const std::vector &get_binary_sensors() { return this->binary_sensors_; } binary_sensor::BinarySensor *get_binary_sensor_by_key(uint32_t key, bool include_internal = false) { @@ -473,6 +490,9 @@ class Application { std::vector components_{}; std::vector looping_components_{}; +#ifdef USE_SUB_DEVICE + std::vector sub_devices_{}; +#endif #ifdef USE_BINARY_SENSOR std::vector binary_sensors_{}; #endif diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 64de41f23a..464ee800d4 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -99,6 +99,7 @@ #define USE_SELECT #define USE_SENSOR #define USE_STATUS_LED +#define USE_SUB_DEVICE #define USE_SWITCH #define USE_TEXT #define USE_TEXT_SENSOR diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index 15864e793c..a08cab622a 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -36,13 +36,13 @@ std::string EntityBase::get_icon() const { void EntityBase::set_icon(const char *icon) { this->icon_c_str_ = icon; } // Entity Device Name -StringRef EntityBase::get_device_name() const { - if (this->device_name_.empty()) { +StringRef EntityBase::get_device_id() const { + if (this->device_id_.empty()) { return StringRef(""); } - return this->device_name_; + return this->device_id_; } -void EntityBase::set_device_name(const StringRef *device_name) { this->device_name_ = *device_name; } +void EntityBase::set_device_id(const StringRef *device_id) { this->device_id_ = *device_id; } // Entity Category EntityCategory EntityBase::get_entity_category() const { return this->entity_category_; } diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 0f6b222efd..6975c524f6 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -48,8 +48,8 @@ class EntityBase { void set_icon(const char *icon); // Get/set this entity's device name - StringRef get_device_name() const; - void set_device_name(const StringRef *device_name); + StringRef get_device_id() const; + void set_device_id(const StringRef *device_id); protected: /// The hash_base() function has been deprecated. It is kept in this @@ -65,7 +65,7 @@ class EntityBase { bool internal_{false}; bool disabled_by_default_{false}; EntityCategory entity_category_{ENTITY_CATEGORY_NONE}; - StringRef device_name_; + StringRef device_id_; }; class EntityBase_DeviceClass { // NOLINT(readability-identifier-naming) diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index afd951b504..df191bafe2 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -89,6 +89,10 @@ async def register_component(var, config): return var +# async def register_sub_device(var, value): +# pass + + async def register_parented(var, value): if isinstance(value, ID): paren = await get_variable(value) @@ -113,7 +117,7 @@ async def setup_entity(var, config): add(var.set_entity_category(config[CONF_ENTITY_CATEGORY])) if CONF_DEVICE_ID in config: parent = await get_variable(config[CONF_DEVICE_ID]) - add(var.set_device_name(parent)) + add(var.set_device_id(parent)) def extract_registry_entry_config( diff --git a/tests/components/device/common.yaml b/tests/components/device/common.yaml index 0f24038167..232bb631c9 100644 --- a/tests/components/device/common.yaml +++ b/tests/components/device/common.yaml @@ -1,4 +1,4 @@ -device: +devices: - id: other_device name: Another device From e79e244eee0ae9ff454dc5ffec48fbe813682907 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Vikstr=C3=B6m?= Date: Wed, 9 Apr 2025 00:43:40 +0200 Subject: [PATCH 008/183] Fix generated proto-files --- .github/workflows/ci-api-proto.yml | 2 ++ esphome/components/api/api_pb2.cpp | 54 ++++++++++++++++++++++++++++++ esphome/components/api/api_pb2.h | 14 ++++++++ 3 files changed, 70 insertions(+) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 233fb64693..a57ea17eb4 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -37,6 +37,8 @@ jobs: run: pip install aioesphomeapi -c requirements.txt -r requirements_dev.txt - name: Generate files run: script/api_protobuf/api_protobuf.py + - name: Show changes + run: git diff - name: Check for changes run: | if ! git diff --quiet; then diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 61a53e4a0c..6f7fcf3604 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -762,6 +762,47 @@ void DeviceInfoRequest::encode(ProtoWriteBuffer buffer) const {} #ifdef HAS_PROTO_MESSAGE_DUMP void DeviceInfoRequest::dump_to(std::string &out) const { out.append("DeviceInfoRequest {}"); } #endif +bool SubDeviceInfo::decode_length(uint32_t field_id, ProtoLengthDelimited value) { + switch (field_id) { + case 1: { + this->id = value.as_string(); + return true; + } + case 2: { + this->name = value.as_string(); + return true; + } + case 3: { + this->suggested_area = value.as_string(); + return true; + } + default: + return false; + } +} +void SubDeviceInfo::encode(ProtoWriteBuffer buffer) const { + buffer.encode_string(1, this->id); + buffer.encode_string(2, this->name); + buffer.encode_string(3, this->suggested_area); +} +#ifdef HAS_PROTO_MESSAGE_DUMP +void SubDeviceInfo::dump_to(std::string &out) const { + __attribute__((unused)) char buffer[64]; + out.append("SubDeviceInfo {\n"); + out.append(" id: "); + out.append("'").append(this->id).append("'"); + out.append("\n"); + + out.append(" name: "); + out.append("'").append(this->name).append("'"); + out.append("\n"); + + out.append(" suggested_area: "); + out.append("'").append(this->suggested_area).append("'"); + out.append("\n"); + out.append("}"); +} +#endif bool DeviceInfoResponse::decode_varint(uint32_t field_id, ProtoVarInt value) { switch (field_id) { case 1: { @@ -842,6 +883,10 @@ bool DeviceInfoResponse::decode_length(uint32_t field_id, ProtoLengthDelimited v this->bluetooth_mac_address = value.as_string(); return true; } + case 19: { + this->sub_devices.push_back(value.as_message()); + return true; + } default: return false; } @@ -865,6 +910,9 @@ void DeviceInfoResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(17, this->voice_assistant_feature_flags); buffer.encode_string(16, this->suggested_area); buffer.encode_string(18, this->bluetooth_mac_address); + for (auto &it : this->sub_devices) { + buffer.encode_message(19, it, true); + } } #ifdef HAS_PROTO_MESSAGE_DUMP void DeviceInfoResponse::dump_to(std::string &out) const { @@ -946,6 +994,12 @@ void DeviceInfoResponse::dump_to(std::string &out) const { out.append(" bluetooth_mac_address: "); out.append("'").append(this->bluetooth_mac_address).append("'"); out.append("\n"); + + for (const auto &it : this->sub_devices) { + out.append(" sub_devices: "); + it.dump_to(out); + out.append("\n"); + } out.append("}"); } #endif diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index fc1b71e8ee..913e375cbf 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -335,6 +335,19 @@ class DeviceInfoRequest : public ProtoMessage { protected: }; +class SubDeviceInfo : public ProtoMessage { + public: + std::string id{}; + std::string name{}; + std::string suggested_area{}; + void encode(ProtoWriteBuffer buffer) const override; +#ifdef HAS_PROTO_MESSAGE_DUMP + void dump_to(std::string &out) const override; +#endif + + protected: + bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; +}; class DeviceInfoResponse : public ProtoMessage { public: bool uses_password{false}; @@ -355,6 +368,7 @@ class DeviceInfoResponse : public ProtoMessage { uint32_t voice_assistant_feature_flags{0}; std::string suggested_area{}; std::string bluetooth_mac_address{}; + std::vector sub_devices{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; From c1fd597757bacc127b1c614995035bc4a838b785 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Vikstr=C3=B6m?= Date: Wed, 9 Apr 2025 01:12:14 +0200 Subject: [PATCH 009/183] Add CODEOWNER --- CODEOWNERS | 1 + 1 file changed, 1 insertion(+) diff --git a/CODEOWNERS b/CODEOWNERS index f6f7ac6f9c..2fdf6cc155 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -116,6 +116,7 @@ esphome/components/dashboard_import/* @esphome/core esphome/components/datetime/* @jesserockz @rfdarter esphome/components/debug/* @OttoWinter esphome/components/delonghi/* @grob6000 +esphome/components/devices/* @dala318 esphome/components/dfplayer/* @glmnet esphome/components/dfrobot_sen0395/* @niklasweber esphome/components/dht/* @OttoWinter From 01ac59ce2afc1f694aaa858a1c2b5868c53f2806 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Vikstr=C3=B6m?= Date: Wed, 9 Apr 2025 01:15:48 +0200 Subject: [PATCH 010/183] Store proto with all additions but commented out --- esphome/components/api/api.proto | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 10f5aace5e..9087ff18e2 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -387,6 +387,7 @@ message ListEntitiesFanResponse { string icon = 10; EntityCategory entity_category = 11; repeated string supported_preset_modes = 12; + // string device_id = 13; } enum FanSpeed { FAN_SPEED_LOW = 0; @@ -467,6 +468,7 @@ message ListEntitiesLightResponse { bool disabled_by_default = 13; string icon = 14; EntityCategory entity_category = 15; + // string device_id = 16; } message LightStateResponse { option (id) = 24; @@ -557,6 +559,7 @@ message ListEntitiesSensorResponse { SensorLastResetType legacy_last_reset_type = 11; bool disabled_by_default = 12; EntityCategory entity_category = 13; + // string device_id = 14; } message SensorStateResponse { option (id) = 25; @@ -587,6 +590,7 @@ message ListEntitiesSwitchResponse { bool disabled_by_default = 7; EntityCategory entity_category = 8; string device_class = 9; + // string device_id = 10; } message SwitchStateResponse { option (id) = 26; @@ -622,6 +626,7 @@ message ListEntitiesTextSensorResponse { bool disabled_by_default = 6; EntityCategory entity_category = 7; string device_class = 8; + // string device_id = 9; } message TextSensorStateResponse { option (id) = 27; @@ -785,6 +790,7 @@ message ListEntitiesCameraResponse { bool disabled_by_default = 5; string icon = 6; EntityCategory entity_category = 7; + // string device_id = 8; } message CameraImageResponse { @@ -886,6 +892,7 @@ message ListEntitiesClimateResponse { bool supports_target_humidity = 23; float visual_min_humidity = 24; float visual_max_humidity = 25; + // string device_id = 26; } message ClimateStateResponse { option (id) = 47; @@ -965,6 +972,7 @@ message ListEntitiesNumberResponse { string unit_of_measurement = 11; NumberMode mode = 12; string device_class = 13; + // string device_id = 14; } message NumberStateResponse { option (id) = 50; @@ -1003,6 +1011,7 @@ message ListEntitiesSelectResponse { repeated string options = 6; bool disabled_by_default = 7; EntityCategory entity_category = 8; + // string device_id = 9; } message SelectStateResponse { option (id) = 53; @@ -1061,6 +1070,7 @@ message ListEntitiesLockResponse { // Not yet implemented: string code_format = 11; + // string device_id = 12; } message LockStateResponse { option (id) = 59; @@ -1098,6 +1108,7 @@ message ListEntitiesButtonResponse { bool disabled_by_default = 6; EntityCategory entity_category = 7; string device_class = 8; + // string device_id = 9; } message ButtonCommandRequest { option (id) = 62; @@ -1152,6 +1163,8 @@ message ListEntitiesMediaPlayerResponse { bool supports_pause = 8; repeated MediaPlayerSupportedFormat supported_formats = 9; + + // string device_id = 10; } message MediaPlayerStateResponse { option (id) = 64; @@ -1658,6 +1671,7 @@ message ListEntitiesAlarmControlPanelResponse { uint32 supported_features = 8; bool requires_code = 9; bool requires_code_to_arm = 10; + // string device_id = 11; } message AlarmControlPanelStateResponse { @@ -1701,6 +1715,7 @@ message ListEntitiesTextResponse { uint32 max_length = 9; string pattern = 10; TextMode mode = 11; + // string device_id = 12; } message TextStateResponse { option (id) = 98; @@ -1739,6 +1754,7 @@ message ListEntitiesDateResponse { string icon = 5; bool disabled_by_default = 6; EntityCategory entity_category = 7; + // string device_id = 8; } message DateStateResponse { option (id) = 101; @@ -1780,6 +1796,7 @@ message ListEntitiesTimeResponse { string icon = 5; bool disabled_by_default = 6; EntityCategory entity_category = 7; + // string device_id = 8; } message TimeStateResponse { option (id) = 104; @@ -1824,6 +1841,7 @@ message ListEntitiesEventResponse { string device_class = 8; repeated string event_types = 9; + // string device_id = 10; } message EventResponse { option (id) = 108; @@ -1853,6 +1871,7 @@ message ListEntitiesValveResponse { bool assumed_state = 9; bool supports_position = 10; bool supports_stop = 11; + // string device_id = 12; } enum ValveOperation { @@ -1897,6 +1916,7 @@ message ListEntitiesDateTimeResponse { string icon = 5; bool disabled_by_default = 6; EntityCategory entity_category = 7; + // string device_id = 8; } message DateTimeStateResponse { option (id) = 113; @@ -1935,6 +1955,7 @@ message ListEntitiesUpdateResponse { bool disabled_by_default = 6; EntityCategory entity_category = 7; string device_class = 8; + // string device_id = 9; } message UpdateStateResponse { option (id) = 117; From 0651f7cb3ca479b3965efa1e3c0a46b6a0382605 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Vikstr=C3=B6m?= Date: Wed, 9 Apr 2025 01:39:24 +0200 Subject: [PATCH 011/183] Work on sub-device creation --- esphome/components/devices/__init__.py | 29 ++++++++++++++++++-------- esphome/components/devices/devices.h | 4 +++- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/esphome/components/devices/__init__.py b/esphome/components/devices/__init__.py index c8249f6f91..b38c051259 100644 --- a/esphome/components/devices/__init__.py +++ b/esphome/components/devices/__init__.py @@ -1,8 +1,8 @@ from esphome import codegen as cg, config_validation as cv -from esphome.const import CONF_ID, CONF_NAME +from esphome.const import CONF_AREA, CONF_ID, CONF_NAME -# ns = cg.esphome_ns.namespace("devices") -# DeviceClass = ns.Class("SubDevice") +ns = cg.esphome_ns.namespace("devices") +DeviceClass = ns.Class("SubDevice") StringRef = cg.esphome_ns.struct("StringRef") MULTI_CONF = True @@ -11,16 +11,27 @@ CODEOWNERS = ["@dala318"] CONFIG_SCHEMA = cv.Schema( { - # cv.Required(CONF_ID): cv.declare_id(DeviceClass), - cv.Required(CONF_ID): cv.declare_id(StringRef), + cv.GenerateID(CONF_ID): cv.declare_id(DeviceClass), + # cv.Required(CONF_NAME): cv.declare_id(StringRef), + # cv.Optional(CONF_AREA, ""): cv.declare_id(StringRef), cv.Required(CONF_NAME): cv.string, + cv.Optional(CONF_AREA, ""): cv.string, } ).extend(cv.COMPONENT_SCHEMA) async def to_code(config): - cg.new_Pvariable( - config[CONF_ID], - config[CONF_NAME], - ) + dev = cg.new_Pvariable(config[CONF_ID]) + cg.add(dev.set_name(config[CONF_NAME])) + if CONF_AREA in config: + cg.add(dev.set_area(config[CONF_AREA])) + cg.add(cg.App.register_sub_device(dev)) + # cg.add( + # cg.App.register_sub_device( + # config[CONF_ID], + # config[CONF_NAME], + # config[CONF_AREA], + # # config.get(CONF_COMMENT, ""), + # ) + # ) cg.add_define("USE_SUB_DEVICE") diff --git a/esphome/components/devices/devices.h b/esphome/components/devices/devices.h index 80d7d9923c..a9e8f311fa 100644 --- a/esphome/components/devices/devices.h +++ b/esphome/components/devices/devices.h @@ -7,11 +7,13 @@ class SubDevice { public: void set_name(std::string name) { name_ = name; } std::string get_name(void) { return name_; } + void set_area(std::string area) { area_ = area; } + std::string get_area(void) { return area_; } protected: // std::string id_ = ""; std::string name_ = ""; - std::string suggested_area_ = ""; + std::string area_ = ""; }; } // namespace devices From 2c01bc5795c19adbe01006f23fcb12c482e09293 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Vikstr=C3=B6m?= Date: Wed, 9 Apr 2025 15:22:40 +0200 Subject: [PATCH 012/183] Fix clang-tidy --- esphome/components/devices/devices.h | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/devices/devices.h b/esphome/components/devices/devices.h index a9e8f311fa..96e3b84887 100644 --- a/esphome/components/devices/devices.h +++ b/esphome/components/devices/devices.h @@ -5,10 +5,10 @@ namespace devices { class SubDevice { public: - void set_name(std::string name) { name_ = name; } - std::string get_name(void) { return name_; } - void set_area(std::string area) { area_ = area; } - std::string get_area(void) { return area_; } + void set_name(std::string name) { name_ = std::move(name); } + std::string get_name() { return name_; } + void set_area(std::string area) { area_ = std::move(area); } + std::string get_area() { return area_; } protected: // std::string id_ = ""; From 962e0c4c336be1869a6d5960074fadb5122e59c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Vikstr=C3=B6m?= Date: Wed, 9 Apr 2025 19:09:31 +0200 Subject: [PATCH 013/183] Make it a Class but only use the id in entities --- esphome/components/devices/__init__.py | 23 ++++++----------------- esphome/components/devices/devices.h | 6 +++++- esphome/config_validation.py | 10 +++++----- esphome/core/entity_base.cpp | 4 ++-- esphome/core/entity_base.h | 6 +++--- esphome/cpp_helpers.py | 2 +- 6 files changed, 22 insertions(+), 29 deletions(-) diff --git a/esphome/components/devices/__init__.py b/esphome/components/devices/__init__.py index b38c051259..5a70be82a7 100644 --- a/esphome/components/devices/__init__.py +++ b/esphome/components/devices/__init__.py @@ -1,9 +1,8 @@ from esphome import codegen as cg, config_validation as cv from esphome.const import CONF_AREA, CONF_ID, CONF_NAME -ns = cg.esphome_ns.namespace("devices") -DeviceClass = ns.Class("SubDevice") -StringRef = cg.esphome_ns.struct("StringRef") +devices_ns = cg.esphome_ns.namespace("devices") +SubDevice = devices_ns.class_("SubDevice") MULTI_CONF = True @@ -11,27 +10,17 @@ CODEOWNERS = ["@dala318"] CONFIG_SCHEMA = cv.Schema( { - cv.GenerateID(CONF_ID): cv.declare_id(DeviceClass), - # cv.Required(CONF_NAME): cv.declare_id(StringRef), - # cv.Optional(CONF_AREA, ""): cv.declare_id(StringRef), + cv.GenerateID(CONF_ID): cv.declare_id(SubDevice), cv.Required(CONF_NAME): cv.string, - cv.Optional(CONF_AREA, ""): cv.string, + cv.Optional(CONF_AREA, default=""): cv.string, } ).extend(cv.COMPONENT_SCHEMA) async def to_code(config): dev = cg.new_Pvariable(config[CONF_ID]) + cg.add(dev.set_id(str(config[CONF_ID]))) cg.add(dev.set_name(config[CONF_NAME])) - if CONF_AREA in config: - cg.add(dev.set_area(config[CONF_AREA])) + cg.add(dev.set_area(config[CONF_AREA])) cg.add(cg.App.register_sub_device(dev)) - # cg.add( - # cg.App.register_sub_device( - # config[CONF_ID], - # config[CONF_NAME], - # config[CONF_AREA], - # # config.get(CONF_COMMENT, ""), - # ) - # ) cg.add_define("USE_SUB_DEVICE") diff --git a/esphome/components/devices/devices.h b/esphome/components/devices/devices.h index 96e3b84887..d8bd0d70a3 100644 --- a/esphome/components/devices/devices.h +++ b/esphome/components/devices/devices.h @@ -1,17 +1,21 @@ #pragma once +#include "esphome/core/string_ref.h" + namespace esphome { namespace devices { class SubDevice { public: + void set_id(std::string id) { id_ = std::move(id); } + std::string get_id() { return id_; } void set_name(std::string name) { name_ = std::move(name); } std::string get_name() { return name_; } void set_area(std::string area) { area_ = std::move(area); } std::string get_area() { return area_; } protected: - // std::string id_ = ""; + std::string id_ = ""; std::string name_ = ""; std::string area_ = ""; }; diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 14a64d2277..f883b6fed9 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -349,9 +349,10 @@ def icon(value): ) -def device_id(value): - StringRef = cg.esphome_ns.struct("StringRef") - validator = use_id(StringRef) +def sub_device_id(value): + devices_ns = cg.esphome_ns.namespace("devices") + SubDevice = devices_ns.class_("SubDevice") + validator = use_id(SubDevice) return validator(value) @@ -1874,8 +1875,7 @@ ENTITY_BASE_SCHEMA = Schema( Optional(CONF_DISABLED_BY_DEFAULT, default=False): boolean, Optional(CONF_ICON): icon, Optional(CONF_ENTITY_CATEGORY): entity_category, - # Optional(CONF_DEVICE_ID): use_id(StringRef), - Optional(CONF_DEVICE_ID): device_id, + Optional(CONF_DEVICE_ID): sub_device_id, } ) diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index a08cab622a..8073879982 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -36,13 +36,13 @@ std::string EntityBase::get_icon() const { void EntityBase::set_icon(const char *icon) { this->icon_c_str_ = icon; } // Entity Device Name -StringRef EntityBase::get_device_id() const { +const StringRef &EntityBase::get_device_id() const { if (this->device_id_.empty()) { return StringRef(""); } return this->device_id_; } -void EntityBase::set_device_id(const StringRef *device_id) { this->device_id_ = *device_id; } +void EntityBase::set_device_id(const std::string device_id) { this->device_id_ = StringRef(device_id); } // Entity Category EntityCategory EntityBase::get_entity_category() const { return this->entity_category_; } diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 6975c524f6..e52406c425 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -47,9 +47,9 @@ class EntityBase { std::string get_icon() const; void set_icon(const char *icon); - // Get/set this entity's device name - StringRef get_device_id() const; - void set_device_id(const StringRef *device_id); + // Get/set this entity's device id + const StringRef &get_device_id() const; + void set_device_id(const std::string device_id); protected: /// The hash_base() function has been deprecated. It is kept in this diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index df191bafe2..bfc9b3dc9b 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -117,7 +117,7 @@ async def setup_entity(var, config): add(var.set_entity_category(config[CONF_ENTITY_CATEGORY])) if CONF_DEVICE_ID in config: parent = await get_variable(config[CONF_DEVICE_ID]) - add(var.set_device_id(parent)) + add(var.set_device_id(parent.get_id())) def extract_registry_entry_config( From 32f4e4ca130188895cb1eafdd6b17c5f05937515 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Vikstr=C3=B6m?= Date: Wed, 9 Apr 2025 19:20:28 +0200 Subject: [PATCH 014/183] Cleaning up --- esphome/core/application.h | 2 ++ esphome/core/entity_base.cpp | 2 +- esphome/cpp_helpers.py | 4 ---- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index 4336ea43d5..2691f760a3 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -252,6 +252,8 @@ class Application { #ifdef USE_SUB_DEVICE const std::vector &get_sub_devices() { return this->sub_devices_; } + // /* Very likely no need for get_sub_device_by_key as it only seem to be used when requesting update from API + // and the sub_devices shaould only be sent once at connection. */ // devices::SubDevice *get_sub_device_by_key(uint32_t key, bool include_internal = false) { // for (auto *obj : this->sub_devices_) { // if (obj->get_object_id_hash() == key && (include_internal || !obj->is_internal())) diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index 8073879982..e5231ed759 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -35,7 +35,7 @@ std::string EntityBase::get_icon() const { } void EntityBase::set_icon(const char *icon) { this->icon_c_str_ = icon; } -// Entity Device Name +// Entity Device id const StringRef &EntityBase::get_device_id() const { if (this->device_id_.empty()) { return StringRef(""); diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index bfc9b3dc9b..3c91eafcf4 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -89,10 +89,6 @@ async def register_component(var, config): return var -# async def register_sub_device(var, value): -# pass - - async def register_parented(var, value): if isinstance(value, ID): paren = await get_variable(value) From f5f1651b31f5407172143351ae9e73af5478b2c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Vikstr=C3=B6m?= Date: Wed, 9 Apr 2025 22:33:03 +0200 Subject: [PATCH 015/183] Fix clang --- esphome/core/entity_base.cpp | 9 --------- esphome/core/entity_base.h | 6 +++--- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index e5231ed759..725a8569a3 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -35,15 +35,6 @@ std::string EntityBase::get_icon() const { } void EntityBase::set_icon(const char *icon) { this->icon_c_str_ = icon; } -// Entity Device id -const StringRef &EntityBase::get_device_id() const { - if (this->device_id_.empty()) { - return StringRef(""); - } - return this->device_id_; -} -void EntityBase::set_device_id(const std::string device_id) { this->device_id_ = StringRef(device_id); } - // Entity Category EntityCategory EntityBase::get_entity_category() const { return this->entity_category_; } void EntityBase::set_entity_category(EntityCategory entity_category) { this->entity_category_ = entity_category; } diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index e52406c425..e66fbb66e6 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -48,8 +48,8 @@ class EntityBase { void set_icon(const char *icon); // Get/set this entity's device id - const StringRef &get_device_id() const; - void set_device_id(const std::string device_id); + const StringRef &get_device_id() const { return this->device_id_; } + void set_device_id(const std::string &device_id) { this->device_id_ = StringRef(device_id); } protected: /// The hash_base() function has been deprecated. It is kept in this @@ -65,7 +65,7 @@ class EntityBase { bool internal_{false}; bool disabled_by_default_{false}; EntityCategory entity_category_{ENTITY_CATEGORY_NONE}; - StringRef device_id_; + StringRef device_id_{""}; }; class EntityBase_DeviceClass { // NOLINT(readability-identifier-naming) From 3922950951191ed3052964b000dac2651595c419 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Vikstr=C3=B6m?= Date: Mon, 14 Apr 2025 21:36:50 +0200 Subject: [PATCH 016/183] Improve stability for unrelated test --- tests/dashboard/test_web_server.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index a61850abf3..13d2bbbf33 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -75,6 +75,9 @@ async def test_devices_page(dashboard: DashboardTestHelper) -> None: assert response.headers["content-type"] == "application/json" json_data = json.loads(response.body.decode()) configured_devices = json_data["configured"] - first_device = configured_devices[0] - assert first_device["name"] == "pico" - assert first_device["configuration"] == "pico.yaml" + if len(configured_devices) == 0: + assert len(configured_devices) != 0 + else: + first_device = configured_devices[0] + assert first_device["name"] == "pico" + assert first_device["configuration"] == "pico.yaml" From 825c0593e1001f95f5bd30411ec0b6a0b675e378 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Vikstr=C3=B6m?= Date: Sat, 19 Apr 2025 19:07:50 +0200 Subject: [PATCH 017/183] Fix generated code after merge --- esphome/components/api/api_pb2.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index cf1d61ab39..d3b16f7d2b 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -887,7 +887,7 @@ bool DeviceInfoResponse::decode_length(uint32_t field_id, ProtoLengthDelimited v this->bluetooth_mac_address = value.as_string(); return true; } - case 19: { + case 20: { this->sub_devices.push_back(value.as_message()); return true; } @@ -1009,7 +1009,6 @@ void DeviceInfoResponse::dump_to(std::string &out) const { it.dump_to(out); out.append("\n"); } - out.append("}"); } #endif From 298cc58433d07ecfa5aa2aef341392cf137c0ef7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Vikstr=C3=B6m?= Date: Sat, 19 Apr 2025 22:48:20 +0200 Subject: [PATCH 018/183] Activate the rest of entities --- esphome/components/api/api.proto | 40 +++---- esphome/components/api/api_pb2.cpp | 180 +++++++++++++++++++++++++++++ esphome/components/api/api_pb2.h | 20 ++++ 3 files changed, 220 insertions(+), 20 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 43a54fb4c2..e9958225e7 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -391,7 +391,7 @@ message ListEntitiesFanResponse { string icon = 10; EntityCategory entity_category = 11; repeated string supported_preset_modes = 12; - // string device_id = 13; + string device_id = 13; } enum FanSpeed { FAN_SPEED_LOW = 0; @@ -472,7 +472,7 @@ message ListEntitiesLightResponse { bool disabled_by_default = 13; string icon = 14; EntityCategory entity_category = 15; - // string device_id = 16; + string device_id = 16; } message LightStateResponse { option (id) = 24; @@ -563,7 +563,7 @@ message ListEntitiesSensorResponse { SensorLastResetType legacy_last_reset_type = 11; bool disabled_by_default = 12; EntityCategory entity_category = 13; - // string device_id = 14; + string device_id = 14; } message SensorStateResponse { option (id) = 25; @@ -594,7 +594,7 @@ message ListEntitiesSwitchResponse { bool disabled_by_default = 7; EntityCategory entity_category = 8; string device_class = 9; - // string device_id = 10; + string device_id = 10; } message SwitchStateResponse { option (id) = 26; @@ -630,7 +630,7 @@ message ListEntitiesTextSensorResponse { bool disabled_by_default = 6; EntityCategory entity_category = 7; string device_class = 8; - // string device_id = 9; + string device_id = 9; } message TextSensorStateResponse { option (id) = 27; @@ -811,7 +811,7 @@ message ListEntitiesCameraResponse { bool disabled_by_default = 5; string icon = 6; EntityCategory entity_category = 7; - // string device_id = 8; + string device_id = 8; } message CameraImageResponse { @@ -913,7 +913,7 @@ message ListEntitiesClimateResponse { bool supports_target_humidity = 23; float visual_min_humidity = 24; float visual_max_humidity = 25; - // string device_id = 26; + string device_id = 26; } message ClimateStateResponse { option (id) = 47; @@ -993,7 +993,7 @@ message ListEntitiesNumberResponse { string unit_of_measurement = 11; NumberMode mode = 12; string device_class = 13; - // string device_id = 14; + string device_id = 14; } message NumberStateResponse { option (id) = 50; @@ -1032,7 +1032,7 @@ message ListEntitiesSelectResponse { repeated string options = 6; bool disabled_by_default = 7; EntityCategory entity_category = 8; - // string device_id = 9; + string device_id = 9; } message SelectStateResponse { option (id) = 53; @@ -1091,7 +1091,7 @@ message ListEntitiesLockResponse { // Not yet implemented: string code_format = 11; - // string device_id = 12; + string device_id = 12; } message LockStateResponse { option (id) = 59; @@ -1129,7 +1129,7 @@ message ListEntitiesButtonResponse { bool disabled_by_default = 6; EntityCategory entity_category = 7; string device_class = 8; - // string device_id = 9; + string device_id = 9; } message ButtonCommandRequest { option (id) = 62; @@ -1185,7 +1185,7 @@ message ListEntitiesMediaPlayerResponse { repeated MediaPlayerSupportedFormat supported_formats = 9; - // string device_id = 10; + string device_id = 10; } message MediaPlayerStateResponse { option (id) = 64; @@ -1692,7 +1692,7 @@ message ListEntitiesAlarmControlPanelResponse { uint32 supported_features = 8; bool requires_code = 9; bool requires_code_to_arm = 10; - // string device_id = 11; + string device_id = 11; } message AlarmControlPanelStateResponse { @@ -1736,7 +1736,7 @@ message ListEntitiesTextResponse { uint32 max_length = 9; string pattern = 10; TextMode mode = 11; - // string device_id = 12; + string device_id = 12; } message TextStateResponse { option (id) = 98; @@ -1775,7 +1775,7 @@ message ListEntitiesDateResponse { string icon = 5; bool disabled_by_default = 6; EntityCategory entity_category = 7; - // string device_id = 8; + string device_id = 8; } message DateStateResponse { option (id) = 101; @@ -1817,7 +1817,7 @@ message ListEntitiesTimeResponse { string icon = 5; bool disabled_by_default = 6; EntityCategory entity_category = 7; - // string device_id = 8; + string device_id = 8; } message TimeStateResponse { option (id) = 104; @@ -1862,7 +1862,7 @@ message ListEntitiesEventResponse { string device_class = 8; repeated string event_types = 9; - // string device_id = 10; + string device_id = 10; } message EventResponse { option (id) = 108; @@ -1892,7 +1892,7 @@ message ListEntitiesValveResponse { bool assumed_state = 9; bool supports_position = 10; bool supports_stop = 11; - // string device_id = 12; + string device_id = 12; } enum ValveOperation { @@ -1937,7 +1937,7 @@ message ListEntitiesDateTimeResponse { string icon = 5; bool disabled_by_default = 6; EntityCategory entity_category = 7; - // string device_id = 8; + string device_id = 8; } message DateTimeStateResponse { option (id) = 113; @@ -1976,7 +1976,7 @@ message ListEntitiesUpdateResponse { bool disabled_by_default = 6; EntityCategory entity_category = 7; string device_class = 8; - // string device_id = 9; + string device_id = 9; } message UpdateStateResponse { option (id) = 117; diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index d3b16f7d2b..e1f17ccee4 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -1548,6 +1548,10 @@ bool ListEntitiesFanResponse::decode_length(uint32_t field_id, ProtoLengthDelimi this->supported_preset_modes.push_back(value.as_string()); return true; } + case 13: { + this->device_id = value.as_string(); + return true; + } default: return false; } @@ -1577,6 +1581,7 @@ void ListEntitiesFanResponse::encode(ProtoWriteBuffer buffer) const { for (auto &it : this->supported_preset_modes) { buffer.encode_string(12, it, true); } + buffer.encode_string(13, this->device_id); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesFanResponse::dump_to(std::string &out) const { @@ -1633,6 +1638,10 @@ void ListEntitiesFanResponse::dump_to(std::string &out) const { out.append("'").append(it).append("'"); out.append("\n"); } + + out.append(" device_id: "); + out.append("'").append(this->device_id).append("'"); + out.append("\n"); out.append("}"); } #endif @@ -1928,6 +1937,10 @@ bool ListEntitiesLightResponse::decode_length(uint32_t field_id, ProtoLengthDeli this->icon = value.as_string(); return true; } + case 16: { + this->device_id = value.as_string(); + return true; + } default: return false; } @@ -1970,6 +1983,7 @@ void ListEntitiesLightResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(13, this->disabled_by_default); buffer.encode_string(14, this->icon); buffer.encode_enum(15, this->entity_category); + buffer.encode_string(16, this->device_id); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesLightResponse::dump_to(std::string &out) const { @@ -2041,6 +2055,10 @@ void ListEntitiesLightResponse::dump_to(std::string &out) const { out.append(" entity_category: "); out.append(proto_enum_to_string(this->entity_category)); out.append("\n"); + + out.append(" device_id: "); + out.append("'").append(this->device_id).append("'"); + out.append("\n"); out.append("}"); } #endif @@ -2534,6 +2552,10 @@ bool ListEntitiesSensorResponse::decode_length(uint32_t field_id, ProtoLengthDel this->device_class = value.as_string(); return true; } + case 14: { + this->device_id = value.as_string(); + return true; + } default: return false; } @@ -2562,6 +2584,7 @@ void ListEntitiesSensorResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_enum(11, this->legacy_last_reset_type); buffer.encode_bool(12, this->disabled_by_default); buffer.encode_enum(13, this->entity_category); + buffer.encode_string(14, this->device_id); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesSensorResponse::dump_to(std::string &out) const { @@ -2620,6 +2643,10 @@ void ListEntitiesSensorResponse::dump_to(std::string &out) const { out.append(" entity_category: "); out.append(proto_enum_to_string(this->entity_category)); out.append("\n"); + + out.append(" device_id: "); + out.append("'").append(this->device_id).append("'"); + out.append("\n"); out.append("}"); } #endif @@ -2712,6 +2739,10 @@ bool ListEntitiesSwitchResponse::decode_length(uint32_t field_id, ProtoLengthDel this->device_class = value.as_string(); return true; } + case 10: { + this->device_id = value.as_string(); + return true; + } default: return false; } @@ -2736,6 +2767,7 @@ void ListEntitiesSwitchResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(7, this->disabled_by_default); buffer.encode_enum(8, this->entity_category); buffer.encode_string(9, this->device_class); + buffer.encode_string(10, this->device_id); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesSwitchResponse::dump_to(std::string &out) const { @@ -2777,6 +2809,10 @@ void ListEntitiesSwitchResponse::dump_to(std::string &out) const { out.append(" device_class: "); out.append("'").append(this->device_class).append("'"); out.append("\n"); + + out.append(" device_id: "); + out.append("'").append(this->device_id).append("'"); + out.append("\n"); out.append("}"); } #endif @@ -2894,6 +2930,10 @@ bool ListEntitiesTextSensorResponse::decode_length(uint32_t field_id, ProtoLengt this->device_class = value.as_string(); return true; } + case 9: { + this->device_id = value.as_string(); + return true; + } default: return false; } @@ -2917,6 +2957,7 @@ void ListEntitiesTextSensorResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(6, this->disabled_by_default); buffer.encode_enum(7, this->entity_category); buffer.encode_string(8, this->device_class); + buffer.encode_string(9, this->device_id); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesTextSensorResponse::dump_to(std::string &out) const { @@ -2954,6 +2995,10 @@ void ListEntitiesTextSensorResponse::dump_to(std::string &out) const { out.append(" device_class: "); out.append("'").append(this->device_class).append("'"); out.append("\n"); + + out.append(" device_id: "); + out.append("'").append(this->device_id).append("'"); + out.append("\n"); out.append("}"); } #endif @@ -3660,6 +3705,10 @@ bool ListEntitiesCameraResponse::decode_length(uint32_t field_id, ProtoLengthDel this->icon = value.as_string(); return true; } + case 8: { + this->device_id = value.as_string(); + return true; + } default: return false; } @@ -3682,6 +3731,7 @@ void ListEntitiesCameraResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(5, this->disabled_by_default); buffer.encode_string(6, this->icon); buffer.encode_enum(7, this->entity_category); + buffer.encode_string(8, this->device_id); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesCameraResponse::dump_to(std::string &out) const { @@ -3715,6 +3765,10 @@ void ListEntitiesCameraResponse::dump_to(std::string &out) const { out.append(" entity_category: "); out.append(proto_enum_to_string(this->entity_category)); out.append("\n"); + + out.append(" device_id: "); + out.append("'").append(this->device_id).append("'"); + out.append("\n"); out.append("}"); } #endif @@ -3884,6 +3938,10 @@ bool ListEntitiesClimateResponse::decode_length(uint32_t field_id, ProtoLengthDe this->icon = value.as_string(); return true; } + case 26: { + this->device_id = value.as_string(); + return true; + } default: return false; } @@ -3960,6 +4018,7 @@ void ListEntitiesClimateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(23, this->supports_target_humidity); buffer.encode_float(24, this->visual_min_humidity); buffer.encode_float(25, this->visual_max_humidity); + buffer.encode_string(26, this->device_id); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesClimateResponse::dump_to(std::string &out) const { @@ -4083,6 +4142,10 @@ void ListEntitiesClimateResponse::dump_to(std::string &out) const { sprintf(buffer, "%g", this->visual_max_humidity); out.append(buffer); out.append("\n"); + + out.append(" device_id: "); + out.append("'").append(this->device_id).append("'"); + out.append("\n"); out.append("}"); } #endif @@ -4536,6 +4599,10 @@ bool ListEntitiesNumberResponse::decode_length(uint32_t field_id, ProtoLengthDel this->device_class = value.as_string(); return true; } + case 14: { + this->device_id = value.as_string(); + return true; + } default: return false; } @@ -4576,6 +4643,7 @@ void ListEntitiesNumberResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(11, this->unit_of_measurement); buffer.encode_enum(12, this->mode); buffer.encode_string(13, this->device_class); + buffer.encode_string(14, this->device_id); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesNumberResponse::dump_to(std::string &out) const { @@ -4636,6 +4704,10 @@ void ListEntitiesNumberResponse::dump_to(std::string &out) const { out.append(" device_class: "); out.append("'").append(this->device_class).append("'"); out.append("\n"); + + out.append(" device_id: "); + out.append("'").append(this->device_id).append("'"); + out.append("\n"); out.append("}"); } #endif @@ -4758,6 +4830,10 @@ bool ListEntitiesSelectResponse::decode_length(uint32_t field_id, ProtoLengthDel this->options.push_back(value.as_string()); return true; } + case 9: { + this->device_id = value.as_string(); + return true; + } default: return false; } @@ -4783,6 +4859,7 @@ void ListEntitiesSelectResponse::encode(ProtoWriteBuffer buffer) const { } buffer.encode_bool(7, this->disabled_by_default); buffer.encode_enum(8, this->entity_category); + buffer.encode_string(9, this->device_id); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesSelectResponse::dump_to(std::string &out) const { @@ -4822,6 +4899,10 @@ void ListEntitiesSelectResponse::dump_to(std::string &out) const { out.append(" entity_category: "); out.append(proto_enum_to_string(this->entity_category)); out.append("\n"); + + out.append(" device_id: "); + out.append("'").append(this->device_id).append("'"); + out.append("\n"); out.append("}"); } #endif @@ -4966,6 +5047,10 @@ bool ListEntitiesLockResponse::decode_length(uint32_t field_id, ProtoLengthDelim this->code_format = value.as_string(); return true; } + case 12: { + this->device_id = value.as_string(); + return true; + } default: return false; } @@ -4992,6 +5077,7 @@ void ListEntitiesLockResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(9, this->supports_open); buffer.encode_bool(10, this->requires_code); buffer.encode_string(11, this->code_format); + buffer.encode_string(12, this->device_id); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesLockResponse::dump_to(std::string &out) const { @@ -5041,6 +5127,10 @@ void ListEntitiesLockResponse::dump_to(std::string &out) const { out.append(" code_format: "); out.append("'").append(this->code_format).append("'"); out.append("\n"); + + out.append(" device_id: "); + out.append("'").append(this->device_id).append("'"); + out.append("\n"); out.append("}"); } #endif @@ -5182,6 +5272,10 @@ bool ListEntitiesButtonResponse::decode_length(uint32_t field_id, ProtoLengthDel this->device_class = value.as_string(); return true; } + case 9: { + this->device_id = value.as_string(); + return true; + } default: return false; } @@ -5205,6 +5299,7 @@ void ListEntitiesButtonResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(6, this->disabled_by_default); buffer.encode_enum(7, this->entity_category); buffer.encode_string(8, this->device_class); + buffer.encode_string(9, this->device_id); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesButtonResponse::dump_to(std::string &out) const { @@ -5242,6 +5337,10 @@ void ListEntitiesButtonResponse::dump_to(std::string &out) const { out.append(" device_class: "); out.append("'").append(this->device_class).append("'"); out.append("\n"); + + out.append(" device_id: "); + out.append("'").append(this->device_id).append("'"); + out.append("\n"); out.append("}"); } #endif @@ -5375,6 +5474,10 @@ bool ListEntitiesMediaPlayerResponse::decode_length(uint32_t field_id, ProtoLeng this->supported_formats.push_back(value.as_message()); return true; } + case 10: { + this->device_id = value.as_string(); + return true; + } default: return false; } @@ -5401,6 +5504,7 @@ void ListEntitiesMediaPlayerResponse::encode(ProtoWriteBuffer buffer) const { for (auto &it : this->supported_formats) { buffer.encode_message(9, it, true); } + buffer.encode_string(10, this->device_id); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesMediaPlayerResponse::dump_to(std::string &out) const { @@ -5444,6 +5548,10 @@ void ListEntitiesMediaPlayerResponse::dump_to(std::string &out) const { it.dump_to(out); out.append("\n"); } + + out.append(" device_id: "); + out.append("'").append(this->device_id).append("'"); + out.append("\n"); out.append("}"); } #endif @@ -7472,6 +7580,10 @@ bool ListEntitiesAlarmControlPanelResponse::decode_length(uint32_t field_id, Pro this->icon = value.as_string(); return true; } + case 11: { + this->device_id = value.as_string(); + return true; + } default: return false; } @@ -7497,6 +7609,7 @@ void ListEntitiesAlarmControlPanelResponse::encode(ProtoWriteBuffer buffer) cons buffer.encode_uint32(8, this->supported_features); buffer.encode_bool(9, this->requires_code); buffer.encode_bool(10, this->requires_code_to_arm); + buffer.encode_string(11, this->device_id); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesAlarmControlPanelResponse::dump_to(std::string &out) const { @@ -7543,6 +7656,10 @@ void ListEntitiesAlarmControlPanelResponse::dump_to(std::string &out) const { out.append(" requires_code_to_arm: "); out.append(YESNO(this->requires_code_to_arm)); out.append("\n"); + + out.append(" device_id: "); + out.append("'").append(this->device_id).append("'"); + out.append("\n"); out.append("}"); } #endif @@ -7687,6 +7804,10 @@ bool ListEntitiesTextResponse::decode_length(uint32_t field_id, ProtoLengthDelim this->pattern = value.as_string(); return true; } + case 12: { + this->device_id = value.as_string(); + return true; + } default: return false; } @@ -7713,6 +7834,7 @@ void ListEntitiesTextResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(9, this->max_length); buffer.encode_string(10, this->pattern); buffer.encode_enum(11, this->mode); + buffer.encode_string(12, this->device_id); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesTextResponse::dump_to(std::string &out) const { @@ -7764,6 +7886,10 @@ void ListEntitiesTextResponse::dump_to(std::string &out) const { out.append(" mode: "); out.append(proto_enum_to_string(this->mode)); out.append("\n"); + + out.append(" device_id: "); + out.append("'").append(this->device_id).append("'"); + out.append("\n"); out.append("}"); } #endif @@ -7892,6 +8018,10 @@ bool ListEntitiesDateResponse::decode_length(uint32_t field_id, ProtoLengthDelim this->icon = value.as_string(); return true; } + case 8: { + this->device_id = value.as_string(); + return true; + } default: return false; } @@ -7914,6 +8044,7 @@ void ListEntitiesDateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(5, this->icon); buffer.encode_bool(6, this->disabled_by_default); buffer.encode_enum(7, this->entity_category); + buffer.encode_string(8, this->device_id); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesDateResponse::dump_to(std::string &out) const { @@ -7947,6 +8078,10 @@ void ListEntitiesDateResponse::dump_to(std::string &out) const { out.append(" entity_category: "); out.append(proto_enum_to_string(this->entity_category)); out.append("\n"); + + out.append(" device_id: "); + out.append("'").append(this->device_id).append("'"); + out.append("\n"); out.append("}"); } #endif @@ -8111,6 +8246,10 @@ bool ListEntitiesTimeResponse::decode_length(uint32_t field_id, ProtoLengthDelim this->icon = value.as_string(); return true; } + case 8: { + this->device_id = value.as_string(); + return true; + } default: return false; } @@ -8133,6 +8272,7 @@ void ListEntitiesTimeResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(5, this->icon); buffer.encode_bool(6, this->disabled_by_default); buffer.encode_enum(7, this->entity_category); + buffer.encode_string(8, this->device_id); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesTimeResponse::dump_to(std::string &out) const { @@ -8166,6 +8306,10 @@ void ListEntitiesTimeResponse::dump_to(std::string &out) const { out.append(" entity_category: "); out.append(proto_enum_to_string(this->entity_category)); out.append("\n"); + + out.append(" device_id: "); + out.append("'").append(this->device_id).append("'"); + out.append("\n"); out.append("}"); } #endif @@ -8338,6 +8482,10 @@ bool ListEntitiesEventResponse::decode_length(uint32_t field_id, ProtoLengthDeli this->event_types.push_back(value.as_string()); return true; } + case 10: { + this->device_id = value.as_string(); + return true; + } default: return false; } @@ -8364,6 +8512,7 @@ void ListEntitiesEventResponse::encode(ProtoWriteBuffer buffer) const { for (auto &it : this->event_types) { buffer.encode_string(9, it, true); } + buffer.encode_string(10, this->device_id); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesEventResponse::dump_to(std::string &out) const { @@ -8407,6 +8556,10 @@ void ListEntitiesEventResponse::dump_to(std::string &out) const { out.append("'").append(it).append("'"); out.append("\n"); } + + out.append(" device_id: "); + out.append("'").append(this->device_id).append("'"); + out.append("\n"); out.append("}"); } #endif @@ -8497,6 +8650,10 @@ bool ListEntitiesValveResponse::decode_length(uint32_t field_id, ProtoLengthDeli this->device_class = value.as_string(); return true; } + case 12: { + this->device_id = value.as_string(); + return true; + } default: return false; } @@ -8523,6 +8680,7 @@ void ListEntitiesValveResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(9, this->assumed_state); buffer.encode_bool(10, this->supports_position); buffer.encode_bool(11, this->supports_stop); + buffer.encode_string(12, this->device_id); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesValveResponse::dump_to(std::string &out) const { @@ -8572,6 +8730,10 @@ void ListEntitiesValveResponse::dump_to(std::string &out) const { out.append(" supports_stop: "); out.append(YESNO(this->supports_stop)); out.append("\n"); + + out.append(" device_id: "); + out.append("'").append(this->device_id).append("'"); + out.append("\n"); out.append("}"); } #endif @@ -8714,6 +8876,10 @@ bool ListEntitiesDateTimeResponse::decode_length(uint32_t field_id, ProtoLengthD this->icon = value.as_string(); return true; } + case 8: { + this->device_id = value.as_string(); + return true; + } default: return false; } @@ -8736,6 +8902,7 @@ void ListEntitiesDateTimeResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(5, this->icon); buffer.encode_bool(6, this->disabled_by_default); buffer.encode_enum(7, this->entity_category); + buffer.encode_string(8, this->device_id); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesDateTimeResponse::dump_to(std::string &out) const { @@ -8769,6 +8936,10 @@ void ListEntitiesDateTimeResponse::dump_to(std::string &out) const { out.append(" entity_category: "); out.append(proto_enum_to_string(this->entity_category)); out.append("\n"); + + out.append(" device_id: "); + out.append("'").append(this->device_id).append("'"); + out.append("\n"); out.append("}"); } #endif @@ -8891,6 +9062,10 @@ bool ListEntitiesUpdateResponse::decode_length(uint32_t field_id, ProtoLengthDel this->device_class = value.as_string(); return true; } + case 9: { + this->device_id = value.as_string(); + return true; + } default: return false; } @@ -8914,6 +9089,7 @@ void ListEntitiesUpdateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(6, this->disabled_by_default); buffer.encode_enum(7, this->entity_category); buffer.encode_string(8, this->device_class); + buffer.encode_string(9, this->device_id); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesUpdateResponse::dump_to(std::string &out) const { @@ -8951,6 +9127,10 @@ void ListEntitiesUpdateResponse::dump_to(std::string &out) const { out.append(" device_class: "); out.append("'").append(this->device_class).append("'"); out.append("\n"); + + out.append(" device_id: "); + out.append("'").append(this->device_id).append("'"); + out.append("\n"); out.append("}"); } #endif diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 78594d8401..f4120843ef 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -516,6 +516,7 @@ class ListEntitiesFanResponse : public ProtoMessage { std::string icon{}; enums::EntityCategory entity_category{}; std::vector supported_preset_modes{}; + std::string device_id{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -587,6 +588,7 @@ class ListEntitiesLightResponse : public ProtoMessage { bool disabled_by_default{false}; std::string icon{}; enums::EntityCategory entity_category{}; + std::string device_id{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -676,6 +678,7 @@ class ListEntitiesSensorResponse : public ProtoMessage { enums::SensorLastResetType legacy_last_reset_type{}; bool disabled_by_default{false}; enums::EntityCategory entity_category{}; + std::string device_id{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -711,6 +714,7 @@ class ListEntitiesSwitchResponse : public ProtoMessage { bool disabled_by_default{false}; enums::EntityCategory entity_category{}; std::string device_class{}; + std::string device_id{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -757,6 +761,7 @@ class ListEntitiesTextSensorResponse : public ProtoMessage { bool disabled_by_default{false}; enums::EntityCategory entity_category{}; std::string device_class{}; + std::string device_id{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -993,6 +998,7 @@ class ListEntitiesCameraResponse : public ProtoMessage { bool disabled_by_default{false}; std::string icon{}; enums::EntityCategory entity_category{}; + std::string device_id{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -1057,6 +1063,7 @@ class ListEntitiesClimateResponse : public ProtoMessage { bool supports_target_humidity{false}; float visual_min_humidity{0.0f}; float visual_max_humidity{0.0f}; + std::string device_id{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -1144,6 +1151,7 @@ class ListEntitiesNumberResponse : public ProtoMessage { std::string unit_of_measurement{}; enums::NumberMode mode{}; std::string device_class{}; + std::string device_id{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -1190,6 +1198,7 @@ class ListEntitiesSelectResponse : public ProtoMessage { std::vector options{}; bool disabled_by_default{false}; enums::EntityCategory entity_category{}; + std::string device_id{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -1241,6 +1250,7 @@ class ListEntitiesLockResponse : public ProtoMessage { bool supports_open{false}; bool requires_code{false}; std::string code_format{}; + std::string device_id{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -1290,6 +1300,7 @@ class ListEntitiesButtonResponse : public ProtoMessage { bool disabled_by_default{false}; enums::EntityCategory entity_category{}; std::string device_class{}; + std::string device_id{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -1338,6 +1349,7 @@ class ListEntitiesMediaPlayerResponse : public ProtoMessage { enums::EntityCategory entity_category{}; bool supports_pause{false}; std::vector supported_formats{}; + std::string device_id{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -1952,6 +1964,7 @@ class ListEntitiesAlarmControlPanelResponse : public ProtoMessage { uint32_t supported_features{0}; bool requires_code{false}; bool requires_code_to_arm{false}; + std::string device_id{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -2003,6 +2016,7 @@ class ListEntitiesTextResponse : public ProtoMessage { uint32_t max_length{0}; std::string pattern{}; enums::TextMode mode{}; + std::string device_id{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -2050,6 +2064,7 @@ class ListEntitiesDateResponse : public ProtoMessage { std::string icon{}; bool disabled_by_default{false}; enums::EntityCategory entity_category{}; + std::string device_id{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -2100,6 +2115,7 @@ class ListEntitiesTimeResponse : public ProtoMessage { std::string icon{}; bool disabled_by_default{false}; enums::EntityCategory entity_category{}; + std::string device_id{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -2152,6 +2168,7 @@ class ListEntitiesEventResponse : public ProtoMessage { enums::EntityCategory entity_category{}; std::string device_class{}; std::vector event_types{}; + std::string device_id{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -2188,6 +2205,7 @@ class ListEntitiesValveResponse : public ProtoMessage { bool assumed_state{false}; bool supports_position{false}; bool supports_stop{false}; + std::string device_id{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -2236,6 +2254,7 @@ class ListEntitiesDateTimeResponse : public ProtoMessage { std::string icon{}; bool disabled_by_default{false}; enums::EntityCategory entity_category{}; + std::string device_id{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -2282,6 +2301,7 @@ class ListEntitiesUpdateResponse : public ProtoMessage { bool disabled_by_default{false}; enums::EntityCategory entity_category{}; std::string device_class{}; + std::string device_id{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; From 31f2376f15523e8125814a192b26b1100e8b693e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Vikstr=C3=B6m?= Date: Tue, 22 Apr 2025 14:03:07 +0200 Subject: [PATCH 019/183] Rename ref in codegen --- esphome/cpp_helpers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index 3c91eafcf4..a1a7d3f516 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -112,8 +112,8 @@ async def setup_entity(var, config): if CONF_ENTITY_CATEGORY in config: add(var.set_entity_category(config[CONF_ENTITY_CATEGORY])) if CONF_DEVICE_ID in config: - parent = await get_variable(config[CONF_DEVICE_ID]) - add(var.set_device_id(parent.get_id())) + device = await get_variable(config[CONF_DEVICE_ID]) + add(var.set_device_id(device.get_id())) def extract_registry_entry_config( From d4fda79ada6f193823c94d656c1951e6ab0e288d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Vikstr=C3=B6m?= Date: Tue, 6 May 2025 02:07:59 +0200 Subject: [PATCH 020/183] Attempt to replace device_id:str with device_uid:uint32 --- esphome/components/api/api.proto | 46 ++-- esphome/components/api/api_connection.cpp | 34 ++- esphome/components/api/api_pb2.cpp | 253 ++++++++-------------- esphome/components/api/api_pb2.h | 46 ++-- esphome/components/devices/__init__.py | 2 +- esphome/components/devices/devices.h | 6 +- esphome/config_validation.py | 6 +- esphome/const.py | 2 +- esphome/core/entity_base.h | 6 +- esphome/cpp_helpers.py | 2 +- tests/components/device/common.yaml | 2 +- 11 files changed, 183 insertions(+), 222 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index d364afe46e..3f5965829a 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -188,7 +188,7 @@ message DeviceInfoRequest { } message SubDeviceInfo { - string id = 1; + uint32 uid = 1; string name = 2; string suggested_area = 3; } @@ -286,7 +286,7 @@ message ListEntitiesBinarySensorResponse { bool disabled_by_default = 7; string icon = 8; EntityCategory entity_category = 9; - string device_id = 10; + uint32 device_uid = 10; } message BinarySensorStateResponse { option (id) = 21; @@ -320,7 +320,7 @@ message ListEntitiesCoverResponse { string icon = 10; EntityCategory entity_category = 11; bool supports_stop = 12; - string device_id = 13; + uint32 device_uid = 13; } enum LegacyCoverState { @@ -392,7 +392,7 @@ message ListEntitiesFanResponse { string icon = 10; EntityCategory entity_category = 11; repeated string supported_preset_modes = 12; - string device_id = 13; + uint32 device_uid = 13; } enum FanSpeed { FAN_SPEED_LOW = 0; @@ -473,7 +473,7 @@ message ListEntitiesLightResponse { bool disabled_by_default = 13; string icon = 14; EntityCategory entity_category = 15; - string device_id = 16; + uint32 device_uid = 16; } message LightStateResponse { option (id) = 24; @@ -564,7 +564,7 @@ message ListEntitiesSensorResponse { SensorLastResetType legacy_last_reset_type = 11; bool disabled_by_default = 12; EntityCategory entity_category = 13; - string device_id = 14; + uint32 device_uid = 14; } message SensorStateResponse { option (id) = 25; @@ -595,7 +595,7 @@ message ListEntitiesSwitchResponse { bool disabled_by_default = 7; EntityCategory entity_category = 8; string device_class = 9; - string device_id = 10; + uint32 device_uid = 10; } message SwitchStateResponse { option (id) = 26; @@ -631,7 +631,7 @@ message ListEntitiesTextSensorResponse { bool disabled_by_default = 6; EntityCategory entity_category = 7; string device_class = 8; - string device_id = 9; + uint32 device_uid = 9; } message TextSensorStateResponse { option (id) = 27; @@ -812,7 +812,7 @@ message ListEntitiesCameraResponse { bool disabled_by_default = 5; string icon = 6; EntityCategory entity_category = 7; - string device_id = 8; + uint32 device_uid = 8; } message CameraImageResponse { @@ -914,7 +914,7 @@ message ListEntitiesClimateResponse { bool supports_target_humidity = 23; float visual_min_humidity = 24; float visual_max_humidity = 25; - string device_id = 26; + uint32 device_uid = 26; } message ClimateStateResponse { option (id) = 47; @@ -994,7 +994,7 @@ message ListEntitiesNumberResponse { string unit_of_measurement = 11; NumberMode mode = 12; string device_class = 13; - string device_id = 14; + uint32 device_uid = 14; } message NumberStateResponse { option (id) = 50; @@ -1033,7 +1033,7 @@ message ListEntitiesSelectResponse { repeated string options = 6; bool disabled_by_default = 7; EntityCategory entity_category = 8; - string device_id = 9; + uint32 device_uid = 9; } message SelectStateResponse { option (id) = 53; @@ -1092,7 +1092,7 @@ message ListEntitiesLockResponse { // Not yet implemented: string code_format = 11; - string device_id = 12; + uint32 device_uid = 12; } message LockStateResponse { option (id) = 59; @@ -1130,7 +1130,7 @@ message ListEntitiesButtonResponse { bool disabled_by_default = 6; EntityCategory entity_category = 7; string device_class = 8; - string device_id = 9; + uint32 device_uid = 9; } message ButtonCommandRequest { option (id) = 62; @@ -1186,7 +1186,7 @@ message ListEntitiesMediaPlayerResponse { repeated MediaPlayerSupportedFormat supported_formats = 9; - string device_id = 10; + uint32 device_uid = 10; } message MediaPlayerStateResponse { option (id) = 64; @@ -1724,7 +1724,7 @@ message ListEntitiesAlarmControlPanelResponse { uint32 supported_features = 8; bool requires_code = 9; bool requires_code_to_arm = 10; - string device_id = 11; + uint32 device_uid = 11; } message AlarmControlPanelStateResponse { @@ -1768,7 +1768,7 @@ message ListEntitiesTextResponse { uint32 max_length = 9; string pattern = 10; TextMode mode = 11; - string device_id = 12; + uint32 device_uid = 12; } message TextStateResponse { option (id) = 98; @@ -1807,7 +1807,7 @@ message ListEntitiesDateResponse { string icon = 5; bool disabled_by_default = 6; EntityCategory entity_category = 7; - string device_id = 8; + uint32 device_uid = 8; } message DateStateResponse { option (id) = 101; @@ -1849,7 +1849,7 @@ message ListEntitiesTimeResponse { string icon = 5; bool disabled_by_default = 6; EntityCategory entity_category = 7; - string device_id = 8; + uint32 device_uid = 8; } message TimeStateResponse { option (id) = 104; @@ -1894,7 +1894,7 @@ message ListEntitiesEventResponse { string device_class = 8; repeated string event_types = 9; - string device_id = 10; + uint32 device_uid = 10; } message EventResponse { option (id) = 108; @@ -1924,7 +1924,7 @@ message ListEntitiesValveResponse { bool assumed_state = 9; bool supports_position = 10; bool supports_stop = 11; - string device_id = 12; + uint32 device_uid = 12; } enum ValveOperation { @@ -1969,7 +1969,7 @@ message ListEntitiesDateTimeResponse { string icon = 5; bool disabled_by_default = 6; EntityCategory entity_category = 7; - string device_id = 8; + uint32 device_uid = 8; } message DateTimeStateResponse { option (id) = 113; @@ -2008,7 +2008,7 @@ message ListEntitiesUpdateResponse { bool disabled_by_default = 6; EntityCategory entity_category = 7; string device_class = 8; - string device_id = 9; + uint32 device_uid = 9; } message UpdateStateResponse { option (id) = 117; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index cf0be8d198..22a5c7b8c1 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -287,7 +287,7 @@ bool APIConnection::try_send_binary_sensor_info(APIConnection *api, void *v_bina msg.disabled_by_default = binary_sensor->is_disabled_by_default(); msg.icon = binary_sensor->get_icon(); msg.entity_category = static_cast(binary_sensor->get_entity_category()); - msg.device_id = binary_sensor->get_device_id(); + msg.device_uid = binary_sensor->get_device_uid(); return api->send_list_entities_binary_sensor_response(msg); } #endif @@ -338,7 +338,7 @@ bool APIConnection::try_send_cover_info(APIConnection *api, void *v_cover) { msg.disabled_by_default = cover->is_disabled_by_default(); msg.icon = cover->get_icon(); msg.entity_category = static_cast(cover->get_entity_category()); - msg.device_id = cover->get_device_id(); + msg.device_uid = cover->get_device_uid(); return api->send_list_entities_cover_response(msg); } void APIConnection::cover_command(const CoverCommandRequest &msg) { @@ -421,6 +421,7 @@ bool APIConnection::try_send_fan_info(APIConnection *api, void *v_fan) { msg.disabled_by_default = fan->is_disabled_by_default(); msg.icon = fan->get_icon(); msg.entity_category = static_cast(fan->get_entity_category()); + msg.device_uid = fan->get_device_uid(); return api->send_list_entities_fan_response(msg); } void APIConnection::fan_command(const FanCommandRequest &msg) { @@ -518,6 +519,7 @@ bool APIConnection::try_send_light_info(APIConnection *api, void *v_light) { for (auto *effect : light->get_effects()) msg.effects.push_back(effect->get_name()); } + msg.device_uid = light->get_device_uid(); return api->send_list_entities_light_response(msg); } void APIConnection::light_command(const LightCommandRequest &msg) { @@ -602,6 +604,7 @@ bool APIConnection::try_send_sensor_info(APIConnection *api, void *v_sensor) { msg.state_class = static_cast(sensor->get_state_class()); msg.disabled_by_default = sensor->is_disabled_by_default(); msg.entity_category = static_cast(sensor->get_entity_category()); + msg.device_uid = sensor->get_device_uid(); return api->send_list_entities_sensor_response(msg); } #endif @@ -645,6 +648,7 @@ bool APIConnection::try_send_switch_info(APIConnection *api, void *v_a_switch) { msg.disabled_by_default = a_switch->is_disabled_by_default(); msg.entity_category = static_cast(a_switch->get_entity_category()); msg.device_class = a_switch->get_device_class(); + msg.device_uid = a_switch->get_device_uid(); return api->send_list_entities_switch_response(msg); } void APIConnection::switch_command(const SwitchCommandRequest &msg) { @@ -701,6 +705,7 @@ bool APIConnection::try_send_text_sensor_info(APIConnection *api, void *v_text_s msg.disabled_by_default = text_sensor->is_disabled_by_default(); msg.entity_category = static_cast(text_sensor->get_entity_category()); msg.device_class = text_sensor->get_device_class(); + msg.device_uid = text_sensor->get_device_uid(); return api->send_list_entities_text_sensor_response(msg); } #endif @@ -795,6 +800,7 @@ bool APIConnection::try_send_climate_info(APIConnection *api, void *v_climate) { msg.supported_custom_presets.push_back(custom_preset); for (auto swing_mode : traits.get_supported_swing_modes()) msg.supported_swing_modes.push_back(static_cast(swing_mode)); + msg.device_uid = climate->get_device_uid(); return api->send_list_entities_climate_response(msg); } void APIConnection::climate_command(const ClimateCommandRequest &msg) { @@ -873,6 +879,8 @@ bool APIConnection::try_send_number_info(APIConnection *api, void *v_number) { msg.max_value = number->traits.get_max_value(); msg.step = number->traits.get_step(); + msg.device_uid = number->get_device_uid(); + return api->send_list_entities_number_response(msg); } void APIConnection::number_command(const NumberCommandRequest &msg) { @@ -923,6 +931,7 @@ bool APIConnection::try_send_date_info(APIConnection *api, void *v_date) { msg.icon = date->get_icon(); msg.disabled_by_default = date->is_disabled_by_default(); msg.entity_category = static_cast(date->get_entity_category()); + msg.device_uid = date->get_device_uid(); return api->send_list_entities_date_response(msg); } @@ -974,6 +983,7 @@ bool APIConnection::try_send_time_info(APIConnection *api, void *v_time) { msg.icon = time->get_icon(); msg.disabled_by_default = time->is_disabled_by_default(); msg.entity_category = static_cast(time->get_entity_category()); + msg.device_uid = time->get_device_uid(); return api->send_list_entities_time_response(msg); } @@ -1026,6 +1036,7 @@ bool APIConnection::try_send_datetime_info(APIConnection *api, void *v_datetime) msg.icon = datetime->get_icon(); msg.disabled_by_default = datetime->is_disabled_by_default(); msg.entity_category = static_cast(datetime->get_entity_category()); + msg.device_uid = datetime->get_device_uid(); return api->send_list_entities_date_time_response(msg); } @@ -1081,6 +1092,7 @@ bool APIConnection::try_send_text_info(APIConnection *api, void *v_text) { msg.min_length = text->traits.get_min_length(); msg.max_length = text->traits.get_max_length(); msg.pattern = text->traits.get_pattern(); + msg.device_uid = text->get_device_uid(); return api->send_list_entities_text_response(msg); } @@ -1136,6 +1148,7 @@ bool APIConnection::try_send_select_info(APIConnection *api, void *v_select) { for (const auto &option : select->traits.get_options()) msg.options.push_back(option); + msg.device_uid = select->get_device_uid(); return api->send_list_entities_select_response(msg); } @@ -1168,6 +1181,7 @@ bool APIConnection::try_send_button_info(APIConnection *api, void *v_button) { msg.disabled_by_default = button->is_disabled_by_default(); msg.entity_category = static_cast(button->get_entity_category()); msg.device_class = button->get_device_class(); + msg.device_uid = button->get_device_uid(); return api->send_list_entities_button_response(msg); } void APIConnection::button_command(const ButtonCommandRequest &msg) { @@ -1219,6 +1233,7 @@ bool APIConnection::try_send_lock_info(APIConnection *api, void *v_a_lock) { msg.entity_category = static_cast(a_lock->get_entity_category()); msg.supports_open = a_lock->traits.get_supports_open(); msg.requires_code = a_lock->traits.get_requires_code(); + msg.device_uid = a_lock->get_device_uid(); return api->send_list_entities_lock_response(msg); } void APIConnection::lock_command(const LockCommandRequest &msg) { @@ -1280,6 +1295,7 @@ bool APIConnection::try_send_valve_info(APIConnection *api, void *v_valve) { msg.assumed_state = traits.get_is_assumed_state(); msg.supports_position = traits.get_supports_position(); msg.supports_stop = traits.get_supports_stop(); + msg.device_uid = valve->get_device_uid(); return api->send_list_entities_valve_response(msg); } void APIConnection::valve_command(const ValveCommandRequest &msg) { @@ -1349,6 +1365,7 @@ bool APIConnection::try_send_media_player_info(APIConnection *api, void *v_media media_format.sample_bytes = supported_format.sample_bytes; msg.supported_formats.push_back(media_format); } + msg.device_uid = media_player->get_device_uid(); return api->send_list_entities_media_player_response(msg); } @@ -1400,6 +1417,7 @@ bool APIConnection::try_send_camera_info(APIConnection *api, void *v_camera) { msg.disabled_by_default = camera->is_disabled_by_default(); msg.icon = camera->get_icon(); msg.entity_category = static_cast(camera->get_entity_category()); + msg.device_uid = camera->get_device_uid(); return api->send_list_entities_camera_response(msg); } void APIConnection::camera_image(const CameraImageRequest &msg) { @@ -1625,6 +1643,7 @@ bool APIConnection::try_send_alarm_control_panel_info(APIConnection *api, void * msg.supported_features = a_alarm_control_panel->get_supported_features(); msg.requires_code = a_alarm_control_panel->get_requires_code(); msg.requires_code_to_arm = a_alarm_control_panel->get_requires_code_to_arm(); + msg.device_uid = a_alarm_control_panel->get_device_uid(); return api->send_list_entities_alarm_control_panel_response(msg); } void APIConnection::alarm_control_panel_command(const AlarmControlPanelCommandRequest &msg) { @@ -1696,6 +1715,7 @@ bool APIConnection::try_send_event_info(APIConnection *api, void *v_event) { msg.device_class = event->get_device_class(); for (const auto &event_type : event->get_event_types()) msg.event_types.push_back(event_type); + msg.device_uid = event->get_device_uid(); return api->send_list_entities_event_response(msg); } #endif @@ -1748,6 +1768,7 @@ bool APIConnection::try_send_update_info(APIConnection *api, void *v_update) { msg.disabled_by_default = update->is_disabled_by_default(); msg.entity_category = static_cast(update->get_entity_category()); msg.device_class = update->get_device_class(); + msg.device_uid = update->get_device_uid(); return api->send_list_entities_update_response(msg); } void APIConnection::update_command(const UpdateCommandRequest &msg) { @@ -1865,6 +1886,15 @@ DeviceInfoResponse APIConnection::device_info(const DeviceInfoRequest &msg) { #endif #ifdef USE_API_NOISE resp.api_encryption_supported = true; +#endif +#ifdef USE_SUB_DEVICE + for (auto const &sub_device : App.get_sub_devices()) { + SubDeviceInfo sub_device_info; + sub_device_info.uid = sub_device->get_uid(); + sub_device_info.name = sub_device->get_name(); + sub_device_info.suggested_area = sub_device->get_area(); + resp.sub_devices.push_back(sub_device_info); + } #endif return resp; } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index bac5994a5b..19549c9a6c 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -796,10 +796,6 @@ void DeviceInfoRequest::dump_to(std::string &out) const { out.append("DeviceInfo #endif bool SubDeviceInfo::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 1: { - this->id = value.as_string(); - return true; - } case 2: { this->name = value.as_string(); return true; @@ -813,7 +809,7 @@ bool SubDeviceInfo::decode_length(uint32_t field_id, ProtoLengthDelimited value) } } void SubDeviceInfo::encode(ProtoWriteBuffer buffer) const { - buffer.encode_string(1, this->id); + buffer.encode_fixed32(1, this->uid); buffer.encode_string(2, this->name); buffer.encode_string(3, this->suggested_area); } @@ -821,8 +817,9 @@ void SubDeviceInfo::encode(ProtoWriteBuffer buffer) const { void SubDeviceInfo::dump_to(std::string &out) const { __attribute__((unused)) char buffer[64]; out.append("SubDeviceInfo {\n"); - out.append(" id: "); - out.append("'").append(this->id).append("'"); + out.append(" uid: "); + sprintf(buffer, "%" PRIu32, this->uid); + out.append(buffer); out.append("\n"); out.append(" name: "); @@ -1096,10 +1093,6 @@ bool ListEntitiesBinarySensorResponse::decode_length(uint32_t field_id, ProtoLen this->icon = value.as_string(); return true; } - case 10: { - this->device_id = value.as_string(); - return true; - } default: return false; } @@ -1124,7 +1117,7 @@ void ListEntitiesBinarySensorResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(7, this->disabled_by_default); buffer.encode_string(8, this->icon); buffer.encode_enum(9, this->entity_category); - buffer.encode_string(10, this->device_id); + buffer.encode_fixed32(10, this->device_uid); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesBinarySensorResponse::dump_to(std::string &out) const { @@ -1167,8 +1160,9 @@ void ListEntitiesBinarySensorResponse::dump_to(std::string &out) const { out.append(proto_enum_to_string(this->entity_category)); out.append("\n"); - out.append(" device_id: "); - out.append("'").append(this->device_id).append("'"); + out.append(" device_uid: "); + sprintf(buffer, "%" PRIu32, this->device_uid); + out.append(buffer); out.append("\n"); out.append("}"); } @@ -1273,10 +1267,6 @@ bool ListEntitiesCoverResponse::decode_length(uint32_t field_id, ProtoLengthDeli this->icon = value.as_string(); return true; } - case 13: { - this->device_id = value.as_string(); - return true; - } default: return false; } @@ -1304,7 +1294,7 @@ void ListEntitiesCoverResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(10, this->icon); buffer.encode_enum(11, this->entity_category); buffer.encode_bool(12, this->supports_stop); - buffer.encode_string(13, this->device_id); + buffer.encode_fixed32(13, this->device_uid); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesCoverResponse::dump_to(std::string &out) const { @@ -1359,8 +1349,9 @@ void ListEntitiesCoverResponse::dump_to(std::string &out) const { out.append(YESNO(this->supports_stop)); out.append("\n"); - out.append(" device_id: "); - out.append("'").append(this->device_id).append("'"); + out.append(" device_uid: "); + sprintf(buffer, "%" PRIu32, this->device_uid); + out.append(buffer); out.append("\n"); out.append("}"); } @@ -1580,10 +1571,6 @@ bool ListEntitiesFanResponse::decode_length(uint32_t field_id, ProtoLengthDelimi this->supported_preset_modes.push_back(value.as_string()); return true; } - case 13: { - this->device_id = value.as_string(); - return true; - } default: return false; } @@ -1613,7 +1600,7 @@ void ListEntitiesFanResponse::encode(ProtoWriteBuffer buffer) const { for (auto &it : this->supported_preset_modes) { buffer.encode_string(12, it, true); } - buffer.encode_string(13, this->device_id); + buffer.encode_fixed32(13, this->device_uid); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesFanResponse::dump_to(std::string &out) const { @@ -1671,8 +1658,9 @@ void ListEntitiesFanResponse::dump_to(std::string &out) const { out.append("\n"); } - out.append(" device_id: "); - out.append("'").append(this->device_id).append("'"); + out.append(" device_uid: "); + sprintf(buffer, "%" PRIu32, this->device_uid); + out.append(buffer); out.append("\n"); out.append("}"); } @@ -1969,10 +1957,6 @@ bool ListEntitiesLightResponse::decode_length(uint32_t field_id, ProtoLengthDeli this->icon = value.as_string(); return true; } - case 16: { - this->device_id = value.as_string(); - return true; - } default: return false; } @@ -2015,7 +1999,7 @@ void ListEntitiesLightResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(13, this->disabled_by_default); buffer.encode_string(14, this->icon); buffer.encode_enum(15, this->entity_category); - buffer.encode_string(16, this->device_id); + buffer.encode_fixed32(16, this->device_uid); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesLightResponse::dump_to(std::string &out) const { @@ -2088,8 +2072,9 @@ void ListEntitiesLightResponse::dump_to(std::string &out) const { out.append(proto_enum_to_string(this->entity_category)); out.append("\n"); - out.append(" device_id: "); - out.append("'").append(this->device_id).append("'"); + out.append(" device_uid: "); + sprintf(buffer, "%" PRIu32, this->device_uid); + out.append(buffer); out.append("\n"); out.append("}"); } @@ -2584,10 +2569,6 @@ bool ListEntitiesSensorResponse::decode_length(uint32_t field_id, ProtoLengthDel this->device_class = value.as_string(); return true; } - case 14: { - this->device_id = value.as_string(); - return true; - } default: return false; } @@ -2616,7 +2597,7 @@ void ListEntitiesSensorResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_enum(11, this->legacy_last_reset_type); buffer.encode_bool(12, this->disabled_by_default); buffer.encode_enum(13, this->entity_category); - buffer.encode_string(14, this->device_id); + buffer.encode_fixed32(14, this->device_uid); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesSensorResponse::dump_to(std::string &out) const { @@ -2676,8 +2657,9 @@ void ListEntitiesSensorResponse::dump_to(std::string &out) const { out.append(proto_enum_to_string(this->entity_category)); out.append("\n"); - out.append(" device_id: "); - out.append("'").append(this->device_id).append("'"); + out.append(" device_uid: "); + sprintf(buffer, "%" PRIu32, this->device_uid); + out.append(buffer); out.append("\n"); out.append("}"); } @@ -2771,10 +2753,6 @@ bool ListEntitiesSwitchResponse::decode_length(uint32_t field_id, ProtoLengthDel this->device_class = value.as_string(); return true; } - case 10: { - this->device_id = value.as_string(); - return true; - } default: return false; } @@ -2799,7 +2777,7 @@ void ListEntitiesSwitchResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(7, this->disabled_by_default); buffer.encode_enum(8, this->entity_category); buffer.encode_string(9, this->device_class); - buffer.encode_string(10, this->device_id); + buffer.encode_fixed32(10, this->device_uid); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesSwitchResponse::dump_to(std::string &out) const { @@ -2842,8 +2820,9 @@ void ListEntitiesSwitchResponse::dump_to(std::string &out) const { out.append("'").append(this->device_class).append("'"); out.append("\n"); - out.append(" device_id: "); - out.append("'").append(this->device_id).append("'"); + out.append(" device_uid: "); + sprintf(buffer, "%" PRIu32, this->device_uid); + out.append(buffer); out.append("\n"); out.append("}"); } @@ -2962,10 +2941,6 @@ bool ListEntitiesTextSensorResponse::decode_length(uint32_t field_id, ProtoLengt this->device_class = value.as_string(); return true; } - case 9: { - this->device_id = value.as_string(); - return true; - } default: return false; } @@ -2989,7 +2964,7 @@ void ListEntitiesTextSensorResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(6, this->disabled_by_default); buffer.encode_enum(7, this->entity_category); buffer.encode_string(8, this->device_class); - buffer.encode_string(9, this->device_id); + buffer.encode_fixed32(9, this->device_uid); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesTextSensorResponse::dump_to(std::string &out) const { @@ -3028,8 +3003,9 @@ void ListEntitiesTextSensorResponse::dump_to(std::string &out) const { out.append("'").append(this->device_class).append("'"); out.append("\n"); - out.append(" device_id: "); - out.append("'").append(this->device_id).append("'"); + out.append(" device_uid: "); + sprintf(buffer, "%" PRIu32, this->device_uid); + out.append(buffer); out.append("\n"); out.append("}"); } @@ -3737,10 +3713,6 @@ bool ListEntitiesCameraResponse::decode_length(uint32_t field_id, ProtoLengthDel this->icon = value.as_string(); return true; } - case 8: { - this->device_id = value.as_string(); - return true; - } default: return false; } @@ -3763,7 +3735,7 @@ void ListEntitiesCameraResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(5, this->disabled_by_default); buffer.encode_string(6, this->icon); buffer.encode_enum(7, this->entity_category); - buffer.encode_string(8, this->device_id); + buffer.encode_fixed32(8, this->device_uid); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesCameraResponse::dump_to(std::string &out) const { @@ -3798,8 +3770,9 @@ void ListEntitiesCameraResponse::dump_to(std::string &out) const { out.append(proto_enum_to_string(this->entity_category)); out.append("\n"); - out.append(" device_id: "); - out.append("'").append(this->device_id).append("'"); + out.append(" device_uid: "); + sprintf(buffer, "%" PRIu32, this->device_uid); + out.append(buffer); out.append("\n"); out.append("}"); } @@ -3970,10 +3943,6 @@ bool ListEntitiesClimateResponse::decode_length(uint32_t field_id, ProtoLengthDe this->icon = value.as_string(); return true; } - case 26: { - this->device_id = value.as_string(); - return true; - } default: return false; } @@ -4050,7 +4019,7 @@ void ListEntitiesClimateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(23, this->supports_target_humidity); buffer.encode_float(24, this->visual_min_humidity); buffer.encode_float(25, this->visual_max_humidity); - buffer.encode_string(26, this->device_id); + buffer.encode_fixed32(26, this->device_uid); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesClimateResponse::dump_to(std::string &out) const { @@ -4175,8 +4144,9 @@ void ListEntitiesClimateResponse::dump_to(std::string &out) const { out.append(buffer); out.append("\n"); - out.append(" device_id: "); - out.append("'").append(this->device_id).append("'"); + out.append(" device_uid: "); + sprintf(buffer, "%" PRIu32, this->device_uid); + out.append(buffer); out.append("\n"); out.append("}"); } @@ -4631,10 +4601,6 @@ bool ListEntitiesNumberResponse::decode_length(uint32_t field_id, ProtoLengthDel this->device_class = value.as_string(); return true; } - case 14: { - this->device_id = value.as_string(); - return true; - } default: return false; } @@ -4675,7 +4641,7 @@ void ListEntitiesNumberResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(11, this->unit_of_measurement); buffer.encode_enum(12, this->mode); buffer.encode_string(13, this->device_class); - buffer.encode_string(14, this->device_id); + buffer.encode_fixed32(14, this->device_uid); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesNumberResponse::dump_to(std::string &out) const { @@ -4737,8 +4703,9 @@ void ListEntitiesNumberResponse::dump_to(std::string &out) const { out.append("'").append(this->device_class).append("'"); out.append("\n"); - out.append(" device_id: "); - out.append("'").append(this->device_id).append("'"); + out.append(" device_uid: "); + sprintf(buffer, "%" PRIu32, this->device_uid); + out.append(buffer); out.append("\n"); out.append("}"); } @@ -4862,10 +4829,6 @@ bool ListEntitiesSelectResponse::decode_length(uint32_t field_id, ProtoLengthDel this->options.push_back(value.as_string()); return true; } - case 9: { - this->device_id = value.as_string(); - return true; - } default: return false; } @@ -4891,7 +4854,7 @@ void ListEntitiesSelectResponse::encode(ProtoWriteBuffer buffer) const { } buffer.encode_bool(7, this->disabled_by_default); buffer.encode_enum(8, this->entity_category); - buffer.encode_string(9, this->device_id); + buffer.encode_fixed32(9, this->device_uid); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesSelectResponse::dump_to(std::string &out) const { @@ -4932,8 +4895,9 @@ void ListEntitiesSelectResponse::dump_to(std::string &out) const { out.append(proto_enum_to_string(this->entity_category)); out.append("\n"); - out.append(" device_id: "); - out.append("'").append(this->device_id).append("'"); + out.append(" device_uid: "); + sprintf(buffer, "%" PRIu32, this->device_uid); + out.append(buffer); out.append("\n"); out.append("}"); } @@ -5079,10 +5043,6 @@ bool ListEntitiesLockResponse::decode_length(uint32_t field_id, ProtoLengthDelim this->code_format = value.as_string(); return true; } - case 12: { - this->device_id = value.as_string(); - return true; - } default: return false; } @@ -5109,7 +5069,7 @@ void ListEntitiesLockResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(9, this->supports_open); buffer.encode_bool(10, this->requires_code); buffer.encode_string(11, this->code_format); - buffer.encode_string(12, this->device_id); + buffer.encode_fixed32(12, this->device_uid); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesLockResponse::dump_to(std::string &out) const { @@ -5160,8 +5120,9 @@ void ListEntitiesLockResponse::dump_to(std::string &out) const { out.append("'").append(this->code_format).append("'"); out.append("\n"); - out.append(" device_id: "); - out.append("'").append(this->device_id).append("'"); + out.append(" device_uid: "); + sprintf(buffer, "%" PRIu32, this->device_uid); + out.append(buffer); out.append("\n"); out.append("}"); } @@ -5304,10 +5265,6 @@ bool ListEntitiesButtonResponse::decode_length(uint32_t field_id, ProtoLengthDel this->device_class = value.as_string(); return true; } - case 9: { - this->device_id = value.as_string(); - return true; - } default: return false; } @@ -5331,7 +5288,7 @@ void ListEntitiesButtonResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(6, this->disabled_by_default); buffer.encode_enum(7, this->entity_category); buffer.encode_string(8, this->device_class); - buffer.encode_string(9, this->device_id); + buffer.encode_fixed32(9, this->device_uid); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesButtonResponse::dump_to(std::string &out) const { @@ -5370,8 +5327,9 @@ void ListEntitiesButtonResponse::dump_to(std::string &out) const { out.append("'").append(this->device_class).append("'"); out.append("\n"); - out.append(" device_id: "); - out.append("'").append(this->device_id).append("'"); + out.append(" device_uid: "); + sprintf(buffer, "%" PRIu32, this->device_uid); + out.append(buffer); out.append("\n"); out.append("}"); } @@ -5506,10 +5464,6 @@ bool ListEntitiesMediaPlayerResponse::decode_length(uint32_t field_id, ProtoLeng this->supported_formats.push_back(value.as_message()); return true; } - case 10: { - this->device_id = value.as_string(); - return true; - } default: return false; } @@ -5536,7 +5490,7 @@ void ListEntitiesMediaPlayerResponse::encode(ProtoWriteBuffer buffer) const { for (auto &it : this->supported_formats) { buffer.encode_message(9, it, true); } - buffer.encode_string(10, this->device_id); + buffer.encode_fixed32(10, this->device_uid); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesMediaPlayerResponse::dump_to(std::string &out) const { @@ -5581,8 +5535,9 @@ void ListEntitiesMediaPlayerResponse::dump_to(std::string &out) const { out.append("\n"); } - out.append(" device_id: "); - out.append("'").append(this->device_id).append("'"); + out.append(" device_uid: "); + sprintf(buffer, "%" PRIu32, this->device_uid); + out.append(buffer); out.append("\n"); out.append("}"); } @@ -7667,10 +7622,6 @@ bool ListEntitiesAlarmControlPanelResponse::decode_length(uint32_t field_id, Pro this->icon = value.as_string(); return true; } - case 11: { - this->device_id = value.as_string(); - return true; - } default: return false; } @@ -7696,7 +7647,7 @@ void ListEntitiesAlarmControlPanelResponse::encode(ProtoWriteBuffer buffer) cons buffer.encode_uint32(8, this->supported_features); buffer.encode_bool(9, this->requires_code); buffer.encode_bool(10, this->requires_code_to_arm); - buffer.encode_string(11, this->device_id); + buffer.encode_fixed32(11, this->device_uid); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesAlarmControlPanelResponse::dump_to(std::string &out) const { @@ -7744,8 +7695,9 @@ void ListEntitiesAlarmControlPanelResponse::dump_to(std::string &out) const { out.append(YESNO(this->requires_code_to_arm)); out.append("\n"); - out.append(" device_id: "); - out.append("'").append(this->device_id).append("'"); + out.append(" device_uid: "); + sprintf(buffer, "%" PRIu32, this->device_uid); + out.append(buffer); out.append("\n"); out.append("}"); } @@ -7891,10 +7843,6 @@ bool ListEntitiesTextResponse::decode_length(uint32_t field_id, ProtoLengthDelim this->pattern = value.as_string(); return true; } - case 12: { - this->device_id = value.as_string(); - return true; - } default: return false; } @@ -7921,7 +7869,7 @@ void ListEntitiesTextResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(9, this->max_length); buffer.encode_string(10, this->pattern); buffer.encode_enum(11, this->mode); - buffer.encode_string(12, this->device_id); + buffer.encode_fixed32(12, this->device_uid); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesTextResponse::dump_to(std::string &out) const { @@ -7974,8 +7922,9 @@ void ListEntitiesTextResponse::dump_to(std::string &out) const { out.append(proto_enum_to_string(this->mode)); out.append("\n"); - out.append(" device_id: "); - out.append("'").append(this->device_id).append("'"); + out.append(" device_uid: "); + sprintf(buffer, "%" PRIu32, this->device_uid); + out.append(buffer); out.append("\n"); out.append("}"); } @@ -8105,10 +8054,6 @@ bool ListEntitiesDateResponse::decode_length(uint32_t field_id, ProtoLengthDelim this->icon = value.as_string(); return true; } - case 8: { - this->device_id = value.as_string(); - return true; - } default: return false; } @@ -8131,7 +8076,7 @@ void ListEntitiesDateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(5, this->icon); buffer.encode_bool(6, this->disabled_by_default); buffer.encode_enum(7, this->entity_category); - buffer.encode_string(8, this->device_id); + buffer.encode_fixed32(8, this->device_uid); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesDateResponse::dump_to(std::string &out) const { @@ -8166,8 +8111,9 @@ void ListEntitiesDateResponse::dump_to(std::string &out) const { out.append(proto_enum_to_string(this->entity_category)); out.append("\n"); - out.append(" device_id: "); - out.append("'").append(this->device_id).append("'"); + out.append(" device_uid: "); + sprintf(buffer, "%" PRIu32, this->device_uid); + out.append(buffer); out.append("\n"); out.append("}"); } @@ -8333,10 +8279,6 @@ bool ListEntitiesTimeResponse::decode_length(uint32_t field_id, ProtoLengthDelim this->icon = value.as_string(); return true; } - case 8: { - this->device_id = value.as_string(); - return true; - } default: return false; } @@ -8359,7 +8301,7 @@ void ListEntitiesTimeResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(5, this->icon); buffer.encode_bool(6, this->disabled_by_default); buffer.encode_enum(7, this->entity_category); - buffer.encode_string(8, this->device_id); + buffer.encode_fixed32(8, this->device_uid); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesTimeResponse::dump_to(std::string &out) const { @@ -8394,8 +8336,9 @@ void ListEntitiesTimeResponse::dump_to(std::string &out) const { out.append(proto_enum_to_string(this->entity_category)); out.append("\n"); - out.append(" device_id: "); - out.append("'").append(this->device_id).append("'"); + out.append(" device_uid: "); + sprintf(buffer, "%" PRIu32, this->device_uid); + out.append(buffer); out.append("\n"); out.append("}"); } @@ -8569,10 +8512,6 @@ bool ListEntitiesEventResponse::decode_length(uint32_t field_id, ProtoLengthDeli this->event_types.push_back(value.as_string()); return true; } - case 10: { - this->device_id = value.as_string(); - return true; - } default: return false; } @@ -8599,7 +8538,7 @@ void ListEntitiesEventResponse::encode(ProtoWriteBuffer buffer) const { for (auto &it : this->event_types) { buffer.encode_string(9, it, true); } - buffer.encode_string(10, this->device_id); + buffer.encode_fixed32(10, this->device_uid); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesEventResponse::dump_to(std::string &out) const { @@ -8644,8 +8583,9 @@ void ListEntitiesEventResponse::dump_to(std::string &out) const { out.append("\n"); } - out.append(" device_id: "); - out.append("'").append(this->device_id).append("'"); + out.append(" device_uid: "); + sprintf(buffer, "%" PRIu32, this->device_uid); + out.append(buffer); out.append("\n"); out.append("}"); } @@ -8737,10 +8677,6 @@ bool ListEntitiesValveResponse::decode_length(uint32_t field_id, ProtoLengthDeli this->device_class = value.as_string(); return true; } - case 12: { - this->device_id = value.as_string(); - return true; - } default: return false; } @@ -8767,7 +8703,7 @@ void ListEntitiesValveResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(9, this->assumed_state); buffer.encode_bool(10, this->supports_position); buffer.encode_bool(11, this->supports_stop); - buffer.encode_string(12, this->device_id); + buffer.encode_fixed32(12, this->device_uid); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesValveResponse::dump_to(std::string &out) const { @@ -8818,8 +8754,9 @@ void ListEntitiesValveResponse::dump_to(std::string &out) const { out.append(YESNO(this->supports_stop)); out.append("\n"); - out.append(" device_id: "); - out.append("'").append(this->device_id).append("'"); + out.append(" device_uid: "); + sprintf(buffer, "%" PRIu32, this->device_uid); + out.append(buffer); out.append("\n"); out.append("}"); } @@ -8963,10 +8900,6 @@ bool ListEntitiesDateTimeResponse::decode_length(uint32_t field_id, ProtoLengthD this->icon = value.as_string(); return true; } - case 8: { - this->device_id = value.as_string(); - return true; - } default: return false; } @@ -8989,7 +8922,7 @@ void ListEntitiesDateTimeResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(5, this->icon); buffer.encode_bool(6, this->disabled_by_default); buffer.encode_enum(7, this->entity_category); - buffer.encode_string(8, this->device_id); + buffer.encode_fixed32(8, this->device_uid); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesDateTimeResponse::dump_to(std::string &out) const { @@ -9024,8 +8957,9 @@ void ListEntitiesDateTimeResponse::dump_to(std::string &out) const { out.append(proto_enum_to_string(this->entity_category)); out.append("\n"); - out.append(" device_id: "); - out.append("'").append(this->device_id).append("'"); + out.append(" device_uid: "); + sprintf(buffer, "%" PRIu32, this->device_uid); + out.append(buffer); out.append("\n"); out.append("}"); } @@ -9149,10 +9083,6 @@ bool ListEntitiesUpdateResponse::decode_length(uint32_t field_id, ProtoLengthDel this->device_class = value.as_string(); return true; } - case 9: { - this->device_id = value.as_string(); - return true; - } default: return false; } @@ -9176,7 +9106,7 @@ void ListEntitiesUpdateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(6, this->disabled_by_default); buffer.encode_enum(7, this->entity_category); buffer.encode_string(8, this->device_class); - buffer.encode_string(9, this->device_id); + buffer.encode_fixed32(9, this->device_uid); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesUpdateResponse::dump_to(std::string &out) const { @@ -9215,8 +9145,9 @@ void ListEntitiesUpdateResponse::dump_to(std::string &out) const { out.append("'").append(this->device_class).append("'"); out.append("\n"); - out.append(" device_id: "); - out.append("'").append(this->device_id).append("'"); + out.append(" device_uid: "); + sprintf(buffer, "%" PRIu32, this->device_uid); + out.append(buffer); out.append("\n"); out.append("}"); } diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index c8daf51e43..5a9d431d54 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -349,7 +349,7 @@ class DeviceInfoRequest : public ProtoMessage { }; class SubDeviceInfo : public ProtoMessage { public: - std::string id{}; + uint32_t uid{}; std::string name{}; std::string suggested_area{}; void encode(ProtoWriteBuffer buffer) const override; @@ -429,7 +429,7 @@ class ListEntitiesBinarySensorResponse : public ProtoMessage { bool disabled_by_default{false}; std::string icon{}; enums::EntityCategory entity_category{}; - std::string device_id{}; + uint32_t device_uid{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -468,7 +468,7 @@ class ListEntitiesCoverResponse : public ProtoMessage { std::string icon{}; enums::EntityCategory entity_category{}; bool supports_stop{false}; - std::string device_id{}; + uint32_t device_uid{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -528,7 +528,7 @@ class ListEntitiesFanResponse : public ProtoMessage { std::string icon{}; enums::EntityCategory entity_category{}; std::vector supported_preset_modes{}; - std::string device_id{}; + uint32_t device_uid{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -600,7 +600,7 @@ class ListEntitiesLightResponse : public ProtoMessage { bool disabled_by_default{false}; std::string icon{}; enums::EntityCategory entity_category{}; - std::string device_id{}; + uint32_t device_uid{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -690,7 +690,7 @@ class ListEntitiesSensorResponse : public ProtoMessage { enums::SensorLastResetType legacy_last_reset_type{}; bool disabled_by_default{false}; enums::EntityCategory entity_category{}; - std::string device_id{}; + uint32_t device_uid{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -726,7 +726,7 @@ class ListEntitiesSwitchResponse : public ProtoMessage { bool disabled_by_default{false}; enums::EntityCategory entity_category{}; std::string device_class{}; - std::string device_id{}; + uint32_t device_uid{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -773,7 +773,7 @@ class ListEntitiesTextSensorResponse : public ProtoMessage { bool disabled_by_default{false}; enums::EntityCategory entity_category{}; std::string device_class{}; - std::string device_id{}; + uint32_t device_uid{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -1010,7 +1010,7 @@ class ListEntitiesCameraResponse : public ProtoMessage { bool disabled_by_default{false}; std::string icon{}; enums::EntityCategory entity_category{}; - std::string device_id{}; + uint32_t device_uid{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -1075,7 +1075,7 @@ class ListEntitiesClimateResponse : public ProtoMessage { bool supports_target_humidity{false}; float visual_min_humidity{0.0f}; float visual_max_humidity{0.0f}; - std::string device_id{}; + uint32_t device_uid{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -1163,7 +1163,7 @@ class ListEntitiesNumberResponse : public ProtoMessage { std::string unit_of_measurement{}; enums::NumberMode mode{}; std::string device_class{}; - std::string device_id{}; + uint32_t device_uid{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -1210,7 +1210,7 @@ class ListEntitiesSelectResponse : public ProtoMessage { std::vector options{}; bool disabled_by_default{false}; enums::EntityCategory entity_category{}; - std::string device_id{}; + uint32_t device_uid{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -1262,7 +1262,7 @@ class ListEntitiesLockResponse : public ProtoMessage { bool supports_open{false}; bool requires_code{false}; std::string code_format{}; - std::string device_id{}; + uint32_t device_uid{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -1312,7 +1312,7 @@ class ListEntitiesButtonResponse : public ProtoMessage { bool disabled_by_default{false}; enums::EntityCategory entity_category{}; std::string device_class{}; - std::string device_id{}; + uint32_t device_uid{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -1361,7 +1361,7 @@ class ListEntitiesMediaPlayerResponse : public ProtoMessage { enums::EntityCategory entity_category{}; bool supports_pause{false}; std::vector supported_formats{}; - std::string device_id{}; + uint32_t device_uid{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -1999,7 +1999,7 @@ class ListEntitiesAlarmControlPanelResponse : public ProtoMessage { uint32_t supported_features{0}; bool requires_code{false}; bool requires_code_to_arm{false}; - std::string device_id{}; + uint32_t device_uid{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -2051,7 +2051,7 @@ class ListEntitiesTextResponse : public ProtoMessage { uint32_t max_length{0}; std::string pattern{}; enums::TextMode mode{}; - std::string device_id{}; + uint32_t device_uid{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -2099,7 +2099,7 @@ class ListEntitiesDateResponse : public ProtoMessage { std::string icon{}; bool disabled_by_default{false}; enums::EntityCategory entity_category{}; - std::string device_id{}; + uint32_t device_uid{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -2150,7 +2150,7 @@ class ListEntitiesTimeResponse : public ProtoMessage { std::string icon{}; bool disabled_by_default{false}; enums::EntityCategory entity_category{}; - std::string device_id{}; + uint32_t device_uid{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -2203,7 +2203,7 @@ class ListEntitiesEventResponse : public ProtoMessage { enums::EntityCategory entity_category{}; std::string device_class{}; std::vector event_types{}; - std::string device_id{}; + uint32_t device_uid{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -2240,7 +2240,7 @@ class ListEntitiesValveResponse : public ProtoMessage { bool assumed_state{false}; bool supports_position{false}; bool supports_stop{false}; - std::string device_id{}; + uint32_t device_uid{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -2289,7 +2289,7 @@ class ListEntitiesDateTimeResponse : public ProtoMessage { std::string icon{}; bool disabled_by_default{false}; enums::EntityCategory entity_category{}; - std::string device_id{}; + uint32_t device_uid{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -2336,7 +2336,7 @@ class ListEntitiesUpdateResponse : public ProtoMessage { bool disabled_by_default{false}; enums::EntityCategory entity_category{}; std::string device_class{}; - std::string device_id{}; + uint32_t device_uid{}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; diff --git a/esphome/components/devices/__init__.py b/esphome/components/devices/__init__.py index 5a70be82a7..5365b8ba3e 100644 --- a/esphome/components/devices/__init__.py +++ b/esphome/components/devices/__init__.py @@ -19,7 +19,7 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config): dev = cg.new_Pvariable(config[CONF_ID]) - cg.add(dev.set_id(str(config[CONF_ID]))) + cg.add(dev.set_uid(hash(str(config[CONF_ID])) % 0xFFFFFFFF)) cg.add(dev.set_name(config[CONF_NAME])) cg.add(dev.set_area(config[CONF_AREA])) cg.add(cg.App.register_sub_device(dev)) diff --git a/esphome/components/devices/devices.h b/esphome/components/devices/devices.h index d8bd0d70a3..06f9309360 100644 --- a/esphome/components/devices/devices.h +++ b/esphome/components/devices/devices.h @@ -7,15 +7,15 @@ namespace devices { class SubDevice { public: - void set_id(std::string id) { id_ = std::move(id); } - std::string get_id() { return id_; } + void set_uid(uint32_t uid) { uid_ = uid; } + uint32_t get_uid() { return uid_; } void set_name(std::string name) { name_ = std::move(name); } std::string get_name() { return name_; } void set_area(std::string area) { area_ = std::move(area); } std::string get_area() { return area_; } protected: - std::string id_ = ""; + uint32_t uid_{}; std::string name_ = ""; std::string area_ = ""; }; diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 4eef985b7c..c5feeea5b9 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -21,7 +21,7 @@ from esphome.const import ( CONF_COMMAND_RETAIN, CONF_COMMAND_TOPIC, CONF_DAY, - CONF_DEVICE_ID, + CONF_DEVICE_UID, CONF_DISABLED_BY_DEFAULT, CONF_DISCOVERY, CONF_ENTITY_CATEGORY, @@ -348,7 +348,7 @@ def icon(value): ) -def sub_device_id(value): +def sub_device_uid(value): devices_ns = cg.esphome_ns.namespace("devices") SubDevice = devices_ns.class_("SubDevice") validator = use_id(SubDevice) @@ -1832,7 +1832,7 @@ ENTITY_BASE_SCHEMA = Schema( Optional(CONF_DISABLED_BY_DEFAULT, default=False): boolean, Optional(CONF_ICON): icon, Optional(CONF_ENTITY_CATEGORY): entity_category, - Optional(CONF_DEVICE_ID): sub_device_id, + Optional(CONF_DEVICE_UID): sub_device_uid, } ) diff --git a/esphome/const.py b/esphome/const.py index 22320e824b..ddd02d8b7e 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -216,7 +216,7 @@ CONF_DEST = "dest" CONF_DEVICE = "device" CONF_DEVICE_CLASS = "device_class" CONF_DEVICE_FACTOR = "device_factor" -CONF_DEVICE_ID = "device_id" +CONF_DEVICE_UID = "device_uid" CONF_DIELECTRIC_CONSTANT = "dielectric_constant" CONF_DIMENSIONS = "dimensions" CONF_DIO_PIN = "dio_pin" diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index e66fbb66e6..86d695add8 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -48,8 +48,8 @@ class EntityBase { void set_icon(const char *icon); // Get/set this entity's device id - const StringRef &get_device_id() const { return this->device_id_; } - void set_device_id(const std::string &device_id) { this->device_id_ = StringRef(device_id); } + const uint32_t get_device_uid() const { return this->device_uid_; } + void set_device_uid(const uint32_t device_uid) { this->device_uid_ = device_uid; } protected: /// The hash_base() function has been deprecated. It is kept in this @@ -65,7 +65,7 @@ class EntityBase { bool internal_{false}; bool disabled_by_default_{false}; EntityCategory entity_category_{ENTITY_CATEGORY_NONE}; - StringRef device_id_{""}; + uint32_t device_uid_{}; }; class EntityBase_DeviceClass { // NOLINT(readability-identifier-naming) diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index a1a7d3f516..f63d9fcb54 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -113,7 +113,7 @@ async def setup_entity(var, config): add(var.set_entity_category(config[CONF_ENTITY_CATEGORY])) if CONF_DEVICE_ID in config: device = await get_variable(config[CONF_DEVICE_ID]) - add(var.set_device_id(device.get_id())) + add(var.set_device_uid(hash(str(device)) % 0xFFFFFFFF)) def extract_registry_entry_config( diff --git a/tests/components/device/common.yaml b/tests/components/device/common.yaml index 232bb631c9..879a7591b1 100644 --- a/tests/components/device/common.yaml +++ b/tests/components/device/common.yaml @@ -8,4 +8,4 @@ binary_sensor: - platform: template name: Other device sensor - device_id: other_device + device_uid: other_device From cef023283b337edde03b647e0023cf6942e7a2a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Vikstr=C3=B6m?= Date: Tue, 6 May 2025 02:55:44 +0200 Subject: [PATCH 021/183] Fix generated files --- esphome/components/api/api_pb2.cpp | 144 ++++++++++++++++++++++++----- esphome/components/api/api_pb2.h | 47 +++++----- 2 files changed, 145 insertions(+), 46 deletions(-) diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 19549c9a6c..a8a1d641f0 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -794,6 +794,16 @@ void DeviceInfoRequest::encode(ProtoWriteBuffer buffer) const {} #ifdef HAS_PROTO_MESSAGE_DUMP void DeviceInfoRequest::dump_to(std::string &out) const { out.append("DeviceInfoRequest {}"); } #endif +bool SubDeviceInfo::decode_varint(uint32_t field_id, ProtoVarInt value) { + switch (field_id) { + case 1: { + this->uid = value.as_uint32(); + return true; + } + default: + return false; + } +} bool SubDeviceInfo::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 2: { @@ -809,7 +819,7 @@ bool SubDeviceInfo::decode_length(uint32_t field_id, ProtoLengthDelimited value) } } void SubDeviceInfo::encode(ProtoWriteBuffer buffer) const { - buffer.encode_fixed32(1, this->uid); + buffer.encode_uint32(1, this->uid); buffer.encode_string(2, this->name); buffer.encode_string(3, this->suggested_area); } @@ -1067,6 +1077,10 @@ bool ListEntitiesBinarySensorResponse::decode_varint(uint32_t field_id, ProtoVar this->entity_category = value.as_enum(); return true; } + case 10: { + this->device_uid = value.as_uint32(); + return true; + } default: return false; } @@ -1117,7 +1131,7 @@ void ListEntitiesBinarySensorResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(7, this->disabled_by_default); buffer.encode_string(8, this->icon); buffer.encode_enum(9, this->entity_category); - buffer.encode_fixed32(10, this->device_uid); + buffer.encode_uint32(10, this->device_uid); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesBinarySensorResponse::dump_to(std::string &out) const { @@ -1241,6 +1255,10 @@ bool ListEntitiesCoverResponse::decode_varint(uint32_t field_id, ProtoVarInt val this->supports_stop = value.as_bool(); return true; } + case 13: { + this->device_uid = value.as_uint32(); + return true; + } default: return false; } @@ -1294,7 +1312,7 @@ void ListEntitiesCoverResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(10, this->icon); buffer.encode_enum(11, this->entity_category); buffer.encode_bool(12, this->supports_stop); - buffer.encode_fixed32(13, this->device_uid); + buffer.encode_uint32(13, this->device_uid); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesCoverResponse::dump_to(std::string &out) const { @@ -1545,6 +1563,10 @@ bool ListEntitiesFanResponse::decode_varint(uint32_t field_id, ProtoVarInt value this->entity_category = value.as_enum(); return true; } + case 13: { + this->device_uid = value.as_uint32(); + return true; + } default: return false; } @@ -1600,7 +1622,7 @@ void ListEntitiesFanResponse::encode(ProtoWriteBuffer buffer) const { for (auto &it : this->supported_preset_modes) { buffer.encode_string(12, it, true); } - buffer.encode_fixed32(13, this->device_uid); + buffer.encode_uint32(13, this->device_uid); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesFanResponse::dump_to(std::string &out) const { @@ -1931,6 +1953,10 @@ bool ListEntitiesLightResponse::decode_varint(uint32_t field_id, ProtoVarInt val this->entity_category = value.as_enum(); return true; } + case 16: { + this->device_uid = value.as_uint32(); + return true; + } default: return false; } @@ -1999,7 +2025,7 @@ void ListEntitiesLightResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(13, this->disabled_by_default); buffer.encode_string(14, this->icon); buffer.encode_enum(15, this->entity_category); - buffer.encode_fixed32(16, this->device_uid); + buffer.encode_uint32(16, this->device_uid); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesLightResponse::dump_to(std::string &out) const { @@ -2569,6 +2595,10 @@ bool ListEntitiesSensorResponse::decode_length(uint32_t field_id, ProtoLengthDel this->device_class = value.as_string(); return true; } + case 14: { + this->device_uid = value.as_uint32(); + return true; + } default: return false; } @@ -2597,7 +2627,7 @@ void ListEntitiesSensorResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_enum(11, this->legacy_last_reset_type); buffer.encode_bool(12, this->disabled_by_default); buffer.encode_enum(13, this->entity_category); - buffer.encode_fixed32(14, this->device_uid); + buffer.encode_uint32(14, this->device_uid); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesSensorResponse::dump_to(std::string &out) const { @@ -2727,6 +2757,10 @@ bool ListEntitiesSwitchResponse::decode_varint(uint32_t field_id, ProtoVarInt va this->entity_category = value.as_enum(); return true; } + case 10: { + this->device_uid = value.as_uint32(); + return true; + } default: return false; } @@ -2777,7 +2811,7 @@ void ListEntitiesSwitchResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(7, this->disabled_by_default); buffer.encode_enum(8, this->entity_category); buffer.encode_string(9, this->device_class); - buffer.encode_fixed32(10, this->device_uid); + buffer.encode_uint32(10, this->device_uid); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesSwitchResponse::dump_to(std::string &out) const { @@ -2915,6 +2949,10 @@ bool ListEntitiesTextSensorResponse::decode_varint(uint32_t field_id, ProtoVarIn this->entity_category = value.as_enum(); return true; } + case 9: { + this->device_uid = value.as_uint32(); + return true; + } default: return false; } @@ -2964,7 +3002,7 @@ void ListEntitiesTextSensorResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(6, this->disabled_by_default); buffer.encode_enum(7, this->entity_category); buffer.encode_string(8, this->device_class); - buffer.encode_fixed32(9, this->device_uid); + buffer.encode_uint32(9, this->device_uid); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesTextSensorResponse::dump_to(std::string &out) const { @@ -3691,6 +3729,10 @@ bool ListEntitiesCameraResponse::decode_varint(uint32_t field_id, ProtoVarInt va this->entity_category = value.as_enum(); return true; } + case 8: { + this->device_uid = value.as_uint32(); + return true; + } default: return false; } @@ -3735,7 +3777,7 @@ void ListEntitiesCameraResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(5, this->disabled_by_default); buffer.encode_string(6, this->icon); buffer.encode_enum(7, this->entity_category); - buffer.encode_fixed32(8, this->device_uid); + buffer.encode_uint32(8, this->device_uid); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesCameraResponse::dump_to(std::string &out) const { @@ -3913,6 +3955,10 @@ bool ListEntitiesClimateResponse::decode_varint(uint32_t field_id, ProtoVarInt v this->supports_target_humidity = value.as_bool(); return true; } + case 26: { + this->device_uid = value.as_uint32(); + return true; + } default: return false; } @@ -4019,7 +4065,7 @@ void ListEntitiesClimateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(23, this->supports_target_humidity); buffer.encode_float(24, this->visual_min_humidity); buffer.encode_float(25, this->visual_max_humidity); - buffer.encode_fixed32(26, this->device_uid); + buffer.encode_uint32(26, this->device_uid); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesClimateResponse::dump_to(std::string &out) const { @@ -4571,6 +4617,10 @@ bool ListEntitiesNumberResponse::decode_varint(uint32_t field_id, ProtoVarInt va this->mode = value.as_enum(); return true; } + case 14: { + this->device_uid = value.as_uint32(); + return true; + } default: return false; } @@ -4641,7 +4691,7 @@ void ListEntitiesNumberResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(11, this->unit_of_measurement); buffer.encode_enum(12, this->mode); buffer.encode_string(13, this->device_class); - buffer.encode_fixed32(14, this->device_uid); + buffer.encode_uint32(14, this->device_uid); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesNumberResponse::dump_to(std::string &out) const { @@ -4829,6 +4879,10 @@ bool ListEntitiesSelectResponse::decode_length(uint32_t field_id, ProtoLengthDel this->options.push_back(value.as_string()); return true; } + case 9: { + this->device_uid = value.as_uint32(); + return true; + } default: return false; } @@ -4854,7 +4908,7 @@ void ListEntitiesSelectResponse::encode(ProtoWriteBuffer buffer) const { } buffer.encode_bool(7, this->disabled_by_default); buffer.encode_enum(8, this->entity_category); - buffer.encode_fixed32(9, this->device_uid); + buffer.encode_uint32(9, this->device_uid); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesSelectResponse::dump_to(std::string &out) const { @@ -5017,6 +5071,10 @@ bool ListEntitiesLockResponse::decode_varint(uint32_t field_id, ProtoVarInt valu this->requires_code = value.as_bool(); return true; } + case 12: { + this->device_uid = value.as_uint32(); + return true; + } default: return false; } @@ -5069,7 +5127,7 @@ void ListEntitiesLockResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(9, this->supports_open); buffer.encode_bool(10, this->requires_code); buffer.encode_string(11, this->code_format); - buffer.encode_fixed32(12, this->device_uid); + buffer.encode_uint32(12, this->device_uid); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesLockResponse::dump_to(std::string &out) const { @@ -5239,6 +5297,10 @@ bool ListEntitiesButtonResponse::decode_varint(uint32_t field_id, ProtoVarInt va this->entity_category = value.as_enum(); return true; } + case 9: { + this->device_uid = value.as_uint32(); + return true; + } default: return false; } @@ -5288,7 +5350,7 @@ void ListEntitiesButtonResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(6, this->disabled_by_default); buffer.encode_enum(7, this->entity_category); buffer.encode_string(8, this->device_class); - buffer.encode_fixed32(9, this->device_uid); + buffer.encode_uint32(9, this->device_uid); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesButtonResponse::dump_to(std::string &out) const { @@ -5438,6 +5500,10 @@ bool ListEntitiesMediaPlayerResponse::decode_varint(uint32_t field_id, ProtoVarI this->supports_pause = value.as_bool(); return true; } + case 10: { + this->device_uid = value.as_uint32(); + return true; + } default: return false; } @@ -5490,7 +5556,7 @@ void ListEntitiesMediaPlayerResponse::encode(ProtoWriteBuffer buffer) const { for (auto &it : this->supported_formats) { buffer.encode_message(9, it, true); } - buffer.encode_fixed32(10, this->device_uid); + buffer.encode_uint32(10, this->device_uid); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesMediaPlayerResponse::dump_to(std::string &out) const { @@ -7600,6 +7666,10 @@ bool ListEntitiesAlarmControlPanelResponse::decode_varint(uint32_t field_id, Pro this->requires_code_to_arm = value.as_bool(); return true; } + case 11: { + this->device_uid = value.as_uint32(); + return true; + } default: return false; } @@ -7647,7 +7717,7 @@ void ListEntitiesAlarmControlPanelResponse::encode(ProtoWriteBuffer buffer) cons buffer.encode_uint32(8, this->supported_features); buffer.encode_bool(9, this->requires_code); buffer.encode_bool(10, this->requires_code_to_arm); - buffer.encode_fixed32(11, this->device_uid); + buffer.encode_uint32(11, this->device_uid); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesAlarmControlPanelResponse::dump_to(std::string &out) const { @@ -7817,6 +7887,10 @@ bool ListEntitiesTextResponse::decode_varint(uint32_t field_id, ProtoVarInt valu this->mode = value.as_enum(); return true; } + case 12: { + this->device_uid = value.as_uint32(); + return true; + } default: return false; } @@ -7869,7 +7943,7 @@ void ListEntitiesTextResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(9, this->max_length); buffer.encode_string(10, this->pattern); buffer.encode_enum(11, this->mode); - buffer.encode_fixed32(12, this->device_uid); + buffer.encode_uint32(12, this->device_uid); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesTextResponse::dump_to(std::string &out) const { @@ -8032,6 +8106,10 @@ bool ListEntitiesDateResponse::decode_varint(uint32_t field_id, ProtoVarInt valu this->entity_category = value.as_enum(); return true; } + case 8: { + this->device_uid = value.as_uint32(); + return true; + } default: return false; } @@ -8076,7 +8154,7 @@ void ListEntitiesDateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(5, this->icon); buffer.encode_bool(6, this->disabled_by_default); buffer.encode_enum(7, this->entity_category); - buffer.encode_fixed32(8, this->device_uid); + buffer.encode_uint32(8, this->device_uid); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesDateResponse::dump_to(std::string &out) const { @@ -8257,6 +8335,10 @@ bool ListEntitiesTimeResponse::decode_varint(uint32_t field_id, ProtoVarInt valu this->entity_category = value.as_enum(); return true; } + case 8: { + this->device_uid = value.as_uint32(); + return true; + } default: return false; } @@ -8301,7 +8383,7 @@ void ListEntitiesTimeResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(5, this->icon); buffer.encode_bool(6, this->disabled_by_default); buffer.encode_enum(7, this->entity_category); - buffer.encode_fixed32(8, this->device_uid); + buffer.encode_uint32(8, this->device_uid); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesTimeResponse::dump_to(std::string &out) const { @@ -8482,6 +8564,10 @@ bool ListEntitiesEventResponse::decode_varint(uint32_t field_id, ProtoVarInt val this->entity_category = value.as_enum(); return true; } + case 10: { + this->device_uid = value.as_uint32(); + return true; + } default: return false; } @@ -8538,7 +8624,7 @@ void ListEntitiesEventResponse::encode(ProtoWriteBuffer buffer) const { for (auto &it : this->event_types) { buffer.encode_string(9, it, true); } - buffer.encode_fixed32(10, this->device_uid); + buffer.encode_uint32(10, this->device_uid); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesEventResponse::dump_to(std::string &out) const { @@ -8651,6 +8737,10 @@ bool ListEntitiesValveResponse::decode_varint(uint32_t field_id, ProtoVarInt val this->supports_stop = value.as_bool(); return true; } + case 12: { + this->device_uid = value.as_uint32(); + return true; + } default: return false; } @@ -8703,7 +8793,7 @@ void ListEntitiesValveResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(9, this->assumed_state); buffer.encode_bool(10, this->supports_position); buffer.encode_bool(11, this->supports_stop); - buffer.encode_fixed32(12, this->device_uid); + buffer.encode_uint32(12, this->device_uid); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesValveResponse::dump_to(std::string &out) const { @@ -8878,6 +8968,10 @@ bool ListEntitiesDateTimeResponse::decode_varint(uint32_t field_id, ProtoVarInt this->entity_category = value.as_enum(); return true; } + case 8: { + this->device_uid = value.as_uint32(); + return true; + } default: return false; } @@ -8922,7 +9016,7 @@ void ListEntitiesDateTimeResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(5, this->icon); buffer.encode_bool(6, this->disabled_by_default); buffer.encode_enum(7, this->entity_category); - buffer.encode_fixed32(8, this->device_uid); + buffer.encode_uint32(8, this->device_uid); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesDateTimeResponse::dump_to(std::string &out) const { @@ -9057,6 +9151,10 @@ bool ListEntitiesUpdateResponse::decode_varint(uint32_t field_id, ProtoVarInt va this->entity_category = value.as_enum(); return true; } + case 9: { + this->device_uid = value.as_uint32(); + return true; + } default: return false; } @@ -9106,7 +9204,7 @@ void ListEntitiesUpdateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(6, this->disabled_by_default); buffer.encode_enum(7, this->entity_category); buffer.encode_string(8, this->device_class); - buffer.encode_fixed32(9, this->device_uid); + buffer.encode_uint32(9, this->device_uid); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesUpdateResponse::dump_to(std::string &out) const { diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 5a9d431d54..6c4e06345b 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -349,7 +349,7 @@ class DeviceInfoRequest : public ProtoMessage { }; class SubDeviceInfo : public ProtoMessage { public: - uint32_t uid{}; + uint32_t uid{0}; std::string name{}; std::string suggested_area{}; void encode(ProtoWriteBuffer buffer) const override; @@ -359,6 +359,7 @@ class SubDeviceInfo : public ProtoMessage { protected: bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; + bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; class DeviceInfoResponse : public ProtoMessage { public: @@ -429,7 +430,7 @@ class ListEntitiesBinarySensorResponse : public ProtoMessage { bool disabled_by_default{false}; std::string icon{}; enums::EntityCategory entity_category{}; - uint32_t device_uid{}; + uint32_t device_uid{0}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -468,7 +469,7 @@ class ListEntitiesCoverResponse : public ProtoMessage { std::string icon{}; enums::EntityCategory entity_category{}; bool supports_stop{false}; - uint32_t device_uid{}; + uint32_t device_uid{0}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -528,7 +529,7 @@ class ListEntitiesFanResponse : public ProtoMessage { std::string icon{}; enums::EntityCategory entity_category{}; std::vector supported_preset_modes{}; - uint32_t device_uid{}; + uint32_t device_uid{0}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -600,7 +601,7 @@ class ListEntitiesLightResponse : public ProtoMessage { bool disabled_by_default{false}; std::string icon{}; enums::EntityCategory entity_category{}; - uint32_t device_uid{}; + uint32_t device_uid{0}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -690,7 +691,7 @@ class ListEntitiesSensorResponse : public ProtoMessage { enums::SensorLastResetType legacy_last_reset_type{}; bool disabled_by_default{false}; enums::EntityCategory entity_category{}; - uint32_t device_uid{}; + uint32_t device_uid{0}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -726,7 +727,7 @@ class ListEntitiesSwitchResponse : public ProtoMessage { bool disabled_by_default{false}; enums::EntityCategory entity_category{}; std::string device_class{}; - uint32_t device_uid{}; + uint32_t device_uid{0}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -773,7 +774,7 @@ class ListEntitiesTextSensorResponse : public ProtoMessage { bool disabled_by_default{false}; enums::EntityCategory entity_category{}; std::string device_class{}; - uint32_t device_uid{}; + uint32_t device_uid{0}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -1010,7 +1011,7 @@ class ListEntitiesCameraResponse : public ProtoMessage { bool disabled_by_default{false}; std::string icon{}; enums::EntityCategory entity_category{}; - uint32_t device_uid{}; + uint32_t device_uid{0}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -1075,7 +1076,7 @@ class ListEntitiesClimateResponse : public ProtoMessage { bool supports_target_humidity{false}; float visual_min_humidity{0.0f}; float visual_max_humidity{0.0f}; - uint32_t device_uid{}; + uint32_t device_uid{0}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -1163,7 +1164,7 @@ class ListEntitiesNumberResponse : public ProtoMessage { std::string unit_of_measurement{}; enums::NumberMode mode{}; std::string device_class{}; - uint32_t device_uid{}; + uint32_t device_uid{0}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -1210,7 +1211,7 @@ class ListEntitiesSelectResponse : public ProtoMessage { std::vector options{}; bool disabled_by_default{false}; enums::EntityCategory entity_category{}; - uint32_t device_uid{}; + uint32_t device_uid{0}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -1262,7 +1263,7 @@ class ListEntitiesLockResponse : public ProtoMessage { bool supports_open{false}; bool requires_code{false}; std::string code_format{}; - uint32_t device_uid{}; + uint32_t device_uid{0}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -1312,7 +1313,7 @@ class ListEntitiesButtonResponse : public ProtoMessage { bool disabled_by_default{false}; enums::EntityCategory entity_category{}; std::string device_class{}; - uint32_t device_uid{}; + uint32_t device_uid{0}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -1361,7 +1362,7 @@ class ListEntitiesMediaPlayerResponse : public ProtoMessage { enums::EntityCategory entity_category{}; bool supports_pause{false}; std::vector supported_formats{}; - uint32_t device_uid{}; + uint32_t device_uid{0}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -1999,7 +2000,7 @@ class ListEntitiesAlarmControlPanelResponse : public ProtoMessage { uint32_t supported_features{0}; bool requires_code{false}; bool requires_code_to_arm{false}; - uint32_t device_uid{}; + uint32_t device_uid{0}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -2051,7 +2052,7 @@ class ListEntitiesTextResponse : public ProtoMessage { uint32_t max_length{0}; std::string pattern{}; enums::TextMode mode{}; - uint32_t device_uid{}; + uint32_t device_uid{0}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -2099,7 +2100,7 @@ class ListEntitiesDateResponse : public ProtoMessage { std::string icon{}; bool disabled_by_default{false}; enums::EntityCategory entity_category{}; - uint32_t device_uid{}; + uint32_t device_uid{0}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -2150,7 +2151,7 @@ class ListEntitiesTimeResponse : public ProtoMessage { std::string icon{}; bool disabled_by_default{false}; enums::EntityCategory entity_category{}; - uint32_t device_uid{}; + uint32_t device_uid{0}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -2203,7 +2204,7 @@ class ListEntitiesEventResponse : public ProtoMessage { enums::EntityCategory entity_category{}; std::string device_class{}; std::vector event_types{}; - uint32_t device_uid{}; + uint32_t device_uid{0}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -2240,7 +2241,7 @@ class ListEntitiesValveResponse : public ProtoMessage { bool assumed_state{false}; bool supports_position{false}; bool supports_stop{false}; - uint32_t device_uid{}; + uint32_t device_uid{0}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -2289,7 +2290,7 @@ class ListEntitiesDateTimeResponse : public ProtoMessage { std::string icon{}; bool disabled_by_default{false}; enums::EntityCategory entity_category{}; - uint32_t device_uid{}; + uint32_t device_uid{0}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -2336,7 +2337,7 @@ class ListEntitiesUpdateResponse : public ProtoMessage { bool disabled_by_default{false}; enums::EntityCategory entity_category{}; std::string device_class{}; - uint32_t device_uid{}; + uint32_t device_uid{0}; void encode(ProtoWriteBuffer buffer) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; From 79bbc475f4e48ab7dfa1ef71dad37244add39fcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Vikstr=C3=B6m?= Date: Tue, 6 May 2025 03:05:00 +0200 Subject: [PATCH 022/183] Fix generated files and revert entity config to device_id --- esphome/components/api/api_pb2.cpp | 16 ++++++++-------- esphome/config_validation.py | 6 +++--- esphome/const.py | 2 +- tests/components/device/common.yaml | 2 +- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index a8a1d641f0..3f19dc5313 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -2565,6 +2565,10 @@ bool ListEntitiesSensorResponse::decode_varint(uint32_t field_id, ProtoVarInt va this->entity_category = value.as_enum(); return true; } + case 14: { + this->device_uid = value.as_uint32(); + return true; + } default: return false; } @@ -2595,10 +2599,6 @@ bool ListEntitiesSensorResponse::decode_length(uint32_t field_id, ProtoLengthDel this->device_class = value.as_string(); return true; } - case 14: { - this->device_uid = value.as_uint32(); - return true; - } default: return false; } @@ -4853,6 +4853,10 @@ bool ListEntitiesSelectResponse::decode_varint(uint32_t field_id, ProtoVarInt va this->entity_category = value.as_enum(); return true; } + case 9: { + this->device_uid = value.as_uint32(); + return true; + } default: return false; } @@ -4879,10 +4883,6 @@ bool ListEntitiesSelectResponse::decode_length(uint32_t field_id, ProtoLengthDel this->options.push_back(value.as_string()); return true; } - case 9: { - this->device_uid = value.as_uint32(); - return true; - } default: return false; } diff --git a/esphome/config_validation.py b/esphome/config_validation.py index c5feeea5b9..4eef985b7c 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -21,7 +21,7 @@ from esphome.const import ( CONF_COMMAND_RETAIN, CONF_COMMAND_TOPIC, CONF_DAY, - CONF_DEVICE_UID, + CONF_DEVICE_ID, CONF_DISABLED_BY_DEFAULT, CONF_DISCOVERY, CONF_ENTITY_CATEGORY, @@ -348,7 +348,7 @@ def icon(value): ) -def sub_device_uid(value): +def sub_device_id(value): devices_ns = cg.esphome_ns.namespace("devices") SubDevice = devices_ns.class_("SubDevice") validator = use_id(SubDevice) @@ -1832,7 +1832,7 @@ ENTITY_BASE_SCHEMA = Schema( Optional(CONF_DISABLED_BY_DEFAULT, default=False): boolean, Optional(CONF_ICON): icon, Optional(CONF_ENTITY_CATEGORY): entity_category, - Optional(CONF_DEVICE_UID): sub_device_uid, + Optional(CONF_DEVICE_ID): sub_device_id, } ) diff --git a/esphome/const.py b/esphome/const.py index ddd02d8b7e..22320e824b 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -216,7 +216,7 @@ CONF_DEST = "dest" CONF_DEVICE = "device" CONF_DEVICE_CLASS = "device_class" CONF_DEVICE_FACTOR = "device_factor" -CONF_DEVICE_UID = "device_uid" +CONF_DEVICE_ID = "device_id" CONF_DIELECTRIC_CONSTANT = "dielectric_constant" CONF_DIMENSIONS = "dimensions" CONF_DIO_PIN = "dio_pin" diff --git a/tests/components/device/common.yaml b/tests/components/device/common.yaml index 879a7591b1..232bb631c9 100644 --- a/tests/components/device/common.yaml +++ b/tests/components/device/common.yaml @@ -8,4 +8,4 @@ binary_sensor: - platform: template name: Other device sensor - device_uid: other_device + device_id: other_device From 8fb8e7973009a5e284a5900438b8efb06b0fd997 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Vikstr=C3=B6m?= Date: Tue, 6 May 2025 03:20:22 +0200 Subject: [PATCH 023/183] Fix clang --- esphome/core/entity_base.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 86d695add8..60db74e616 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -48,7 +48,7 @@ class EntityBase { void set_icon(const char *icon); // Get/set this entity's device id - const uint32_t get_device_uid() const { return this->device_uid_; } + uint32_t get_device_uid() const { return this->device_uid_; } void set_device_uid(const uint32_t device_uid) { this->device_uid_ = device_uid; } protected: From 7b460b6224fc459762ba1378774e1e4baeeebfa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Vikstr=C3=B6m?= Date: Tue, 6 May 2025 03:34:33 +0200 Subject: [PATCH 024/183] Restore ci-api-proto.yml --- .github/workflows/ci-api-proto.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 77caad2d22..d6469236d5 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -37,8 +37,6 @@ jobs: run: pip install aioesphomeapi -c requirements.txt -r requirements_dev.txt - name: Generate files run: script/api_protobuf/api_protobuf.py - - name: Show changes - run: git diff - name: Check for changes run: | if ! git diff --quiet; then From 3915e1f0120b6ffc9483c7d845b1ba80eda77eab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Vikstr=C3=B6m?= Date: Tue, 6 May 2025 03:36:03 +0200 Subject: [PATCH 025/183] Revert "Improve stability for unrelated test" This reverts commit 3922950951191ed3052964b000dac2651595c419. --- tests/dashboard/test_web_server.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index 13d2bbbf33..a61850abf3 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -75,9 +75,6 @@ async def test_devices_page(dashboard: DashboardTestHelper) -> None: assert response.headers["content-type"] == "application/json" json_data = json.loads(response.body.decode()) configured_devices = json_data["configured"] - if len(configured_devices) == 0: - assert len(configured_devices) != 0 - else: - first_device = configured_devices[0] - assert first_device["name"] == "pico" - assert first_device["configuration"] == "pico.yaml" + first_device = configured_devices[0] + assert first_device["name"] == "pico" + assert first_device["configuration"] == "pico.yaml" From ff626b428f28042cebccfee2e3162e527f520bfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Vikstr=C3=B6m?= Date: Tue, 6 May 2025 10:41:46 +0200 Subject: [PATCH 026/183] Attempt moving it to esphome config section --- esphome/components/devices/__init__.py | 26 ------------------- esphome/const.py | 1 + esphome/core/config.py | 22 +++++++++++++++- .../devices/devices.h => core/sub_device.h} | 2 -- tests/components/device/common.yaml | 11 -------- tests/components/esphome/common.yaml | 8 ++++++ 6 files changed, 30 insertions(+), 40 deletions(-) delete mode 100644 esphome/components/devices/__init__.py rename esphome/{components/devices/devices.h => core/sub_device.h} (92%) delete mode 100644 tests/components/device/common.yaml diff --git a/esphome/components/devices/__init__.py b/esphome/components/devices/__init__.py deleted file mode 100644 index 5365b8ba3e..0000000000 --- a/esphome/components/devices/__init__.py +++ /dev/null @@ -1,26 +0,0 @@ -from esphome import codegen as cg, config_validation as cv -from esphome.const import CONF_AREA, CONF_ID, CONF_NAME - -devices_ns = cg.esphome_ns.namespace("devices") -SubDevice = devices_ns.class_("SubDevice") - -MULTI_CONF = True - -CODEOWNERS = ["@dala318"] - -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(CONF_ID): cv.declare_id(SubDevice), - cv.Required(CONF_NAME): cv.string, - cv.Optional(CONF_AREA, default=""): cv.string, - } -).extend(cv.COMPONENT_SCHEMA) - - -async def to_code(config): - dev = cg.new_Pvariable(config[CONF_ID]) - cg.add(dev.set_uid(hash(str(config[CONF_ID])) % 0xFFFFFFFF)) - cg.add(dev.set_name(config[CONF_NAME])) - cg.add(dev.set_area(config[CONF_AREA])) - cg.add(cg.App.register_sub_device(dev)) - cg.add_define("USE_SUB_DEVICE") diff --git a/esphome/const.py b/esphome/const.py index 22320e824b..03e4010300 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -835,6 +835,7 @@ CONF_STEP_PIN = "step_pin" CONF_STOP = "stop" CONF_STOP_ACTION = "stop_action" CONF_STORE_BASELINE = "store_baseline" +CONF_SUB_DEVICES = "sub_devices" CONF_SUBNET = "subnet" CONF_SUBSCRIBE_QOS = "subscribe_qos" CONF_SUBSTITUTIONS = "substitutions" diff --git a/esphome/core/config.py b/esphome/core/config.py index 72e9f6a65c..f3d8b7e715 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -13,6 +13,7 @@ from esphome.const import ( CONF_DEBUG_SCHEDULER, CONF_ESPHOME, CONF_FRIENDLY_NAME, + CONF_ID, CONF_INCLUDES, CONF_LIBRARIES, CONF_MIN_VERSION, @@ -26,6 +27,7 @@ from esphome.const import ( CONF_PLATFORMIO_OPTIONS, CONF_PRIORITY, CONF_PROJECT, + CONF_SUB_DEVICES, CONF_TRIGGER_ID, CONF_VERSION, KEY_CORE, @@ -48,7 +50,7 @@ LoopTrigger = cg.esphome_ns.class_( ProjectUpdateTrigger = cg.esphome_ns.class_( "ProjectUpdateTrigger", cg.Component, automation.Trigger.template(cg.std_string) ) - +SubDevice = cg.esphome_ns.class_("SubDevice") VALID_INCLUDE_EXTS = {".h", ".hpp", ".tcc", ".ino", ".cpp", ".c"} @@ -167,6 +169,15 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_COMPILE_PROCESS_LIMIT, default=_compile_process_limit_default ): cv.int_range(min=1, max=get_usable_cpu_count()), + cv.Optional(CONF_SUB_DEVICES, default=[]): cv.ensure_list( + cv.Schema( + { + cv.GenerateID(CONF_ID): cv.declare_id(SubDevice), + cv.Required(CONF_NAME): cv.string, + cv.Optional(CONF_AREA, default=""): cv.string, + } + ), + ), } ), validate_hostname, @@ -405,3 +416,12 @@ async def to_code(config): if config[CONF_PLATFORMIO_OPTIONS]: CORE.add_job(_add_platformio_options, config[CONF_PLATFORMIO_OPTIONS]) + + if config[CONF_SUB_DEVICES]: + for dev_conf in config[CONF_SUB_DEVICES]: + dev = cg.new_Pvariable(dev_conf[CONF_ID]) + cg.add(dev.set_uid(hash(str(dev_conf[CONF_ID])) % 0xFFFFFFFF)) + cg.add(dev.set_name(dev_conf[CONF_NAME])) + cg.add(dev.set_area(dev_conf[CONF_AREA])) + cg.add(cg.App.register_sub_device(dev)) + cg.add_define("USE_SUB_DEVICE") diff --git a/esphome/components/devices/devices.h b/esphome/core/sub_device.h similarity index 92% rename from esphome/components/devices/devices.h rename to esphome/core/sub_device.h index 06f9309360..9e7c4d2261 100644 --- a/esphome/components/devices/devices.h +++ b/esphome/core/sub_device.h @@ -3,7 +3,6 @@ #include "esphome/core/string_ref.h" namespace esphome { -namespace devices { class SubDevice { public: @@ -20,5 +19,4 @@ class SubDevice { std::string area_ = ""; }; -} // namespace devices } // namespace esphome diff --git a/tests/components/device/common.yaml b/tests/components/device/common.yaml deleted file mode 100644 index 232bb631c9..0000000000 --- a/tests/components/device/common.yaml +++ /dev/null @@ -1,11 +0,0 @@ -devices: - - id: other_device - name: Another device - -binary_sensor: - - platform: template - name: Basic sensor - - - platform: template - name: Other device sensor - device_id: other_device diff --git a/tests/components/esphome/common.yaml b/tests/components/esphome/common.yaml index 05954e37d7..3754390e89 100644 --- a/tests/components/esphome/common.yaml +++ b/tests/components/esphome/common.yaml @@ -17,4 +17,12 @@ esphome: version: "1.1" on_update: logger.log: on_update + sub_devices: + - id: other_device + name: Another device + area: Another area +binary_sensor: + - platform: template + name: Other device sensor + device_id: other_device From 39beccbbb0f9c3a1c910e9a3b500fc17cc22e59d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Vikstr=C3=B6m?= Date: Tue, 6 May 2025 10:50:09 +0200 Subject: [PATCH 027/183] remove from CODEOWNERS --- CODEOWNERS | 1 - 1 file changed, 1 deletion(-) diff --git a/CODEOWNERS b/CODEOWNERS index 7dca09e0ac..29919b6d70 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -117,7 +117,6 @@ esphome/components/dashboard_import/* @esphome/core esphome/components/datetime/* @jesserockz @rfdarter esphome/components/debug/* @OttoWinter esphome/components/delonghi/* @grob6000 -esphome/components/devices/* @dala318 esphome/components/dfplayer/* @glmnet esphome/components/dfrobot_sen0395/* @niklasweber esphome/components/dht/* @OttoWinter From dd2b931f6194c75b0ad9f95b907587c6cd0221c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Vikstr=C3=B6m?= Date: Tue, 6 May 2025 11:46:23 +0200 Subject: [PATCH 028/183] Fix namespace error --- esphome/config_validation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 4eef985b7c..ae9d1308ce 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -349,8 +349,8 @@ def icon(value): def sub_device_id(value): - devices_ns = cg.esphome_ns.namespace("devices") - SubDevice = devices_ns.class_("SubDevice") + # Duplicate definition of SubDevice to avoid circular import + SubDevice = cg.esphome_ns.class_("SubDevice") validator = use_id(SubDevice) return validator(value) From 856829bcbb6bb56fa667898df026c91201aed2d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Vikstr=C3=B6m?= Date: Tue, 6 May 2025 12:05:45 +0200 Subject: [PATCH 029/183] More namespace and import fixes --- esphome/core/application.h | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index 796ce39ef9..a57cdb4bf2 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -10,7 +10,7 @@ #include "esphome/core/scheduler.h" #ifdef USE_SUB_DEVICE -#include "esphome/components/devices/devices.h" +#include "esphome/core/sub_device.h" #endif #ifdef USE_BINARY_SENSOR #include "esphome/components/binary_sensor/binary_sensor.h" @@ -101,7 +101,7 @@ class Application { } #ifdef USE_SUB_DEVICE - void register_sub_device(devices::SubDevice *sub_device) { this->sub_devices_.push_back(sub_device); } + void register_sub_device(SubDevice *sub_device) { this->sub_devices_.push_back(sub_device); } #endif void set_current_component(Component *component) { this->current_component_ = component; } @@ -254,10 +254,10 @@ class Application { uint32_t get_app_state() const { return this->app_state_; } #ifdef USE_SUB_DEVICE - const std::vector &get_sub_devices() { return this->sub_devices_; } + const std::vector &get_sub_devices() { return this->sub_devices_; } // /* Very likely no need for get_sub_device_by_key as it only seem to be used when requesting update from API // and the sub_devices shaould only be sent once at connection. */ - // devices::SubDevice *get_sub_device_by_key(uint32_t key, bool include_internal = false) { + // SubDevice *get_sub_device_by_key(uint32_t key, bool include_internal = false) { // for (auto *obj : this->sub_devices_) { // if (obj->get_object_id_hash() == key && (include_internal || !obj->is_internal())) // return obj; @@ -496,7 +496,7 @@ class Application { std::vector looping_components_{}; #ifdef USE_SUB_DEVICE - std::vector sub_devices_{}; + std::vector sub_devices_{}; #endif #ifdef USE_BINARY_SENSOR std::vector binary_sensors_{}; From a59a8c563e1357be79e71854e7379acb7f3f4479 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Vikstr=C3=B6m?= Date: Tue, 6 May 2025 12:30:04 +0200 Subject: [PATCH 030/183] Attempt fixing circular import by lazy import --- esphome/config_validation.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index ae9d1308ce..eca78746d8 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -349,8 +349,9 @@ def icon(value): def sub_device_id(value): - # Duplicate definition of SubDevice to avoid circular import - SubDevice = cg.esphome_ns.class_("SubDevice") + # Lazy import to avoid circular imports + from esphome.core.config import SubDevice + validator = use_id(SubDevice) return validator(value) From 9624efa21e456d18fc618029012a07cb2be19d8e Mon Sep 17 00:00:00 2001 From: Daniel Vikstrom Date: Thu, 22 May 2025 14:18:46 +0200 Subject: [PATCH 031/183] Fix proto generation and clang --- esphome/components/api/api_connection.cpp | 4 ++-- esphome/components/api/api_pb2.cpp | 28 +++++++++++++++++++++++ esphome/components/api/api_pb2.h | 1 + 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index f094ff7d46..6a6edbec02 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -298,8 +298,8 @@ bool APIConnection::try_send_binary_sensor_info_(binary_sensor::BinarySensor *bi msg.unique_id = get_default_unique_id("binary_sensor", binary_sensor); return this->try_send_entity_info_(static_cast(binary_sensor), msg, &APIConnection::send_list_entities_binary_sensor_response); -} -#endif +} +#endif #ifdef USE_COVER bool APIConnection::send_cover_state(cover::Cover *cover) { diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index f5fe4bca06..2674b9c475 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -848,6 +848,11 @@ void SubDeviceInfo::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(2, this->name); buffer.encode_string(3, this->suggested_area); } +void SubDeviceInfo::calculate_size(uint32_t &total_size) const { + ProtoSize::add_uint32_field(total_size, 1, this->uid, false); + ProtoSize::add_string_field(total_size, 1, this->name, false); + ProtoSize::add_string_field(total_size, 1, this->suggested_area, false); +} #ifdef HAS_PROTO_MESSAGE_DUMP void SubDeviceInfo::dump_to(std::string &out) const { __attribute__((unused)) char buffer[64]; @@ -1003,6 +1008,7 @@ void DeviceInfoResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 2, this->suggested_area, false); ProtoSize::add_string_field(total_size, 2, this->bluetooth_mac_address, false); ProtoSize::add_bool_field(total_size, 2, this->api_encryption_supported, false); + ProtoSize::add_repeated_message(total_size, 2, this->sub_devices); } #ifdef HAS_PROTO_MESSAGE_DUMP void DeviceInfoResponse::dump_to(std::string &out) const { @@ -1192,6 +1198,7 @@ void ListEntitiesBinarySensorResponse::calculate_size(uint32_t &total_size) cons ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default, false); ProtoSize::add_string_field(total_size, 1, this->icon, false); ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category), false); + ProtoSize::add_uint32_field(total_size, 1, this->device_uid, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesBinarySensorResponse::dump_to(std::string &out) const { @@ -1392,6 +1399,7 @@ void ListEntitiesCoverResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 1, this->icon, false); ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category), false); ProtoSize::add_bool_field(total_size, 1, this->supports_stop, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_uid, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesCoverResponse::dump_to(std::string &out) const { @@ -1737,6 +1745,7 @@ void ListEntitiesFanResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 1, it, true); } } + ProtoSize::add_uint32_field(total_size, 1, this->device_uid, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesFanResponse::dump_to(std::string &out) const { @@ -2189,6 +2198,7 @@ void ListEntitiesLightResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default, false); ProtoSize::add_string_field(total_size, 1, this->icon, false); ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category), false); + ProtoSize::add_uint32_field(total_size, 2, this->device_uid, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesLightResponse::dump_to(std::string &out) const { @@ -2850,6 +2860,7 @@ void ListEntitiesSensorResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_enum_field(total_size, 1, static_cast(this->legacy_last_reset_type), false); ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default, false); ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category), false); + ProtoSize::add_uint32_field(total_size, 1, this->device_uid, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesSensorResponse::dump_to(std::string &out) const { @@ -3050,6 +3061,7 @@ void ListEntitiesSwitchResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default, false); ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category), false); ProtoSize::add_string_field(total_size, 1, this->device_class, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_uid, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesSwitchResponse::dump_to(std::string &out) const { @@ -3259,6 +3271,7 @@ void ListEntitiesTextSensorResponse::calculate_size(uint32_t &total_size) const ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default, false); ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category), false); ProtoSize::add_string_field(total_size, 1, this->device_class, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_uid, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesTextSensorResponse::dump_to(std::string &out) const { @@ -4130,6 +4143,7 @@ void ListEntitiesCameraResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default, false); ProtoSize::add_string_field(total_size, 1, this->icon, false); ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category), false); + ProtoSize::add_uint32_field(total_size, 1, this->device_uid, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesCameraResponse::dump_to(std::string &out) const { @@ -4478,6 +4492,7 @@ void ListEntitiesClimateResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_bool_field(total_size, 2, this->supports_target_humidity, false); ProtoSize::add_fixed_field<4>(total_size, 2, this->visual_min_humidity != 0.0f, false); ProtoSize::add_fixed_field<4>(total_size, 2, this->visual_max_humidity != 0.0f, false); + ProtoSize::add_uint32_field(total_size, 2, this->device_uid, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesClimateResponse::dump_to(std::string &out) const { @@ -5161,6 +5176,7 @@ void ListEntitiesNumberResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 1, this->unit_of_measurement, false); ProtoSize::add_enum_field(total_size, 1, static_cast(this->mode), false); ProtoSize::add_string_field(total_size, 1, this->device_class, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_uid, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesNumberResponse::dump_to(std::string &out) const { @@ -5401,6 +5417,7 @@ void ListEntitiesSelectResponse::calculate_size(uint32_t &total_size) const { } ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default, false); ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category), false); + ProtoSize::add_uint32_field(total_size, 1, this->device_uid, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesSelectResponse::dump_to(std::string &out) const { @@ -5943,6 +5960,7 @@ void ListEntitiesLockResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_bool_field(total_size, 1, this->supports_open, false); ProtoSize::add_bool_field(total_size, 1, this->requires_code, false); ProtoSize::add_string_field(total_size, 1, this->code_format, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_uid, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesLockResponse::dump_to(std::string &out) const { @@ -6186,6 +6204,7 @@ void ListEntitiesButtonResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default, false); ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category), false); ProtoSize::add_string_field(total_size, 1, this->device_class, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_uid, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesButtonResponse::dump_to(std::string &out) const { @@ -6413,6 +6432,7 @@ void ListEntitiesMediaPlayerResponse::calculate_size(uint32_t &total_size) const ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category), false); ProtoSize::add_bool_field(total_size, 1, this->supports_pause, false); ProtoSize::add_repeated_message(total_size, 1, this->supported_formats); + ProtoSize::add_uint32_field(total_size, 1, this->device_uid, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesMediaPlayerResponse::dump_to(std::string &out) const { @@ -8841,6 +8861,7 @@ void ListEntitiesAlarmControlPanelResponse::calculate_size(uint32_t &total_size) ProtoSize::add_uint32_field(total_size, 1, this->supported_features, false); ProtoSize::add_bool_field(total_size, 1, this->requires_code, false); ProtoSize::add_bool_field(total_size, 1, this->requires_code_to_arm, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_uid, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesAlarmControlPanelResponse::dump_to(std::string &out) const { @@ -9089,6 +9110,7 @@ void ListEntitiesTextResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_uint32_field(total_size, 1, this->max_length, false); ProtoSize::add_string_field(total_size, 1, this->pattern, false); ProtoSize::add_enum_field(total_size, 1, static_cast(this->mode), false); + ProtoSize::add_uint32_field(total_size, 1, this->device_uid, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesTextResponse::dump_to(std::string &out) const { @@ -9318,6 +9340,7 @@ void ListEntitiesDateResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 1, this->icon, false); ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default, false); ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category), false); + ProtoSize::add_uint32_field(total_size, 1, this->device_uid, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesDateResponse::dump_to(std::string &out) const { @@ -9569,6 +9592,7 @@ void ListEntitiesTimeResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 1, this->icon, false); ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default, false); ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category), false); + ProtoSize::add_uint32_field(total_size, 1, this->device_uid, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesTimeResponse::dump_to(std::string &out) const { @@ -9838,6 +9862,7 @@ void ListEntitiesEventResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 1, it, true); } } + ProtoSize::add_uint32_field(total_size, 1, this->device_uid, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesEventResponse::dump_to(std::string &out) const { @@ -10024,6 +10049,7 @@ void ListEntitiesValveResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_bool_field(total_size, 1, this->assumed_state, false); ProtoSize::add_bool_field(total_size, 1, this->supports_position, false); ProtoSize::add_bool_field(total_size, 1, this->supports_stop, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_uid, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesValveResponse::dump_to(std::string &out) const { @@ -10267,6 +10293,7 @@ void ListEntitiesDateTimeResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 1, this->icon, false); ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default, false); ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category), false); + ProtoSize::add_uint32_field(total_size, 1, this->device_uid, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesDateTimeResponse::dump_to(std::string &out) const { @@ -10474,6 +10501,7 @@ void ListEntitiesUpdateResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default, false); ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category), false); ProtoSize::add_string_field(total_size, 1, this->device_class, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_uid, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesUpdateResponse::dump_to(std::string &out) const { diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index e78ba6b4ba..114f2c1604 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -364,6 +364,7 @@ class SubDeviceInfo : public ProtoMessage { std::string name{}; std::string suggested_area{}; void encode(ProtoWriteBuffer buffer) const override; + void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif From f4a9221232ee270388bb7f5116344d10c3aae4f1 Mon Sep 17 00:00:00 2001 From: Daniel Vikstrom Date: Mon, 2 Jun 2025 08:31:06 +0200 Subject: [PATCH 032/183] Change hash method --- esphome/core/config.py | 11 ++++++++++- esphome/cpp_helpers.py | 4 ++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/esphome/core/config.py b/esphome/core/config.py index f3d8b7e715..d27ec1d6bf 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -340,6 +340,15 @@ async def _add_automations(config): await automation.build_automation(trigger, [], conf) +def fnv1a_32bit_hash(string: str) -> int: + """FNV-1a 32-bit hash function.""" + hash_value = 2166136261 + for char in string: + hash_value ^= ord(char) + hash_value = (hash_value * 16777619) & 0xFFFFFFFF + return hash_value + + @coroutine_with_priority(100.0) async def to_code(config): cg.add_global(cg.global_ns.namespace("esphome").using) @@ -420,7 +429,7 @@ async def to_code(config): if config[CONF_SUB_DEVICES]: for dev_conf in config[CONF_SUB_DEVICES]: dev = cg.new_Pvariable(dev_conf[CONF_ID]) - cg.add(dev.set_uid(hash(str(dev_conf[CONF_ID])) % 0xFFFFFFFF)) + cg.add(dev.set_uid(fnv1a_32bit_hash(str(dev_conf[CONF_ID])))) cg.add(dev.set_name(dev_conf[CONF_NAME])) cg.add(dev.set_area(dev_conf[CONF_AREA])) cg.add(cg.App.register_sub_device(dev)) diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index f63d9fcb54..7a8ad060e4 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -13,7 +13,7 @@ from esphome.const import ( CONF_UPDATE_INTERVAL, KEY_PAST_SAFE_MODE, ) -from esphome.core import CORE, ID, coroutine +from esphome.core import CORE, ID, coroutine, fnv1a_32bit_hash from esphome.coroutine import FakeAwaitable from esphome.cpp_generator import add, get_variable from esphome.cpp_types import App @@ -113,7 +113,7 @@ async def setup_entity(var, config): add(var.set_entity_category(config[CONF_ENTITY_CATEGORY])) if CONF_DEVICE_ID in config: device = await get_variable(config[CONF_DEVICE_ID]) - add(var.set_device_uid(hash(str(device)) % 0xFFFFFFFF)) + add(var.set_device_uid(fnv1a_32bit_hash(str(device)))) def extract_registry_entry_config( From 57f4067fbf425c4a456b928e2ee97cc5d28e4a6b Mon Sep 17 00:00:00 2001 From: Daniel Vikstrom Date: Mon, 2 Jun 2025 14:42:39 +0200 Subject: [PATCH 033/183] Move fnv1a_32bit_hash to helpers --- esphome/core/config.py | 16 ++++++---------- esphome/cpp_helpers.py | 4 ++-- esphome/helpers.py | 9 +++++++++ 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/esphome/core/config.py b/esphome/core/config.py index d27ec1d6bf..22bb7b0472 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -34,7 +34,12 @@ from esphome.const import ( __version__ as ESPHOME_VERSION, ) from esphome.core import CORE, coroutine_with_priority -from esphome.helpers import copy_file_if_changed, get_str_env, walk_files +from esphome.helpers import ( + copy_file_if_changed, + fnv1a_32bit_hash, + get_str_env, + walk_files, +) _LOGGER = logging.getLogger(__name__) @@ -340,15 +345,6 @@ async def _add_automations(config): await automation.build_automation(trigger, [], conf) -def fnv1a_32bit_hash(string: str) -> int: - """FNV-1a 32-bit hash function.""" - hash_value = 2166136261 - for char in string: - hash_value ^= ord(char) - hash_value = (hash_value * 16777619) & 0xFFFFFFFF - return hash_value - - @coroutine_with_priority(100.0) async def to_code(config): cg.add_global(cg.global_ns.namespace("esphome").using) diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index 7a8ad060e4..66ff58f4a7 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -13,11 +13,11 @@ from esphome.const import ( CONF_UPDATE_INTERVAL, KEY_PAST_SAFE_MODE, ) -from esphome.core import CORE, ID, coroutine, fnv1a_32bit_hash +from esphome.core import CORE, ID, coroutine from esphome.coroutine import FakeAwaitable from esphome.cpp_generator import add, get_variable from esphome.cpp_types import App -from esphome.helpers import sanitize, snake_case +from esphome.helpers import fnv1a_32bit_hash, sanitize, snake_case from esphome.types import ConfigFragmentType, ConfigType from esphome.util import Registry, RegistryEntry diff --git a/esphome/helpers.py b/esphome/helpers.py index d95546ac94..242c05e892 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -29,6 +29,15 @@ def ensure_unique_string(preferred_string, current_strings): return test_string +def fnv1a_32bit_hash(string: str) -> int: + """FNV-1a 32-bit hash function.""" + hash_value = 2166136261 + for char in string: + hash_value ^= ord(char) + hash_value = (hash_value * 16777619) & 0xFFFFFFFF + return hash_value + + def indent_all_but_first_and_last(text, padding=" "): lines = text.splitlines(True) if len(lines) <= 2: From eb6a7cf3b9f89924e0197e80f6a564595053f4ab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 20 Jun 2025 22:02:19 +0200 Subject: [PATCH 034/183] fix last component being charged for stats --- esphome/core/application.cpp | 4 ++++ esphome/core/runtime_stats.cpp | 17 ++++++++++++----- esphome/core/runtime_stats.h | 3 +++ 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 49c1e5fd61..43e7b79b8a 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -136,6 +136,10 @@ void Application::loop() { this->in_loop_ = false; this->app_state_ = new_app_state; + // Process any pending runtime stats printing after all components have run + // This ensures stats printing doesn't affect component timing measurements + runtime_stats.process_pending_stats(last_op_end_time); + // Use the last component's end time instead of calling millis() again auto elapsed = last_op_end_time - this->last_loop_; if (elapsed >= this->loop_interval_ || HighFrequencyLoopRequester::is_high_frequency()) { diff --git a/esphome/core/runtime_stats.cpp b/esphome/core/runtime_stats.cpp index ec49835752..0ce0d29e8d 100644 --- a/esphome/core/runtime_stats.cpp +++ b/esphome/core/runtime_stats.cpp @@ -28,11 +28,7 @@ void RuntimeStatsCollector::record_component_time(Component *component, uint32_t return; } - if (current_time >= this->next_log_time_) { - this->log_stats_(); - this->reset_stats_(); - this->next_log_time_ = current_time + this->log_interval_; - } + // Don't print stats here anymore - let process_pending_stats handle it } void RuntimeStatsCollector::log_stats_() { @@ -82,4 +78,15 @@ void RuntimeStatsCollector::log_stats_() { } } +void RuntimeStatsCollector::process_pending_stats(uint32_t current_time) { + if (!this->enabled_ || this->next_log_time_ == 0) + return; + + if (current_time >= this->next_log_time_) { + this->log_stats_(); + this->reset_stats_(); + this->next_log_time_ = current_time + this->log_interval_; + } +} + } // namespace esphome \ No newline at end of file diff --git a/esphome/core/runtime_stats.h b/esphome/core/runtime_stats.h index ca5dcb9310..6ae80750a6 100644 --- a/esphome/core/runtime_stats.h +++ b/esphome/core/runtime_stats.h @@ -95,6 +95,9 @@ class RuntimeStatsCollector { void record_component_time(Component *component, uint32_t duration_ms, uint32_t current_time); + // Process any pending stats printing (should be called after component loop) + void process_pending_stats(uint32_t current_time); + protected: void log_stats_(); From e17619841ddb2bc20a3c1433ea67b1194ec6ba94 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 20 Jun 2025 22:03:53 +0200 Subject: [PATCH 035/183] fix last component being charged for stats --- esphome/core/runtime_stats.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/core/runtime_stats.cpp b/esphome/core/runtime_stats.cpp index 0ce0d29e8d..da19349537 100644 --- a/esphome/core/runtime_stats.cpp +++ b/esphome/core/runtime_stats.cpp @@ -89,4 +89,4 @@ void RuntimeStatsCollector::process_pending_stats(uint32_t current_time) { } } -} // namespace esphome \ No newline at end of file +} // namespace esphome From b0d9ffc6a1daedf3259f06fc1c5e12c830fd1a1e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 20 Jun 2025 22:53:12 +0200 Subject: [PATCH 036/183] Reduce logger CPU usage by disabling loop when buffer is empty --- esphome/components/logger/logger.cpp | 15 ++++++++++++++- esphome/components/logger/logger.h | 22 +++++++++++++++++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index 28a66b23b7..783f58af18 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -48,6 +48,11 @@ void HOT Logger::log_vprintf_(int level, const char *tag, int line, const char * // For non-main tasks, queue the message for callbacks - but only if we have any callbacks registered message_sent = this->log_buffer_->send_message_thread_safe(static_cast(level), tag, static_cast(line), current_task, format, args); + if (message_sent) { + // Enable logger loop to process the buffered message + // This is safe to call from any context including ISRs + this->enable_loop_soon_any_context(); + } #endif // USE_ESPHOME_TASK_LOG_BUFFER // Emergency console logging for non-main tasks when ring buffer is full or disabled @@ -139,10 +144,14 @@ Logger::Logger(uint32_t baud_rate, size_t tx_buffer_size) : baud_rate_(baud_rate #ifdef USE_ESPHOME_TASK_LOG_BUFFER void Logger::init_log_buffer(size_t total_buffer_size) { this->log_buffer_ = esphome::make_unique(total_buffer_size); + + // Start with loop disabled when using task buffer (unless using USB CDC) + // The loop will be enabled automatically when messages arrive + this->disable_loop_when_buffer_empty_(); } #endif -#if defined(USE_LOGGER_USB_CDC) || defined(USE_ESP32) +#if defined(USE_LOGGER_USB_CDC) || defined(USE_ESPHOME_TASK_LOG_BUFFER) void Logger::loop() { #if defined(USE_LOGGER_USB_CDC) && defined(USE_ARDUINO) if (this->uart_ == UART_SELECTION_USB_CDC) { @@ -189,6 +198,10 @@ void Logger::loop() { this->write_msg_(this->tx_buffer_); } } + } else { + // No messages to process, disable loop if appropriate + // This reduces overhead when there's no async logging activity + this->disable_loop_when_buffer_empty_(); } #endif } diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index 9f09208b66..ac46139ecc 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -107,7 +107,7 @@ class Logger : public Component { #ifdef USE_ESPHOME_TASK_LOG_BUFFER void init_log_buffer(size_t total_buffer_size); #endif -#if defined(USE_LOGGER_USB_CDC) || defined(USE_ESP32) +#if defined(USE_LOGGER_USB_CDC) || defined(USE_ESPHOME_TASK_LOG_BUFFER) void loop() override; #endif /// Manually set the baud rate for serial, set to 0 to disable. @@ -347,6 +347,26 @@ class Logger : public Component { static const int RESET_COLOR_LEN = strlen(ESPHOME_LOG_RESET_COLOR); this->write_body_to_buffer_(ESPHOME_LOG_RESET_COLOR, RESET_COLOR_LEN, buffer, buffer_at, buffer_size); } + +#ifdef USE_ESPHOME_TASK_LOG_BUFFER + // Disable loop when task buffer is empty (with USB CDC check) + inline void disable_loop_when_buffer_empty_() { + // Thread safety note: This is safe even if another task calls enable_loop_soon_any_context() + // concurrently. If that happens between our check and disable_loop(), the enable request + // will be processed on the next main loop iteration since: + // - disable_loop() takes effect immediately + // - enable_loop_soon_any_context() sets a pending flag that's checked at loop start +#if defined(USE_LOGGER_USB_CDC) && defined(USE_ARDUINO) + // Only disable if not using USB CDC (which needs loop for connection detection) + if (this->uart_ != UART_SELECTION_USB_CDC) { + this->disable_loop(); + } +#else + // No USB CDC support, always safe to disable + this->disable_loop(); +#endif + } +#endif }; extern Logger *global_logger; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) From fdde9c468127c87cac371c8cc33a36c500173cd9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Jun 2025 00:27:05 +0200 Subject: [PATCH 037/183] Reduce Logger memory usage by optimizing variable sizes --- esphome/components/logger/__init__.py | 4 +- esphome/components/logger/logger.cpp | 20 ++--- esphome/components/logger/logger.h | 111 ++++++++++++++------------ esphome/core/log.cpp | 4 +- 4 files changed, 76 insertions(+), 63 deletions(-) diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index 26516e1506..af62d8a73f 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -184,7 +184,9 @@ CONFIG_SCHEMA = cv.All( { cv.GenerateID(): cv.declare_id(Logger), cv.Optional(CONF_BAUD_RATE, default=115200): cv.positive_int, - cv.Optional(CONF_TX_BUFFER_SIZE, default=512): cv.validate_bytes, + cv.Optional(CONF_TX_BUFFER_SIZE, default=512): cv.All( + cv.validate_bytes, cv.int_range(min=160, max=65535) + ), cv.Optional(CONF_DEASSERT_RTS_DTR, default=False): cv.boolean, cv.SplitDefault( CONF_TASK_LOG_BUFFER_SIZE, diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index 28a66b23b7..b42496af66 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -24,7 +24,7 @@ static const char *const TAG = "logger"; // - Messages are serialized through main loop for proper console output // - Fallback to emergency console logging only if ring buffer is full // - WITHOUT task log buffer: Only emergency console output, no callbacks -void HOT Logger::log_vprintf_(int level, const char *tag, int line, const char *format, va_list args) { // NOLINT +void HOT Logger::log_vprintf_(uint8_t level, const char *tag, int line, const char *format, va_list args) { // NOLINT if (level > this->level_for(tag)) return; @@ -46,8 +46,8 @@ void HOT Logger::log_vprintf_(int level, const char *tag, int line, const char * bool message_sent = false; #ifdef USE_ESPHOME_TASK_LOG_BUFFER // For non-main tasks, queue the message for callbacks - but only if we have any callbacks registered - message_sent = this->log_buffer_->send_message_thread_safe(static_cast(level), tag, - static_cast(line), current_task, format, args); + message_sent = + this->log_buffer_->send_message_thread_safe(level, tag, static_cast(line), current_task, format, args); #endif // USE_ESPHOME_TASK_LOG_BUFFER // Emergency console logging for non-main tasks when ring buffer is full or disabled @@ -58,7 +58,7 @@ void HOT Logger::log_vprintf_(int level, const char *tag, int line, const char * // Maximum size for console log messages (includes null terminator) static const size_t MAX_CONSOLE_LOG_MSG_SIZE = 144; char console_buffer[MAX_CONSOLE_LOG_MSG_SIZE]; // MUST be stack allocated for thread safety - int buffer_at = 0; // Initialize buffer position + uint16_t buffer_at = 0; // Initialize buffer position this->format_log_to_buffer_with_terminator_(level, tag, line, format, args, console_buffer, &buffer_at, MAX_CONSOLE_LOG_MSG_SIZE); this->write_msg_(console_buffer); @@ -69,7 +69,7 @@ void HOT Logger::log_vprintf_(int level, const char *tag, int line, const char * } #else // Implementation for all other platforms -void HOT Logger::log_vprintf_(int level, const char *tag, int line, const char *format, va_list args) { // NOLINT +void HOT Logger::log_vprintf_(uint8_t level, const char *tag, int line, const char *format, va_list args) { // NOLINT if (level > this->level_for(tag) || global_recursion_guard_) return; @@ -85,7 +85,7 @@ void HOT Logger::log_vprintf_(int level, const char *tag, int line, const char * #ifdef USE_STORE_LOG_STR_IN_FLASH // Implementation for ESP8266 with flash string support. // Note: USE_STORE_LOG_STR_IN_FLASH is only defined for ESP8266. -void Logger::log_vprintf_(int level, const char *tag, int line, const __FlashStringHelper *format, +void Logger::log_vprintf_(uint8_t level, const char *tag, int line, const __FlashStringHelper *format, va_list args) { // NOLINT if (level > this->level_for(tag) || global_recursion_guard_) return; @@ -122,7 +122,7 @@ void Logger::log_vprintf_(int level, const char *tag, int line, const __FlashStr } #endif // USE_STORE_LOG_STR_IN_FLASH -inline int Logger::level_for(const char *tag) { +inline uint8_t Logger::level_for(const char *tag) { auto it = this->log_levels_.find(tag); if (it != this->log_levels_.end()) return it->second; @@ -195,13 +195,13 @@ void Logger::loop() { #endif void Logger::set_baud_rate(uint32_t baud_rate) { this->baud_rate_ = baud_rate; } -void Logger::set_log_level(const std::string &tag, int log_level) { this->log_levels_[tag] = log_level; } +void Logger::set_log_level(const std::string &tag, uint8_t log_level) { this->log_levels_[tag] = log_level; } #if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) UARTSelection Logger::get_uart() const { return this->uart_; } #endif -void Logger::add_on_log_callback(std::function &&callback) { +void Logger::add_on_log_callback(std::function &&callback) { this->log_callback_.add(std::move(callback)); } float Logger::get_setup_priority() const { return setup_priority::BUS + 500.0f; } @@ -230,7 +230,7 @@ void Logger::dump_config() { } } -void Logger::set_log_level(int level) { +void Logger::set_log_level(uint8_t level) { if (level > ESPHOME_LOG_LEVEL) { level = ESPHOME_LOG_LEVEL; ESP_LOGW(TAG, "Cannot set log level higher than pre-compiled %s", LOG_LEVELS[ESPHOME_LOG_LEVEL]); diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index 9f09208b66..ea82764393 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -61,7 +61,7 @@ static const char *const LOG_LEVEL_LETTERS[] = { * * Advanced configuration (pin selection, etc) is not supported. */ -enum UARTSelection { +enum UARTSelection : uint8_t { #ifdef USE_LIBRETINY UART_SELECTION_DEFAULT = 0, UART_SELECTION_UART0, @@ -129,10 +129,10 @@ class Logger : public Component { #endif /// Set the default log level for this logger. - void set_log_level(int level); + void set_log_level(uint8_t level); /// Set the log level of the specified tag. - void set_log_level(const std::string &tag, int log_level); - int get_log_level() { return this->current_level_; } + void set_log_level(const std::string &tag, uint8_t log_level); + uint8_t get_log_level() { return this->current_level_; } // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) @@ -140,19 +140,20 @@ class Logger : public Component { void pre_setup(); void dump_config() override; - inline int level_for(const char *tag); + inline uint8_t level_for(const char *tag); /// Register a callback that will be called for every log message sent - void add_on_log_callback(std::function &&callback); + void add_on_log_callback(std::function &&callback); // add a listener for log level changes - void add_listener(std::function &&callback) { this->level_callback_.add(std::move(callback)); } + void add_listener(std::function &&callback) { this->level_callback_.add(std::move(callback)); } float get_setup_priority() const override; - void log_vprintf_(int level, const char *tag, int line, const char *format, va_list args); // NOLINT + void log_vprintf_(uint8_t level, const char *tag, int line, const char *format, va_list args); // NOLINT #ifdef USE_STORE_LOG_STR_IN_FLASH - void log_vprintf_(int level, const char *tag, int line, const __FlashStringHelper *format, va_list args); // NOLINT + void log_vprintf_(uint8_t level, const char *tag, int line, const __FlashStringHelper *format, + va_list args); // NOLINT #endif protected: @@ -160,8 +161,9 @@ class Logger : public Component { // Format a log message with printf-style arguments and write it to a buffer with header, footer, and null terminator // It's the caller's responsibility to initialize buffer_at (typically to 0) - inline void HOT format_log_to_buffer_with_terminator_(int level, const char *tag, int line, const char *format, - va_list args, char *buffer, int *buffer_at, int buffer_size) { + inline void HOT format_log_to_buffer_with_terminator_(uint8_t level, const char *tag, int line, const char *format, + va_list args, char *buffer, uint16_t *buffer_at, + uint16_t buffer_size) { #if defined(USE_ESP32) || defined(USE_LIBRETINY) this->write_header_to_buffer_(level, tag, line, this->get_thread_name_(), buffer, buffer_at, buffer_size); #else @@ -180,7 +182,7 @@ class Logger : public Component { } // Helper to format and send a log message to both console and callbacks - inline void HOT log_message_to_buffer_and_send_(int level, const char *tag, int line, const char *format, + inline void HOT log_message_to_buffer_and_send_(uint8_t level, const char *tag, int line, const char *format, va_list args) { // Format to tx_buffer and prepare for output this->tx_buffer_at_ = 0; // Initialize buffer position @@ -194,11 +196,12 @@ class Logger : public Component { } // Write the body of the log message to the buffer - inline void write_body_to_buffer_(const char *value, size_t length, char *buffer, int *buffer_at, int buffer_size) { + inline void write_body_to_buffer_(const char *value, size_t length, char *buffer, uint16_t *buffer_at, + uint16_t buffer_size) { // Calculate available space - const int available = buffer_size - *buffer_at; - if (available <= 0) + if (*buffer_at >= buffer_size) return; + const uint16_t available = buffer_size - *buffer_at; // Determine copy length (minimum of remaining capacity and string length) const size_t copy_len = (length < static_cast(available)) ? length : available; @@ -211,7 +214,7 @@ class Logger : public Component { } // Format string to explicit buffer with varargs - inline void printf_to_buffer_(char *buffer, int *buffer_at, int buffer_size, const char *format, ...) { + inline void printf_to_buffer_(char *buffer, uint16_t *buffer_at, uint16_t buffer_size, const char *format, ...) { va_list arg; va_start(arg, format); this->format_body_to_buffer_(buffer, buffer_at, buffer_size, format, arg); @@ -222,41 +225,50 @@ class Logger : public Component { const char *get_uart_selection_(); #endif + // Group 4-byte aligned members first uint32_t baud_rate_; char *tx_buffer_{nullptr}; - int tx_buffer_at_{0}; - int tx_buffer_size_{0}; +#ifdef USE_ARDUINO + Stream *hw_serial_{nullptr}; +#endif +#if defined(USE_ESP32) || defined(USE_LIBRETINY) + void *main_task_ = nullptr; // Only used for thread name identification +#endif +#ifdef USE_ESP32 + // Task-specific recursion guards: + // - Main task uses a dedicated member variable for efficiency + // - Other tasks use pthread TLS with a dynamically created key via pthread_key_create + pthread_key_t log_recursion_key_; // 4 bytes +#endif +#ifdef USE_ESP_IDF + uart_port_t uart_num_; // 4 bytes (enum defaults to int size) +#endif + + // Large objects (internally aligned) + std::map log_levels_{}; + CallbackManager log_callback_{}; + CallbackManager level_callback_{}; +#ifdef USE_ESPHOME_TASK_LOG_BUFFER + std::unique_ptr log_buffer_; // Will be initialized with init_log_buffer +#endif + + // Group smaller types together at the end + uint16_t tx_buffer_at_{0}; + uint16_t tx_buffer_size_{0}; + uint8_t current_level_{ESPHOME_LOG_LEVEL_VERY_VERBOSE}; #if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) UARTSelection uart_{UART_SELECTION_UART0}; #endif #ifdef USE_LIBRETINY UARTSelection uart_{UART_SELECTION_DEFAULT}; #endif -#ifdef USE_ARDUINO - Stream *hw_serial_{nullptr}; -#endif -#ifdef USE_ESP_IDF - uart_port_t uart_num_; -#endif - std::map log_levels_{}; - CallbackManager log_callback_{}; - int current_level_{ESPHOME_LOG_LEVEL_VERY_VERBOSE}; -#ifdef USE_ESPHOME_TASK_LOG_BUFFER - std::unique_ptr log_buffer_; // Will be initialized with init_log_buffer -#endif #ifdef USE_ESP32 - // Task-specific recursion guards: - // - Main task uses a dedicated member variable for efficiency - // - Other tasks use pthread TLS with a dynamically created key via pthread_key_create bool main_task_recursion_guard_{false}; - pthread_key_t log_recursion_key_; #else bool global_recursion_guard_{false}; // Simple global recursion guard for single-task platforms #endif - CallbackManager level_callback_{}; #if defined(USE_ESP32) || defined(USE_LIBRETINY) - void *main_task_ = nullptr; // Only used for thread name identification const char *HOT get_thread_name_() { TaskHandle_t current_task = xTaskGetCurrentTaskHandle(); if (current_task == main_task_) { @@ -297,11 +309,10 @@ class Logger : public Component { } #endif - inline void HOT write_header_to_buffer_(int level, const char *tag, int line, const char *thread_name, char *buffer, - int *buffer_at, int buffer_size) { + inline void HOT write_header_to_buffer_(uint8_t level, const char *tag, int line, const char *thread_name, + char *buffer, uint16_t *buffer_at, uint16_t buffer_size) { // Format header - if (level < 0) - level = 0; + // uint8_t level is already bounded 0-255, just ensure it's <= 7 if (level > 7) level = 7; @@ -320,12 +331,12 @@ class Logger : public Component { this->printf_to_buffer_(buffer, buffer_at, buffer_size, "%s[%s][%s:%03u]: ", color, letter, tag, line); } - inline void HOT format_body_to_buffer_(char *buffer, int *buffer_at, int buffer_size, const char *format, + inline void HOT format_body_to_buffer_(char *buffer, uint16_t *buffer_at, uint16_t buffer_size, const char *format, va_list args) { // Get remaining capacity in the buffer - const int remaining = buffer_size - *buffer_at; - if (remaining <= 0) + if (*buffer_at >= buffer_size) return; + const uint16_t remaining = buffer_size - *buffer_at; const int ret = vsnprintf(buffer + *buffer_at, remaining, format, args); @@ -334,7 +345,7 @@ class Logger : public Component { } // Update buffer_at with the formatted length (handle truncation) - int formatted_len = (ret >= remaining) ? remaining : ret; + uint16_t formatted_len = (ret >= remaining) ? remaining : ret; *buffer_at += formatted_len; // Remove all trailing newlines right after formatting @@ -343,18 +354,18 @@ class Logger : public Component { } } - inline void HOT write_footer_to_buffer_(char *buffer, int *buffer_at, int buffer_size) { - static const int RESET_COLOR_LEN = strlen(ESPHOME_LOG_RESET_COLOR); + inline void HOT write_footer_to_buffer_(char *buffer, uint16_t *buffer_at, uint16_t buffer_size) { + static const uint16_t RESET_COLOR_LEN = strlen(ESPHOME_LOG_RESET_COLOR); this->write_body_to_buffer_(ESPHOME_LOG_RESET_COLOR, RESET_COLOR_LEN, buffer, buffer_at, buffer_size); } }; extern Logger *global_logger; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -class LoggerMessageTrigger : public Trigger { +class LoggerMessageTrigger : public Trigger { public: - explicit LoggerMessageTrigger(Logger *parent, int level) { + explicit LoggerMessageTrigger(Logger *parent, uint8_t level) { this->level_ = level; - parent->add_on_log_callback([this](int level, const char *tag, const char *message) { + parent->add_on_log_callback([this](uint8_t level, const char *tag, const char *message) { if (level <= this->level_) { this->trigger(level, tag, message); } @@ -362,7 +373,7 @@ class LoggerMessageTrigger : public Trigger { } protected: - int level_; + uint8_t level_; }; } // namespace logger diff --git a/esphome/core/log.cpp b/esphome/core/log.cpp index 424154d253..909319dd28 100644 --- a/esphome/core/log.cpp +++ b/esphome/core/log.cpp @@ -29,7 +29,7 @@ void HOT esp_log_vprintf_(int level, const char *tag, int line, const char *form if (log == nullptr) return; - log->log_vprintf_(level, tag, line, format, args); + log->log_vprintf_(static_cast(level), tag, line, format, args); #endif } @@ -41,7 +41,7 @@ void HOT esp_log_vprintf_(int level, const char *tag, int line, const __FlashStr if (log == nullptr) return; - log->log_vprintf_(level, tag, line, format, args); + log->log_vprintf_(static_cast(level), tag, line, format, args); #endif } #endif From 788dba8ef369bf2a0649a4d7c2858b81a607a0b5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Jun 2025 11:16:14 +0200 Subject: [PATCH 038/183] define --- esphome/core/defines.h | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 657827c364..043ab13f7a 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -17,6 +17,7 @@ // logger #define ESPHOME_LOG_LEVEL ESPHOME_LOG_LEVEL_VERY_VERBOSE +#define USE_ESPHOME_TASK_LOG_BUFFER // Feature flags #define USE_ALARM_CONTROL_PANEL From bf9e901ab97da460a12cfc54bd94c99a0885a02b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Jun 2025 13:13:44 +0200 Subject: [PATCH 039/183] cleanups to address review comments --- esphome/components/api/api.proto | 56 ++-- esphome/components/api/api_connection.cpp | 32 +-- esphome/components/api/api_connection.h | 3 + esphome/components/api/api_pb2.cpp | 304 +++++++++++++--------- esphome/components/api/api_pb2.h | 65 +++-- esphome/const.py | 2 + esphome/core/application.h | 15 +- esphome/core/config.py | 36 ++- esphome/core/entity_base.h | 10 +- esphome/core/sub_area.h | 20 ++ esphome/core/sub_device.h | 12 +- esphome/cpp_helpers.py | 2 +- tests/components/esphome/common.yaml | 5 +- 13 files changed, 340 insertions(+), 222 deletions(-) create mode 100644 esphome/core/sub_area.h diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 9603694ae8..850ca4a575 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -188,10 +188,15 @@ message DeviceInfoRequest { // Empty } -message SubDeviceInfo { - uint32 uid = 1; +message SubAreaInfo { + uint32 area_id = 1; string name = 2; - string suggested_area = 3; +} + +message SubDeviceInfo { + uint32 device_id = 1; + string name = 2; + uint32 area_id = 3; } message DeviceInfoResponse { @@ -244,6 +249,7 @@ message DeviceInfoResponse { bool api_encryption_supported = 19; repeated SubDeviceInfo sub_devices = 20; + repeated SubAreaInfo sub_areas = 21; } message ListEntitiesRequest { @@ -288,7 +294,7 @@ message ListEntitiesBinarySensorResponse { bool disabled_by_default = 7; string icon = 8; EntityCategory entity_category = 9; - uint32 device_uid = 10; + uint32 device_id = 10; } message BinarySensorStateResponse { option (id) = 21; @@ -324,7 +330,7 @@ message ListEntitiesCoverResponse { string icon = 10; EntityCategory entity_category = 11; bool supports_stop = 12; - uint32 device_uid = 13; + uint32 device_id = 13; } enum LegacyCoverState { @@ -398,7 +404,7 @@ message ListEntitiesFanResponse { string icon = 10; EntityCategory entity_category = 11; repeated string supported_preset_modes = 12; - uint32 device_uid = 13; + uint32 device_id = 13; } enum FanSpeed { FAN_SPEED_LOW = 0; @@ -482,7 +488,7 @@ message ListEntitiesLightResponse { bool disabled_by_default = 13; string icon = 14; EntityCategory entity_category = 15; - uint32 device_uid = 16; + uint32 device_id = 16; } message LightStateResponse { option (id) = 24; @@ -575,7 +581,7 @@ message ListEntitiesSensorResponse { SensorLastResetType legacy_last_reset_type = 11; bool disabled_by_default = 12; EntityCategory entity_category = 13; - uint32 device_uid = 14; + uint32 device_id = 14; } message SensorStateResponse { option (id) = 25; @@ -608,7 +614,7 @@ message ListEntitiesSwitchResponse { bool disabled_by_default = 7; EntityCategory entity_category = 8; string device_class = 9; - uint32 device_uid = 10; + uint32 device_id = 10; } message SwitchStateResponse { option (id) = 26; @@ -646,7 +652,7 @@ message ListEntitiesTextSensorResponse { bool disabled_by_default = 6; EntityCategory entity_category = 7; string device_class = 8; - uint32 device_uid = 9; + uint32 device_id = 9; } message TextSensorStateResponse { option (id) = 27; @@ -829,7 +835,7 @@ message ListEntitiesCameraResponse { bool disabled_by_default = 5; string icon = 6; EntityCategory entity_category = 7; - uint32 device_uid = 8; + uint32 device_id = 8; } message CameraImageResponse { @@ -932,7 +938,7 @@ message ListEntitiesClimateResponse { bool supports_target_humidity = 23; float visual_min_humidity = 24; float visual_max_humidity = 25; - uint32 device_uid = 26; + uint32 device_id = 26; } message ClimateStateResponse { option (id) = 47; @@ -1016,7 +1022,7 @@ message ListEntitiesNumberResponse { string unit_of_measurement = 11; NumberMode mode = 12; string device_class = 13; - uint32 device_uid = 14; + uint32 device_id = 14; } message NumberStateResponse { option (id) = 50; @@ -1057,7 +1063,7 @@ message ListEntitiesSelectResponse { repeated string options = 6; bool disabled_by_default = 7; EntityCategory entity_category = 8; - uint32 device_uid = 9; + uint32 device_id = 9; } message SelectStateResponse { option (id) = 53; @@ -1163,7 +1169,7 @@ message ListEntitiesLockResponse { // Not yet implemented: string code_format = 11; - uint32 device_uid = 12; + uint32 device_id = 12; } message LockStateResponse { option (id) = 59; @@ -1203,7 +1209,7 @@ message ListEntitiesButtonResponse { bool disabled_by_default = 6; EntityCategory entity_category = 7; string device_class = 8; - uint32 device_uid = 9; + uint32 device_id = 9; } message ButtonCommandRequest { option (id) = 62; @@ -1260,7 +1266,7 @@ message ListEntitiesMediaPlayerResponse { repeated MediaPlayerSupportedFormat supported_formats = 9; - uint32 device_uid = 10; + uint32 device_id = 10; } message MediaPlayerStateResponse { option (id) = 64; @@ -1801,7 +1807,7 @@ message ListEntitiesAlarmControlPanelResponse { uint32 supported_features = 8; bool requires_code = 9; bool requires_code_to_arm = 10; - uint32 device_uid = 11; + uint32 device_id = 11; } message AlarmControlPanelStateResponse { @@ -1847,7 +1853,7 @@ message ListEntitiesTextResponse { uint32 max_length = 9; string pattern = 10; TextMode mode = 11; - uint32 device_uid = 12; + uint32 device_id = 12; } message TextStateResponse { option (id) = 98; @@ -1888,7 +1894,7 @@ message ListEntitiesDateResponse { string icon = 5; bool disabled_by_default = 6; EntityCategory entity_category = 7; - uint32 device_uid = 8; + uint32 device_id = 8; } message DateStateResponse { option (id) = 101; @@ -1932,7 +1938,7 @@ message ListEntitiesTimeResponse { string icon = 5; bool disabled_by_default = 6; EntityCategory entity_category = 7; - uint32 device_uid = 8; + uint32 device_id = 8; } message TimeStateResponse { option (id) = 104; @@ -1979,7 +1985,7 @@ message ListEntitiesEventResponse { string device_class = 8; repeated string event_types = 9; - uint32 device_uid = 10; + uint32 device_id = 10; } message EventResponse { option (id) = 108; @@ -2011,7 +2017,7 @@ message ListEntitiesValveResponse { bool assumed_state = 9; bool supports_position = 10; bool supports_stop = 11; - uint32 device_uid = 12; + uint32 device_id = 12; } enum ValveOperation { @@ -2058,7 +2064,7 @@ message ListEntitiesDateTimeResponse { string icon = 5; bool disabled_by_default = 6; EntityCategory entity_category = 7; - uint32 device_uid = 8; + uint32 device_id = 8; } message DateTimeStateResponse { option (id) = 113; @@ -2099,7 +2105,7 @@ message ListEntitiesUpdateResponse { bool disabled_by_default = 6; EntityCategory entity_category = 7; string device_class = 8; - uint32 device_uid = 9; + uint32 device_id = 9; } message UpdateStateResponse { option (id) = 117; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 0288419405..2e2e4ec003 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -311,7 +311,6 @@ uint16_t APIConnection::try_send_binary_sensor_info(EntityBase *entity, APIConne ListEntitiesBinarySensorResponse msg; msg.device_class = binary_sensor->get_device_class(); msg.is_status_binary_sensor = binary_sensor->is_status_binary_sensor(); - msg.device_uid = binary_sensor->get_device_uid(); msg.unique_id = get_default_unique_id("binary_sensor", binary_sensor); fill_entity_info_base(binary_sensor, msg); return encode_message_to_buffer(msg, ListEntitiesBinarySensorResponse::MESSAGE_TYPE, conn, remaining_size, is_single); @@ -349,7 +348,6 @@ uint16_t APIConnection::try_send_cover_info(EntityBase *entity, APIConnection *c msg.supports_tilt = traits.get_supports_tilt(); msg.supports_stop = traits.get_supports_stop(); msg.device_class = cover->get_device_class(); - msg.device_uid = cover->get_device_uid(); msg.unique_id = get_default_unique_id("cover", cover); fill_entity_info_base(cover, msg); return encode_message_to_buffer(msg, ListEntitiesCoverResponse::MESSAGE_TYPE, conn, remaining_size, is_single); @@ -419,7 +417,6 @@ uint16_t APIConnection::try_send_fan_info(EntityBase *entity, APIConnection *con msg.supported_speed_count = traits.supported_speed_count(); for (auto const &preset : traits.supported_preset_modes()) msg.supported_preset_modes.push_back(preset); - msg.device_uid = fan->get_device_uid(); msg.unique_id = get_default_unique_id("fan", fan); fill_entity_info_base(fan, msg); return encode_message_to_buffer(msg, ListEntitiesFanResponse::MESSAGE_TYPE, conn, remaining_size, is_single); @@ -500,7 +497,6 @@ uint16_t APIConnection::try_send_light_info(EntityBase *entity, APIConnection *c msg.effects.push_back(effect->get_name()); } } - msg.device_uid = light->get_device_uid(); msg.unique_id = get_default_unique_id("light", light); fill_entity_info_base(light, msg); return encode_message_to_buffer(msg, ListEntitiesLightResponse::MESSAGE_TYPE, conn, remaining_size, is_single); @@ -569,7 +565,6 @@ uint16_t APIConnection::try_send_sensor_info(EntityBase *entity, APIConnection * msg.force_update = sensor->get_force_update(); msg.device_class = sensor->get_device_class(); msg.state_class = static_cast(sensor->get_state_class()); - msg.device_uid = sensor->get_device_uid(); msg.unique_id = sensor->unique_id(); if (msg.unique_id.empty()) msg.unique_id = get_default_unique_id("sensor", sensor); @@ -601,7 +596,6 @@ uint16_t APIConnection::try_send_switch_info(EntityBase *entity, APIConnection * ListEntitiesSwitchResponse msg; msg.assumed_state = a_switch->assumed_state(); msg.device_class = a_switch->get_device_class(); - msg.device_uid = a_switch->get_device_uid(); msg.unique_id = get_default_unique_id("switch", a_switch); fill_entity_info_base(a_switch, msg); return encode_message_to_buffer(msg, ListEntitiesSwitchResponse::MESSAGE_TYPE, conn, remaining_size, is_single); @@ -644,7 +638,6 @@ uint16_t APIConnection::try_send_text_sensor_info(EntityBase *entity, APIConnect ListEntitiesTextSensorResponse msg; msg.device_class = text_sensor->get_device_class(); msg.unique_id = text_sensor->unique_id(); - msg.device_uid = text_sensor->get_device_uid(); if (msg.unique_id.empty()) msg.unique_id = get_default_unique_id("text_sensor", text_sensor); fill_entity_info_base(text_sensor, msg); @@ -721,7 +714,6 @@ uint16_t APIConnection::try_send_climate_info(EntityBase *entity, APIConnection msg.supported_custom_presets.push_back(custom_preset); for (auto swing_mode : traits.get_supported_swing_modes()) msg.supported_swing_modes.push_back(static_cast(swing_mode)); - msg.device_uid = climate->get_device_uid(); msg.unique_id = get_default_unique_id("climate", climate); fill_entity_info_base(climate, msg); return encode_message_to_buffer(msg, ListEntitiesClimateResponse::MESSAGE_TYPE, conn, remaining_size, is_single); @@ -784,7 +776,6 @@ uint16_t APIConnection::try_send_number_info(EntityBase *entity, APIConnection * msg.min_value = number->traits.get_min_value(); msg.max_value = number->traits.get_max_value(); msg.step = number->traits.get_step(); - msg.device_uid = number->get_device_uid(); msg.unique_id = get_default_unique_id("number", number); fill_entity_info_base(number, msg); return encode_message_to_buffer(msg, ListEntitiesNumberResponse::MESSAGE_TYPE, conn, remaining_size, is_single); @@ -822,7 +813,6 @@ uint16_t APIConnection::try_send_date_info(EntityBase *entity, APIConnection *co bool is_single) { auto *date = static_cast(entity); ListEntitiesDateResponse msg; - msg.device_uid = date->get_device_uid(); msg.unique_id = get_default_unique_id("date", date); fill_entity_info_base(date, msg); return encode_message_to_buffer(msg, ListEntitiesDateResponse::MESSAGE_TYPE, conn, remaining_size, is_single); @@ -860,7 +850,6 @@ uint16_t APIConnection::try_send_time_info(EntityBase *entity, APIConnection *co bool is_single) { auto *time = static_cast(entity); ListEntitiesTimeResponse msg; - msg.device_uid = time->get_device_uid(); msg.unique_id = get_default_unique_id("time", time); fill_entity_info_base(time, msg); return encode_message_to_buffer(msg, ListEntitiesTimeResponse::MESSAGE_TYPE, conn, remaining_size, is_single); @@ -900,7 +889,6 @@ uint16_t APIConnection::try_send_datetime_info(EntityBase *entity, APIConnection bool is_single) { auto *datetime = static_cast(entity); ListEntitiesDateTimeResponse msg; - msg.device_uid = datetime->get_device_uid(); msg.unique_id = get_default_unique_id("datetime", datetime); fill_entity_info_base(datetime, msg); return encode_message_to_buffer(msg, ListEntitiesDateTimeResponse::MESSAGE_TYPE, conn, remaining_size, is_single); @@ -942,7 +930,6 @@ uint16_t APIConnection::try_send_text_info(EntityBase *entity, APIConnection *co msg.min_length = text->traits.get_min_length(); msg.max_length = text->traits.get_max_length(); msg.pattern = text->traits.get_pattern(); - msg.device_uid = text->get_device_uid(); msg.unique_id = get_default_unique_id("text", text); fill_entity_info_base(text, msg); return encode_message_to_buffer(msg, ListEntitiesTextResponse::MESSAGE_TYPE, conn, remaining_size, is_single); @@ -982,7 +969,6 @@ uint16_t APIConnection::try_send_select_info(EntityBase *entity, APIConnection * ListEntitiesSelectResponse msg; for (const auto &option : select->traits.get_options()) msg.options.push_back(option); - msg.device_uid = select->get_device_uid(); msg.unique_id = get_default_unique_id("select", select); fill_entity_info_base(select, msg); return encode_message_to_buffer(msg, ListEntitiesSelectResponse::MESSAGE_TYPE, conn, remaining_size, is_single); @@ -1007,7 +993,6 @@ uint16_t APIConnection::try_send_button_info(EntityBase *entity, APIConnection * auto *button = static_cast(entity); ListEntitiesButtonResponse msg; msg.device_class = button->get_device_class(); - msg.device_uid = button->get_device_uid(); msg.unique_id = get_default_unique_id("button", button); fill_entity_info_base(button, msg); return encode_message_to_buffer(msg, ListEntitiesButtonResponse::MESSAGE_TYPE, conn, remaining_size, is_single); @@ -1045,7 +1030,6 @@ uint16_t APIConnection::try_send_lock_info(EntityBase *entity, APIConnection *co msg.assumed_state = a_lock->traits.get_assumed_state(); msg.supports_open = a_lock->traits.get_supports_open(); msg.requires_code = a_lock->traits.get_requires_code(); - msg.device_uid = a_lock->get_device_uid(); msg.unique_id = get_default_unique_id("lock", a_lock); fill_entity_info_base(a_lock, msg); return encode_message_to_buffer(msg, ListEntitiesLockResponse::MESSAGE_TYPE, conn, remaining_size, is_single); @@ -1094,7 +1078,6 @@ uint16_t APIConnection::try_send_valve_info(EntityBase *entity, APIConnection *c msg.assumed_state = traits.get_is_assumed_state(); msg.supports_position = traits.get_supports_position(); msg.supports_stop = traits.get_supports_stop(); - msg.device_uid = valve->get_device_uid(); msg.unique_id = get_default_unique_id("valve", valve); fill_entity_info_base(valve, msg); return encode_message_to_buffer(msg, ListEntitiesValveResponse::MESSAGE_TYPE, conn, remaining_size, is_single); @@ -1150,7 +1133,6 @@ uint16_t APIConnection::try_send_media_player_info(EntityBase *entity, APIConnec media_format.sample_bytes = supported_format.sample_bytes; msg.supported_formats.push_back(media_format); } - msg.device_uid = media_player->get_device_uid(); msg.unique_id = get_default_unique_id("media_player", media_player); fill_entity_info_base(media_player, msg); return encode_message_to_buffer(msg, ListEntitiesMediaPlayerResponse::MESSAGE_TYPE, conn, remaining_size, is_single); @@ -1194,7 +1176,6 @@ uint16_t APIConnection::try_send_camera_info(EntityBase *entity, APIConnection * bool is_single) { auto *camera = static_cast(entity); ListEntitiesCameraResponse msg; - msg.device_uid = camera->get_device_uid(); msg.unique_id = get_default_unique_id("camera", camera); fill_entity_info_base(camera, msg); return encode_message_to_buffer(msg, ListEntitiesCameraResponse::MESSAGE_TYPE, conn, remaining_size, is_single); @@ -1408,7 +1389,6 @@ uint16_t APIConnection::try_send_alarm_control_panel_info(EntityBase *entity, AP msg.supported_features = a_alarm_control_panel->get_supported_features(); msg.requires_code = a_alarm_control_panel->get_requires_code(); msg.requires_code_to_arm = a_alarm_control_panel->get_requires_code_to_arm(); - msg.device_uid = a_alarm_control_panel->get_device_uid(); msg.unique_id = get_default_unique_id("alarm_control_panel", a_alarm_control_panel); fill_entity_info_base(a_alarm_control_panel, msg); return encode_message_to_buffer(msg, ListEntitiesAlarmControlPanelResponse::MESSAGE_TYPE, conn, remaining_size, @@ -1470,7 +1450,6 @@ uint16_t APIConnection::try_send_event_info(EntityBase *entity, APIConnection *c msg.device_class = event->get_device_class(); for (const auto &event_type : event->get_event_types()) msg.event_types.push_back(event_type); - msg.device_uid = event->get_device_uid(); msg.unique_id = get_default_unique_id("event", event); fill_entity_info_base(event, msg); return encode_message_to_buffer(msg, ListEntitiesEventResponse::MESSAGE_TYPE, conn, remaining_size, is_single); @@ -1509,7 +1488,6 @@ uint16_t APIConnection::try_send_update_info(EntityBase *entity, APIConnection * auto *update = static_cast(entity); ListEntitiesUpdateResponse msg; msg.device_class = update->get_device_class(); - msg.device_uid = update->get_device_uid(); msg.unique_id = get_default_unique_id("update", update); fill_entity_info_base(update, msg); return encode_message_to_buffer(msg, ListEntitiesUpdateResponse::MESSAGE_TYPE, conn, remaining_size, is_single); @@ -1645,11 +1623,17 @@ DeviceInfoResponse APIConnection::device_info(const DeviceInfoRequest &msg) { #ifdef USE_SUB_DEVICE for (auto const &sub_device : App.get_sub_devices()) { SubDeviceInfo sub_device_info; - sub_device_info.uid = sub_device->get_uid(); + sub_device_info.device_id = sub_device->get_device_id(); sub_device_info.name = sub_device->get_name(); - sub_device_info.suggested_area = sub_device->get_area(); + sub_device_info.area_id = sub_device->get_area_id(); resp.sub_devices.push_back(sub_device_info); } + for (auto const &area : App.get_areas()) { + SubAreaInfo area_info; + area_info.area_id = area->get_area_id(); + area_info.name = area->get_name(); + resp.sub_areas.push_back(area_info); + } #endif return resp; } diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 66b7ce38a7..9166dbbc94 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -301,6 +301,9 @@ class APIConnection : public APIServerConnection { response.icon = entity->get_icon(); response.disabled_by_default = entity->is_disabled_by_default(); response.entity_category = static_cast(entity->get_entity_category()); +#ifdef USE_SUB_DEVICE + response.device_id = entity->get_device_id(); +#endif } // Helper function to fill common entity state fields diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 682778a881..baa78f4358 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -812,10 +812,57 @@ void PingResponse::dump_to(std::string &out) const { out.append("PingResponse {} #ifdef HAS_PROTO_MESSAGE_DUMP void DeviceInfoRequest::dump_to(std::string &out) const { out.append("DeviceInfoRequest {}"); } #endif +bool SubAreaInfo::decode_varint(uint32_t field_id, ProtoVarInt value) { + switch (field_id) { + case 1: { + this->area_id = value.as_uint32(); + return true; + } + default: + return false; + } +} +bool SubAreaInfo::decode_length(uint32_t field_id, ProtoLengthDelimited value) { + switch (field_id) { + case 2: { + this->name = value.as_string(); + return true; + } + default: + return false; + } +} +void SubAreaInfo::encode(ProtoWriteBuffer buffer) const { + buffer.encode_uint32(1, this->area_id); + buffer.encode_string(2, this->name); +} +void SubAreaInfo::calculate_size(uint32_t &total_size) const { + ProtoSize::add_uint32_field(total_size, 1, this->area_id, false); + ProtoSize::add_string_field(total_size, 1, this->name, false); +} +#ifdef HAS_PROTO_MESSAGE_DUMP +void SubAreaInfo::dump_to(std::string &out) const { + __attribute__((unused)) char buffer[64]; + out.append("SubAreaInfo {\n"); + out.append(" area_id: "); + sprintf(buffer, "%" PRIu32, this->area_id); + out.append(buffer); + out.append("\n"); + + out.append(" name: "); + out.append("'").append(this->name).append("'"); + out.append("\n"); + out.append("}"); +} +#endif bool SubDeviceInfo::decode_varint(uint32_t field_id, ProtoVarInt value) { switch (field_id) { case 1: { - this->uid = value.as_uint32(); + this->device_id = value.as_uint32(); + return true; + } + case 3: { + this->area_id = value.as_uint32(); return true; } default: @@ -828,30 +875,26 @@ bool SubDeviceInfo::decode_length(uint32_t field_id, ProtoLengthDelimited value) this->name = value.as_string(); return true; } - case 3: { - this->suggested_area = value.as_string(); - return true; - } default: return false; } } void SubDeviceInfo::encode(ProtoWriteBuffer buffer) const { - buffer.encode_uint32(1, this->uid); + buffer.encode_uint32(1, this->device_id); buffer.encode_string(2, this->name); - buffer.encode_string(3, this->suggested_area); + buffer.encode_uint32(3, this->area_id); } void SubDeviceInfo::calculate_size(uint32_t &total_size) const { - ProtoSize::add_uint32_field(total_size, 1, this->uid, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); ProtoSize::add_string_field(total_size, 1, this->name, false); - ProtoSize::add_string_field(total_size, 1, this->suggested_area, false); + ProtoSize::add_uint32_field(total_size, 1, this->area_id, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void SubDeviceInfo::dump_to(std::string &out) const { __attribute__((unused)) char buffer[64]; out.append("SubDeviceInfo {\n"); - out.append(" uid: "); - sprintf(buffer, "%" PRIu32, this->uid); + out.append(" device_id: "); + sprintf(buffer, "%" PRIu32, this->device_id); out.append(buffer); out.append("\n"); @@ -859,8 +902,9 @@ void SubDeviceInfo::dump_to(std::string &out) const { out.append("'").append(this->name).append("'"); out.append("\n"); - out.append(" suggested_area: "); - out.append("'").append(this->suggested_area).append("'"); + out.append(" area_id: "); + sprintf(buffer, "%" PRIu32, this->area_id); + out.append(buffer); out.append("\n"); out.append("}"); } @@ -953,6 +997,10 @@ bool DeviceInfoResponse::decode_length(uint32_t field_id, ProtoLengthDelimited v this->sub_devices.push_back(value.as_message()); return true; } + case 21: { + this->sub_areas.push_back(value.as_message()); + return true; + } default: return false; } @@ -980,6 +1028,9 @@ void DeviceInfoResponse::encode(ProtoWriteBuffer buffer) const { for (auto &it : this->sub_devices) { buffer.encode_message(20, it, true); } + for (auto &it : this->sub_areas) { + buffer.encode_message(21, it, true); + } } void DeviceInfoResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_bool_field(total_size, 1, this->uses_password, false); @@ -1002,6 +1053,7 @@ void DeviceInfoResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 2, this->bluetooth_mac_address, false); ProtoSize::add_bool_field(total_size, 2, this->api_encryption_supported, false); ProtoSize::add_repeated_message(total_size, 2, this->sub_devices); + ProtoSize::add_repeated_message(total_size, 2, this->sub_areas); } #ifdef HAS_PROTO_MESSAGE_DUMP void DeviceInfoResponse::dump_to(std::string &out) const { @@ -1093,6 +1145,12 @@ void DeviceInfoResponse::dump_to(std::string &out) const { it.dump_to(out); out.append("\n"); } + + for (const auto &it : this->sub_areas) { + out.append(" sub_areas: "); + it.dump_to(out); + out.append("\n"); + } out.append("}"); } #endif @@ -1120,7 +1178,7 @@ bool ListEntitiesBinarySensorResponse::decode_varint(uint32_t field_id, ProtoVar return true; } case 10: { - this->device_uid = value.as_uint32(); + this->device_id = value.as_uint32(); return true; } default: @@ -1173,7 +1231,7 @@ void ListEntitiesBinarySensorResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(7, this->disabled_by_default); buffer.encode_string(8, this->icon); buffer.encode_enum(9, this->entity_category); - buffer.encode_uint32(10, this->device_uid); + buffer.encode_uint32(10, this->device_id); } void ListEntitiesBinarySensorResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 1, this->object_id, false); @@ -1185,7 +1243,7 @@ void ListEntitiesBinarySensorResponse::calculate_size(uint32_t &total_size) cons ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default, false); ProtoSize::add_string_field(total_size, 1, this->icon, false); ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category), false); - ProtoSize::add_uint32_field(total_size, 1, this->device_uid, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesBinarySensorResponse::dump_to(std::string &out) const { @@ -1228,8 +1286,8 @@ void ListEntitiesBinarySensorResponse::dump_to(std::string &out) const { out.append(proto_enum_to_string(this->entity_category)); out.append("\n"); - out.append(" device_uid: "); - sprintf(buffer, "%" PRIu32, this->device_uid); + out.append(" device_id: "); + sprintf(buffer, "%" PRIu32, this->device_id); out.append(buffer); out.append("\n"); out.append("}"); @@ -1315,7 +1373,7 @@ bool ListEntitiesCoverResponse::decode_varint(uint32_t field_id, ProtoVarInt val return true; } case 13: { - this->device_uid = value.as_uint32(); + this->device_id = value.as_uint32(); return true; } default: @@ -1371,7 +1429,7 @@ void ListEntitiesCoverResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(10, this->icon); buffer.encode_enum(11, this->entity_category); buffer.encode_bool(12, this->supports_stop); - buffer.encode_uint32(13, this->device_uid); + buffer.encode_uint32(13, this->device_id); } void ListEntitiesCoverResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 1, this->object_id, false); @@ -1386,7 +1444,7 @@ void ListEntitiesCoverResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 1, this->icon, false); ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category), false); ProtoSize::add_bool_field(total_size, 1, this->supports_stop, false); - ProtoSize::add_uint32_field(total_size, 1, this->device_uid, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesCoverResponse::dump_to(std::string &out) const { @@ -1441,8 +1499,8 @@ void ListEntitiesCoverResponse::dump_to(std::string &out) const { out.append(YESNO(this->supports_stop)); out.append("\n"); - out.append(" device_uid: "); - sprintf(buffer, "%" PRIu32, this->device_uid); + out.append(" device_id: "); + sprintf(buffer, "%" PRIu32, this->device_id); out.append(buffer); out.append("\n"); out.append("}"); @@ -1655,7 +1713,7 @@ bool ListEntitiesFanResponse::decode_varint(uint32_t field_id, ProtoVarInt value return true; } case 13: { - this->device_uid = value.as_uint32(); + this->device_id = value.as_uint32(); return true; } default: @@ -1713,7 +1771,7 @@ void ListEntitiesFanResponse::encode(ProtoWriteBuffer buffer) const { for (auto &it : this->supported_preset_modes) { buffer.encode_string(12, it, true); } - buffer.encode_uint32(13, this->device_uid); + buffer.encode_uint32(13, this->device_id); } void ListEntitiesFanResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 1, this->object_id, false); @@ -1732,7 +1790,7 @@ void ListEntitiesFanResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 1, it, true); } } - ProtoSize::add_uint32_field(total_size, 1, this->device_uid, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesFanResponse::dump_to(std::string &out) const { @@ -1790,8 +1848,8 @@ void ListEntitiesFanResponse::dump_to(std::string &out) const { out.append("\n"); } - out.append(" device_uid: "); - sprintf(buffer, "%" PRIu32, this->device_uid); + out.append(" device_id: "); + sprintf(buffer, "%" PRIu32, this->device_id); out.append(buffer); out.append("\n"); out.append("}"); @@ -2088,7 +2146,7 @@ bool ListEntitiesLightResponse::decode_varint(uint32_t field_id, ProtoVarInt val return true; } case 16: { - this->device_uid = value.as_uint32(); + this->device_id = value.as_uint32(); return true; } default: @@ -2159,7 +2217,7 @@ void ListEntitiesLightResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(13, this->disabled_by_default); buffer.encode_string(14, this->icon); buffer.encode_enum(15, this->entity_category); - buffer.encode_uint32(16, this->device_uid); + buffer.encode_uint32(16, this->device_id); } void ListEntitiesLightResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 1, this->object_id, false); @@ -2185,7 +2243,7 @@ void ListEntitiesLightResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default, false); ProtoSize::add_string_field(total_size, 1, this->icon, false); ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category), false); - ProtoSize::add_uint32_field(total_size, 2, this->device_uid, false); + ProtoSize::add_uint32_field(total_size, 2, this->device_id, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesLightResponse::dump_to(std::string &out) const { @@ -2258,8 +2316,8 @@ void ListEntitiesLightResponse::dump_to(std::string &out) const { out.append(proto_enum_to_string(this->entity_category)); out.append("\n"); - out.append(" device_uid: "); - sprintf(buffer, "%" PRIu32, this->device_uid); + out.append(" device_id: "); + sprintf(buffer, "%" PRIu32, this->device_id); out.append(buffer); out.append("\n"); out.append("}"); @@ -2770,7 +2828,7 @@ bool ListEntitiesSensorResponse::decode_varint(uint32_t field_id, ProtoVarInt va return true; } case 14: { - this->device_uid = value.as_uint32(); + this->device_id = value.as_uint32(); return true; } default: @@ -2831,7 +2889,7 @@ void ListEntitiesSensorResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_enum(11, this->legacy_last_reset_type); buffer.encode_bool(12, this->disabled_by_default); buffer.encode_enum(13, this->entity_category); - buffer.encode_uint32(14, this->device_uid); + buffer.encode_uint32(14, this->device_id); } void ListEntitiesSensorResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 1, this->object_id, false); @@ -2847,7 +2905,7 @@ void ListEntitiesSensorResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_enum_field(total_size, 1, static_cast(this->legacy_last_reset_type), false); ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default, false); ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category), false); - ProtoSize::add_uint32_field(total_size, 1, this->device_uid, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesSensorResponse::dump_to(std::string &out) const { @@ -2907,8 +2965,8 @@ void ListEntitiesSensorResponse::dump_to(std::string &out) const { out.append(proto_enum_to_string(this->entity_category)); out.append("\n"); - out.append(" device_uid: "); - sprintf(buffer, "%" PRIu32, this->device_uid); + out.append(" device_id: "); + sprintf(buffer, "%" PRIu32, this->device_id); out.append(buffer); out.append("\n"); out.append("}"); @@ -2983,7 +3041,7 @@ bool ListEntitiesSwitchResponse::decode_varint(uint32_t field_id, ProtoVarInt va return true; } case 10: { - this->device_uid = value.as_uint32(); + this->device_id = value.as_uint32(); return true; } default: @@ -3036,7 +3094,7 @@ void ListEntitiesSwitchResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(7, this->disabled_by_default); buffer.encode_enum(8, this->entity_category); buffer.encode_string(9, this->device_class); - buffer.encode_uint32(10, this->device_uid); + buffer.encode_uint32(10, this->device_id); } void ListEntitiesSwitchResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 1, this->object_id, false); @@ -3048,7 +3106,7 @@ void ListEntitiesSwitchResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default, false); ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category), false); ProtoSize::add_string_field(total_size, 1, this->device_class, false); - ProtoSize::add_uint32_field(total_size, 1, this->device_uid, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesSwitchResponse::dump_to(std::string &out) const { @@ -3091,8 +3149,8 @@ void ListEntitiesSwitchResponse::dump_to(std::string &out) const { out.append("'").append(this->device_class).append("'"); out.append("\n"); - out.append(" device_uid: "); - sprintf(buffer, "%" PRIu32, this->device_uid); + out.append(" device_id: "); + sprintf(buffer, "%" PRIu32, this->device_id); out.append(buffer); out.append("\n"); out.append("}"); @@ -3195,7 +3253,7 @@ bool ListEntitiesTextSensorResponse::decode_varint(uint32_t field_id, ProtoVarIn return true; } case 9: { - this->device_uid = value.as_uint32(); + this->device_id = value.as_uint32(); return true; } default: @@ -3247,7 +3305,7 @@ void ListEntitiesTextSensorResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(6, this->disabled_by_default); buffer.encode_enum(7, this->entity_category); buffer.encode_string(8, this->device_class); - buffer.encode_uint32(9, this->device_uid); + buffer.encode_uint32(9, this->device_id); } void ListEntitiesTextSensorResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 1, this->object_id, false); @@ -3258,7 +3316,7 @@ void ListEntitiesTextSensorResponse::calculate_size(uint32_t &total_size) const ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default, false); ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category), false); ProtoSize::add_string_field(total_size, 1, this->device_class, false); - ProtoSize::add_uint32_field(total_size, 1, this->device_uid, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesTextSensorResponse::dump_to(std::string &out) const { @@ -3297,8 +3355,8 @@ void ListEntitiesTextSensorResponse::dump_to(std::string &out) const { out.append("'").append(this->device_class).append("'"); out.append("\n"); - out.append(" device_uid: "); - sprintf(buffer, "%" PRIu32, this->device_uid); + out.append(" device_id: "); + sprintf(buffer, "%" PRIu32, this->device_id); out.append(buffer); out.append("\n"); out.append("}"); @@ -4067,7 +4125,7 @@ bool ListEntitiesCameraResponse::decode_varint(uint32_t field_id, ProtoVarInt va return true; } case 8: { - this->device_uid = value.as_uint32(); + this->device_id = value.as_uint32(); return true; } default: @@ -4114,7 +4172,7 @@ void ListEntitiesCameraResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(5, this->disabled_by_default); buffer.encode_string(6, this->icon); buffer.encode_enum(7, this->entity_category); - buffer.encode_uint32(8, this->device_uid); + buffer.encode_uint32(8, this->device_id); } void ListEntitiesCameraResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 1, this->object_id, false); @@ -4124,7 +4182,7 @@ void ListEntitiesCameraResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default, false); ProtoSize::add_string_field(total_size, 1, this->icon, false); ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category), false); - ProtoSize::add_uint32_field(total_size, 1, this->device_uid, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesCameraResponse::dump_to(std::string &out) const { @@ -4159,8 +4217,8 @@ void ListEntitiesCameraResponse::dump_to(std::string &out) const { out.append(proto_enum_to_string(this->entity_category)); out.append("\n"); - out.append(" device_uid: "); - sprintf(buffer, "%" PRIu32, this->device_uid); + out.append(" device_id: "); + sprintf(buffer, "%" PRIu32, this->device_id); out.append(buffer); out.append("\n"); out.append("}"); @@ -4312,7 +4370,7 @@ bool ListEntitiesClimateResponse::decode_varint(uint32_t field_id, ProtoVarInt v return true; } case 26: { - this->device_uid = value.as_uint32(); + this->device_id = value.as_uint32(); return true; } default: @@ -4421,7 +4479,7 @@ void ListEntitiesClimateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(23, this->supports_target_humidity); buffer.encode_float(24, this->visual_min_humidity); buffer.encode_float(25, this->visual_max_humidity); - buffer.encode_uint32(26, this->device_uid); + buffer.encode_uint32(26, this->device_id); } void ListEntitiesClimateResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 1, this->object_id, false); @@ -4473,7 +4531,7 @@ void ListEntitiesClimateResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_bool_field(total_size, 2, this->supports_target_humidity, false); ProtoSize::add_fixed_field<4>(total_size, 2, this->visual_min_humidity != 0.0f, false); ProtoSize::add_fixed_field<4>(total_size, 2, this->visual_max_humidity != 0.0f, false); - ProtoSize::add_uint32_field(total_size, 2, this->device_uid, false); + ProtoSize::add_uint32_field(total_size, 2, this->device_id, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesClimateResponse::dump_to(std::string &out) const { @@ -4598,8 +4656,8 @@ void ListEntitiesClimateResponse::dump_to(std::string &out) const { out.append(buffer); out.append("\n"); - out.append(" device_uid: "); - sprintf(buffer, "%" PRIu32, this->device_uid); + out.append(" device_id: "); + sprintf(buffer, "%" PRIu32, this->device_id); out.append(buffer); out.append("\n"); out.append("}"); @@ -5068,7 +5126,7 @@ bool ListEntitiesNumberResponse::decode_varint(uint32_t field_id, ProtoVarInt va return true; } case 14: { - this->device_uid = value.as_uint32(); + this->device_id = value.as_uint32(); return true; } default: @@ -5141,7 +5199,7 @@ void ListEntitiesNumberResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(11, this->unit_of_measurement); buffer.encode_enum(12, this->mode); buffer.encode_string(13, this->device_class); - buffer.encode_uint32(14, this->device_uid); + buffer.encode_uint32(14, this->device_id); } void ListEntitiesNumberResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 1, this->object_id, false); @@ -5157,7 +5215,7 @@ void ListEntitiesNumberResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 1, this->unit_of_measurement, false); ProtoSize::add_enum_field(total_size, 1, static_cast(this->mode), false); ProtoSize::add_string_field(total_size, 1, this->device_class, false); - ProtoSize::add_uint32_field(total_size, 1, this->device_uid, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesNumberResponse::dump_to(std::string &out) const { @@ -5219,8 +5277,8 @@ void ListEntitiesNumberResponse::dump_to(std::string &out) const { out.append("'").append(this->device_class).append("'"); out.append("\n"); - out.append(" device_uid: "); - sprintf(buffer, "%" PRIu32, this->device_uid); + out.append(" device_id: "); + sprintf(buffer, "%" PRIu32, this->device_id); out.append(buffer); out.append("\n"); out.append("}"); @@ -5329,7 +5387,7 @@ bool ListEntitiesSelectResponse::decode_varint(uint32_t field_id, ProtoVarInt va return true; } case 9: { - this->device_uid = value.as_uint32(); + this->device_id = value.as_uint32(); return true; } default: @@ -5383,7 +5441,7 @@ void ListEntitiesSelectResponse::encode(ProtoWriteBuffer buffer) const { } buffer.encode_bool(7, this->disabled_by_default); buffer.encode_enum(8, this->entity_category); - buffer.encode_uint32(9, this->device_uid); + buffer.encode_uint32(9, this->device_id); } void ListEntitiesSelectResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 1, this->object_id, false); @@ -5398,7 +5456,7 @@ void ListEntitiesSelectResponse::calculate_size(uint32_t &total_size) const { } ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default, false); ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category), false); - ProtoSize::add_uint32_field(total_size, 1, this->device_uid, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesSelectResponse::dump_to(std::string &out) const { @@ -5439,8 +5497,8 @@ void ListEntitiesSelectResponse::dump_to(std::string &out) const { out.append(proto_enum_to_string(this->entity_category)); out.append("\n"); - out.append(" device_uid: "); - sprintf(buffer, "%" PRIu32, this->device_uid); + out.append(" device_id: "); + sprintf(buffer, "%" PRIu32, this->device_id); out.append(buffer); out.append("\n"); out.append("}"); @@ -5872,7 +5930,7 @@ bool ListEntitiesLockResponse::decode_varint(uint32_t field_id, ProtoVarInt valu return true; } case 12: { - this->device_uid = value.as_uint32(); + this->device_id = value.as_uint32(); return true; } default: @@ -5927,7 +5985,7 @@ void ListEntitiesLockResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(9, this->supports_open); buffer.encode_bool(10, this->requires_code); buffer.encode_string(11, this->code_format); - buffer.encode_uint32(12, this->device_uid); + buffer.encode_uint32(12, this->device_id); } void ListEntitiesLockResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 1, this->object_id, false); @@ -5941,7 +5999,7 @@ void ListEntitiesLockResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_bool_field(total_size, 1, this->supports_open, false); ProtoSize::add_bool_field(total_size, 1, this->requires_code, false); ProtoSize::add_string_field(total_size, 1, this->code_format, false); - ProtoSize::add_uint32_field(total_size, 1, this->device_uid, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesLockResponse::dump_to(std::string &out) const { @@ -5992,8 +6050,8 @@ void ListEntitiesLockResponse::dump_to(std::string &out) const { out.append("'").append(this->code_format).append("'"); out.append("\n"); - out.append(" device_uid: "); - sprintf(buffer, "%" PRIu32, this->device_uid); + out.append(" device_id: "); + sprintf(buffer, "%" PRIu32, this->device_id); out.append(buffer); out.append("\n"); out.append("}"); @@ -6122,7 +6180,7 @@ bool ListEntitiesButtonResponse::decode_varint(uint32_t field_id, ProtoVarInt va return true; } case 9: { - this->device_uid = value.as_uint32(); + this->device_id = value.as_uint32(); return true; } default: @@ -6174,7 +6232,7 @@ void ListEntitiesButtonResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(6, this->disabled_by_default); buffer.encode_enum(7, this->entity_category); buffer.encode_string(8, this->device_class); - buffer.encode_uint32(9, this->device_uid); + buffer.encode_uint32(9, this->device_id); } void ListEntitiesButtonResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 1, this->object_id, false); @@ -6185,7 +6243,7 @@ void ListEntitiesButtonResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default, false); ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category), false); ProtoSize::add_string_field(total_size, 1, this->device_class, false); - ProtoSize::add_uint32_field(total_size, 1, this->device_uid, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesButtonResponse::dump_to(std::string &out) const { @@ -6224,8 +6282,8 @@ void ListEntitiesButtonResponse::dump_to(std::string &out) const { out.append("'").append(this->device_class).append("'"); out.append("\n"); - out.append(" device_uid: "); - sprintf(buffer, "%" PRIu32, this->device_uid); + out.append(" device_id: "); + sprintf(buffer, "%" PRIu32, this->device_id); out.append(buffer); out.append("\n"); out.append("}"); @@ -6346,7 +6404,7 @@ bool ListEntitiesMediaPlayerResponse::decode_varint(uint32_t field_id, ProtoVarI return true; } case 10: { - this->device_uid = value.as_uint32(); + this->device_id = value.as_uint32(); return true; } default: @@ -6401,7 +6459,7 @@ void ListEntitiesMediaPlayerResponse::encode(ProtoWriteBuffer buffer) const { for (auto &it : this->supported_formats) { buffer.encode_message(9, it, true); } - buffer.encode_uint32(10, this->device_uid); + buffer.encode_uint32(10, this->device_id); } void ListEntitiesMediaPlayerResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 1, this->object_id, false); @@ -6413,7 +6471,7 @@ void ListEntitiesMediaPlayerResponse::calculate_size(uint32_t &total_size) const ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category), false); ProtoSize::add_bool_field(total_size, 1, this->supports_pause, false); ProtoSize::add_repeated_message(total_size, 1, this->supported_formats); - ProtoSize::add_uint32_field(total_size, 1, this->device_uid, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesMediaPlayerResponse::dump_to(std::string &out) const { @@ -6458,8 +6516,8 @@ void ListEntitiesMediaPlayerResponse::dump_to(std::string &out) const { out.append("\n"); } - out.append(" device_uid: "); - sprintf(buffer, "%" PRIu32, this->device_uid); + out.append(" device_id: "); + sprintf(buffer, "%" PRIu32, this->device_id); out.append(buffer); out.append("\n"); out.append("}"); @@ -8773,7 +8831,7 @@ bool ListEntitiesAlarmControlPanelResponse::decode_varint(uint32_t field_id, Pro return true; } case 11: { - this->device_uid = value.as_uint32(); + this->device_id = value.as_uint32(); return true; } default: @@ -8823,7 +8881,7 @@ void ListEntitiesAlarmControlPanelResponse::encode(ProtoWriteBuffer buffer) cons buffer.encode_uint32(8, this->supported_features); buffer.encode_bool(9, this->requires_code); buffer.encode_bool(10, this->requires_code_to_arm); - buffer.encode_uint32(11, this->device_uid); + buffer.encode_uint32(11, this->device_id); } void ListEntitiesAlarmControlPanelResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 1, this->object_id, false); @@ -8836,7 +8894,7 @@ void ListEntitiesAlarmControlPanelResponse::calculate_size(uint32_t &total_size) ProtoSize::add_uint32_field(total_size, 1, this->supported_features, false); ProtoSize::add_bool_field(total_size, 1, this->requires_code, false); ProtoSize::add_bool_field(total_size, 1, this->requires_code_to_arm, false); - ProtoSize::add_uint32_field(total_size, 1, this->device_uid, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesAlarmControlPanelResponse::dump_to(std::string &out) const { @@ -8884,8 +8942,8 @@ void ListEntitiesAlarmControlPanelResponse::dump_to(std::string &out) const { out.append(YESNO(this->requires_code_to_arm)); out.append("\n"); - out.append(" device_uid: "); - sprintf(buffer, "%" PRIu32, this->device_uid); + out.append(" device_id: "); + sprintf(buffer, "%" PRIu32, this->device_id); out.append(buffer); out.append("\n"); out.append("}"); @@ -9016,7 +9074,7 @@ bool ListEntitiesTextResponse::decode_varint(uint32_t field_id, ProtoVarInt valu return true; } case 12: { - this->device_uid = value.as_uint32(); + this->device_id = value.as_uint32(); return true; } default: @@ -9071,7 +9129,7 @@ void ListEntitiesTextResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(9, this->max_length); buffer.encode_string(10, this->pattern); buffer.encode_enum(11, this->mode); - buffer.encode_uint32(12, this->device_uid); + buffer.encode_uint32(12, this->device_id); } void ListEntitiesTextResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 1, this->object_id, false); @@ -9085,7 +9143,7 @@ void ListEntitiesTextResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_uint32_field(total_size, 1, this->max_length, false); ProtoSize::add_string_field(total_size, 1, this->pattern, false); ProtoSize::add_enum_field(total_size, 1, static_cast(this->mode), false); - ProtoSize::add_uint32_field(total_size, 1, this->device_uid, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesTextResponse::dump_to(std::string &out) const { @@ -9138,8 +9196,8 @@ void ListEntitiesTextResponse::dump_to(std::string &out) const { out.append(proto_enum_to_string(this->mode)); out.append("\n"); - out.append(" device_uid: "); - sprintf(buffer, "%" PRIu32, this->device_uid); + out.append(" device_id: "); + sprintf(buffer, "%" PRIu32, this->device_id); out.append(buffer); out.append("\n"); out.append("}"); @@ -9258,7 +9316,7 @@ bool ListEntitiesDateResponse::decode_varint(uint32_t field_id, ProtoVarInt valu return true; } case 8: { - this->device_uid = value.as_uint32(); + this->device_id = value.as_uint32(); return true; } default: @@ -9305,7 +9363,7 @@ void ListEntitiesDateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(5, this->icon); buffer.encode_bool(6, this->disabled_by_default); buffer.encode_enum(7, this->entity_category); - buffer.encode_uint32(8, this->device_uid); + buffer.encode_uint32(8, this->device_id); } void ListEntitiesDateResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 1, this->object_id, false); @@ -9315,7 +9373,7 @@ void ListEntitiesDateResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 1, this->icon, false); ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default, false); ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category), false); - ProtoSize::add_uint32_field(total_size, 1, this->device_uid, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesDateResponse::dump_to(std::string &out) const { @@ -9350,8 +9408,8 @@ void ListEntitiesDateResponse::dump_to(std::string &out) const { out.append(proto_enum_to_string(this->entity_category)); out.append("\n"); - out.append(" device_uid: "); - sprintf(buffer, "%" PRIu32, this->device_uid); + out.append(" device_id: "); + sprintf(buffer, "%" PRIu32, this->device_id); out.append(buffer); out.append("\n"); out.append("}"); @@ -9510,7 +9568,7 @@ bool ListEntitiesTimeResponse::decode_varint(uint32_t field_id, ProtoVarInt valu return true; } case 8: { - this->device_uid = value.as_uint32(); + this->device_id = value.as_uint32(); return true; } default: @@ -9557,7 +9615,7 @@ void ListEntitiesTimeResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(5, this->icon); buffer.encode_bool(6, this->disabled_by_default); buffer.encode_enum(7, this->entity_category); - buffer.encode_uint32(8, this->device_uid); + buffer.encode_uint32(8, this->device_id); } void ListEntitiesTimeResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 1, this->object_id, false); @@ -9567,7 +9625,7 @@ void ListEntitiesTimeResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 1, this->icon, false); ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default, false); ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category), false); - ProtoSize::add_uint32_field(total_size, 1, this->device_uid, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesTimeResponse::dump_to(std::string &out) const { @@ -9602,8 +9660,8 @@ void ListEntitiesTimeResponse::dump_to(std::string &out) const { out.append(proto_enum_to_string(this->entity_category)); out.append("\n"); - out.append(" device_uid: "); - sprintf(buffer, "%" PRIu32, this->device_uid); + out.append(" device_id: "); + sprintf(buffer, "%" PRIu32, this->device_id); out.append(buffer); out.append("\n"); out.append("}"); @@ -9762,7 +9820,7 @@ bool ListEntitiesEventResponse::decode_varint(uint32_t field_id, ProtoVarInt val return true; } case 10: { - this->device_uid = value.as_uint32(); + this->device_id = value.as_uint32(); return true; } default: @@ -9821,7 +9879,7 @@ void ListEntitiesEventResponse::encode(ProtoWriteBuffer buffer) const { for (auto &it : this->event_types) { buffer.encode_string(9, it, true); } - buffer.encode_uint32(10, this->device_uid); + buffer.encode_uint32(10, this->device_id); } void ListEntitiesEventResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 1, this->object_id, false); @@ -9837,7 +9895,7 @@ void ListEntitiesEventResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 1, it, true); } } - ProtoSize::add_uint32_field(total_size, 1, this->device_uid, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesEventResponse::dump_to(std::string &out) const { @@ -9882,8 +9940,8 @@ void ListEntitiesEventResponse::dump_to(std::string &out) const { out.append("\n"); } - out.append(" device_uid: "); - sprintf(buffer, "%" PRIu32, this->device_uid); + out.append(" device_id: "); + sprintf(buffer, "%" PRIu32, this->device_id); out.append(buffer); out.append("\n"); out.append("}"); @@ -9955,7 +10013,7 @@ bool ListEntitiesValveResponse::decode_varint(uint32_t field_id, ProtoVarInt val return true; } case 12: { - this->device_uid = value.as_uint32(); + this->device_id = value.as_uint32(); return true; } default: @@ -10010,7 +10068,7 @@ void ListEntitiesValveResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(9, this->assumed_state); buffer.encode_bool(10, this->supports_position); buffer.encode_bool(11, this->supports_stop); - buffer.encode_uint32(12, this->device_uid); + buffer.encode_uint32(12, this->device_id); } void ListEntitiesValveResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 1, this->object_id, false); @@ -10024,7 +10082,7 @@ void ListEntitiesValveResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_bool_field(total_size, 1, this->assumed_state, false); ProtoSize::add_bool_field(total_size, 1, this->supports_position, false); ProtoSize::add_bool_field(total_size, 1, this->supports_stop, false); - ProtoSize::add_uint32_field(total_size, 1, this->device_uid, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesValveResponse::dump_to(std::string &out) const { @@ -10075,8 +10133,8 @@ void ListEntitiesValveResponse::dump_to(std::string &out) const { out.append(YESNO(this->supports_stop)); out.append("\n"); - out.append(" device_uid: "); - sprintf(buffer, "%" PRIu32, this->device_uid); + out.append(" device_id: "); + sprintf(buffer, "%" PRIu32, this->device_id); out.append(buffer); out.append("\n"); out.append("}"); @@ -10211,7 +10269,7 @@ bool ListEntitiesDateTimeResponse::decode_varint(uint32_t field_id, ProtoVarInt return true; } case 8: { - this->device_uid = value.as_uint32(); + this->device_id = value.as_uint32(); return true; } default: @@ -10258,7 +10316,7 @@ void ListEntitiesDateTimeResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(5, this->icon); buffer.encode_bool(6, this->disabled_by_default); buffer.encode_enum(7, this->entity_category); - buffer.encode_uint32(8, this->device_uid); + buffer.encode_uint32(8, this->device_id); } void ListEntitiesDateTimeResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 1, this->object_id, false); @@ -10268,7 +10326,7 @@ void ListEntitiesDateTimeResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 1, this->icon, false); ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default, false); ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category), false); - ProtoSize::add_uint32_field(total_size, 1, this->device_uid, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesDateTimeResponse::dump_to(std::string &out) const { @@ -10303,8 +10361,8 @@ void ListEntitiesDateTimeResponse::dump_to(std::string &out) const { out.append(proto_enum_to_string(this->entity_category)); out.append("\n"); - out.append(" device_uid: "); - sprintf(buffer, "%" PRIu32, this->device_uid); + out.append(" device_id: "); + sprintf(buffer, "%" PRIu32, this->device_id); out.append(buffer); out.append("\n"); out.append("}"); @@ -10413,7 +10471,7 @@ bool ListEntitiesUpdateResponse::decode_varint(uint32_t field_id, ProtoVarInt va return true; } case 9: { - this->device_uid = value.as_uint32(); + this->device_id = value.as_uint32(); return true; } default: @@ -10465,7 +10523,7 @@ void ListEntitiesUpdateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(6, this->disabled_by_default); buffer.encode_enum(7, this->entity_category); buffer.encode_string(8, this->device_class); - buffer.encode_uint32(9, this->device_uid); + buffer.encode_uint32(9, this->device_id); } void ListEntitiesUpdateResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 1, this->object_id, false); @@ -10476,7 +10534,7 @@ void ListEntitiesUpdateResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default, false); ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category), false); ProtoSize::add_string_field(total_size, 1, this->device_class, false); - ProtoSize::add_uint32_field(total_size, 1, this->device_uid, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesUpdateResponse::dump_to(std::string &out) const { @@ -10515,8 +10573,8 @@ void ListEntitiesUpdateResponse::dump_to(std::string &out) const { out.append("'").append(this->device_class).append("'"); out.append("\n"); - out.append(" device_uid: "); - sprintf(buffer, "%" PRIu32, this->device_uid); + out.append(" device_id: "); + sprintf(buffer, "%" PRIu32, this->device_id); out.append(buffer); out.append("\n"); out.append("}"); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index ab30c3a593..7dedaa032d 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -415,11 +415,25 @@ class DeviceInfoRequest : public ProtoMessage { protected: }; +class SubAreaInfo : public ProtoMessage { + public: + uint32_t area_id{0}; + std::string name{}; + void encode(ProtoWriteBuffer buffer) const override; + void calculate_size(uint32_t &total_size) const override; +#ifdef HAS_PROTO_MESSAGE_DUMP + void dump_to(std::string &out) const override; +#endif + + protected: + bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; + bool decode_varint(uint32_t field_id, ProtoVarInt value) override; +}; class SubDeviceInfo : public ProtoMessage { public: - uint32_t uid{0}; + uint32_t device_id{0}; std::string name{}; - std::string suggested_area{}; + uint32_t area_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -433,7 +447,7 @@ class SubDeviceInfo : public ProtoMessage { class DeviceInfoResponse : public ProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 10; - static constexpr uint16_t ESTIMATED_SIZE = 165; + static constexpr uint16_t ESTIMATED_SIZE = 201; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "device_info_response"; } #endif @@ -457,6 +471,7 @@ class DeviceInfoResponse : public ProtoMessage { std::string bluetooth_mac_address{}; bool api_encryption_supported{false}; std::vector sub_devices{}; + std::vector sub_areas{}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -515,7 +530,7 @@ class ListEntitiesBinarySensorResponse : public InfoResponseProtoMessage { #endif std::string device_class{}; bool is_status_binary_sensor{false}; - uint32_t device_uid{0}; + uint32_t device_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -558,7 +573,7 @@ class ListEntitiesCoverResponse : public InfoResponseProtoMessage { bool supports_tilt{false}; std::string device_class{}; bool supports_stop{false}; - uint32_t device_uid{0}; + uint32_t device_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -628,7 +643,7 @@ class ListEntitiesFanResponse : public InfoResponseProtoMessage { bool supports_direction{false}; int32_t supported_speed_count{0}; std::vector supported_preset_modes{}; - uint32_t device_uid{0}; + uint32_t device_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -710,7 +725,7 @@ class ListEntitiesLightResponse : public InfoResponseProtoMessage { float min_mireds{0.0f}; float max_mireds{0.0f}; std::vector effects{}; - uint32_t device_uid{0}; + uint32_t device_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -810,7 +825,7 @@ class ListEntitiesSensorResponse : public InfoResponseProtoMessage { std::string device_class{}; enums::SensorStateClass state_class{}; enums::SensorLastResetType legacy_last_reset_type{}; - uint32_t device_uid{0}; + uint32_t device_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -850,7 +865,7 @@ class ListEntitiesSwitchResponse : public InfoResponseProtoMessage { #endif bool assumed_state{false}; std::string device_class{}; - uint32_t device_uid{0}; + uint32_t device_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -907,7 +922,7 @@ class ListEntitiesTextSensorResponse : public InfoResponseProtoMessage { static constexpr const char *message_name() { return "list_entities_text_sensor_response"; } #endif std::string device_class{}; - uint32_t device_uid{0}; + uint32_t device_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -1223,7 +1238,7 @@ class ListEntitiesCameraResponse : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "list_entities_camera_response"; } #endif - uint32_t device_uid{0}; + uint32_t device_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -1299,7 +1314,7 @@ class ListEntitiesClimateResponse : public InfoResponseProtoMessage { bool supports_target_humidity{false}; float visual_min_humidity{0.0f}; float visual_max_humidity{0.0f}; - uint32_t device_uid{0}; + uint32_t device_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -1397,7 +1412,7 @@ class ListEntitiesNumberResponse : public InfoResponseProtoMessage { std::string unit_of_measurement{}; enums::NumberMode mode{}; std::string device_class{}; - uint32_t device_uid{0}; + uint32_t device_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -1454,7 +1469,7 @@ class ListEntitiesSelectResponse : public InfoResponseProtoMessage { static constexpr const char *message_name() { return "list_entities_select_response"; } #endif std::vector options{}; - uint32_t device_uid{0}; + uint32_t device_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -1582,7 +1597,7 @@ class ListEntitiesLockResponse : public InfoResponseProtoMessage { bool supports_open{false}; bool requires_code{false}; std::string code_format{}; - uint32_t device_uid{0}; + uint32_t device_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -1642,7 +1657,7 @@ class ListEntitiesButtonResponse : public InfoResponseProtoMessage { static constexpr const char *message_name() { return "list_entities_button_response"; } #endif std::string device_class{}; - uint32_t device_uid{0}; + uint32_t device_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -1697,7 +1712,7 @@ class ListEntitiesMediaPlayerResponse : public InfoResponseProtoMessage { #endif bool supports_pause{false}; std::vector supported_formats{}; - uint32_t device_uid{0}; + uint32_t device_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -2569,7 +2584,7 @@ class ListEntitiesAlarmControlPanelResponse : public InfoResponseProtoMessage { uint32_t supported_features{0}; bool requires_code{false}; bool requires_code_to_arm{false}; - uint32_t device_uid{0}; + uint32_t device_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -2631,7 +2646,7 @@ class ListEntitiesTextResponse : public InfoResponseProtoMessage { uint32_t max_length{0}; std::string pattern{}; enums::TextMode mode{}; - uint32_t device_uid{0}; + uint32_t device_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -2689,7 +2704,7 @@ class ListEntitiesDateResponse : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "list_entities_date_response"; } #endif - uint32_t device_uid{0}; + uint32_t device_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -2750,7 +2765,7 @@ class ListEntitiesTimeResponse : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "list_entities_time_response"; } #endif - uint32_t device_uid{0}; + uint32_t device_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -2813,7 +2828,7 @@ class ListEntitiesEventResponse : public InfoResponseProtoMessage { #endif std::string device_class{}; std::vector event_types{}; - uint32_t device_uid{0}; + uint32_t device_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -2854,7 +2869,7 @@ class ListEntitiesValveResponse : public InfoResponseProtoMessage { bool assumed_state{false}; bool supports_position{false}; bool supports_stop{false}; - uint32_t device_uid{0}; + uint32_t device_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -2913,7 +2928,7 @@ class ListEntitiesDateTimeResponse : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "list_entities_date_time_response"; } #endif - uint32_t device_uid{0}; + uint32_t device_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -2970,7 +2985,7 @@ class ListEntitiesUpdateResponse : public InfoResponseProtoMessage { static constexpr const char *message_name() { return "list_entities_update_response"; } #endif std::string device_class{}; - uint32_t device_uid{0}; + uint32_t device_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP diff --git a/esphome/const.py b/esphome/const.py index 3a5cd2215f..47f20a71cb 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -56,6 +56,7 @@ CONF_AP = "ap" CONF_APPARENT_POWER = "apparent_power" CONF_ARDUINO_VERSION = "arduino_version" CONF_AREA = "area" +CONF_AREA_ID = "area_id" CONF_ARGS = "args" CONF_ASSUMED_STATE = "assumed_state" CONF_AT = "at" @@ -843,6 +844,7 @@ CONF_STILL_THRESHOLD = "still_threshold" CONF_STOP = "stop" CONF_STOP_ACTION = "stop_action" CONF_STORE_BASELINE = "store_baseline" +CONF_SUB_AREAS = "sub_areas" CONF_SUB_DEVICES = "sub_devices" CONF_SUBNET = "subnet" CONF_SUBSCRIBE_QOS = "subscribe_qos" diff --git a/esphome/core/application.h b/esphome/core/application.h index c17fd8ba74..ee1f5db726 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -11,6 +11,7 @@ #ifdef USE_SUB_DEVICE #include "esphome/core/sub_device.h" +#include "esphome/core/sub_area.h" #endif #ifdef USE_SOCKET_SELECT_SUPPORT @@ -114,6 +115,9 @@ class Application { #ifdef USE_SUB_DEVICE void register_sub_device(SubDevice *sub_device) { this->sub_devices_.push_back(sub_device); } #endif +#ifdef USE_SUB_DEVICE + void register_area(SubArea *area) { this->areas_.push_back(area); } +#endif void set_current_component(Component *component) { this->current_component_ = component; } Component *get_current_component() { return this->current_component_; } @@ -344,15 +348,7 @@ class Application { #ifdef USE_SUB_DEVICE const std::vector &get_sub_devices() { return this->sub_devices_; } - // /* Very likely no need for get_sub_device_by_key as it only seem to be used when requesting update from API - // and the sub_devices shaould only be sent once at connection. */ - // SubDevice *get_sub_device_by_key(uint32_t key, bool include_internal = false) { - // for (auto *obj : this->sub_devices_) { - // if (obj->get_object_id_hash() == key && (include_internal || !obj->is_internal())) - // return obj; - // } - // return nullptr; - // } + const std::vector &get_areas() { return this->areas_; } #endif #ifdef USE_BINARY_SENSOR const std::vector &get_binary_sensors() { return this->binary_sensors_; } @@ -632,6 +628,7 @@ class Application { #ifdef USE_SUB_DEVICE std::vector sub_devices_{}; + std::vector areas_{}; #endif #ifdef USE_BINARY_SENSOR std::vector binary_sensors_{}; diff --git a/esphome/core/config.py b/esphome/core/config.py index 484f0dbac0..fbbdf1217a 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -7,6 +7,7 @@ import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import ( CONF_AREA, + CONF_AREA_ID, CONF_BUILD_PATH, CONF_COMMENT, CONF_COMPILE_PROCESS_LIMIT, @@ -27,6 +28,7 @@ from esphome.const import ( CONF_PLATFORMIO_OPTIONS, CONF_PRIORITY, CONF_PROJECT, + CONF_SUB_AREAS, CONF_SUB_DEVICES, CONF_TRIGGER_ID, CONF_VERSION, @@ -56,6 +58,7 @@ ProjectUpdateTrigger = cg.esphome_ns.class_( "ProjectUpdateTrigger", cg.Component, automation.Trigger.template(cg.std_string) ) SubDevice = cg.esphome_ns.class_("SubDevice") +SubArea = cg.esphome_ns.class_("SubArea") VALID_INCLUDE_EXTS = {".h", ".hpp", ".tcc", ".ino", ".cpp", ".c"} @@ -174,12 +177,20 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_COMPILE_PROCESS_LIMIT, default=_compile_process_limit_default ): cv.int_range(min=1, max=get_usable_cpu_count()), + cv.Optional(CONF_SUB_AREAS, default=[]): cv.ensure_list( + cv.Schema( + { + cv.GenerateID(CONF_ID): cv.declare_id(SubArea), + cv.Required(CONF_NAME): cv.string, + } + ), + ), cv.Optional(CONF_SUB_DEVICES, default=[]): cv.ensure_list( cv.Schema( { cv.GenerateID(CONF_ID): cv.declare_id(SubDevice), cv.Required(CONF_NAME): cv.string, - cv.Optional(CONF_AREA, default=""): cv.string, + cv.Optional(CONF_AREA_ID): cv.use_id(SubArea), } ), ), @@ -434,11 +445,26 @@ async def to_code(config): if config[CONF_PLATFORMIO_OPTIONS]: CORE.add_job(_add_platformio_options, config[CONF_PLATFORMIO_OPTIONS]) - if config[CONF_SUB_DEVICES]: - for dev_conf in config[CONF_SUB_DEVICES]: + # Process sub-devices and areas + if sub_devices := config.get(CONF_SUB_DEVICES): + # Process areas first + if sub_areas := config.get(CONF_SUB_AREAS): + for area_conf in sub_areas: + area = cg.new_Pvariable(area_conf[CONF_ID]) + area_id = fnv1a_32bit_hash(str(area_conf[CONF_ID])) + cg.add(area.set_area_id(area_id)) + cg.add(area.set_name(area_conf[CONF_NAME])) + cg.add(cg.App.register_area(area)) + + # Process sub-devices + for dev_conf in sub_devices: dev = cg.new_Pvariable(dev_conf[CONF_ID]) - cg.add(dev.set_uid(fnv1a_32bit_hash(str(dev_conf[CONF_ID])))) + cg.add(dev.set_device_id(fnv1a_32bit_hash(str(dev_conf[CONF_ID])))) cg.add(dev.set_name(dev_conf[CONF_NAME])) - cg.add(dev.set_area(dev_conf[CONF_AREA])) + if CONF_AREA_ID in dev_conf: + # The area_id in dev_conf is already the ID reference from cv.use_id + # We need to get the hash of that area's ID + area_id = fnv1a_32bit_hash(str(dev_conf[CONF_AREA_ID])) + cg.add(dev.set_area_id(area_id)) cg.add(cg.App.register_sub_device(dev)) cg.add_define("USE_SUB_DEVICE") diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 165ae0e7cd..b21ae196f1 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -51,9 +51,11 @@ class EntityBase { std::string get_icon() const; void set_icon(const char *icon); +#ifdef USE_SUB_DEVICE // Get/set this entity's device id - uint32_t get_device_uid() const { return this->device_uid_; } - void set_device_uid(const uint32_t device_uid) { this->device_uid_ = device_uid; } + uint32_t get_device_id() const { return this->device_id_; } + void set_device_id(const uint32_t device_id) { this->device_id_ = device_id; } +#endif // Check if this entity has state bool has_state() const { return this->flags_.has_state; } @@ -71,7 +73,9 @@ class EntityBase { const char *object_id_c_str_{nullptr}; const char *icon_c_str_{nullptr}; uint32_t object_id_hash_{}; - uint32_t device_uid_{}; +#ifdef USE_SUB_DEVICE + uint32_t device_id_{}; +#endif // Bit-packed flags to save memory (1 byte instead of 5) struct EntityFlags { diff --git a/esphome/core/sub_area.h b/esphome/core/sub_area.h new file mode 100644 index 0000000000..55ea4b4541 --- /dev/null +++ b/esphome/core/sub_area.h @@ -0,0 +1,20 @@ +#pragma once + +#include +#include + +namespace esphome { + +class SubArea { + public: + void set_area_id(uint32_t area_id) { area_id_ = area_id; } + uint32_t get_area_id() { return area_id_; } + void set_name(std::string name) { name_ = std::move(name); } + std::string get_name() { return name_; } + + protected: + uint32_t area_id_{}; + std::string name_ = ""; +}; + +} // namespace esphome \ No newline at end of file diff --git a/esphome/core/sub_device.h b/esphome/core/sub_device.h index 9e7c4d2261..f17f882dfd 100644 --- a/esphome/core/sub_device.h +++ b/esphome/core/sub_device.h @@ -6,17 +6,17 @@ namespace esphome { class SubDevice { public: - void set_uid(uint32_t uid) { uid_ = uid; } - uint32_t get_uid() { return uid_; } + void set_device_id(uint32_t device_id) { device_id_ = device_id; } + uint32_t get_device_id() { return device_id_; } void set_name(std::string name) { name_ = std::move(name); } std::string get_name() { return name_; } - void set_area(std::string area) { area_ = std::move(area); } - std::string get_area() { return area_; } + void set_area_id(uint32_t area_id) { area_id_ = area_id; } + uint32_t get_area_id() { return area_id_; } protected: - uint32_t uid_{}; + uint32_t device_id_{}; + uint32_t area_id_{}; std::string name_ = ""; - std::string area_ = ""; }; } // namespace esphome diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index 66ff58f4a7..cef7b31020 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -113,7 +113,7 @@ async def setup_entity(var, config): add(var.set_entity_category(config[CONF_ENTITY_CATEGORY])) if CONF_DEVICE_ID in config: device = await get_variable(config[CONF_DEVICE_ID]) - add(var.set_device_uid(fnv1a_32bit_hash(str(device)))) + add(var.set_device_id(fnv1a_32bit_hash(str(device)))) def extract_registry_entry_config( diff --git a/tests/components/esphome/common.yaml b/tests/components/esphome/common.yaml index 3754390e89..aa1ce9e111 100644 --- a/tests/components/esphome/common.yaml +++ b/tests/components/esphome/common.yaml @@ -17,10 +17,13 @@ esphome: version: "1.1" on_update: logger.log: on_update + sub_areas: + - id: another_area + name: Another area sub_devices: - id: other_device name: Another device - area: Another area + area_id: another_area binary_sensor: - platform: template From 02e922b56f1b49fdd324b7cfb2e1d80225574b62 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Jun 2025 13:16:42 +0200 Subject: [PATCH 040/183] cleanups to address review comments --- esphome/core/sub_area.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/core/sub_area.h b/esphome/core/sub_area.h index 55ea4b4541..2a70086c1c 100644 --- a/esphome/core/sub_area.h +++ b/esphome/core/sub_area.h @@ -17,4 +17,4 @@ class SubArea { std::string name_ = ""; }; -} // namespace esphome \ No newline at end of file +} // namespace esphome From 8937ed226957429317ea81e7ebf57511fe09d754 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Jun 2025 13:18:25 +0200 Subject: [PATCH 041/183] cleanups to address review comments --- esphome/core/application.h | 2 -- 1 file changed, 2 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index ee1f5db726..0e3869800f 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -114,8 +114,6 @@ class Application { #ifdef USE_SUB_DEVICE void register_sub_device(SubDevice *sub_device) { this->sub_devices_.push_back(sub_device); } -#endif -#ifdef USE_SUB_DEVICE void register_area(SubArea *area) { this->areas_.push_back(area); } #endif From 153a6440dcb8e470964b11b4c85fe700423b7733 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Jun 2025 13:20:59 +0200 Subject: [PATCH 042/183] cleanups to address review comments --- esphome/core/config.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/esphome/core/config.py b/esphome/core/config.py index fbbdf1217a..2c33ad1df0 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -462,9 +462,10 @@ async def to_code(config): cg.add(dev.set_device_id(fnv1a_32bit_hash(str(dev_conf[CONF_ID])))) cg.add(dev.set_name(dev_conf[CONF_NAME])) if CONF_AREA_ID in dev_conf: - # The area_id in dev_conf is already the ID reference from cv.use_id - # We need to get the hash of that area's ID - area_id = fnv1a_32bit_hash(str(dev_conf[CONF_AREA_ID])) + # The area_id in dev_conf is the ID reference from cv.use_id + # We need to get the same hash that was used when creating the area + area_id_str = str(dev_conf[CONF_AREA_ID].id) + area_id = fnv1a_32bit_hash(area_id_str) cg.add(dev.set_area_id(area_id)) cg.add(cg.App.register_sub_device(dev)) cg.add_define("USE_SUB_DEVICE") From 63de88dd57963dbbc495001634ed16336d5db3b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Jun 2025 13:33:05 +0200 Subject: [PATCH 043/183] fixes --- esphome/components/api/api_connection.h | 3 --- esphome/core/config.py | 8 +++----- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 9166dbbc94..66b7ce38a7 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -301,9 +301,6 @@ class APIConnection : public APIServerConnection { response.icon = entity->get_icon(); response.disabled_by_default = entity->is_disabled_by_default(); response.entity_category = static_cast(entity->get_entity_category()); -#ifdef USE_SUB_DEVICE - response.device_id = entity->get_device_id(); -#endif } // Helper function to fill common entity state fields diff --git a/esphome/core/config.py b/esphome/core/config.py index 2c33ad1df0..76c7505393 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -462,10 +462,8 @@ async def to_code(config): cg.add(dev.set_device_id(fnv1a_32bit_hash(str(dev_conf[CONF_ID])))) cg.add(dev.set_name(dev_conf[CONF_NAME])) if CONF_AREA_ID in dev_conf: - # The area_id in dev_conf is the ID reference from cv.use_id - # We need to get the same hash that was used when creating the area - area_id_str = str(dev_conf[CONF_AREA_ID].id) - area_id = fnv1a_32bit_hash(area_id_str) - cg.add(dev.set_area_id(area_id)) + # Get the area variable and use its area_id + area = await cg.get_variable(dev_conf[CONF_AREA_ID]) + cg.add(dev.set_area_id(area.get_area_id())) cg.add(cg.App.register_sub_device(dev)) cg.add_define("USE_SUB_DEVICE") From 32088d5ef7f1ead6a6e86947bbf57b8dfa72c1cd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Jun 2025 13:35:32 +0200 Subject: [PATCH 044/183] revert --- esphome/components/api/api_connection.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 66b7ce38a7..9166dbbc94 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -301,6 +301,9 @@ class APIConnection : public APIServerConnection { response.icon = entity->get_icon(); response.disabled_by_default = entity->is_disabled_by_default(); response.entity_category = static_cast(entity->get_entity_category()); +#ifdef USE_SUB_DEVICE + response.device_id = entity->get_device_id(); +#endif } // Helper function to fill common entity state fields From 86fb0e317f5be639e658f1b9ad02acfdc8c276e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Jun 2025 15:22:35 +0200 Subject: [PATCH 045/183] fixes --- esphome/components/api/api.proto | 1 + esphome/components/api/api_pb2.cpp | 11 +++++++++++ esphome/components/api/api_pb2.h | 25 ++----------------------- 3 files changed, 14 insertions(+), 23 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 850ca4a575..29e26bc0e5 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1106,6 +1106,7 @@ message ListEntitiesSirenResponse { bool supports_duration = 8; bool supports_volume = 9; EntityCategory entity_category = 10; + uint32 device_id = 11; } message SirenStateResponse { option (id) = 56; diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index baa78f4358..501b8bd91d 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -5624,6 +5624,10 @@ bool ListEntitiesSirenResponse::decode_varint(uint32_t field_id, ProtoVarInt val this->entity_category = value.as_enum(); return true; } + case 11: { + this->device_id = value.as_uint32(); + return true; + } default: return false; } @@ -5677,6 +5681,7 @@ void ListEntitiesSirenResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(8, this->supports_duration); buffer.encode_bool(9, this->supports_volume); buffer.encode_enum(10, this->entity_category); + buffer.encode_uint32(11, this->device_id); } void ListEntitiesSirenResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 1, this->object_id, false); @@ -5693,6 +5698,7 @@ void ListEntitiesSirenResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_bool_field(total_size, 1, this->supports_duration, false); ProtoSize::add_bool_field(total_size, 1, this->supports_volume, false); ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category), false); + ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void ListEntitiesSirenResponse::dump_to(std::string &out) const { @@ -5740,6 +5746,11 @@ void ListEntitiesSirenResponse::dump_to(std::string &out) const { out.append(" entity_category: "); out.append(proto_enum_to_string(this->entity_category)); out.append("\n"); + + out.append(" device_id: "); + sprintf(buffer, "%" PRIu32, this->device_id); + out.append(buffer); + out.append("\n"); out.append("}"); } #endif diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 7dedaa032d..2e4e32f038 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -264,6 +264,7 @@ class InfoResponseProtoMessage : public ProtoMessage { bool disabled_by_default{false}; std::string icon{}; enums::EntityCategory entity_category{}; + uint32_t device_id{0}; protected: }; @@ -530,7 +531,6 @@ class ListEntitiesBinarySensorResponse : public InfoResponseProtoMessage { #endif std::string device_class{}; bool is_status_binary_sensor{false}; - uint32_t device_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -573,7 +573,6 @@ class ListEntitiesCoverResponse : public InfoResponseProtoMessage { bool supports_tilt{false}; std::string device_class{}; bool supports_stop{false}; - uint32_t device_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -643,7 +642,6 @@ class ListEntitiesFanResponse : public InfoResponseProtoMessage { bool supports_direction{false}; int32_t supported_speed_count{0}; std::vector supported_preset_modes{}; - uint32_t device_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -725,7 +723,6 @@ class ListEntitiesLightResponse : public InfoResponseProtoMessage { float min_mireds{0.0f}; float max_mireds{0.0f}; std::vector effects{}; - uint32_t device_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -825,7 +822,6 @@ class ListEntitiesSensorResponse : public InfoResponseProtoMessage { std::string device_class{}; enums::SensorStateClass state_class{}; enums::SensorLastResetType legacy_last_reset_type{}; - uint32_t device_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -865,7 +861,6 @@ class ListEntitiesSwitchResponse : public InfoResponseProtoMessage { #endif bool assumed_state{false}; std::string device_class{}; - uint32_t device_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -922,7 +917,6 @@ class ListEntitiesTextSensorResponse : public InfoResponseProtoMessage { static constexpr const char *message_name() { return "list_entities_text_sensor_response"; } #endif std::string device_class{}; - uint32_t device_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -1238,7 +1232,6 @@ class ListEntitiesCameraResponse : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "list_entities_camera_response"; } #endif - uint32_t device_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -1314,7 +1307,6 @@ class ListEntitiesClimateResponse : public InfoResponseProtoMessage { bool supports_target_humidity{false}; float visual_min_humidity{0.0f}; float visual_max_humidity{0.0f}; - uint32_t device_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -1412,7 +1404,6 @@ class ListEntitiesNumberResponse : public InfoResponseProtoMessage { std::string unit_of_measurement{}; enums::NumberMode mode{}; std::string device_class{}; - uint32_t device_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -1469,7 +1460,6 @@ class ListEntitiesSelectResponse : public InfoResponseProtoMessage { static constexpr const char *message_name() { return "list_entities_select_response"; } #endif std::vector options{}; - uint32_t device_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -1523,7 +1513,7 @@ class SelectCommandRequest : public ProtoMessage { class ListEntitiesSirenResponse : public InfoResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 55; - static constexpr uint16_t ESTIMATED_SIZE = 67; + static constexpr uint16_t ESTIMATED_SIZE = 71; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "list_entities_siren_response"; } #endif @@ -1597,7 +1587,6 @@ class ListEntitiesLockResponse : public InfoResponseProtoMessage { bool supports_open{false}; bool requires_code{false}; std::string code_format{}; - uint32_t device_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -1657,7 +1646,6 @@ class ListEntitiesButtonResponse : public InfoResponseProtoMessage { static constexpr const char *message_name() { return "list_entities_button_response"; } #endif std::string device_class{}; - uint32_t device_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -1712,7 +1700,6 @@ class ListEntitiesMediaPlayerResponse : public InfoResponseProtoMessage { #endif bool supports_pause{false}; std::vector supported_formats{}; - uint32_t device_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -2584,7 +2571,6 @@ class ListEntitiesAlarmControlPanelResponse : public InfoResponseProtoMessage { uint32_t supported_features{0}; bool requires_code{false}; bool requires_code_to_arm{false}; - uint32_t device_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -2646,7 +2632,6 @@ class ListEntitiesTextResponse : public InfoResponseProtoMessage { uint32_t max_length{0}; std::string pattern{}; enums::TextMode mode{}; - uint32_t device_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -2704,7 +2689,6 @@ class ListEntitiesDateResponse : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "list_entities_date_response"; } #endif - uint32_t device_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -2765,7 +2749,6 @@ class ListEntitiesTimeResponse : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "list_entities_time_response"; } #endif - uint32_t device_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -2828,7 +2811,6 @@ class ListEntitiesEventResponse : public InfoResponseProtoMessage { #endif std::string device_class{}; std::vector event_types{}; - uint32_t device_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -2869,7 +2851,6 @@ class ListEntitiesValveResponse : public InfoResponseProtoMessage { bool assumed_state{false}; bool supports_position{false}; bool supports_stop{false}; - uint32_t device_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -2928,7 +2909,6 @@ class ListEntitiesDateTimeResponse : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "list_entities_date_time_response"; } #endif - uint32_t device_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -2985,7 +2965,6 @@ class ListEntitiesUpdateResponse : public InfoResponseProtoMessage { static constexpr const char *message_name() { return "list_entities_update_response"; } #endif std::string device_class{}; - uint32_t device_id{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP From 7d84f0e65036098d730dabee54d5728825f9960d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Jun 2025 16:37:21 +0200 Subject: [PATCH 046/183] migrate to using same area info for top level and sub devices --- esphome/components/api/api.proto | 7 ++- esphome/components/api/api_connection.cpp | 6 ++- esphome/components/api/api_pb2.cpp | 34 +++++++++------ esphome/components/api/api_pb2.h | 7 +-- esphome/core/application.h | 31 ++++++++++---- esphome/core/{sub_area.h => area.h} | 2 +- esphome/core/config.py | 52 ++++++++++++++++++++--- esphome/dashboard/util/text.py | 24 ++--------- esphome/helpers.py | 26 ++++++++++++ 9 files changed, 136 insertions(+), 53 deletions(-) rename esphome/core/{sub_area.h => area.h} (96%) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 29e26bc0e5..0ac9cd3aab 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -188,7 +188,7 @@ message DeviceInfoRequest { // Empty } -message SubAreaInfo { +message AreaInfo { uint32 area_id = 1; string name = 2; } @@ -249,7 +249,10 @@ message DeviceInfoResponse { bool api_encryption_supported = 19; repeated SubDeviceInfo sub_devices = 20; - repeated SubAreaInfo sub_areas = 21; + repeated AreaInfo areas = 21; + + // Top-level area info to phase out suggested_area + AreaInfo area = 22; } message ListEntitiesRequest { diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 2e2e4ec003..799cd2f102 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1628,11 +1628,13 @@ DeviceInfoResponse APIConnection::device_info(const DeviceInfoRequest &msg) { sub_device_info.area_id = sub_device->get_area_id(); resp.sub_devices.push_back(sub_device_info); } +#endif +#ifdef USE_AREAS for (auto const &area : App.get_areas()) { - SubAreaInfo area_info; + AreaInfo area_info; area_info.area_id = area->get_area_id(); area_info.name = area->get_name(); - resp.sub_areas.push_back(area_info); + resp.areas.push_back(area_info); } #endif return resp; diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 501b8bd91d..cbe18e172e 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -812,7 +812,7 @@ void PingResponse::dump_to(std::string &out) const { out.append("PingResponse {} #ifdef HAS_PROTO_MESSAGE_DUMP void DeviceInfoRequest::dump_to(std::string &out) const { out.append("DeviceInfoRequest {}"); } #endif -bool SubAreaInfo::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool AreaInfo::decode_varint(uint32_t field_id, ProtoVarInt value) { switch (field_id) { case 1: { this->area_id = value.as_uint32(); @@ -822,7 +822,7 @@ bool SubAreaInfo::decode_varint(uint32_t field_id, ProtoVarInt value) { return false; } } -bool SubAreaInfo::decode_length(uint32_t field_id, ProtoLengthDelimited value) { +bool AreaInfo::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 2: { this->name = value.as_string(); @@ -832,18 +832,18 @@ bool SubAreaInfo::decode_length(uint32_t field_id, ProtoLengthDelimited value) { return false; } } -void SubAreaInfo::encode(ProtoWriteBuffer buffer) const { +void AreaInfo::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(1, this->area_id); buffer.encode_string(2, this->name); } -void SubAreaInfo::calculate_size(uint32_t &total_size) const { +void AreaInfo::calculate_size(uint32_t &total_size) const { ProtoSize::add_uint32_field(total_size, 1, this->area_id, false); ProtoSize::add_string_field(total_size, 1, this->name, false); } #ifdef HAS_PROTO_MESSAGE_DUMP -void SubAreaInfo::dump_to(std::string &out) const { +void AreaInfo::dump_to(std::string &out) const { __attribute__((unused)) char buffer[64]; - out.append("SubAreaInfo {\n"); + out.append("AreaInfo {\n"); out.append(" area_id: "); sprintf(buffer, "%" PRIu32, this->area_id); out.append(buffer); @@ -998,7 +998,11 @@ bool DeviceInfoResponse::decode_length(uint32_t field_id, ProtoLengthDelimited v return true; } case 21: { - this->sub_areas.push_back(value.as_message()); + this->areas.push_back(value.as_message()); + return true; + } + case 22: { + this->area = value.as_message(); return true; } default: @@ -1028,9 +1032,10 @@ void DeviceInfoResponse::encode(ProtoWriteBuffer buffer) const { for (auto &it : this->sub_devices) { buffer.encode_message(20, it, true); } - for (auto &it : this->sub_areas) { - buffer.encode_message(21, it, true); + for (auto &it : this->areas) { + buffer.encode_message(21, it, true); } + buffer.encode_message(22, this->area); } void DeviceInfoResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_bool_field(total_size, 1, this->uses_password, false); @@ -1053,7 +1058,8 @@ void DeviceInfoResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 2, this->bluetooth_mac_address, false); ProtoSize::add_bool_field(total_size, 2, this->api_encryption_supported, false); ProtoSize::add_repeated_message(total_size, 2, this->sub_devices); - ProtoSize::add_repeated_message(total_size, 2, this->sub_areas); + ProtoSize::add_repeated_message(total_size, 2, this->areas); + ProtoSize::add_message_object(total_size, 2, this->area, false); } #ifdef HAS_PROTO_MESSAGE_DUMP void DeviceInfoResponse::dump_to(std::string &out) const { @@ -1146,11 +1152,15 @@ void DeviceInfoResponse::dump_to(std::string &out) const { out.append("\n"); } - for (const auto &it : this->sub_areas) { - out.append(" sub_areas: "); + for (const auto &it : this->areas) { + out.append(" areas: "); it.dump_to(out); out.append("\n"); } + + out.append(" area: "); + this->area.dump_to(out); + out.append("\n"); out.append("}"); } #endif diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 2e4e32f038..e71fd23619 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -416,7 +416,7 @@ class DeviceInfoRequest : public ProtoMessage { protected: }; -class SubAreaInfo : public ProtoMessage { +class AreaInfo : public ProtoMessage { public: uint32_t area_id{0}; std::string name{}; @@ -448,7 +448,7 @@ class SubDeviceInfo : public ProtoMessage { class DeviceInfoResponse : public ProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 10; - static constexpr uint16_t ESTIMATED_SIZE = 201; + static constexpr uint16_t ESTIMATED_SIZE = 219; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "device_info_response"; } #endif @@ -472,7 +472,8 @@ class DeviceInfoResponse : public ProtoMessage { std::string bluetooth_mac_address{}; bool api_encryption_supported{false}; std::vector sub_devices{}; - std::vector sub_areas{}; + std::vector areas{}; + AreaInfo area{}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP diff --git a/esphome/core/application.h b/esphome/core/application.h index 0e3869800f..09e2cfefbf 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -11,7 +11,9 @@ #ifdef USE_SUB_DEVICE #include "esphome/core/sub_device.h" -#include "esphome/core/sub_area.h" +#endif +#ifdef USE_AREAS +#include "esphome/core/area.h" #endif #ifdef USE_SOCKET_SELECT_SUPPORT @@ -92,7 +94,7 @@ static const uint32_t TEARDOWN_TIMEOUT_REBOOT_MS = 1000; // 1 second for quick class Application { public: - void pre_setup(const std::string &name, const std::string &friendly_name, const char *area, const char *comment, + void pre_setup(const std::string &name, const std::string &friendly_name, const char *comment, const char *compilation_time, bool name_add_mac_suffix) { arch_init(); this->name_add_mac_suffix_ = name_add_mac_suffix; @@ -107,14 +109,16 @@ class Application { this->name_ = name; this->friendly_name_ = friendly_name; } - this->area_ = area; + // area is now handled through the areas system this->comment_ = comment; this->compilation_time_ = compilation_time; } #ifdef USE_SUB_DEVICE void register_sub_device(SubDevice *sub_device) { this->sub_devices_.push_back(sub_device); } - void register_area(SubArea *area) { this->areas_.push_back(area); } +#endif +#ifdef USE_AREAS + void register_area(Area *area) { this->areas_.push_back(area); } #endif void set_current_component(Component *component) { this->current_component_ = component; } @@ -295,7 +299,15 @@ class Application { const std::string &get_friendly_name() const { return this->friendly_name_; } /// Get the area of this Application set by pre_setup(). - std::string get_area() const { return this->area_ == nullptr ? "" : this->area_; } + std::string get_area() const { +#ifdef USE_AREAS + // If we have areas registered, return the name of the first one (which is the top-level area) + if (!this->areas_.empty() && this->areas_[0] != nullptr) { + return this->areas_[0]->get_name(); + } +#endif + return ""; + } /// Get the comment of this Application set by pre_setup(). std::string get_comment() const { return this->comment_; } @@ -346,7 +358,9 @@ class Application { #ifdef USE_SUB_DEVICE const std::vector &get_sub_devices() { return this->sub_devices_; } - const std::vector &get_areas() { return this->areas_; } +#endif +#ifdef USE_AREAS + const std::vector &get_areas() { return this->areas_; } #endif #ifdef USE_BINARY_SENSOR const std::vector &get_binary_sensors() { return this->binary_sensors_; } @@ -626,7 +640,9 @@ class Application { #ifdef USE_SUB_DEVICE std::vector sub_devices_{}; - std::vector areas_{}; +#endif +#ifdef USE_AREAS + std::vector areas_{}; #endif #ifdef USE_BINARY_SENSOR std::vector binary_sensors_{}; @@ -694,7 +710,6 @@ class Application { std::string name_; std::string friendly_name_; - const char *area_{nullptr}; const char *comment_{nullptr}; const char *compilation_time_{nullptr}; bool name_add_mac_suffix_; diff --git a/esphome/core/sub_area.h b/esphome/core/area.h similarity index 96% rename from esphome/core/sub_area.h rename to esphome/core/area.h index 2a70086c1c..f239983741 100644 --- a/esphome/core/sub_area.h +++ b/esphome/core/area.h @@ -5,7 +5,7 @@ namespace esphome { -class SubArea { +class Area { public: void set_area_id(uint32_t area_id) { area_id_ = area_id; } uint32_t get_area_id() { return area_id_; } diff --git a/esphome/core/config.py b/esphome/core/config.py index 76c7505393..921e7653a8 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -40,6 +40,7 @@ from esphome.helpers import ( copy_file_if_changed, fnv1a_32bit_hash, get_str_env, + slugify, walk_files, ) @@ -58,7 +59,7 @@ ProjectUpdateTrigger = cg.esphome_ns.class_( "ProjectUpdateTrigger", cg.Component, automation.Trigger.template(cg.std_string) ) SubDevice = cg.esphome_ns.class_("SubDevice") -SubArea = cg.esphome_ns.class_("SubArea") +Area = cg.esphome_ns.class_("Area") VALID_INCLUDE_EXTS = {".h", ".hpp", ".tcc", ".ino", ".cpp", ".c"} @@ -127,7 +128,15 @@ CONFIG_SCHEMA = cv.All( { cv.Required(CONF_NAME): cv.valid_name, cv.Optional(CONF_FRIENDLY_NAME, ""): cv.string, - cv.Optional(CONF_AREA, ""): cv.string, + cv.Optional(CONF_AREA): cv.Any( + cv.string, # Old way: just a string + cv.Schema( # New way: structured area + { + cv.GenerateID(CONF_ID): cv.declare_id(Area), + cv.Required(CONF_NAME): cv.string, + } + ), + ), cv.Optional(CONF_COMMENT): cv.string, cv.Required(CONF_BUILD_PATH): cv.string, cv.Optional(CONF_PLATFORMIO_OPTIONS, default={}): cv.Schema( @@ -180,7 +189,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_SUB_AREAS, default=[]): cv.ensure_list( cv.Schema( { - cv.GenerateID(CONF_ID): cv.declare_id(SubArea), + cv.GenerateID(CONF_ID): cv.declare_id(Area), cv.Required(CONF_NAME): cv.string, } ), @@ -190,7 +199,7 @@ CONFIG_SCHEMA = cv.All( { cv.GenerateID(CONF_ID): cv.declare_id(SubDevice), cv.Required(CONF_NAME): cv.string, - cv.Optional(CONF_AREA_ID): cv.use_id(SubArea), + cv.Optional(CONF_AREA_ID): cv.use_id(Area), } ), ), @@ -374,7 +383,6 @@ async def to_code(config): cg.App.pre_setup( config[CONF_NAME], config[CONF_FRIENDLY_NAME], - config[CONF_AREA], config.get(CONF_COMMENT, ""), cg.RawExpression('__DATE__ ", " __TIME__'), config[CONF_NAME_ADD_MAC_SUFFIX], @@ -445,6 +453,38 @@ async def to_code(config): if config[CONF_PLATFORMIO_OPTIONS]: CORE.add_job(_add_platformio_options, config[CONF_PLATFORMIO_OPTIONS]) + # Handle area configuration + if area_conf := config.get(CONF_AREA): + if isinstance(area_conf, dict): + # New way: structured area configuration + area_var = cg.new_Pvariable(area_conf[CONF_ID]) + area_id = fnv1a_32bit_hash(str(area_conf[CONF_ID])) + area_name = area_conf[CONF_NAME] + else: + # Old way: string-based area (deprecated) + area_slug = slugify(area_conf) + _LOGGER.warning( + "Using 'area' as a string is deprecated. Please use the new format:\n" + "area:\n" + " id: %s\n" + ' name: "%s"', + area_slug, + area_conf, + ) + # Create a synthetic area for backwards compatibility + area_var = cg.new_Pvariable( + cg.ID(f"area_{area_slug}", is_declaration=True, type=Area) + ) + area_id = fnv1a_32bit_hash(area_conf) + area_name = area_conf + + # Common setup for both ways + cg.add(area_var.set_area_id(area_id)) + cg.add(area_var.set_name(area_name)) + cg.add(cg.App.register_area(area_var)) + # Define USE_AREAS to enable area processing + cg.add_define("USE_AREAS") + # Process sub-devices and areas if sub_devices := config.get(CONF_SUB_DEVICES): # Process areas first @@ -455,6 +495,8 @@ async def to_code(config): cg.add(area.set_area_id(area_id)) cg.add(area.set_name(area_conf[CONF_NAME])) cg.add(cg.App.register_area(area)) + # Define USE_AREAS since we have areas + cg.add_define("USE_AREAS") # Process sub-devices for dev_conf in sub_devices: diff --git a/esphome/dashboard/util/text.py b/esphome/dashboard/util/text.py index 08d2df6abf..5c75061637 100644 --- a/esphome/dashboard/util/text.py +++ b/esphome/dashboard/util/text.py @@ -1,25 +1,9 @@ from __future__ import annotations -import unicodedata - -from esphome.const import ALLOWED_NAME_CHARS - - -def strip_accents(value): - return "".join( - c - for c in unicodedata.normalize("NFD", str(value)) - if unicodedata.category(c) != "Mn" - ) +from esphome.helpers import slugify def friendly_name_slugify(value): - value = ( - strip_accents(value) - .lower() - .replace(" ", "-") - .replace("_", "-") - .replace("--", "-") - .strip("-") - ) - return "".join(c for c in value if c in ALLOWED_NAME_CHARS) + """Convert a friendly name to a slug with dashes instead of underscores.""" + # First use the standard slugify, then convert underscores to dashes + return slugify(value).replace("_", "-") diff --git a/esphome/helpers.py b/esphome/helpers.py index 242c05e892..c84d597999 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -38,6 +38,32 @@ def fnv1a_32bit_hash(string: str) -> int: return hash_value +def strip_accents(value: str) -> str: + """Remove accents from a string.""" + import unicodedata + + return "".join( + c + for c in unicodedata.normalize("NFD", str(value)) + if unicodedata.category(c) != "Mn" + ) + + +def slugify(value: str) -> str: + """Convert a string to a valid C++ identifier slug.""" + from esphome.const import ALLOWED_NAME_CHARS + + value = ( + strip_accents(value) + .lower() + .replace(" ", "_") + .replace("-", "_") + .replace("__", "_") + .strip("_") + ) + return "".join(c for c in value if c in ALLOWED_NAME_CHARS) + + def indent_all_but_first_and_last(text, padding=" "): lines = text.splitlines(True) if len(lines) <= 2: From 1589a131db8894f2487c5b791b0d22012160d13e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Jun 2025 16:39:07 +0200 Subject: [PATCH 047/183] migrate to using same area info for top level and sub devices --- esphome/components/api/api.proto | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 0ac9cd3aab..b3ca1ce5c5 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -250,7 +250,7 @@ message DeviceInfoResponse { repeated SubDeviceInfo sub_devices = 20; repeated AreaInfo areas = 21; - + // Top-level area info to phase out suggested_area AreaInfo area = 22; } From e7a4eac8bdbc16d9a702482bc7cc81a01b069781 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Jun 2025 16:42:05 +0200 Subject: [PATCH 048/183] migrate to using same area info for top level and sub devices --- tests/components/esphome/common.yaml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/components/esphome/common.yaml b/tests/components/esphome/common.yaml index aa1ce9e111..8597987708 100644 --- a/tests/components/esphome/common.yaml +++ b/tests/components/esphome/common.yaml @@ -2,7 +2,9 @@ esphome: debug_scheduler: true platformio_options: board_build.flash_mode: dio - area: testing + area: + id: testing_area + name: Testing Area on_boot: logger.log: on_boot on_shutdown: @@ -24,6 +26,9 @@ esphome: - id: other_device name: Another device area_id: another_area + - id: test_device + name: Test device in main area + area_id: testing_area # Reference the main area (not in sub_areas) binary_sensor: - platform: template From 41e11e9a0e849a82776de2869ffca5bf4ba3da52 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Jun 2025 16:43:48 +0200 Subject: [PATCH 049/183] migrate to using same area info for top level and sub devices --- tests/components/esphome/common.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/components/esphome/common.yaml b/tests/components/esphome/common.yaml index 8597987708..24f8eb9433 100644 --- a/tests/components/esphome/common.yaml +++ b/tests/components/esphome/common.yaml @@ -29,6 +29,8 @@ esphome: - id: test_device name: Test device in main area area_id: testing_area # Reference the main area (not in sub_areas) + - id: no_area_device + name: Device without area # This device has no area_id binary_sensor: - platform: template From 98de53f60ba02377d8cb0cb309aef26fdc09b87d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Jun 2025 16:47:03 +0200 Subject: [PATCH 050/183] migrate to using same area info for top level and sub devices --- esphome/components/api/api.proto | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index b3ca1ce5c5..96ef93ef46 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -250,7 +250,7 @@ message DeviceInfoResponse { repeated SubDeviceInfo sub_devices = 20; repeated AreaInfo areas = 21; - + // Top-level area info to phase out suggested_area AreaInfo area = 22; } From 8714e809786aee5a4806135a3b6f2b1dfc23cea4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Jun 2025 17:05:46 +0200 Subject: [PATCH 051/183] make areas and devices consistant --- esphome/components/api/api.proto | 4 ++-- esphome/components/api/api_connection.cpp | 14 +++++++------- esphome/components/api/api_connection.h | 2 +- esphome/core/application.h | 18 +++++++++--------- esphome/core/area.h | 7 +++---- esphome/core/config.py | 8 ++++---- esphome/core/entity_base.h | 4 ++-- esphome/core/sub_device.h | 22 ---------------------- 8 files changed, 28 insertions(+), 51 deletions(-) delete mode 100644 esphome/core/sub_device.h diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 96ef93ef46..58a0b52555 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -193,7 +193,7 @@ message AreaInfo { string name = 2; } -message SubDeviceInfo { +message DeviceInfo { uint32 device_id = 1; string name = 2; uint32 area_id = 3; @@ -248,7 +248,7 @@ message DeviceInfoResponse { // Supports receiving and saving api encryption key bool api_encryption_supported = 19; - repeated SubDeviceInfo sub_devices = 20; + repeated DeviceInfo devices = 20; repeated AreaInfo areas = 21; // Top-level area info to phase out suggested_area diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 799cd2f102..948b67456b 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1620,13 +1620,13 @@ DeviceInfoResponse APIConnection::device_info(const DeviceInfoRequest &msg) { #ifdef USE_API_NOISE resp.api_encryption_supported = true; #endif -#ifdef USE_SUB_DEVICE - for (auto const &sub_device : App.get_sub_devices()) { - SubDeviceInfo sub_device_info; - sub_device_info.device_id = sub_device->get_device_id(); - sub_device_info.name = sub_device->get_name(); - sub_device_info.area_id = sub_device->get_area_id(); - resp.sub_devices.push_back(sub_device_info); +#ifdef USE_DEVICES + for (auto const &device : App.get_devices()) { + DeviceInfo device_info; + device_info.device_id = device->get_device_id(); + device_info.name = device->get_name(); + device_info.area_id = device->get_area_id(); + resp.devices.push_back(device_info); } #endif #ifdef USE_AREAS diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 9166dbbc94..da12a3e449 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -301,7 +301,7 @@ class APIConnection : public APIServerConnection { response.icon = entity->get_icon(); response.disabled_by_default = entity->is_disabled_by_default(); response.entity_category = static_cast(entity->get_entity_category()); -#ifdef USE_SUB_DEVICE +#ifdef USE_DEVICES response.device_id = entity->get_device_id(); #endif } diff --git a/esphome/core/application.h b/esphome/core/application.h index 09e2cfefbf..347cbca304 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -9,8 +9,8 @@ #include "esphome/core/preferences.h" #include "esphome/core/scheduler.h" -#ifdef USE_SUB_DEVICE -#include "esphome/core/sub_device.h" +#ifdef USE_DEVICES +#include "esphome/core/device.h" #endif #ifdef USE_AREAS #include "esphome/core/area.h" @@ -114,8 +114,8 @@ class Application { this->compilation_time_ = compilation_time; } -#ifdef USE_SUB_DEVICE - void register_sub_device(SubDevice *sub_device) { this->sub_devices_.push_back(sub_device); } +#ifdef USE_DEVICES + void register_device(Device *device) { this->devices_.push_back(device); } #endif #ifdef USE_AREAS void register_area(Area *area) { this->areas_.push_back(area); } @@ -299,7 +299,7 @@ class Application { const std::string &get_friendly_name() const { return this->friendly_name_; } /// Get the area of this Application set by pre_setup(). - std::string get_area() const { + const char *get_area() const { #ifdef USE_AREAS // If we have areas registered, return the name of the first one (which is the top-level area) if (!this->areas_.empty() && this->areas_[0] != nullptr) { @@ -356,8 +356,8 @@ class Application { uint8_t get_app_state() const { return this->app_state_; } -#ifdef USE_SUB_DEVICE - const std::vector &get_sub_devices() { return this->sub_devices_; } +#ifdef USE_DEVICES + const std::vector &get_devices() { return this->devices_; } #endif #ifdef USE_AREAS const std::vector &get_areas() { return this->areas_; } @@ -638,8 +638,8 @@ class Application { uint16_t current_loop_index_{0}; bool in_loop_{false}; -#ifdef USE_SUB_DEVICE - std::vector sub_devices_{}; +#ifdef USE_DEVICES + std::vector devices_{}; #endif #ifdef USE_AREAS std::vector areas_{}; diff --git a/esphome/core/area.h b/esphome/core/area.h index f239983741..30b82aad6d 100644 --- a/esphome/core/area.h +++ b/esphome/core/area.h @@ -1,6 +1,5 @@ #pragma once -#include #include namespace esphome { @@ -9,12 +8,12 @@ class Area { public: void set_area_id(uint32_t area_id) { area_id_ = area_id; } uint32_t get_area_id() { return area_id_; } - void set_name(std::string name) { name_ = std::move(name); } - std::string get_name() { return name_; } + void set_name(const char *name) { name_ = name; } + const char *get_name() { return name_; } protected: uint32_t area_id_{}; - std::string name_ = ""; + const char *name_ = ""; }; } // namespace esphome diff --git a/esphome/core/config.py b/esphome/core/config.py index 921e7653a8..ba7516d939 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -58,7 +58,7 @@ LoopTrigger = cg.esphome_ns.class_( ProjectUpdateTrigger = cg.esphome_ns.class_( "ProjectUpdateTrigger", cg.Component, automation.Trigger.template(cg.std_string) ) -SubDevice = cg.esphome_ns.class_("SubDevice") +Device = cg.esphome_ns.class_("Device") Area = cg.esphome_ns.class_("Area") VALID_INCLUDE_EXTS = {".h", ".hpp", ".tcc", ".ino", ".cpp", ".c"} @@ -197,7 +197,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_SUB_DEVICES, default=[]): cv.ensure_list( cv.Schema( { - cv.GenerateID(CONF_ID): cv.declare_id(SubDevice), + cv.GenerateID(CONF_ID): cv.declare_id(Device), cv.Required(CONF_NAME): cv.string, cv.Optional(CONF_AREA_ID): cv.use_id(Area), } @@ -507,5 +507,5 @@ async def to_code(config): # Get the area variable and use its area_id area = await cg.get_variable(dev_conf[CONF_AREA_ID]) cg.add(dev.set_area_id(area.get_area_id())) - cg.add(cg.App.register_sub_device(dev)) - cg.add_define("USE_SUB_DEVICE") + cg.add(cg.App.register_device(dev)) + cg.add_define("USE_DEVICES") diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index b21ae196f1..4bd04a9b1c 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -51,7 +51,7 @@ class EntityBase { std::string get_icon() const; void set_icon(const char *icon); -#ifdef USE_SUB_DEVICE +#ifdef USE_DEVICES // Get/set this entity's device id uint32_t get_device_id() const { return this->device_id_; } void set_device_id(const uint32_t device_id) { this->device_id_ = device_id; } @@ -73,7 +73,7 @@ class EntityBase { const char *object_id_c_str_{nullptr}; const char *icon_c_str_{nullptr}; uint32_t object_id_hash_{}; -#ifdef USE_SUB_DEVICE +#ifdef USE_DEVICES uint32_t device_id_{}; #endif diff --git a/esphome/core/sub_device.h b/esphome/core/sub_device.h deleted file mode 100644 index f17f882dfd..0000000000 --- a/esphome/core/sub_device.h +++ /dev/null @@ -1,22 +0,0 @@ -#pragma once - -#include "esphome/core/string_ref.h" - -namespace esphome { - -class SubDevice { - public: - void set_device_id(uint32_t device_id) { device_id_ = device_id; } - uint32_t get_device_id() { return device_id_; } - void set_name(std::string name) { name_ = std::move(name); } - std::string get_name() { return name_; } - void set_area_id(uint32_t area_id) { area_id_ = area_id; } - uint32_t get_area_id() { return area_id_; } - - protected: - uint32_t device_id_{}; - uint32_t area_id_{}; - std::string name_ = ""; -}; - -} // namespace esphome From 65e3c6bfbbb775965bf42e9b525dc85804e1feb5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Jun 2025 17:12:00 +0200 Subject: [PATCH 052/183] make areas and devices consistant --- esphome/const.py | 4 ++-- esphome/core/config.py | 12 ++++++------ esphome/core/defines.h | 3 ++- tests/components/esphome/common.yaml | 6 +++--- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/esphome/const.py b/esphome/const.py index 47f20a71cb..577b9beae7 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -844,8 +844,8 @@ CONF_STILL_THRESHOLD = "still_threshold" CONF_STOP = "stop" CONF_STOP_ACTION = "stop_action" CONF_STORE_BASELINE = "store_baseline" -CONF_SUB_AREAS = "sub_areas" -CONF_SUB_DEVICES = "sub_devices" +CONF_AREAS = "areas" +CONF_DEVICES = "devices" CONF_SUBNET = "subnet" CONF_SUBSCRIBE_QOS = "subscribe_qos" CONF_SUBSTITUTIONS = "substitutions" diff --git a/esphome/core/config.py b/esphome/core/config.py index ba7516d939..46034575f9 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -8,10 +8,12 @@ import esphome.config_validation as cv from esphome.const import ( CONF_AREA, CONF_AREA_ID, + CONF_AREAS, CONF_BUILD_PATH, CONF_COMMENT, CONF_COMPILE_PROCESS_LIMIT, CONF_DEBUG_SCHEDULER, + CONF_DEVICES, CONF_ESPHOME, CONF_FRIENDLY_NAME, CONF_ID, @@ -28,8 +30,6 @@ from esphome.const import ( CONF_PLATFORMIO_OPTIONS, CONF_PRIORITY, CONF_PROJECT, - CONF_SUB_AREAS, - CONF_SUB_DEVICES, CONF_TRIGGER_ID, CONF_VERSION, KEY_CORE, @@ -186,7 +186,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_COMPILE_PROCESS_LIMIT, default=_compile_process_limit_default ): cv.int_range(min=1, max=get_usable_cpu_count()), - cv.Optional(CONF_SUB_AREAS, default=[]): cv.ensure_list( + cv.Optional(CONF_AREAS, default=[]): cv.ensure_list( cv.Schema( { cv.GenerateID(CONF_ID): cv.declare_id(Area), @@ -194,7 +194,7 @@ CONFIG_SCHEMA = cv.All( } ), ), - cv.Optional(CONF_SUB_DEVICES, default=[]): cv.ensure_list( + cv.Optional(CONF_DEVICES, default=[]): cv.ensure_list( cv.Schema( { cv.GenerateID(CONF_ID): cv.declare_id(Device), @@ -486,9 +486,9 @@ async def to_code(config): cg.add_define("USE_AREAS") # Process sub-devices and areas - if sub_devices := config.get(CONF_SUB_DEVICES): + if sub_devices := config.get(CONF_DEVICES): # Process areas first - if sub_areas := config.get(CONF_SUB_AREAS): + if sub_areas := config.get(CONF_AREAS): for area_conf in sub_areas: area = cg.new_Pvariable(area_conf[CONF_ID]) area_id = fnv1a_32bit_hash(str(area_conf[CONF_ID])) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 32625a6a04..b1ee597942 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -84,7 +84,8 @@ #define USE_SELECT #define USE_SENSOR #define USE_STATUS_LED -#define USE_SUB_DEVICE +#define USE_DEVICES +#define USE_AREAS #define USE_SWITCH #define USE_TEXT #define USE_TEXT_SENSOR diff --git a/tests/components/esphome/common.yaml b/tests/components/esphome/common.yaml index 24f8eb9433..a4b309b69d 100644 --- a/tests/components/esphome/common.yaml +++ b/tests/components/esphome/common.yaml @@ -19,16 +19,16 @@ esphome: version: "1.1" on_update: logger.log: on_update - sub_areas: + areas: - id: another_area name: Another area - sub_devices: + devices: - id: other_device name: Another device area_id: another_area - id: test_device name: Test device in main area - area_id: testing_area # Reference the main area (not in sub_areas) + area_id: testing_area # Reference the main area (not in areas) - id: no_area_device name: Device without area # This device has no area_id From 66cce6a2f2306a2fe65d5c4a7f69182a53ac73c3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Jun 2025 17:12:25 +0200 Subject: [PATCH 053/183] make areas and devices consistant --- esphome/components/api/api_pb2.cpp | 24 ++++++++++++------------ esphome/components/api/api_pb2.h | 4 ++-- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index cbe18e172e..9793565ee5 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -855,7 +855,7 @@ void AreaInfo::dump_to(std::string &out) const { out.append("}"); } #endif -bool SubDeviceInfo::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool DeviceInfo::decode_varint(uint32_t field_id, ProtoVarInt value) { switch (field_id) { case 1: { this->device_id = value.as_uint32(); @@ -869,7 +869,7 @@ bool SubDeviceInfo::decode_varint(uint32_t field_id, ProtoVarInt value) { return false; } } -bool SubDeviceInfo::decode_length(uint32_t field_id, ProtoLengthDelimited value) { +bool DeviceInfo::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 2: { this->name = value.as_string(); @@ -879,20 +879,20 @@ bool SubDeviceInfo::decode_length(uint32_t field_id, ProtoLengthDelimited value) return false; } } -void SubDeviceInfo::encode(ProtoWriteBuffer buffer) const { +void DeviceInfo::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(1, this->device_id); buffer.encode_string(2, this->name); buffer.encode_uint32(3, this->area_id); } -void SubDeviceInfo::calculate_size(uint32_t &total_size) const { +void DeviceInfo::calculate_size(uint32_t &total_size) const { ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); ProtoSize::add_string_field(total_size, 1, this->name, false); ProtoSize::add_uint32_field(total_size, 1, this->area_id, false); } #ifdef HAS_PROTO_MESSAGE_DUMP -void SubDeviceInfo::dump_to(std::string &out) const { +void DeviceInfo::dump_to(std::string &out) const { __attribute__((unused)) char buffer[64]; - out.append("SubDeviceInfo {\n"); + out.append("DeviceInfo {\n"); out.append(" device_id: "); sprintf(buffer, "%" PRIu32, this->device_id); out.append(buffer); @@ -994,7 +994,7 @@ bool DeviceInfoResponse::decode_length(uint32_t field_id, ProtoLengthDelimited v return true; } case 20: { - this->sub_devices.push_back(value.as_message()); + this->devices.push_back(value.as_message()); return true; } case 21: { @@ -1029,8 +1029,8 @@ void DeviceInfoResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(16, this->suggested_area); buffer.encode_string(18, this->bluetooth_mac_address); buffer.encode_bool(19, this->api_encryption_supported); - for (auto &it : this->sub_devices) { - buffer.encode_message(20, it, true); + for (auto &it : this->devices) { + buffer.encode_message(20, it, true); } for (auto &it : this->areas) { buffer.encode_message(21, it, true); @@ -1057,7 +1057,7 @@ void DeviceInfoResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 2, this->suggested_area, false); ProtoSize::add_string_field(total_size, 2, this->bluetooth_mac_address, false); ProtoSize::add_bool_field(total_size, 2, this->api_encryption_supported, false); - ProtoSize::add_repeated_message(total_size, 2, this->sub_devices); + ProtoSize::add_repeated_message(total_size, 2, this->devices); ProtoSize::add_repeated_message(total_size, 2, this->areas); ProtoSize::add_message_object(total_size, 2, this->area, false); } @@ -1146,8 +1146,8 @@ void DeviceInfoResponse::dump_to(std::string &out) const { out.append(YESNO(this->api_encryption_supported)); out.append("\n"); - for (const auto &it : this->sub_devices) { - out.append(" sub_devices: "); + for (const auto &it : this->devices) { + out.append(" devices: "); it.dump_to(out); out.append("\n"); } diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index e71fd23619..6a5b51d3a1 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -430,7 +430,7 @@ class AreaInfo : public ProtoMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class SubDeviceInfo : public ProtoMessage { +class DeviceInfo : public ProtoMessage { public: uint32_t device_id{0}; std::string name{}; @@ -471,7 +471,7 @@ class DeviceInfoResponse : public ProtoMessage { std::string suggested_area{}; std::string bluetooth_mac_address{}; bool api_encryption_supported{false}; - std::vector sub_devices{}; + std::vector devices{}; std::vector areas{}; AreaInfo area{}; void encode(ProtoWriteBuffer buffer) const override; From d300d2605b7999a66dc6238eaab297bd4949b9c6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Jun 2025 17:13:04 +0200 Subject: [PATCH 054/183] make areas and devices consistant --- esphome/core/config.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/esphome/core/config.py b/esphome/core/config.py index 46034575f9..8374a3d3be 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -485,11 +485,11 @@ async def to_code(config): # Define USE_AREAS to enable area processing cg.add_define("USE_AREAS") - # Process sub-devices and areas - if sub_devices := config.get(CONF_DEVICES): + # Process devices and areas + if devices := config.get(CONF_DEVICES): # Process areas first - if sub_areas := config.get(CONF_AREAS): - for area_conf in sub_areas: + if areas := config.get(CONF_AREAS): + for area_conf in areas: area = cg.new_Pvariable(area_conf[CONF_ID]) area_id = fnv1a_32bit_hash(str(area_conf[CONF_ID])) cg.add(area.set_area_id(area_id)) @@ -498,8 +498,8 @@ async def to_code(config): # Define USE_AREAS since we have areas cg.add_define("USE_AREAS") - # Process sub-devices - for dev_conf in sub_devices: + # Process devices + for dev_conf in devices: dev = cg.new_Pvariable(dev_conf[CONF_ID]) cg.add(dev.set_device_id(fnv1a_32bit_hash(str(dev_conf[CONF_ID])))) cg.add(dev.set_name(dev_conf[CONF_NAME])) From 3d0392d668f35b1133c7479ed0d6e41886d73b03 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Jun 2025 17:17:29 +0200 Subject: [PATCH 055/183] make areas and devices consistant --- esphome/components/usb_host/__init__.py | 3 +-- esphome/const.py | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/esphome/components/usb_host/__init__.py b/esphome/components/usb_host/__init__.py index 3204562dc8..0fe3310127 100644 --- a/esphome/components/usb_host/__init__.py +++ b/esphome/components/usb_host/__init__.py @@ -6,7 +6,7 @@ from esphome.components.esp32 import ( only_on_variant, ) import esphome.config_validation as cv -from esphome.const import CONF_ID +from esphome.const import CONF_DEVICES, CONF_ID from esphome.cpp_types import Component AUTO_LOAD = ["bytebuffer"] @@ -16,7 +16,6 @@ usb_host_ns = cg.esphome_ns.namespace("usb_host") USBHost = usb_host_ns.class_("USBHost", Component) USBClient = usb_host_ns.class_("USBClient", Component) -CONF_DEVICES = "devices" CONF_VID = "vid" CONF_PID = "pid" CONF_ENABLE_HUBS = "enable_hubs" diff --git a/esphome/const.py b/esphome/const.py index 577b9beae7..6d7d9c0c1b 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -57,6 +57,7 @@ CONF_APPARENT_POWER = "apparent_power" CONF_ARDUINO_VERSION = "arduino_version" CONF_AREA = "area" CONF_AREA_ID = "area_id" +CONF_AREAS = "areas" CONF_ARGS = "args" CONF_ASSUMED_STATE = "assumed_state" CONF_AT = "at" @@ -219,6 +220,7 @@ CONF_DEVICE = "device" CONF_DEVICE_CLASS = "device_class" CONF_DEVICE_FACTOR = "device_factor" CONF_DEVICE_ID = "device_id" +CONF_DEVICES = "devices" CONF_DIELECTRIC_CONSTANT = "dielectric_constant" CONF_DIMENSIONS = "dimensions" CONF_DIO_PIN = "dio_pin" @@ -844,8 +846,6 @@ CONF_STILL_THRESHOLD = "still_threshold" CONF_STOP = "stop" CONF_STOP_ACTION = "stop_action" CONF_STORE_BASELINE = "store_baseline" -CONF_AREAS = "areas" -CONF_DEVICES = "devices" CONF_SUBNET = "subnet" CONF_SUBSCRIBE_QOS = "subscribe_qos" CONF_SUBSTITUTIONS = "substitutions" From f44ecd08913b8f3ac3c9e87222cab6aa5c7acd8f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Jun 2025 17:18:23 +0200 Subject: [PATCH 056/183] make areas and devices consistant --- esphome/config_validation.py | 4 ++-- esphome/core/config.py | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 072b4d69d1..a3627efe7b 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -350,9 +350,9 @@ def icon(value): def sub_device_id(value): # Lazy import to avoid circular imports - from esphome.core.config import SubDevice + from esphome.core.config import Device - validator = use_id(SubDevice) + validator = use_id(Device) return validator(value) diff --git a/esphome/core/config.py b/esphome/core/config.py index 8374a3d3be..95419fee70 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -472,9 +472,8 @@ async def to_code(config): area_conf, ) # Create a synthetic area for backwards compatibility - area_var = cg.new_Pvariable( - cg.ID(f"area_{area_slug}", is_declaration=True, type=Area) - ) + area_id_obj = cv.ID(f"area_{area_slug}") + area_var = cg.new_Pvariable(area_id_obj, type_=Area) area_id = fnv1a_32bit_hash(area_conf) area_name = area_conf From 4a7958586ecac97a0622de973c2ffff823967faa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Jun 2025 17:19:16 +0200 Subject: [PATCH 057/183] make areas and devices consistant --- esphome/core/config.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/esphome/core/config.py b/esphome/core/config.py index 95419fee70..544fba4aba 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -472,8 +472,7 @@ async def to_code(config): area_conf, ) # Create a synthetic area for backwards compatibility - area_id_obj = cv.ID(f"area_{area_slug}") - area_var = cg.new_Pvariable(area_id_obj, type_=Area) + area_var = cg.Pvariable(f"area_{area_slug}", Area) area_id = fnv1a_32bit_hash(area_conf) area_name = area_conf From fad86c655eedb790d791adc37d115ed21e31e840 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Jun 2025 17:30:17 +0200 Subject: [PATCH 058/183] make areas and devices consistant --- esphome/core/device.h | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 esphome/core/device.h diff --git a/esphome/core/device.h b/esphome/core/device.h new file mode 100644 index 0000000000..29f78b023e --- /dev/null +++ b/esphome/core/device.h @@ -0,0 +1,22 @@ +#pragma once + +#include "esphome/core/string_ref.h" + +namespace esphome { + +class Device { + public: + void set_device_id(uint32_t device_id) { device_id_ = device_id; } + uint32_t get_device_id() { return device_id_; } + void set_name(const char *name) { name_ = name; } + const char *get_name() { return name_; } + void set_area_id(uint32_t area_id) { area_id_ = area_id; } + uint32_t get_area_id() { return area_id_; } + + protected: + uint32_t device_id_{}; + uint32_t area_id_{}; + const char *name_ = ""; +}; + +} // namespace esphome From be37178ef8b7c7dc251a5dfb5d67cd22fefd7b57 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Jun 2025 17:32:11 +0200 Subject: [PATCH 059/183] make areas and devices consistant --- esphome/core/defines.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index b1ee597942..b064653ca3 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -83,9 +83,9 @@ #define USE_QR_CODE #define USE_SELECT #define USE_SENSOR -#define USE_STATUS_LED -#define USE_DEVICES #define USE_AREAS +#define USE_DEVICES +#define USE_STATUS_LED #define USE_SWITCH #define USE_TEXT #define USE_TEXT_SENSOR From 1f99d18982eabcb6741366fa8b0719e087ce5fca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Jun 2025 17:34:08 +0200 Subject: [PATCH 060/183] reverse space in vectors --- esphome/core/application.h | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/esphome/core/application.h b/esphome/core/application.h index 347cbca304..160a7b35ca 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -278,6 +278,12 @@ class Application { #ifdef USE_UPDATE void reserve_update(size_t count) { this->updates_.reserve(count); } #endif +#ifdef USE_AREAS + void reserve_area(size_t count) { this->areas_.reserve(count); } +#endif +#ifdef USE_DEVICES + void reserve_device(size_t count) { this->devices_.reserve(count); } +#endif /// Register the component in this Application instance. template C *register_component(C *c) { From aa4c3996574715c51296e4cde94412583d7fdea0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Jun 2025 17:36:25 +0200 Subject: [PATCH 061/183] reverse space in vectors --- esphome/core/config.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/esphome/core/config.py b/esphome/core/config.py index 544fba4aba..00c739b079 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -453,6 +453,18 @@ async def to_code(config): if config[CONF_PLATFORMIO_OPTIONS]: CORE.add_job(_add_platformio_options, config[CONF_PLATFORMIO_OPTIONS]) + # Count total areas for reservation + total_areas = 0 + if config.get(CONF_AREA): + total_areas += 1 + if areas_list := config.get(CONF_AREAS): + total_areas += len(areas_list) + + # Reserve space for areas if any are defined + if total_areas > 0: + cg.add(cg.RawStatement(f"App.reserve_area({total_areas});")) + cg.add_define("USE_AREAS") + # Handle area configuration if area_conf := config.get(CONF_AREA): if isinstance(area_conf, dict): @@ -480,12 +492,13 @@ async def to_code(config): cg.add(area_var.set_area_id(area_id)) cg.add(area_var.set_name(area_name)) cg.add(cg.App.register_area(area_var)) - # Define USE_AREAS to enable area processing - cg.add_define("USE_AREAS") # Process devices and areas if devices := config.get(CONF_DEVICES): - # Process areas first + # Reserve space for devices + cg.add(cg.RawStatement(f"App.reserve_device({len(devices)});")) + + # Process additional areas if areas := config.get(CONF_AREAS): for area_conf in areas: area = cg.new_Pvariable(area_conf[CONF_ID]) @@ -493,8 +506,6 @@ async def to_code(config): cg.add(area.set_area_id(area_id)) cg.add(area.set_name(area_conf[CONF_NAME])) cg.add(cg.App.register_area(area)) - # Define USE_AREAS since we have areas - cg.add_define("USE_AREAS") # Process devices for dev_conf in devices: From 4d231953f4d793a1f4fd12861eb8532cc66ada37 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Jun 2025 17:57:10 +0200 Subject: [PATCH 062/183] preen --- esphome/core/config.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/esphome/core/config.py b/esphome/core/config.py index 00c739b079..23201788ab 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -454,14 +454,12 @@ async def to_code(config): CORE.add_job(_add_platformio_options, config[CONF_PLATFORMIO_OPTIONS]) # Count total areas for reservation - total_areas = 0 + total_areas = len(config[CONF_AREAS]) if config.get(CONF_AREA): total_areas += 1 - if areas_list := config.get(CONF_AREAS): - total_areas += len(areas_list) # Reserve space for areas if any are defined - if total_areas > 0: + if total_areas: cg.add(cg.RawStatement(f"App.reserve_area({total_areas});")) cg.add_define("USE_AREAS") From 1873490b24d51927c1b1c5e4280db8f8cf82a18b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Jun 2025 17:57:36 +0200 Subject: [PATCH 063/183] preen --- esphome/core/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/core/config.py b/esphome/core/config.py index 23201788ab..63f2ad4f3b 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -495,6 +495,7 @@ async def to_code(config): if devices := config.get(CONF_DEVICES): # Reserve space for devices cg.add(cg.RawStatement(f"App.reserve_device({len(devices)});")) + cg.add_define("USE_DEVICES") # Process additional areas if areas := config.get(CONF_AREAS): @@ -515,4 +516,3 @@ async def to_code(config): area = await cg.get_variable(dev_conf[CONF_AREA_ID]) cg.add(dev.set_area_id(area.get_area_id())) cg.add(cg.App.register_device(dev)) - cg.add_define("USE_DEVICES") From 8e7841c880ceb192adbeb2a6cec11f448428bdee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Jun 2025 18:00:17 +0200 Subject: [PATCH 064/183] preen --- esphome/core/config.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/core/config.py b/esphome/core/config.py index 63f2ad4f3b..b8288d534d 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import logging import os from pathlib import Path @@ -43,6 +45,7 @@ from esphome.helpers import ( slugify, walk_files, ) +from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -372,7 +375,7 @@ async def _add_platform_reserves() -> None: @coroutine_with_priority(100.0) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(cg.global_ns.namespace("esphome").using) # These can be used by user lambdas, put them to default scope cg.add_global(cg.RawExpression("using std::isnan")) @@ -464,6 +467,7 @@ async def to_code(config): cg.add_define("USE_AREAS") # Handle area configuration + area_conf: dict[str, str] | str | None if area_conf := config.get(CONF_AREA): if isinstance(area_conf, dict): # New way: structured area configuration From f2b04a077eaf84ebb8d4f00abb182c031379f135 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Jun 2025 18:01:12 +0200 Subject: [PATCH 065/183] preen --- esphome/core/config.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/core/config.py b/esphome/core/config.py index b8288d534d..a84f2d85ab 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -496,12 +496,14 @@ async def to_code(config: ConfigType) -> None: cg.add(cg.App.register_area(area_var)) # Process devices and areas + devices: dict[str, str] | None if devices := config.get(CONF_DEVICES): # Reserve space for devices cg.add(cg.RawStatement(f"App.reserve_device({len(devices)});")) cg.add_define("USE_DEVICES") # Process additional areas + areas: dict[str, str] | None if areas := config.get(CONF_AREAS): for area_conf in areas: area = cg.new_Pvariable(area_conf[CONF_ID]) From c19065f112b70288989773fd9f3c64adda142ae6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Jun 2025 18:02:32 +0200 Subject: [PATCH 066/183] preen --- esphome/core/config.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/core/config.py b/esphome/core/config.py index a84f2d85ab..1947f46e80 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -496,15 +496,15 @@ async def to_code(config: ConfigType) -> None: cg.add(cg.App.register_area(area_var)) # Process devices and areas - devices: dict[str, str] | None - if devices := config.get(CONF_DEVICES): + devices: list[dict[str, str]] + if devices := config[CONF_DEVICES]: # Reserve space for devices cg.add(cg.RawStatement(f"App.reserve_device({len(devices)});")) cg.add_define("USE_DEVICES") # Process additional areas - areas: dict[str, str] | None - if areas := config.get(CONF_AREAS): + areas: list[dict[str, str]] + if areas := config[CONF_AREAS]: for area_conf in areas: area = cg.new_Pvariable(area_conf[CONF_ID]) area_id = fnv1a_32bit_hash(str(area_conf[CONF_ID])) From fb1679d5726b88d55196105b138d257c75b11035 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Jun 2025 18:07:45 +0200 Subject: [PATCH 067/183] preen --- esphome/core/defines.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index b064653ca3..c9fea90386 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -20,6 +20,7 @@ // Feature flags #define USE_ALARM_CONTROL_PANEL +#define USE_AREAS #define USE_BINARY_SENSOR #define USE_BUTTON #define USE_CLIMATE @@ -29,6 +30,7 @@ #define USE_DATETIME_DATETIME #define USE_DATETIME_TIME #define USE_DEEP_SLEEP +#define USE_DEVICES #define USE_DISPLAY #define USE_ESP32_IMPROV_STATE_CALLBACK #define USE_EVENT @@ -83,8 +85,6 @@ #define USE_QR_CODE #define USE_SELECT #define USE_SENSOR -#define USE_AREAS -#define USE_DEVICES #define USE_STATUS_LED #define USE_SWITCH #define USE_TEXT From 221e3c6c9c641a4d90123eb7c2be3fd671df9237 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Jun 2025 18:09:16 +0200 Subject: [PATCH 068/183] preen --- esphome/core/device.h | 2 -- 1 file changed, 2 deletions(-) diff --git a/esphome/core/device.h b/esphome/core/device.h index 29f78b023e..de25963110 100644 --- a/esphome/core/device.h +++ b/esphome/core/device.h @@ -1,7 +1,5 @@ #pragma once -#include "esphome/core/string_ref.h" - namespace esphome { class Device { From ffccce7ffcde0e54a3605eac98c4bf99ef75de19 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Jun 2025 09:58:12 +0200 Subject: [PATCH 069/183] handle collisions --- esphome/core/config.py | 52 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/esphome/core/config.py b/esphome/core/config.py index 1947f46e80..6489c21826 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -4,6 +4,8 @@ import logging import os from pathlib import Path +import voluptuous as vol + from esphome import automation import esphome.codegen as cg import esphome.config_validation as cv @@ -374,6 +376,17 @@ async def _add_platform_reserves() -> None: cg.add(cg.RawStatement(f"App.reserve_{platform_name}({count});"), prepend=True) +def _verify_no_collisions( + hashes: dict[int, str], id: str, id_hash: int, conf_key: str +) -> None: + """Verify that the given id and name do not collide with existing ones.""" + if id_hash in hashes: + raise vol.Invalid( + f"ID '{id}' with hash {id_hash} collides with existing ID '{hashes[id_hash]}'", + path=[conf_key], + ) + + @coroutine_with_priority(100.0) async def to_code(config: ConfigType) -> None: cg.add_global(cg.global_ns.namespace("esphome").using) @@ -467,6 +480,9 @@ async def to_code(config: ConfigType) -> None: cg.add_define("USE_AREAS") # Handle area configuration + area_hashes = dict[int, str] = {} + area_ids = set[str] = set() + device_hashes = dict[int, str] = {} area_conf: dict[str, str] | str | None if area_conf := config.get(CONF_AREA): if isinstance(area_conf, dict): @@ -491,6 +507,8 @@ async def to_code(config: ConfigType) -> None: area_name = area_conf # Common setup for both ways + area_hashes[area_id] = area_name + area_ids.add(area_id) cg.add(area_var.set_area_id(area_id)) cg.add(area_var.set_name(area_name)) cg.add(cg.App.register_area(area_var)) @@ -506,19 +524,35 @@ async def to_code(config: ConfigType) -> None: areas: list[dict[str, str]] if areas := config[CONF_AREAS]: for area_conf in areas: - area = cg.new_Pvariable(area_conf[CONF_ID]) - area_id = fnv1a_32bit_hash(str(area_conf[CONF_ID])) - cg.add(area.set_area_id(area_id)) - cg.add(area.set_name(area_conf[CONF_NAME])) + area_id = area_conf[CONF_ID] + area_ids.add(area_id) + area = cg.new_Pvariable(area_id) + area_id_hash = fnv1a_32bit_hash(area_id) + area_name = area_conf[CONF_NAME] + _verify_no_collisions(area_hashes, area_id, area_id_hash, CONF_AREAS) + cg.add(area.set_area_id(area_id_hash)) + cg.add(area.set_name(name)) cg.add(cg.App.register_area(area)) # Process devices for dev_conf in devices: - dev = cg.new_Pvariable(dev_conf[CONF_ID]) - cg.add(dev.set_device_id(fnv1a_32bit_hash(str(dev_conf[CONF_ID])))) - cg.add(dev.set_name(dev_conf[CONF_NAME])) + device_id = dev_conf[CONF_ID] + device_id_hash = fnv1a_32bit_hash(device_id) + device_name = dev_conf[CONF_NAME] + _verify_no_collisions( + device_hashes, device_id, device_id_hash, CONF_DEVICES + ) + dev = cg.new_Pvariable(device_id) + cg.add(dev.set_device_id(device_id_hash)) + cg.add(dev.set_name(device_name)) if CONF_AREA_ID in dev_conf: # Get the area variable and use its area_id - area = await cg.get_variable(dev_conf[CONF_AREA_ID]) - cg.add(dev.set_area_id(area.get_area_id())) + area_id = dev_conf[CONF_AREA_ID] + area_id_hash = fnv1a_32bit_hash(area_id) + if area_id not in area_ids: + raise vol.Invalid( + f"Device '{device_name}' has an area_id '{area_id}' that does not exist.", + path=[CONF_DEVICES, dev_conf[CONF_ID], CONF_AREA_ID], + ) + cg.add(dev.set_area_id(area_id_hash)) cg.add(cg.App.register_device(dev)) From 57599f7a98bfa2035c81994126eb4bc1088badc2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Jun 2025 10:00:31 +0200 Subject: [PATCH 070/183] handle collisions --- esphome/core/config.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/esphome/core/config.py b/esphome/core/config.py index 6489c21826..cb8d2100db 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -487,12 +487,14 @@ async def to_code(config: ConfigType) -> None: if area_conf := config.get(CONF_AREA): if isinstance(area_conf, dict): # New way: structured area configuration - area_var = cg.new_Pvariable(area_conf[CONF_ID]) - area_id = fnv1a_32bit_hash(str(area_conf[CONF_ID])) + area_id_str = area_conf[CONF_ID] + area_var = cg.new_Pvariable(area_id_str) + area_id = fnv1a_32bit_hash(area_id_str) area_name = area_conf[CONF_NAME] else: # Old way: string-based area (deprecated) area_slug = slugify(area_conf) + area_id_str = area_slug _LOGGER.warning( "Using 'area' as a string is deprecated. Please use the new format:\n" "area:\n" @@ -502,13 +504,13 @@ async def to_code(config: ConfigType) -> None: area_conf, ) # Create a synthetic area for backwards compatibility - area_var = cg.Pvariable(f"area_{area_slug}", Area) + area_var = cg.Pvariable(area_slug, Area) area_id = fnv1a_32bit_hash(area_conf) area_name = area_conf # Common setup for both ways area_hashes[area_id] = area_name - area_ids.add(area_id) + area_ids.add(area_id_str) cg.add(area_var.set_area_id(area_id)) cg.add(area_var.set_name(area_name)) cg.add(cg.App.register_area(area_var)) @@ -531,7 +533,7 @@ async def to_code(config: ConfigType) -> None: area_name = area_conf[CONF_NAME] _verify_no_collisions(area_hashes, area_id, area_id_hash, CONF_AREAS) cg.add(area.set_area_id(area_id_hash)) - cg.add(area.set_name(name)) + cg.add(area.set_name(area_name)) cg.add(cg.App.register_area(area)) # Process devices From bf8d8b6e630c98ca25a99698eeb59df399e83a19 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Jun 2025 10:01:53 +0200 Subject: [PATCH 071/183] handle collisions --- esphome/core/config.py | 78 +++++++++++++++++++++--------------------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/esphome/core/config.py b/esphome/core/config.py index cb8d2100db..0e2a127942 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -517,44 +517,44 @@ async def to_code(config: ConfigType) -> None: # Process devices and areas devices: list[dict[str, str]] - if devices := config[CONF_DEVICES]: - # Reserve space for devices - cg.add(cg.RawStatement(f"App.reserve_device({len(devices)});")) - cg.add_define("USE_DEVICES") + if not (devices := config[CONF_DEVICES]): + return - # Process additional areas - areas: list[dict[str, str]] - if areas := config[CONF_AREAS]: - for area_conf in areas: - area_id = area_conf[CONF_ID] - area_ids.add(area_id) - area = cg.new_Pvariable(area_id) - area_id_hash = fnv1a_32bit_hash(area_id) - area_name = area_conf[CONF_NAME] - _verify_no_collisions(area_hashes, area_id, area_id_hash, CONF_AREAS) - cg.add(area.set_area_id(area_id_hash)) - cg.add(area.set_name(area_name)) - cg.add(cg.App.register_area(area)) + # Reserve space for devices + cg.add(cg.RawStatement(f"App.reserve_device({len(devices)});")) + cg.add_define("USE_DEVICES") - # Process devices - for dev_conf in devices: - device_id = dev_conf[CONF_ID] - device_id_hash = fnv1a_32bit_hash(device_id) - device_name = dev_conf[CONF_NAME] - _verify_no_collisions( - device_hashes, device_id, device_id_hash, CONF_DEVICES - ) - dev = cg.new_Pvariable(device_id) - cg.add(dev.set_device_id(device_id_hash)) - cg.add(dev.set_name(device_name)) - if CONF_AREA_ID in dev_conf: - # Get the area variable and use its area_id - area_id = dev_conf[CONF_AREA_ID] - area_id_hash = fnv1a_32bit_hash(area_id) - if area_id not in area_ids: - raise vol.Invalid( - f"Device '{device_name}' has an area_id '{area_id}' that does not exist.", - path=[CONF_DEVICES, dev_conf[CONF_ID], CONF_AREA_ID], - ) - cg.add(dev.set_area_id(area_id_hash)) - cg.add(cg.App.register_device(dev)) + # Process additional areas + areas: list[dict[str, str]] + if areas := config[CONF_AREAS]: + for area_conf in areas: + area_id = area_conf[CONF_ID] + area_ids.add(area_id) + area = cg.new_Pvariable(area_id) + area_id_hash = fnv1a_32bit_hash(area_id) + area_name = area_conf[CONF_NAME] + _verify_no_collisions(area_hashes, area_id, area_id_hash, CONF_AREAS) + cg.add(area.set_area_id(area_id_hash)) + cg.add(area.set_name(area_name)) + cg.add(cg.App.register_area(area)) + + # Process devices + for dev_conf in devices: + device_id = dev_conf[CONF_ID] + device_id_hash = fnv1a_32bit_hash(device_id) + device_name = dev_conf[CONF_NAME] + _verify_no_collisions(device_hashes, device_id, device_id_hash, CONF_DEVICES) + dev = cg.new_Pvariable(device_id) + cg.add(dev.set_device_id(device_id_hash)) + cg.add(dev.set_name(device_name)) + if CONF_AREA_ID in dev_conf: + # Get the area variable and use its area_id + area_id = dev_conf[CONF_AREA_ID] + area_id_hash = fnv1a_32bit_hash(area_id) + if area_id not in area_ids: + raise vol.Invalid( + f"Device '{device_name}' has an area_id '{area_id}' that does not exist.", + path=[CONF_DEVICES, dev_conf[CONF_ID], CONF_AREA_ID], + ) + cg.add(dev.set_area_id(area_id_hash)) + cg.add(cg.App.register_device(dev)) From a98e34d1906402ed1ad504a13c1d16cdb7028b2f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Jun 2025 10:02:59 +0200 Subject: [PATCH 072/183] handle collisions --- esphome/core/config.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/core/config.py b/esphome/core/config.py index 0e2a127942..d870513cf9 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -534,6 +534,7 @@ async def to_code(config: ConfigType) -> None: area_id_hash = fnv1a_32bit_hash(area_id) area_name = area_conf[CONF_NAME] _verify_no_collisions(area_hashes, area_id, area_id_hash, CONF_AREAS) + area_hashes[area_id_hash] = area_name cg.add(area.set_area_id(area_id_hash)) cg.add(area.set_name(area_name)) cg.add(cg.App.register_area(area)) @@ -544,6 +545,7 @@ async def to_code(config: ConfigType) -> None: device_id_hash = fnv1a_32bit_hash(device_id) device_name = dev_conf[CONF_NAME] _verify_no_collisions(device_hashes, device_id, device_id_hash, CONF_DEVICES) + device_hashes[device_id_hash] = device_name dev = cg.new_Pvariable(device_id) cg.add(dev.set_device_id(device_id_hash)) cg.add(dev.set_name(device_name)) From b03e3b8d4abe67ee406fbfbb2aebd203a888b93d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Jun 2025 10:07:05 +0200 Subject: [PATCH 073/183] fixes --- esphome/core/config.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/core/config.py b/esphome/core/config.py index d870513cf9..cd8c0d7420 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -480,9 +480,9 @@ async def to_code(config: ConfigType) -> None: cg.add_define("USE_AREAS") # Handle area configuration - area_hashes = dict[int, str] = {} - area_ids = set[str] = set() - device_hashes = dict[int, str] = {} + area_hashes: dict[int, str] = {} + area_ids: set[str] = set() + device_hashes: dict[int, str] = {} area_conf: dict[str, str] | str | None if area_conf := config.get(CONF_AREA): if isinstance(area_conf, dict): @@ -524,7 +524,7 @@ async def to_code(config: ConfigType) -> None: cg.add(cg.RawStatement(f"App.reserve_device({len(devices)});")) cg.add_define("USE_DEVICES") - # Process additional areas + # Process additional areas from the areas list areas: list[dict[str, str]] if areas := config[CONF_AREAS]: for area_conf in areas: From 502b8a6073c8e64aef15d6907da1d0df17422048 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Jun 2025 12:32:25 +0200 Subject: [PATCH 074/183] fixes --- tests/dummy_main.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/dummy_main.cpp b/tests/dummy_main.cpp index 3ba4c8bd07..afd393c095 100644 --- a/tests/dummy_main.cpp +++ b/tests/dummy_main.cpp @@ -12,7 +12,7 @@ using namespace esphome; void setup() { - App.pre_setup("livingroom", "LivingRoom", "LivingRoomArea", "comment", __DATE__ ", " __TIME__, false); + App.pre_setup("livingroom", "LivingRoom", "comment", __DATE__ ", " __TIME__, false); auto *log = new logger::Logger(115200, 512); // NOLINT log->pre_setup(); log->set_uart_selection(logger::UART_SELECTION_UART0); From 61c29213a7a90794c6b11cf81391ddf589a730d8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Jun 2025 13:29:41 +0200 Subject: [PATCH 075/183] fixes --- esphome/core/config.py | 38 +++--- .../fixtures/areas_and_devices.yaml | 56 +++++++++ tests/integration/test_areas_and_devices.py | 116 ++++++++++++++++++ 3 files changed, 192 insertions(+), 18 deletions(-) create mode 100644 tests/integration/fixtures/areas_and_devices.yaml create mode 100644 tests/integration/test_areas_and_devices.py diff --git a/esphome/core/config.py b/esphome/core/config.py index cd8c0d7420..3a238e0453 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -6,7 +6,7 @@ from pathlib import Path import voluptuous as vol -from esphome import automation +from esphome import automation, core import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import ( @@ -483,17 +483,19 @@ async def to_code(config: ConfigType) -> None: area_hashes: dict[int, str] = {} area_ids: set[str] = set() device_hashes: dict[int, str] = {} - area_conf: dict[str, str] | str | None + area_conf: dict[str, str | core.ID] | str | None if area_conf := config.get(CONF_AREA): if isinstance(area_conf, dict): # New way: structured area configuration - area_id_str = area_conf[CONF_ID] - area_var = cg.new_Pvariable(area_id_str) - area_id = fnv1a_32bit_hash(area_id_str) + area_id: core.ID = area_conf[CONF_ID] + area_id_str: str = area_id.id + area_var = cg.new_Pvariable(area_id) + area_id_hash = fnv1a_32bit_hash(area_id_str) area_name = area_conf[CONF_NAME] else: # Old way: string-based area (deprecated) area_slug = slugify(area_conf) + area_id: core.ID = cv.declare_id(Area) area_id_str = area_slug _LOGGER.warning( "Using 'area' as a string is deprecated. Please use the new format:\n" @@ -504,19 +506,19 @@ async def to_code(config: ConfigType) -> None: area_conf, ) # Create a synthetic area for backwards compatibility - area_var = cg.Pvariable(area_slug, Area) - area_id = fnv1a_32bit_hash(area_conf) + area_var = cg.Pvariable(area_id) + area_id_hash = fnv1a_32bit_hash(area_conf) area_name = area_conf # Common setup for both ways - area_hashes[area_id] = area_name + area_hashes[area_id_hash] = area_name area_ids.add(area_id_str) - cg.add(area_var.set_area_id(area_id)) + cg.add(area_var.set_area_id(area_id_hash)) cg.add(area_var.set_name(area_name)) cg.add(cg.App.register_area(area_var)) # Process devices and areas - devices: list[dict[str, str]] + devices: list[dict[str, str | core.ID]] if not (devices := config[CONF_DEVICES]): return @@ -528,11 +530,11 @@ async def to_code(config: ConfigType) -> None: areas: list[dict[str, str]] if areas := config[CONF_AREAS]: for area_conf in areas: - area_id = area_conf[CONF_ID] - area_ids.add(area_id) + area_id: core.ID = area_conf[CONF_ID] + area_ids.add(area_id.id) area = cg.new_Pvariable(area_id) - area_id_hash = fnv1a_32bit_hash(area_id) - area_name = area_conf[CONF_NAME] + area_id_hash = fnv1a_32bit_hash(area_id.id) + area_name: str = area_conf[CONF_NAME] _verify_no_collisions(area_hashes, area_id, area_id_hash, CONF_AREAS) area_hashes[area_id_hash] = area_name cg.add(area.set_area_id(area_id_hash)) @@ -542,7 +544,7 @@ async def to_code(config: ConfigType) -> None: # Process devices for dev_conf in devices: device_id = dev_conf[CONF_ID] - device_id_hash = fnv1a_32bit_hash(device_id) + device_id_hash = fnv1a_32bit_hash(device_id.id) device_name = dev_conf[CONF_NAME] _verify_no_collisions(device_hashes, device_id, device_id_hash, CONF_DEVICES) device_hashes[device_id_hash] = device_name @@ -552,10 +554,10 @@ async def to_code(config: ConfigType) -> None: if CONF_AREA_ID in dev_conf: # Get the area variable and use its area_id area_id = dev_conf[CONF_AREA_ID] - area_id_hash = fnv1a_32bit_hash(area_id) - if area_id not in area_ids: + area_id_hash = fnv1a_32bit_hash(area_id.id) + if area_id.id not in area_ids: raise vol.Invalid( - f"Device '{device_name}' has an area_id '{area_id}' that does not exist.", + f"Device '{device_name}' has an area_id '{area_id.id}' that does not exist.", path=[CONF_DEVICES, dev_conf[CONF_ID], CONF_AREA_ID], ) cg.add(dev.set_area_id(area_id_hash)) diff --git a/tests/integration/fixtures/areas_and_devices.yaml b/tests/integration/fixtures/areas_and_devices.yaml new file mode 100644 index 0000000000..6bf1519c79 --- /dev/null +++ b/tests/integration/fixtures/areas_and_devices.yaml @@ -0,0 +1,56 @@ +esphome: + name: areas-devices-test + # Define top-level area + area: + id: living_room_area + name: Living Room + # Define additional areas + areas: + - id: bedroom_area + name: Bedroom + - id: kitchen_area + name: Kitchen + # Define devices with area assignments + devices: + - id: light_controller_device + name: Light Controller + area_id: living_room_area # Uses top-level area + - id: temp_sensor_device + name: Temperature Sensor + area_id: bedroom_area + - id: motion_detector_device + name: Motion Detector + area_id: living_room_area # Reuses top-level area + - id: smart_switch_device + name: Smart Switch + area_id: kitchen_area + +host: +api: +logger: + +# Sensors assigned to different devices +sensor: + - platform: template + name: Light Controller Sensor + device_id: light_controller_device + lambda: return 1.0; + update_interval: 0.1s + + - platform: template + name: Temperature Sensor Reading + device_id: temp_sensor_device + lambda: return 2.0; + update_interval: 0.1s + + - platform: template + name: Motion Detector Status + device_id: motion_detector_device + lambda: return 3.0; + update_interval: 0.1s + + - platform: template + name: Smart Switch Power + device_id: smart_switch_device + lambda: return 4.0; + update_interval: 0.1s diff --git a/tests/integration/test_areas_and_devices.py b/tests/integration/test_areas_and_devices.py new file mode 100644 index 0000000000..32361f2844 --- /dev/null +++ b/tests/integration/test_areas_and_devices.py @@ -0,0 +1,116 @@ +"""Integration test for areas and devices feature.""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import EntityState +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_areas_and_devices( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test areas and devices configuration with entity mapping.""" + async with run_compiled(yaml_config), api_client_connected() as client: + # Get device info which includes areas and devices + device_info = await client.device_info() + assert device_info is not None + + # Verify areas are reported + areas = device_info.areas + assert len(areas) >= 2, f"Expected at least 2 areas, got {len(areas)}" + + # Find our specific areas + main_area = next((a for a in areas if a.name == "Living Room"), None) + bedroom_area = next((a for a in areas if a.name == "Bedroom"), None) + kitchen_area = next((a for a in areas if a.name == "Kitchen"), None) + + assert main_area is not None, "Living Room area not found" + assert bedroom_area is not None, "Bedroom area not found" + assert kitchen_area is not None, "Kitchen area not found" + + # Verify devices are reported + devices = device_info.devices + assert len(devices) >= 4, f"Expected at least 4 devices, got {len(devices)}" + + # Find our specific devices + light_controller = next( + (d for d in devices if d.name == "Light Controller"), None + ) + temp_sensor = next((d for d in devices if d.name == "Temperature Sensor"), None) + motion_detector = next( + (d for d in devices if d.name == "Motion Detector"), None + ) + smart_switch = next((d for d in devices if d.name == "Smart Switch"), None) + + assert light_controller is not None, "Light Controller device not found" + assert temp_sensor is not None, "Temperature Sensor device not found" + assert motion_detector is not None, "Motion Detector device not found" + assert smart_switch is not None, "Smart Switch device not found" + + # Verify device area assignments + assert light_controller.area_id == main_area.area_id, ( + "Light Controller should be in Living Room" + ) + assert temp_sensor.area_id == bedroom_area.area_id, ( + "Temperature Sensor should be in Bedroom" + ) + assert motion_detector.area_id == main_area.area_id, ( + "Motion Detector should be in Living Room" + ) + assert smart_switch.area_id == kitchen_area.area_id, ( + "Smart Switch should be in Kitchen" + ) + + # Get entity list to verify device_id mapping + entities = await client.list_entities_services() + + # Collect sensor entities + sensor_entities = [e for e in entities[0] if hasattr(e, "device_id")] + assert len(sensor_entities) >= 4, ( + f"Expected at least 4 sensor entities, got {len(sensor_entities)}" + ) + + # Subscribe to states to get sensor values + loop = asyncio.get_running_loop() + states: dict[int, EntityState] = {} + states_future: asyncio.Future[bool] = loop.create_future() + + def on_state(state: EntityState) -> None: + states[state.key] = state + # Check if we have all expected sensor states + if len(states) >= 4 and not states_future.done(): + states_future.set_result(True) + + client.subscribe_states(on_state) + + # Wait for sensor states + try: + await asyncio.wait_for(states_future, timeout=10.0) + except asyncio.TimeoutError: + pytest.fail( + f"Did not receive all sensor states within 10 seconds. " + f"Received {len(states)} states" + ) + + # Verify we have sensor entities with proper device_id assignments + device_id_mapping = { + "Light Controller Sensor": light_controller.device_id, + "Temperature Sensor Reading": temp_sensor.device_id, + "Motion Detector Status": motion_detector.device_id, + "Smart Switch Power": smart_switch.device_id, + } + + for entity in sensor_entities: + if entity.name in device_id_mapping: + expected_device_id = device_id_mapping[entity.name] + assert entity.device_id == expected_device_id, ( + f"{entity.name} has device_id {entity.device_id}, " + f"expected {expected_device_id}" + ) From f4f14a75070a2d758d6908f9fa92d7f0d113d4f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Jun 2025 13:29:49 +0200 Subject: [PATCH 076/183] fixes --- tests/integration/fixtures/areas_and_devices.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/integration/fixtures/areas_and_devices.yaml b/tests/integration/fixtures/areas_and_devices.yaml index 6bf1519c79..4a327b73a1 100644 --- a/tests/integration/fixtures/areas_and_devices.yaml +++ b/tests/integration/fixtures/areas_and_devices.yaml @@ -54,3 +54,4 @@ sensor: device_id: smart_switch_device lambda: return 4.0; update_interval: 0.1s + From 41b1bfc5043d0c8e294131cb3dfa5bc953fb15da Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Jun 2025 13:37:01 +0200 Subject: [PATCH 077/183] legacy test --- esphome/core/config.py | 6 ++- tests/integration/fixtures/legacy_area.yaml | 15 ++++++++ tests/integration/test_legacy_area.py | 41 +++++++++++++++++++++ 3 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 tests/integration/fixtures/legacy_area.yaml create mode 100644 tests/integration/test_legacy_area.py diff --git a/esphome/core/config.py b/esphome/core/config.py index 3a238e0453..1f40d1608e 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -495,7 +495,9 @@ async def to_code(config: ConfigType) -> None: else: # Old way: string-based area (deprecated) area_slug = slugify(area_conf) - area_id: core.ID = cv.declare_id(Area) + area_id = core.ID( + cv.validate_id_name(area_slug), is_declaration=True, type=Area + ) area_id_str = area_slug _LOGGER.warning( "Using 'area' as a string is deprecated. Please use the new format:\n" @@ -506,7 +508,7 @@ async def to_code(config: ConfigType) -> None: area_conf, ) # Create a synthetic area for backwards compatibility - area_var = cg.Pvariable(area_id) + area_var = cg.new_Pvariable(area_id) area_id_hash = fnv1a_32bit_hash(area_conf) area_name = area_conf diff --git a/tests/integration/fixtures/legacy_area.yaml b/tests/integration/fixtures/legacy_area.yaml new file mode 100644 index 0000000000..4d1617c395 --- /dev/null +++ b/tests/integration/fixtures/legacy_area.yaml @@ -0,0 +1,15 @@ +esphome: + name: legacy-area-test + # Using legacy string-based area configuration + area: Master Bedroom + +host: +api: +logger: + +# Simple sensor to ensure the device compiles and runs +sensor: + - platform: template + name: Test Sensor + lambda: return 42.0; + update_interval: 1s diff --git a/tests/integration/test_legacy_area.py b/tests/integration/test_legacy_area.py new file mode 100644 index 0000000000..d10a01ec6a --- /dev/null +++ b/tests/integration/test_legacy_area.py @@ -0,0 +1,41 @@ +"""Integration test for legacy string-based area configuration.""" + +from __future__ import annotations + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_legacy_area( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test legacy string-based area configuration.""" + async with run_compiled(yaml_config), api_client_connected() as client: + # Get device info which includes areas + device_info = await client.device_info() + assert device_info is not None + + # Verify the area is reported (should be converted to structured format) + areas = device_info.areas + assert len(areas) == 1, f"Expected exactly 1 area, got {len(areas)}" + + # Find the area - should be slugified from "Master Bedroom" + area = areas[0] + assert area.name == "Master Bedroom", ( + f"Expected area name 'Master Bedroom', got '{area.name}'" + ) + + # Verify area.id is set (it should be a hash) + assert area.area_id > 0, "Area ID should be a positive hash value" + + # The suggested_area field should be set for backward compatibility + assert device_info.suggested_area == "Master Bedroom", ( + f"Expected suggested_area to be 'Master Bedroom', got '{device_info.suggested_area}'" + ) + + # Verify deprecated warning would have been logged during compilation + # (We can't check logs directly in integration tests, but the code should work) From b30b527ff9ba3c65ec0c109237bfaf42a36a4bba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Jun 2025 13:37:30 +0200 Subject: [PATCH 078/183] one more place to check --- tests/integration/test_areas_and_devices.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/integration/test_areas_and_devices.py b/tests/integration/test_areas_and_devices.py index 32361f2844..4ce55a30a7 100644 --- a/tests/integration/test_areas_and_devices.py +++ b/tests/integration/test_areas_and_devices.py @@ -68,6 +68,11 @@ async def test_areas_and_devices( "Smart Switch should be in Kitchen" ) + # Verify suggested_area is set to the top-level area name + assert device_info.suggested_area == "Living Room", ( + f"Expected suggested_area to be 'Living Room', got '{device_info.suggested_area}'" + ) + # Get entity list to verify device_id mapping entities = await client.list_entities_services() From 46b419ea8b70f5f55e5a0c847273cf91f020135c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Jun 2025 13:38:14 +0200 Subject: [PATCH 079/183] preen --- esphome/core/config.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/core/config.py b/esphome/core/config.py index 1f40d1608e..be6e2cae95 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -559,7 +559,8 @@ async def to_code(config: ConfigType) -> None: area_id_hash = fnv1a_32bit_hash(area_id.id) if area_id.id not in area_ids: raise vol.Invalid( - f"Device '{device_name}' has an area_id '{area_id.id}' that does not exist.", + f"Device '{device_name}' has an area_id '{area_id.id}'" + " that does not exist.", path=[CONF_DEVICES, dev_conf[CONF_ID], CONF_AREA_ID], ) cg.add(dev.set_area_id(area_id_hash)) From 7f2d97925542eff51eb4b70de1a120ae55216876 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Jun 2025 13:39:12 +0200 Subject: [PATCH 080/183] preen --- esphome/core/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/core/config.py b/esphome/core/config.py index be6e2cae95..c246e8dc2e 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -529,7 +529,7 @@ async def to_code(config: ConfigType) -> None: cg.add_define("USE_DEVICES") # Process additional areas from the areas list - areas: list[dict[str, str]] + areas: list[dict[str, str | core.ID]] if areas := config[CONF_AREAS]: for area_conf in areas: area_id: core.ID = area_conf[CONF_ID] From d7eae1c1a055035b0fac01a604936cceada929b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Jun 2025 13:43:52 +0200 Subject: [PATCH 081/183] simplify --- esphome/core/config.py | 62 +++++++++++++++++++++--------------------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/esphome/core/config.py b/esphome/core/config.py index c246e8dc2e..74b2d0daa4 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -69,6 +69,27 @@ Area = cg.esphome_ns.class_("Area") VALID_INCLUDE_EXTS = {".h", ".hpp", ".tcc", ".ino", ".cpp", ".c"} +def validate_area_config(value): + """Convert legacy string area to structured format.""" + if isinstance(value, str): + # Legacy string format - convert to structured format + _LOGGER.warning( + "Using 'area' as a string is deprecated. Please use the new format:\n" + "area:\n" + " id: %s\n" + ' name: "%s"', + slugify(value), + value, + ) + # Return a structured area config with the ID generated here + return { + CONF_ID: cv.declare_id(Area)(slugify(value)), + CONF_NAME: value, + } + # Already structured format + return value + + def validate_hostname(config): max_length = 31 if config[CONF_NAME_ADD_MAC_SUFFIX]: @@ -133,9 +154,9 @@ CONFIG_SCHEMA = cv.All( { cv.Required(CONF_NAME): cv.valid_name, cv.Optional(CONF_FRIENDLY_NAME, ""): cv.string, - cv.Optional(CONF_AREA): cv.Any( - cv.string, # Old way: just a string - cv.Schema( # New way: structured area + cv.Optional(CONF_AREA): cv.All( + validate_area_config, + cv.Schema( { cv.GenerateID(CONF_ID): cv.declare_id(Area), cv.Required(CONF_NAME): cv.string, @@ -483,36 +504,15 @@ async def to_code(config: ConfigType) -> None: area_hashes: dict[int, str] = {} area_ids: set[str] = set() device_hashes: dict[int, str] = {} - area_conf: dict[str, str | core.ID] | str | None + area_conf: dict[str, str | core.ID] | None if area_conf := config.get(CONF_AREA): - if isinstance(area_conf, dict): - # New way: structured area configuration - area_id: core.ID = area_conf[CONF_ID] - area_id_str: str = area_id.id - area_var = cg.new_Pvariable(area_id) - area_id_hash = fnv1a_32bit_hash(area_id_str) - area_name = area_conf[CONF_NAME] - else: - # Old way: string-based area (deprecated) - area_slug = slugify(area_conf) - area_id = core.ID( - cv.validate_id_name(area_slug), is_declaration=True, type=Area - ) - area_id_str = area_slug - _LOGGER.warning( - "Using 'area' as a string is deprecated. Please use the new format:\n" - "area:\n" - " id: %s\n" - ' name: "%s"', - area_slug, - area_conf, - ) - # Create a synthetic area for backwards compatibility - area_var = cg.new_Pvariable(area_id) - area_id_hash = fnv1a_32bit_hash(area_conf) - area_name = area_conf + # At this point, validation has already converted string to structured format + area_id: core.ID = area_conf[CONF_ID] + area_id_str: str = area_id.id + area_var = cg.new_Pvariable(area_id) + area_id_hash = fnv1a_32bit_hash(area_id_str) + area_name = area_conf[CONF_NAME] - # Common setup for both ways area_hashes[area_id_hash] = area_name area_ids.add(area_id_str) cg.add(area_var.set_area_id(area_id_hash)) From 17bf533ed7955c3dca702b9a1282b7f3d54ac5b8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Jun 2025 13:44:05 +0200 Subject: [PATCH 082/183] simplify --- esphome/core/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/core/config.py b/esphome/core/config.py index 74b2d0daa4..a232746e19 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -69,7 +69,7 @@ Area = cg.esphome_ns.class_("Area") VALID_INCLUDE_EXTS = {".h", ".hpp", ".tcc", ".ino", ".cpp", ".c"} -def validate_area_config(value): +def validate_area_config(value: dict | str) -> dict[str, str | core.ID]: """Convert legacy string area to structured format.""" if isinstance(value, str): # Legacy string format - convert to structured format From 0764fa729269ba21046495de4a655799eaa34c6b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Jun 2025 13:48:27 +0200 Subject: [PATCH 083/183] simplify --- esphome/core/config.py | 95 +++++++++++++++++++++++------------------- 1 file changed, 51 insertions(+), 44 deletions(-) diff --git a/esphome/core/config.py b/esphome/core/config.py index a232746e19..93fabe1495 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -490,78 +490,85 @@ async def to_code(config: ConfigType) -> None: if config[CONF_PLATFORMIO_OPTIONS]: CORE.add_job(_add_platformio_options, config[CONF_PLATFORMIO_OPTIONS]) - # Count total areas for reservation - total_areas = len(config[CONF_AREAS]) - if config.get(CONF_AREA): - total_areas += 1 - - # Reserve space for areas if any are defined - if total_areas: - cg.add(cg.RawStatement(f"App.reserve_area({total_areas});")) - cg.add_define("USE_AREAS") - - # Handle area configuration - area_hashes: dict[int, str] = {} - area_ids: set[str] = set() - device_hashes: dict[int, str] = {} - area_conf: dict[str, str | core.ID] | None - if area_conf := config.get(CONF_AREA): - # At this point, validation has already converted string to structured format + # Helper function to process an area configuration + def process_area( + area_conf: dict[str, str | core.ID], + area_hashes: dict[int, str], + area_ids: set[str], + conf_path: str | None = None, + ) -> None: + """Process and register an area configuration.""" area_id: core.ID = area_conf[CONF_ID] area_id_str: str = area_id.id - area_var = cg.new_Pvariable(area_id) area_id_hash = fnv1a_32bit_hash(area_id_str) - area_name = area_conf[CONF_NAME] + area_name: str = area_conf[CONF_NAME] + + if conf_path: # Only verify collisions for areas from CONF_AREAS list + _verify_no_collisions(area_hashes, area_id, area_id_hash, conf_path) area_hashes[area_id_hash] = area_name area_ids.add(area_id_str) + + area_var = cg.new_Pvariable(area_id) cg.add(area_var.set_area_id(area_id_hash)) cg.add(area_var.set_name(area_name)) cg.add(cg.App.register_area(area_var)) - # Process devices and areas - devices: list[dict[str, str | core.ID]] - if not (devices := config[CONF_DEVICES]): + # Initialize tracking structures + area_hashes: dict[int, str] = {} + area_ids: set[str] = set() + device_hashes: dict[int, str] = {} + + # Collect all areas to process + all_areas: list[tuple[dict[str, str | core.ID], str | None]] = [] + + # Add top-level area if present + if area_conf := config.get(CONF_AREA): + all_areas.append((area_conf, None)) + + # Add areas from CONF_AREAS list + all_areas.extend((area, CONF_AREAS) for area in config[CONF_AREAS]) + + # Reserve space for areas and process them + if all_areas: + cg.add(cg.RawStatement(f"App.reserve_area({len(all_areas)});")) + cg.add_define("USE_AREAS") + + for area_conf, conf_path in all_areas: + process_area(area_conf, area_hashes, area_ids, conf_path) + + # Process devices + devices: list[dict[str, str | core.ID]] = config[CONF_DEVICES] + if not devices: return # Reserve space for devices cg.add(cg.RawStatement(f"App.reserve_device({len(devices)});")) cg.add_define("USE_DEVICES") - # Process additional areas from the areas list - areas: list[dict[str, str | core.ID]] - if areas := config[CONF_AREAS]: - for area_conf in areas: - area_id: core.ID = area_conf[CONF_ID] - area_ids.add(area_id.id) - area = cg.new_Pvariable(area_id) - area_id_hash = fnv1a_32bit_hash(area_id.id) - area_name: str = area_conf[CONF_NAME] - _verify_no_collisions(area_hashes, area_id, area_id_hash, CONF_AREAS) - area_hashes[area_id_hash] = area_name - cg.add(area.set_area_id(area_id_hash)) - cg.add(area.set_name(area_name)) - cg.add(cg.App.register_area(area)) - - # Process devices + # Process each device for dev_conf in devices: - device_id = dev_conf[CONF_ID] + device_id: core.ID = dev_conf[CONF_ID] device_id_hash = fnv1a_32bit_hash(device_id.id) - device_name = dev_conf[CONF_NAME] + device_name: str = dev_conf[CONF_NAME] + _verify_no_collisions(device_hashes, device_id, device_id_hash, CONF_DEVICES) device_hashes[device_id_hash] = device_name + dev = cg.new_Pvariable(device_id) cg.add(dev.set_device_id(device_id_hash)) cg.add(dev.set_name(device_name)) + + # Set area if specified if CONF_AREA_ID in dev_conf: - # Get the area variable and use its area_id - area_id = dev_conf[CONF_AREA_ID] - area_id_hash = fnv1a_32bit_hash(area_id.id) + area_id: core.ID = dev_conf[CONF_AREA_ID] if area_id.id not in area_ids: raise vol.Invalid( f"Device '{device_name}' has an area_id '{area_id.id}'" " that does not exist.", - path=[CONF_DEVICES, dev_conf[CONF_ID], CONF_AREA_ID], + path=[CONF_DEVICES, device_id, CONF_AREA_ID], ) + area_id_hash = fnv1a_32bit_hash(area_id.id) cg.add(dev.set_area_id(area_id_hash)) + cg.add(cg.App.register_device(dev)) From 180aeb7d8e2c79dd78f5681a7eb833087b52337c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Jun 2025 13:50:29 +0200 Subject: [PATCH 084/183] simplify --- esphome/core/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/core/config.py b/esphome/core/config.py index 93fabe1495..45ba214e44 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -566,7 +566,7 @@ async def to_code(config: ConfigType) -> None: raise vol.Invalid( f"Device '{device_name}' has an area_id '{area_id.id}'" " that does not exist.", - path=[CONF_DEVICES, device_id, CONF_AREA_ID], + path=[CONF_DEVICES, dev_conf[CONF_ID], CONF_AREA_ID], ) area_id_hash = fnv1a_32bit_hash(area_id.id) cg.add(dev.set_area_id(area_id_hash)) From 818a978dfc0e8b1190183791692b552cd8e5625a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Jun 2025 19:40:53 +0200 Subject: [PATCH 085/183] units --- tests/unit_tests/core/test_config.py | 187 +++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 tests/unit_tests/core/test_config.py diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py new file mode 100644 index 0000000000..35245b82d3 --- /dev/null +++ b/tests/unit_tests/core/test_config.py @@ -0,0 +1,187 @@ +"""Unit tests for core config functionality including areas and devices.""" + +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import pytest + +from esphome import config, config_validation as cv +from esphome.config import Config +from esphome.const import CONF_AREA, CONF_AREAS, CONF_DEVICES +from esphome.core import CORE +from esphome.core.config import Area, validate_area_config + +FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" / "core" / "config" + + +@pytest.fixture +def yaml_file(tmp_path: Path) -> Callable[[str], str]: + """Create a temporary YAML file for testing.""" + + def _yaml_file(content: str) -> str: + yaml_path = tmp_path / "test.yaml" + yaml_path.write_text(content) + return str(yaml_path) + + return _yaml_file + + +@pytest.fixture(autouse=True) +def reset_core(): + """Reset CORE after each test.""" + yield + CORE.reset() + + +def load_config_from_yaml( + yaml_file: Callable[[str], str], yaml_content: str +) -> Config | None: + """Load configuration from YAML content.""" + CORE.config_path = yaml_file(yaml_content) + return config.read_config({}) + + +def load_config_from_fixture( + yaml_file: Callable[[str], str], fixture_name: str +) -> Config | None: + """Load configuration from a fixture file.""" + fixture_path = FIXTURES_DIR / fixture_name + yaml_content = fixture_path.read_text() + return load_config_from_yaml(yaml_file, yaml_content) + + +def test_validate_area_config_with_string() -> None: + """Test that string area config is converted to structured format.""" + result: dict[str, Any] = validate_area_config("Living Room") + + assert isinstance(result, dict) + assert "id" in result + assert "name" in result + assert result["name"] == "Living Room" + # ID should be based on slugified name + assert result["id"].id == "living_room" + + +def test_validate_area_config_with_dict() -> None: + """Test that structured area config passes through unchanged.""" + area_id = cv.declare_id(Area)("test_area") + input_config: dict[str, Any] = { + "id": area_id, + "name": "Test Area", + } + + result: dict[str, Any] = validate_area_config(input_config) + + assert result == input_config + assert result["id"] == area_id + assert result["name"] == "Test Area" + + +def test_device_with_valid_area_id(yaml_file: Callable[[str], str]) -> None: + """Test that device with valid area_id works correctly.""" + result = load_config_from_fixture(yaml_file, "valid_area_device.yaml") + assert result is not None + + esphome_config = result["esphome"] + + # Verify areas were parsed correctly + assert CONF_AREAS in esphome_config + areas = esphome_config[CONF_AREAS] + assert len(areas) == 1 + assert areas[0]["id"].id == "bedroom_area" + assert areas[0]["name"] == "Bedroom" + + # Verify devices were parsed correctly + assert CONF_DEVICES in esphome_config + devices = esphome_config[CONF_DEVICES] + assert len(devices) == 1 + assert devices[0]["id"].id == "test_device" + assert devices[0]["name"] == "Test Device" + assert devices[0]["area_id"].id == "bedroom_area" + + +def test_multiple_areas_and_devices(yaml_file: Callable[[str], str]) -> None: + """Test multiple areas and devices configuration.""" + result = load_config_from_fixture(yaml_file, "multiple_areas_devices.yaml") + assert result is not None + + esphome_config = result["esphome"] + + # Verify main area + assert CONF_AREA in esphome_config + main_area = esphome_config[CONF_AREA] + assert main_area["id"].id == "main_area" + assert main_area["name"] == "Main Area" + + # Verify additional areas + assert CONF_AREAS in esphome_config + areas = esphome_config[CONF_AREAS] + assert len(areas) == 2 + area_ids = {area["id"].id for area in areas} + assert area_ids == {"area1", "area2"} + + # Verify devices + assert CONF_DEVICES in esphome_config + devices = esphome_config[CONF_DEVICES] + assert len(devices) == 3 + + # Check device-area associations + device_area_map = {dev["id"].id: dev["area_id"].id for dev in devices} + assert device_area_map == { + "device1": "main_area", + "device2": "area1", + "device3": "area2", + } + + +def test_legacy_string_area( + yaml_file: Callable[[str], str], caplog: pytest.LogCaptureFixture +) -> None: + """Test legacy string area configuration with deprecation warning.""" + result = load_config_from_fixture(yaml_file, "legacy_string_area.yaml") + assert result is not None + + esphome_config = result["esphome"] + + # Verify the string was converted to structured format + assert CONF_AREA in esphome_config + area = esphome_config[CONF_AREA] + assert isinstance(area, dict) + assert area["name"] == "Living Room" + assert area["id"].id == "living_room" + + # Check for deprecation warning + assert "Using 'area' as a string is deprecated" in caplog.text + + +def test_area_id_collision( + yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] +) -> None: + """Test that duplicate area IDs are detected.""" + result = load_config_from_fixture(yaml_file, "area_id_collision.yaml") + assert result is None + + # Check for the specific error message in stdout + captured = capsys.readouterr() + assert "ID duplicate_id redefined! Check esphome->area->id." in captured.out + + +def test_device_without_area(yaml_file: Callable[[str], str]) -> None: + """Test that devices without area_id work correctly.""" + result = load_config_from_fixture(yaml_file, "device_without_area.yaml") + assert result is not None + + esphome_config = result["esphome"] + + # Verify device was parsed + assert CONF_DEVICES in esphome_config + devices = esphome_config[CONF_DEVICES] + assert len(devices) == 1 + + device = devices[0] + assert device["id"].id == "test_device" + assert device["name"] == "Test Device" + + # Verify no area_id is present + assert "area_id" not in device From a37bac1956fee10a1491523f080976c9495d98fc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Jun 2025 19:46:48 +0200 Subject: [PATCH 086/183] add files --- .../core/config/area_id_collision.yaml | 10 +++++++++ .../core/config/device_without_area.yaml | 7 ++++++ .../core/config/legacy_string_area.yaml | 5 +++++ .../core/config/multiple_areas_devices.yaml | 22 +++++++++++++++++++ .../core/config/valid_area_device.yaml | 11 ++++++++++ 5 files changed, 55 insertions(+) create mode 100644 tests/unit_tests/fixtures/core/config/area_id_collision.yaml create mode 100644 tests/unit_tests/fixtures/core/config/device_without_area.yaml create mode 100644 tests/unit_tests/fixtures/core/config/legacy_string_area.yaml create mode 100644 tests/unit_tests/fixtures/core/config/multiple_areas_devices.yaml create mode 100644 tests/unit_tests/fixtures/core/config/valid_area_device.yaml diff --git a/tests/unit_tests/fixtures/core/config/area_id_collision.yaml b/tests/unit_tests/fixtures/core/config/area_id_collision.yaml new file mode 100644 index 0000000000..985db073da --- /dev/null +++ b/tests/unit_tests/fixtures/core/config/area_id_collision.yaml @@ -0,0 +1,10 @@ +esphome: + name: test-collision + area: + id: duplicate_id + name: Area 1 + areas: + - id: duplicate_id + name: Area 2 + +host: \ No newline at end of file diff --git a/tests/unit_tests/fixtures/core/config/device_without_area.yaml b/tests/unit_tests/fixtures/core/config/device_without_area.yaml new file mode 100644 index 0000000000..cc81953d42 --- /dev/null +++ b/tests/unit_tests/fixtures/core/config/device_without_area.yaml @@ -0,0 +1,7 @@ +esphome: + name: test-device-no-area + devices: + - id: test_device + name: Test Device + +host: \ No newline at end of file diff --git a/tests/unit_tests/fixtures/core/config/legacy_string_area.yaml b/tests/unit_tests/fixtures/core/config/legacy_string_area.yaml new file mode 100644 index 0000000000..136c2aafac --- /dev/null +++ b/tests/unit_tests/fixtures/core/config/legacy_string_area.yaml @@ -0,0 +1,5 @@ +esphome: + name: test-legacy-area + area: Living Room + +host: \ No newline at end of file diff --git a/tests/unit_tests/fixtures/core/config/multiple_areas_devices.yaml b/tests/unit_tests/fixtures/core/config/multiple_areas_devices.yaml new file mode 100644 index 0000000000..0ffee3177c --- /dev/null +++ b/tests/unit_tests/fixtures/core/config/multiple_areas_devices.yaml @@ -0,0 +1,22 @@ +esphome: + name: test-multiple + area: + id: main_area + name: Main Area + areas: + - id: area1 + name: Area 1 + - id: area2 + name: Area 2 + devices: + - id: device1 + name: Device 1 + area_id: main_area + - id: device2 + name: Device 2 + area_id: area1 + - id: device3 + name: Device 3 + area_id: area2 + +host: \ No newline at end of file diff --git a/tests/unit_tests/fixtures/core/config/valid_area_device.yaml b/tests/unit_tests/fixtures/core/config/valid_area_device.yaml new file mode 100644 index 0000000000..54e1262819 --- /dev/null +++ b/tests/unit_tests/fixtures/core/config/valid_area_device.yaml @@ -0,0 +1,11 @@ +esphome: + name: test-valid-area + areas: + - id: bedroom_area + name: Bedroom + devices: + - id: test_device + name: Test Device + area_id: bedroom_area + +host: \ No newline at end of file From 85e3b63f059c1e5728304cb937bf5789a268e804 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Jun 2025 19:49:12 +0200 Subject: [PATCH 087/183] adjust --- tests/unit_tests/fixtures/core/config/area_id_collision.yaml | 2 +- tests/unit_tests/fixtures/core/config/device_without_area.yaml | 2 +- tests/unit_tests/fixtures/core/config/legacy_string_area.yaml | 2 +- .../unit_tests/fixtures/core/config/multiple_areas_devices.yaml | 2 +- tests/unit_tests/fixtures/core/config/valid_area_device.yaml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/unit_tests/fixtures/core/config/area_id_collision.yaml b/tests/unit_tests/fixtures/core/config/area_id_collision.yaml index 985db073da..fb2e930e61 100644 --- a/tests/unit_tests/fixtures/core/config/area_id_collision.yaml +++ b/tests/unit_tests/fixtures/core/config/area_id_collision.yaml @@ -7,4 +7,4 @@ esphome: - id: duplicate_id name: Area 2 -host: \ No newline at end of file +host: diff --git a/tests/unit_tests/fixtures/core/config/device_without_area.yaml b/tests/unit_tests/fixtures/core/config/device_without_area.yaml index cc81953d42..8464cf37df 100644 --- a/tests/unit_tests/fixtures/core/config/device_without_area.yaml +++ b/tests/unit_tests/fixtures/core/config/device_without_area.yaml @@ -4,4 +4,4 @@ esphome: - id: test_device name: Test Device -host: \ No newline at end of file +host: diff --git a/tests/unit_tests/fixtures/core/config/legacy_string_area.yaml b/tests/unit_tests/fixtures/core/config/legacy_string_area.yaml index 136c2aafac..fe2dc3db17 100644 --- a/tests/unit_tests/fixtures/core/config/legacy_string_area.yaml +++ b/tests/unit_tests/fixtures/core/config/legacy_string_area.yaml @@ -2,4 +2,4 @@ esphome: name: test-legacy-area area: Living Room -host: \ No newline at end of file +host: diff --git a/tests/unit_tests/fixtures/core/config/multiple_areas_devices.yaml b/tests/unit_tests/fixtures/core/config/multiple_areas_devices.yaml index 0ffee3177c..ef3b4f6e67 100644 --- a/tests/unit_tests/fixtures/core/config/multiple_areas_devices.yaml +++ b/tests/unit_tests/fixtures/core/config/multiple_areas_devices.yaml @@ -19,4 +19,4 @@ esphome: name: Device 3 area_id: area2 -host: \ No newline at end of file +host: diff --git a/tests/unit_tests/fixtures/core/config/valid_area_device.yaml b/tests/unit_tests/fixtures/core/config/valid_area_device.yaml index 54e1262819..fc97894586 100644 --- a/tests/unit_tests/fixtures/core/config/valid_area_device.yaml +++ b/tests/unit_tests/fixtures/core/config/valid_area_device.yaml @@ -8,4 +8,4 @@ esphome: name: Test Device area_id: bedroom_area -host: \ No newline at end of file +host: From 25ed7c890b821bc84431024cd4b62083c68d34d4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Jun 2025 20:03:02 +0200 Subject: [PATCH 088/183] cleanups --- tests/unit_tests/core/test_config.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 35245b82d3..d31a66bdf6 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -3,10 +3,11 @@ from collections.abc import Callable from pathlib import Path from typing import Any +from unittest.mock import patch import pytest -from esphome import config, config_validation as cv +from esphome import config, config_validation as cv, yaml_util from esphome.config import Config from esphome.const import CONF_AREA, CONF_AREAS, CONF_DEVICES from esphome.core import CORE @@ -38,8 +39,15 @@ def load_config_from_yaml( yaml_file: Callable[[str], str], yaml_content: str ) -> Config | None: """Load configuration from YAML content.""" - CORE.config_path = yaml_file(yaml_content) - return config.read_config({}) + yaml_path = yaml_file(yaml_content) + parsed_yaml = yaml_util.load_yaml(yaml_path) + + # Mock yaml_util.load_yaml to return our parsed content + with ( + patch.object(yaml_util, "load_yaml", return_value=parsed_yaml), + patch.object(CORE, "config_path", yaml_path), + ): + return config.read_config({}) def load_config_from_fixture( From a90d59b6ba3a894385c4e5a3d6e4e9f0c630f217 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Jun 2025 20:59:07 +0200 Subject: [PATCH 089/183] validate sooner --- esphome/core/config.py | 77 ++++++++++++++++++---------- tests/unit_tests/core/test_config.py | 50 +++++++++++++++++- 2 files changed, 99 insertions(+), 28 deletions(-) diff --git a/esphome/core/config.py b/esphome/core/config.py index 45ba214e44..4d28a81229 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -108,6 +108,50 @@ def validate_hostname(config): return config +def validate_id_hash_collisions(config: dict) -> dict: + """Validate that there are no hash collisions between IDs of the same type.""" + from esphome.helpers import fnv1a_32bit_hash + + # Check area hash collisions + area_hashes: dict[int, str] = {} + + # Check main area if present + if CONF_AREA in config: + area_id: core.ID = config[CONF_AREA][CONF_ID] + if area_id.id: + area_hash = fnv1a_32bit_hash(area_id.id) + area_hashes[area_hash] = area_id.id + + # Check areas list + for area in config.get(CONF_AREAS, []): + area_id: core.ID = area[CONF_ID] + if area_id.id: + area_hash = fnv1a_32bit_hash(area_id.id) + if area_hash in area_hashes: + raise cv.Invalid( + f"Area ID '{area_id.id}' with hash {area_hash} collides with " + f"existing area ID '{area_hashes[area_hash]}'", + path=[CONF_AREAS, area_id.id], + ) + area_hashes[area_hash] = area_id.id + + # Check device hash collisions + device_hashes: dict[int, str] = {} + for device in config.get(CONF_DEVICES, []): + device_id: core.ID = device[CONF_ID] + if device_id.id: + device_hash = fnv1a_32bit_hash(device_id.id) + if device_hash in device_hashes: + raise cv.Invalid( + f"Device ID '{device_id.id}' with hash {device_hash} collides with " + f"existing device ID '{device_hashes[device_hash]}'", + path=[CONF_DEVICES, device_id.id], + ) + device_hashes[device_hash] = device_id.id + + return config + + def valid_include(value): # Look for "<...>" includes if value.startswith("<") and value.endswith(">"): @@ -232,6 +276,7 @@ CONFIG_SCHEMA = cv.All( } ), validate_hostname, + validate_id_hash_collisions, ) PRELOAD_CONFIG_SCHEMA = cv.Schema( @@ -397,17 +442,6 @@ async def _add_platform_reserves() -> None: cg.add(cg.RawStatement(f"App.reserve_{platform_name}({count});"), prepend=True) -def _verify_no_collisions( - hashes: dict[int, str], id: str, id_hash: int, conf_key: str -) -> None: - """Verify that the given id and name do not collide with existing ones.""" - if id_hash in hashes: - raise vol.Invalid( - f"ID '{id}' with hash {id_hash} collides with existing ID '{hashes[id_hash]}'", - path=[conf_key], - ) - - @coroutine_with_priority(100.0) async def to_code(config: ConfigType) -> None: cg.add_global(cg.global_ns.namespace("esphome").using) @@ -493,9 +527,7 @@ async def to_code(config: ConfigType) -> None: # Helper function to process an area configuration def process_area( area_conf: dict[str, str | core.ID], - area_hashes: dict[int, str], area_ids: set[str], - conf_path: str | None = None, ) -> None: """Process and register an area configuration.""" area_id: core.ID = area_conf[CONF_ID] @@ -503,10 +535,6 @@ async def to_code(config: ConfigType) -> None: area_id_hash = fnv1a_32bit_hash(area_id_str) area_name: str = area_conf[CONF_NAME] - if conf_path: # Only verify collisions for areas from CONF_AREAS list - _verify_no_collisions(area_hashes, area_id, area_id_hash, conf_path) - - area_hashes[area_id_hash] = area_name area_ids.add(area_id_str) area_var = cg.new_Pvariable(area_id) @@ -515,27 +543,25 @@ async def to_code(config: ConfigType) -> None: cg.add(cg.App.register_area(area_var)) # Initialize tracking structures - area_hashes: dict[int, str] = {} area_ids: set[str] = set() - device_hashes: dict[int, str] = {} # Collect all areas to process - all_areas: list[tuple[dict[str, str | core.ID], str | None]] = [] + all_areas: list[dict[str, str | core.ID]] = [] # Add top-level area if present if area_conf := config.get(CONF_AREA): - all_areas.append((area_conf, None)) + all_areas.append(area_conf) # Add areas from CONF_AREAS list - all_areas.extend((area, CONF_AREAS) for area in config[CONF_AREAS]) + all_areas.extend(config[CONF_AREAS]) # Reserve space for areas and process them if all_areas: cg.add(cg.RawStatement(f"App.reserve_area({len(all_areas)});")) cg.add_define("USE_AREAS") - for area_conf, conf_path in all_areas: - process_area(area_conf, area_hashes, area_ids, conf_path) + for area_conf in all_areas: + process_area(area_conf, area_ids) # Process devices devices: list[dict[str, str | core.ID]] = config[CONF_DEVICES] @@ -552,9 +578,6 @@ async def to_code(config: ConfigType) -> None: device_id_hash = fnv1a_32bit_hash(device_id.id) device_name: str = dev_conf[CONF_NAME] - _verify_no_collisions(device_hashes, device_id, device_id_hash, CONF_DEVICES) - device_hashes[device_id_hash] = device_name - dev = cg.new_Pvariable(device_id) cg.add(dev.set_device_id(device_id_hash)) cg.add(dev.set_name(device_name)) diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index d31a66bdf6..11a80e4cc5 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -172,7 +172,11 @@ def test_area_id_collision( # Check for the specific error message in stdout captured = capsys.readouterr() - assert "ID duplicate_id redefined! Check esphome->area->id." in captured.out + # Since duplicate IDs have the same hash, our hash collision detection catches this + assert ( + "Area ID 'duplicate_id' with hash 1805131238 collides with existing area ID 'duplicate_id'" + in captured.out + ) def test_device_without_area(yaml_file: Callable[[str], str]) -> None: @@ -193,3 +197,47 @@ def test_device_without_area(yaml_file: Callable[[str], str]) -> None: # Verify no area_id is present assert "area_id" not in device + + +def test_device_with_invalid_area_id( + yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] +) -> None: + """Test that device with non-existent area_id fails validation.""" + result = load_config_from_fixture(yaml_file, "device_invalid_area.yaml") + assert result is None + + # Check for the specific error message in stdout + captured = capsys.readouterr() + assert "Couldn't find ID 'nonexistent_area'" in captured.out + + +def test_device_id_hash_collision( + yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] +) -> None: + """Test that device IDs with hash collisions are detected.""" + result = load_config_from_fixture(yaml_file, "device_id_collision.yaml") + assert result is None + + # Check for the specific error message about hash collision + captured = capsys.readouterr() + # The error message shows the ID that collides and includes the hash value + assert ( + "Device ID 'd6ka' with hash 3082558663 collides with existing device ID 'test_2258'" + in captured.out + ) + + +def test_area_id_hash_collision( + yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] +) -> None: + """Test that area IDs with hash collisions are detected.""" + result = load_config_from_fixture(yaml_file, "area_id_hash_collision.yaml") + assert result is None + + # Check for the specific error message about hash collision + captured = capsys.readouterr() + # The error message shows the ID that collides and includes the hash value + assert ( + "Area ID 'd6ka' with hash 3082558663 collides with existing area ID 'test_2258'" + in captured.out + ) From 7be12f5ff6d43d075c797c315f8399a976aadaa5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Jun 2025 20:59:54 +0200 Subject: [PATCH 090/183] validate sooner --- .../fixtures/core/config/area_id_hash_collision.yaml | 10 ++++++++++ .../fixtures/core/config/device_id_collision.yaml | 10 ++++++++++ .../fixtures/core/config/device_invalid_area.yaml | 12 ++++++++++++ 3 files changed, 32 insertions(+) create mode 100644 tests/unit_tests/fixtures/core/config/area_id_hash_collision.yaml create mode 100644 tests/unit_tests/fixtures/core/config/device_id_collision.yaml create mode 100644 tests/unit_tests/fixtures/core/config/device_invalid_area.yaml diff --git a/tests/unit_tests/fixtures/core/config/area_id_hash_collision.yaml b/tests/unit_tests/fixtures/core/config/area_id_hash_collision.yaml new file mode 100644 index 0000000000..0fb932494d --- /dev/null +++ b/tests/unit_tests/fixtures/core/config/area_id_hash_collision.yaml @@ -0,0 +1,10 @@ +esphome: + name: test + areas: + - id: test_2258 + name: "Area 1" + - id: d6ka + name: "Area 2" + +esp32: + board: esp32dev \ No newline at end of file diff --git a/tests/unit_tests/fixtures/core/config/device_id_collision.yaml b/tests/unit_tests/fixtures/core/config/device_id_collision.yaml new file mode 100644 index 0000000000..a34454fc26 --- /dev/null +++ b/tests/unit_tests/fixtures/core/config/device_id_collision.yaml @@ -0,0 +1,10 @@ +esphome: + name: test + devices: + - id: test_2258 + name: "Device 1" + - id: d6ka + name: "Device 2" + +esp32: + board: esp32dev \ No newline at end of file diff --git a/tests/unit_tests/fixtures/core/config/device_invalid_area.yaml b/tests/unit_tests/fixtures/core/config/device_invalid_area.yaml new file mode 100644 index 0000000000..e27976cbbc --- /dev/null +++ b/tests/unit_tests/fixtures/core/config/device_invalid_area.yaml @@ -0,0 +1,12 @@ +esphome: + name: test + areas: + - id: valid_area + name: "Valid Area" + devices: + - id: test_device + name: "Test Device" + area_id: nonexistent_area + +esp32: + board: esp32dev \ No newline at end of file From 02019dd16c60fd0cbb0ec3eb80b85bbda42fc4e9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Jun 2025 21:04:42 +0200 Subject: [PATCH 091/183] validate sooner --- esphome/core/config.py | 55 +++++++++++++++++----------- tests/unit_tests/core/test_config.py | 19 +++++++--- 2 files changed, 48 insertions(+), 26 deletions(-) diff --git a/esphome/core/config.py b/esphome/core/config.py index 4d28a81229..7358276754 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -109,13 +109,10 @@ def validate_hostname(config): def validate_id_hash_collisions(config: dict) -> dict: - """Validate that there are no hash collisions between IDs of the same type.""" - from esphome.helpers import fnv1a_32bit_hash - - # Check area hash collisions + """Validate that there are no hash collisions between IDs.""" area_hashes: dict[int, str] = {} - # Check main area if present + # Check main area if CONF_AREA in config: area_id: core.ID = config[CONF_AREA][CONF_ID] if area_id.id: @@ -125,29 +122,45 @@ def validate_id_hash_collisions(config: dict) -> dict: # Check areas list for area in config.get(CONF_AREAS, []): area_id: core.ID = area[CONF_ID] - if area_id.id: - area_hash = fnv1a_32bit_hash(area_id.id) - if area_hash in area_hashes: - raise cv.Invalid( - f"Area ID '{area_id.id}' with hash {area_hash} collides with " - f"existing area ID '{area_hashes[area_hash]}'", - path=[CONF_AREAS, area_id.id], - ) + if not area_id.id: + continue + + area_hash = fnv1a_32bit_hash(area_id.id) + if area_hash not in area_hashes: area_hashes[area_hash] = area_id.id + continue + + # Skip exact duplicates (handled by IDPassValidationStep) + if area_id.id == area_hashes[area_hash]: + continue + + raise cv.Invalid( + f"Area ID '{area_id.id}' with hash {area_hash} collides with " + f"existing area ID '{area_hashes[area_hash]}'", + path=[CONF_AREAS, area_id.id], + ) # Check device hash collisions device_hashes: dict[int, str] = {} for device in config.get(CONF_DEVICES, []): device_id: core.ID = device[CONF_ID] - if device_id.id: - device_hash = fnv1a_32bit_hash(device_id.id) - if device_hash in device_hashes: - raise cv.Invalid( - f"Device ID '{device_id.id}' with hash {device_hash} collides with " - f"existing device ID '{device_hashes[device_hash]}'", - path=[CONF_DEVICES, device_id.id], - ) + if not device_id.id: + continue + + device_hash = fnv1a_32bit_hash(device_id.id) + if device_hash not in device_hashes: device_hashes[device_hash] = device_id.id + continue + + # Skip exact duplicates (handled by IDPassValidationStep) + if device_id.id == device_hashes[device_hash]: + continue + + raise cv.Invalid( + f"Device ID '{device_id.id}' with hash {device_hash} collides " + f"with existing device ID '{device_hashes[device_hash]}'", + path=[CONF_DEVICES, device_id.id], + ) return config diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 11a80e4cc5..ed442b93fa 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -172,11 +172,8 @@ def test_area_id_collision( # Check for the specific error message in stdout captured = capsys.readouterr() - # Since duplicate IDs have the same hash, our hash collision detection catches this - assert ( - "Area ID 'duplicate_id' with hash 1805131238 collides with existing area ID 'duplicate_id'" - in captured.out - ) + # Exact duplicates are now caught by IDPassValidationStep + assert "ID duplicate_id redefined! Check esphome->area->id." in captured.out def test_device_without_area(yaml_file: Callable[[str], str]) -> None: @@ -241,3 +238,15 @@ def test_area_id_hash_collision( "Area ID 'd6ka' with hash 3082558663 collides with existing area ID 'test_2258'" in captured.out ) + + +def test_device_duplicate_id( + yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] +) -> None: + """Test that duplicate device IDs are detected by IDPassValidationStep.""" + result = load_config_from_fixture(yaml_file, "device_duplicate_id.yaml") + assert result is None + + # Check for the specific error message from IDPassValidationStep + captured = capsys.readouterr() + assert "ID duplicate_device redefined!" in captured.out From b01eb28d4248b435f6736feafc29750d4f1f78a4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Jun 2025 21:05:15 +0200 Subject: [PATCH 092/183] validate sooner --- .../fixtures/core/config/device_duplicate_id.yaml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 tests/unit_tests/fixtures/core/config/device_duplicate_id.yaml diff --git a/tests/unit_tests/fixtures/core/config/device_duplicate_id.yaml b/tests/unit_tests/fixtures/core/config/device_duplicate_id.yaml new file mode 100644 index 0000000000..345d05502f --- /dev/null +++ b/tests/unit_tests/fixtures/core/config/device_duplicate_id.yaml @@ -0,0 +1,10 @@ +esphome: + name: test + devices: + - id: duplicate_device + name: "Device 1" + - id: duplicate_device + name: "Device 2" + +esp32: + board: esp32dev \ No newline at end of file From d3b18debf9dc237622890e5c07779d850b8854e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Jun 2025 21:06:33 +0200 Subject: [PATCH 093/183] validate sooner --- esphome/core/config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/core/config.py b/esphome/core/config.py index 7358276754..d08441d3fd 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -120,7 +120,7 @@ def validate_id_hash_collisions(config: dict) -> dict: area_hashes[area_hash] = area_id.id # Check areas list - for area in config.get(CONF_AREAS, []): + for area in config[CONF_AREAS]: area_id: core.ID = area[CONF_ID] if not area_id.id: continue @@ -142,7 +142,7 @@ def validate_id_hash_collisions(config: dict) -> dict: # Check device hash collisions device_hashes: dict[int, str] = {} - for device in config.get(CONF_DEVICES, []): + for device in config[CONF_DEVICES]: device_id: core.ID = device[CONF_ID] if not device_id.id: continue From 2b9b7e285379096911829fef9ea4a6981fdb0c33 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Jun 2025 21:18:04 +0200 Subject: [PATCH 094/183] validation should happen sooner --- esphome/core/config.py | 142 +++++++++++---------------- tests/unit_tests/core/test_config.py | 5 +- 2 files changed, 62 insertions(+), 85 deletions(-) diff --git a/esphome/core/config.py b/esphome/core/config.py index d08441d3fd..fb658de6b9 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -4,8 +4,6 @@ import logging import os from pathlib import Path -import voluptuous as vol - from esphome import automation, core import esphome.codegen as cg import esphome.config_validation as cv @@ -108,60 +106,61 @@ def validate_hostname(config): return config -def validate_id_hash_collisions(config: dict) -> dict: - """Validate that there are no hash collisions between IDs.""" - area_hashes: dict[int, str] = {} +def validate_ids_and_references(config: ConfigType) -> ConfigType: + """Validate that there are no hash collisions between IDs and that area_id references are valid.""" - # Check main area + # Helper to check hash collisions + def check_hash_collision( + id_obj: core.ID, + hash_dict: dict[int, str], + item_type: str, + path: list[str | int], + ) -> bool: + if not id_obj.id: + return False + + hash_val: int = fnv1a_32bit_hash(id_obj.id) + if hash_val in hash_dict and hash_dict[hash_val] != id_obj.id: + raise cv.Invalid( + f"{item_type} ID '{id_obj.id}' with hash {hash_val} collides with " + f"existing {item_type.lower()} ID '{hash_dict[hash_val]}'", + path=path, + ) + hash_dict[hash_val] = id_obj.id + return True + + # Collect all areas + all_areas: list[dict[str, str | core.ID]] = [] if CONF_AREA in config: - area_id: core.ID = config[CONF_AREA][CONF_ID] - if area_id.id: - area_hash = fnv1a_32bit_hash(area_id.id) - area_hashes[area_hash] = area_id.id + all_areas.append(config[CONF_AREA]) + all_areas.extend(config[CONF_AREAS]) - # Check areas list - for area in config[CONF_AREAS]: + # Validate area hash collisions and collect IDs + area_hashes: dict[int, str] = {} + area_ids: set[str] = set() + for area in all_areas: area_id: core.ID = area[CONF_ID] - if not area_id.id: - continue + if check_hash_collision(area_id, area_hashes, "Area", [CONF_AREAS, area_id.id]): + area_ids.add(area_id.id) - area_hash = fnv1a_32bit_hash(area_id.id) - if area_hash not in area_hashes: - area_hashes[area_hash] = area_id.id - continue - - # Skip exact duplicates (handled by IDPassValidationStep) - if area_id.id == area_hashes[area_hash]: - continue - - raise cv.Invalid( - f"Area ID '{area_id.id}' with hash {area_hash} collides with " - f"existing area ID '{area_hashes[area_hash]}'", - path=[CONF_AREAS, area_id.id], - ) - - # Check device hash collisions + # Validate device hash collisions and area references device_hashes: dict[int, str] = {} - for device in config[CONF_DEVICES]: + for i, device in enumerate(config[CONF_DEVICES]): device_id: core.ID = device[CONF_ID] - if not device_id.id: - continue - - device_hash = fnv1a_32bit_hash(device_id.id) - if device_hash not in device_hashes: - device_hashes[device_hash] = device_id.id - continue - - # Skip exact duplicates (handled by IDPassValidationStep) - if device_id.id == device_hashes[device_hash]: - continue - - raise cv.Invalid( - f"Device ID '{device_id.id}' with hash {device_hash} collides " - f"with existing device ID '{device_hashes[device_hash]}'", - path=[CONF_DEVICES, device_id.id], + check_hash_collision( + device_id, device_hashes, "Device", [CONF_DEVICES, device_id.id] ) + # Validate area_id reference if present + if CONF_AREA_ID in device: + area_ref_id: core.ID = device[CONF_AREA_ID] + if area_ref_id.id not in area_ids: + raise cv.Invalid( + f"Device '{device[CONF_NAME]}' has an area_id '{area_ref_id.id}'" + " that does not exist.", + path=[CONF_DEVICES, i, CONF_AREA_ID], + ) + return config @@ -289,7 +288,7 @@ CONFIG_SCHEMA = cv.All( } ), validate_hostname, - validate_id_hash_collisions, + validate_ids_and_references, ) PRELOAD_CONFIG_SCHEMA = cv.Schema( @@ -537,44 +536,25 @@ async def to_code(config: ConfigType) -> None: if config[CONF_PLATFORMIO_OPTIONS]: CORE.add_job(_add_platformio_options, config[CONF_PLATFORMIO_OPTIONS]) - # Helper function to process an area configuration - def process_area( - area_conf: dict[str, str | core.ID], - area_ids: set[str], - ) -> None: - """Process and register an area configuration.""" - area_id: core.ID = area_conf[CONF_ID] - area_id_str: str = area_id.id - area_id_hash = fnv1a_32bit_hash(area_id_str) - area_name: str = area_conf[CONF_NAME] - - area_ids.add(area_id_str) - - area_var = cg.new_Pvariable(area_id) - cg.add(area_var.set_area_id(area_id_hash)) - cg.add(area_var.set_name(area_name)) - cg.add(cg.App.register_area(area_var)) - - # Initialize tracking structures - area_ids: set[str] = set() - - # Collect all areas to process + # Process areas all_areas: list[dict[str, str | core.ID]] = [] - - # Add top-level area if present - if area_conf := config.get(CONF_AREA): - all_areas.append(area_conf) - - # Add areas from CONF_AREAS list + if CONF_AREA in config: + all_areas.append(config[CONF_AREA]) all_areas.extend(config[CONF_AREAS]) - # Reserve space for areas and process them if all_areas: cg.add(cg.RawStatement(f"App.reserve_area({len(all_areas)});")) cg.add_define("USE_AREAS") for area_conf in all_areas: - process_area(area_conf, area_ids) + area_id: core.ID = area_conf[CONF_ID] + area_id_hash: int = fnv1a_32bit_hash(area_id.id) + area_name: str = area_conf[CONF_NAME] + + area_var = cg.new_Pvariable(area_id) + cg.add(area_var.set_area_id(area_id_hash)) + cg.add(area_var.set_name(area_name)) + cg.add(cg.App.register_area(area_var)) # Process devices devices: list[dict[str, str | core.ID]] = config[CONF_DEVICES] @@ -598,12 +578,6 @@ async def to_code(config: ConfigType) -> None: # Set area if specified if CONF_AREA_ID in dev_conf: area_id: core.ID = dev_conf[CONF_AREA_ID] - if area_id.id not in area_ids: - raise vol.Invalid( - f"Device '{device_name}' has an area_id '{area_id.id}'" - " that does not exist.", - path=[CONF_DEVICES, dev_conf[CONF_ID], CONF_AREA_ID], - ) area_id_hash = fnv1a_32bit_hash(area_id.id) cg.add(dev.set_area_id(area_id_hash)) diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index ed442b93fa..6a28925dd3 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -205,7 +205,10 @@ def test_device_with_invalid_area_id( # Check for the specific error message in stdout captured = capsys.readouterr() - assert "Couldn't find ID 'nonexistent_area'" in captured.out + assert ( + "Device 'Test Device' has an area_id 'nonexistent_area' that does not exist." + in captured.out + ) def test_device_id_hash_collision( From c1853f8b84098e4de7fb35193da66451d68d4e7c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Jun 2025 21:21:29 +0200 Subject: [PATCH 095/183] document design decisions --- esphome/core/config.py | 8 +++++++- esphome/helpers.py | 14 +++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/esphome/core/config.py b/esphome/core/config.py index fb658de6b9..23a18e4c2e 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -107,7 +107,13 @@ def validate_hostname(config): def validate_ids_and_references(config: ConfigType) -> ConfigType: - """Validate that there are no hash collisions between IDs and that area_id references are valid.""" + """Validate that there are no hash collisions between IDs and that area_id references are valid. + + This validation is critical because we use 32-bit hashes for performance on microcontrollers. + By detecting collisions at compile time, we prevent any runtime issues while maintaining + optimal performance on 32-bit platforms. In practice, with typical deployments having only + a handful of areas and devices, hash collisions are virtually impossible. + """ # Helper to check hash collisions def check_hash_collision( diff --git a/esphome/helpers.py b/esphome/helpers.py index c84d597999..bf0e3b5cf7 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -30,7 +30,19 @@ def ensure_unique_string(preferred_string, current_strings): def fnv1a_32bit_hash(string: str) -> int: - """FNV-1a 32-bit hash function.""" + """FNV-1a 32-bit hash function. + + Note: This uses 32-bit hash instead of 64-bit for several reasons: + 1. ESPHome targets 32-bit microcontrollers with limited RAM (often <320KB) + 2. Using 64-bit hashes would double the RAM usage for storing IDs + 3. 64-bit operations are slower on 32-bit processors + + While there's a ~50% collision probability at ~77,000 unique IDs, + ESPHome validates for collisions at compile time, preventing any + runtime issues. In practice, most ESPHome installations only have + a handful of area_ids and device_ids (typically <10 areas and <100 + devices), making collisions virtually impossible. + """ hash_value = 2166136261 for char in string: hash_value ^= ord(char) From 8831999ea6a5da7aa6ecb64778f21fcc5add903c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Jun 2025 21:23:41 +0200 Subject: [PATCH 096/183] lint --- .../fixtures/core/config/area_id_hash_collision.yaml | 9 --------- .../fixtures/core/config/device_duplicate_id.yaml | 9 --------- .../fixtures/core/config/device_invalid_area.yaml | 11 ----------- 3 files changed, 29 deletions(-) diff --git a/tests/unit_tests/fixtures/core/config/area_id_hash_collision.yaml b/tests/unit_tests/fixtures/core/config/area_id_hash_collision.yaml index 0fb932494d..8b13789179 100644 --- a/tests/unit_tests/fixtures/core/config/area_id_hash_collision.yaml +++ b/tests/unit_tests/fixtures/core/config/area_id_hash_collision.yaml @@ -1,10 +1 @@ -esphome: - name: test - areas: - - id: test_2258 - name: "Area 1" - - id: d6ka - name: "Area 2" -esp32: - board: esp32dev \ No newline at end of file diff --git a/tests/unit_tests/fixtures/core/config/device_duplicate_id.yaml b/tests/unit_tests/fixtures/core/config/device_duplicate_id.yaml index 345d05502f..8b13789179 100644 --- a/tests/unit_tests/fixtures/core/config/device_duplicate_id.yaml +++ b/tests/unit_tests/fixtures/core/config/device_duplicate_id.yaml @@ -1,10 +1 @@ -esphome: - name: test - devices: - - id: duplicate_device - name: "Device 1" - - id: duplicate_device - name: "Device 2" -esp32: - board: esp32dev \ No newline at end of file diff --git a/tests/unit_tests/fixtures/core/config/device_invalid_area.yaml b/tests/unit_tests/fixtures/core/config/device_invalid_area.yaml index e27976cbbc..8b13789179 100644 --- a/tests/unit_tests/fixtures/core/config/device_invalid_area.yaml +++ b/tests/unit_tests/fixtures/core/config/device_invalid_area.yaml @@ -1,12 +1 @@ -esphome: - name: test - areas: - - id: valid_area - name: "Valid Area" - devices: - - id: test_device - name: "Test Device" - area_id: nonexistent_area -esp32: - board: esp32dev \ No newline at end of file From 68b13340fb5b866de26c2b72fd459bbebaba4fc6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Jun 2025 21:24:17 +0200 Subject: [PATCH 097/183] lint --- esphome/config_validation.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index a3627efe7b..0665ffe39c 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -1,5 +1,7 @@ """Helpers for config validation using voluptuous.""" +from __future__ import annotations + from contextlib import contextmanager from dataclasses import dataclass from datetime import datetime @@ -348,7 +350,7 @@ def icon(value): ) -def sub_device_id(value): +def sub_device_id(value) -> core.ID: # Lazy import to avoid circular imports from esphome.core.config import Device @@ -1931,7 +1933,7 @@ class Version: return f"{self.major}.{self.minor}.{self.patch}" @classmethod - def parse(cls, value: str) -> "Version": + def parse(cls, value: str) -> Version: match = re.match(r"^(\d+).(\d+).(\d+)-?\w*$", value) if match is None: raise ValueError(f"Not a valid version number {value}") From c34ba3deb593be97d58ed04b78ddb5134f90804d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Jun 2025 21:25:55 +0200 Subject: [PATCH 098/183] lint --- esphome/config_validation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 0665ffe39c..ec17ec986d 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -350,7 +350,7 @@ def icon(value): ) -def sub_device_id(value) -> core.ID: +def sub_device_id(value: str | None) -> core.ID: # Lazy import to avoid circular imports from esphome.core.config import Device From b725bb3dd199eadded51bf00377c1daf21bcd6db Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Jun 2025 21:28:16 +0200 Subject: [PATCH 099/183] lint --- esphome/cpp_helpers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index cef7b31020..8d5440f591 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -112,8 +112,8 @@ async def setup_entity(var, config): if CONF_ENTITY_CATEGORY in config: add(var.set_entity_category(config[CONF_ENTITY_CATEGORY])) if CONF_DEVICE_ID in config: - device = await get_variable(config[CONF_DEVICE_ID]) - add(var.set_device_id(fnv1a_32bit_hash(str(device)))) + device_id: ID = config[CONF_DEVICE_ID] + add(var.set_device_id(fnv1a_32bit_hash(device_id.id))) def extract_registry_entry_config( From ba87a0b63c0845942a5b466831caf0ffc1bf20c9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Jun 2025 21:32:20 +0200 Subject: [PATCH 100/183] cleanups --- esphome/config_validation.py | 3 +-- esphome/dashboard/util/text.py | 2 +- .../fixtures/core/config/area_id_hash_collision.yaml | 9 +++++++++ .../fixtures/core/config/device_duplicate_id.yaml | 9 +++++++++ .../fixtures/core/config/device_id_collision.yaml | 2 +- .../fixtures/core/config/device_invalid_area.yaml | 11 +++++++++++ 6 files changed, 32 insertions(+), 4 deletions(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index ec17ec986d..27f9a5b83f 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -354,8 +354,7 @@ def sub_device_id(value: str | None) -> core.ID: # Lazy import to avoid circular imports from esphome.core.config import Device - validator = use_id(Device) - return validator(value) + return use_id(Device)(value) def boolean(value): diff --git a/esphome/dashboard/util/text.py b/esphome/dashboard/util/text.py index 5c75061637..2a3b9042e6 100644 --- a/esphome/dashboard/util/text.py +++ b/esphome/dashboard/util/text.py @@ -3,7 +3,7 @@ from __future__ import annotations from esphome.helpers import slugify -def friendly_name_slugify(value): +def friendly_name_slugify(value: str) -> str: """Convert a friendly name to a slug with dashes instead of underscores.""" # First use the standard slugify, then convert underscores to dashes return slugify(value).replace("_", "-") diff --git a/tests/unit_tests/fixtures/core/config/area_id_hash_collision.yaml b/tests/unit_tests/fixtures/core/config/area_id_hash_collision.yaml index 8b13789179..3a2e8ab8a9 100644 --- a/tests/unit_tests/fixtures/core/config/area_id_hash_collision.yaml +++ b/tests/unit_tests/fixtures/core/config/area_id_hash_collision.yaml @@ -1 +1,10 @@ +esphome: + name: test + areas: + - id: test_2258 + name: "Area 1" + - id: d6ka + name: "Area 2" +esp32: + board: esp32dev diff --git a/tests/unit_tests/fixtures/core/config/device_duplicate_id.yaml b/tests/unit_tests/fixtures/core/config/device_duplicate_id.yaml index 8b13789179..2aa3055686 100644 --- a/tests/unit_tests/fixtures/core/config/device_duplicate_id.yaml +++ b/tests/unit_tests/fixtures/core/config/device_duplicate_id.yaml @@ -1 +1,10 @@ +esphome: + name: test + devices: + - id: duplicate_device + name: "Device 1" + - id: duplicate_device + name: "Device 2" +esp32: + board: esp32dev diff --git a/tests/unit_tests/fixtures/core/config/device_id_collision.yaml b/tests/unit_tests/fixtures/core/config/device_id_collision.yaml index a34454fc26..9cf04e0595 100644 --- a/tests/unit_tests/fixtures/core/config/device_id_collision.yaml +++ b/tests/unit_tests/fixtures/core/config/device_id_collision.yaml @@ -7,4 +7,4 @@ esphome: name: "Device 2" esp32: - board: esp32dev \ No newline at end of file + board: esp32dev diff --git a/tests/unit_tests/fixtures/core/config/device_invalid_area.yaml b/tests/unit_tests/fixtures/core/config/device_invalid_area.yaml index 8b13789179..9a8ec0a1eb 100644 --- a/tests/unit_tests/fixtures/core/config/device_invalid_area.yaml +++ b/tests/unit_tests/fixtures/core/config/device_invalid_area.yaml @@ -1 +1,12 @@ +esphome: + name: test + areas: + - id: valid_area + name: "Valid Area" + devices: + - id: test_device + name: "Test Device" + area_id: nonexistent_area +esp32: + board: esp32dev From a5ea0cd41f4800d7bf48123c6c14087c08b39b84 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Jun 2025 21:55:23 +0200 Subject: [PATCH 101/183] remove unreachable code --- esphome/core/config.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/esphome/core/config.py b/esphome/core/config.py index 23a18e4c2e..bc7d31534b 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -121,10 +121,7 @@ def validate_ids_and_references(config: ConfigType) -> ConfigType: hash_dict: dict[int, str], item_type: str, path: list[str | int], - ) -> bool: - if not id_obj.id: - return False - + ) -> None: hash_val: int = fnv1a_32bit_hash(id_obj.id) if hash_val in hash_dict and hash_dict[hash_val] != id_obj.id: raise cv.Invalid( @@ -133,7 +130,6 @@ def validate_ids_and_references(config: ConfigType) -> ConfigType: path=path, ) hash_dict[hash_val] = id_obj.id - return True # Collect all areas all_areas: list[dict[str, str | core.ID]] = [] @@ -146,8 +142,8 @@ def validate_ids_and_references(config: ConfigType) -> ConfigType: area_ids: set[str] = set() for area in all_areas: area_id: core.ID = area[CONF_ID] - if check_hash_collision(area_id, area_hashes, "Area", [CONF_AREAS, area_id.id]): - area_ids.add(area_id.id) + check_hash_collision(area_id, area_hashes, "Area", [CONF_AREAS, area_id.id]) + area_ids.add(area_id.id) # Validate device hash collisions and area references device_hashes: dict[int, str] = {} From 13d53590b240c07fa471833598e4d7127d27494e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Jun 2025 22:56:31 +0200 Subject: [PATCH 102/183] Pre-reserve looping components vector to reduce memory allocations --- esphome/core/application.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 49c1e5fd61..f64070fa3d 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -257,6 +257,17 @@ void Application::teardown_components(uint32_t timeout_ms) { } void Application::calculate_looping_components_() { + // Count total components that need looping + size_t total_looping = 0; + for (auto *obj : this->components_) { + if (obj->has_overridden_loop()) { + total_looping++; + } + } + + // Pre-reserve vector to avoid reallocations + this->looping_components_.reserve(total_looping); + // First add all active components for (auto *obj : this->components_) { if (obj->has_overridden_loop() && From 06de58ff8b0a0b210e289fc978473456df567eb3 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 23 Jun 2025 09:20:53 +1200 Subject: [PATCH 103/183] Dont need to warning about simple string area A single device in a single area can have a simple string as the area --- esphome/core/config.py | 66 ++++++++++++------------------------------ 1 file changed, 18 insertions(+), 48 deletions(-) diff --git a/esphome/core/config.py b/esphome/core/config.py index bc7d31534b..00b36e7899 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -42,7 +42,6 @@ from esphome.helpers import ( copy_file_if_changed, fnv1a_32bit_hash, get_str_env, - slugify, walk_files, ) from esphome.types import ConfigType @@ -67,27 +66,6 @@ Area = cg.esphome_ns.class_("Area") VALID_INCLUDE_EXTS = {".h", ".hpp", ".tcc", ".ino", ".cpp", ".c"} -def validate_area_config(value: dict | str) -> dict[str, str | core.ID]: - """Convert legacy string area to structured format.""" - if isinstance(value, str): - # Legacy string format - convert to structured format - _LOGGER.warning( - "Using 'area' as a string is deprecated. Please use the new format:\n" - "area:\n" - " id: %s\n" - ' name: "%s"', - slugify(value), - value, - ) - # Return a structured area config with the ID generated here - return { - CONF_ID: cv.declare_id(Area)(slugify(value)), - CONF_NAME: value, - } - # Already structured format - return value - - def validate_hostname(config): max_length = 31 if config[CONF_NAME_ADD_MAC_SUFFIX]: @@ -206,21 +184,28 @@ if "ESPHOME_DEFAULT_COMPILE_PROCESS_LIMIT" in os.environ: else: _compile_process_limit_default = cv.UNDEFINED +AREA_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_ID): cv.declare_id(Area), + cv.Required(CONF_NAME): cv.string, + } +) + +DEVICE_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_ID): cv.declare_id(Device), + cv.Required(CONF_NAME): cv.string, + cv.Optional(CONF_AREA_ID): cv.use_id(Area), + } +) + CONFIG_SCHEMA = cv.All( cv.Schema( { cv.Required(CONF_NAME): cv.valid_name, cv.Optional(CONF_FRIENDLY_NAME, ""): cv.string, - cv.Optional(CONF_AREA): cv.All( - validate_area_config, - cv.Schema( - { - cv.GenerateID(CONF_ID): cv.declare_id(Area), - cv.Required(CONF_NAME): cv.string, - } - ), - ), + cv.Optional(CONF_AREA): cv.maybe_simple_value(AREA_SCHEMA, key=CONF_NAME), cv.Optional(CONF_COMMENT): cv.string, cv.Required(CONF_BUILD_PATH): cv.string, cv.Optional(CONF_PLATFORMIO_OPTIONS, default={}): cv.Schema( @@ -270,23 +255,8 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_COMPILE_PROCESS_LIMIT, default=_compile_process_limit_default ): cv.int_range(min=1, max=get_usable_cpu_count()), - cv.Optional(CONF_AREAS, default=[]): cv.ensure_list( - cv.Schema( - { - cv.GenerateID(CONF_ID): cv.declare_id(Area), - cv.Required(CONF_NAME): cv.string, - } - ), - ), - cv.Optional(CONF_DEVICES, default=[]): cv.ensure_list( - cv.Schema( - { - cv.GenerateID(CONF_ID): cv.declare_id(Device), - cv.Required(CONF_NAME): cv.string, - cv.Optional(CONF_AREA_ID): cv.use_id(Area), - } - ), - ), + cv.Optional(CONF_AREAS, default=[]): cv.ensure_list(AREA_SCHEMA), + cv.Optional(CONF_DEVICES, default=[]): cv.ensure_list(DEVICE_SCHEMA), } ), validate_hostname, From 754d2874e7903c8a49f4f74795516d559d382a5e Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 23 Jun 2025 09:21:29 +1200 Subject: [PATCH 104/183] ``this->`` --- esphome/core/area.h | 8 ++++---- esphome/core/device.h | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/esphome/core/area.h b/esphome/core/area.h index 30b82aad6d..f6d88fe703 100644 --- a/esphome/core/area.h +++ b/esphome/core/area.h @@ -6,10 +6,10 @@ namespace esphome { class Area { public: - void set_area_id(uint32_t area_id) { area_id_ = area_id; } - uint32_t get_area_id() { return area_id_; } - void set_name(const char *name) { name_ = name; } - const char *get_name() { return name_; } + void set_area_id(uint32_t area_id) { this->area_id_ = area_id; } + uint32_t get_area_id() { return this->area_id_; } + void set_name(const char *name) { this->name_ = name; } + const char *get_name() { return this->name_; } protected: uint32_t area_id_{}; diff --git a/esphome/core/device.h b/esphome/core/device.h index de25963110..3d0d1e7c23 100644 --- a/esphome/core/device.h +++ b/esphome/core/device.h @@ -4,12 +4,12 @@ namespace esphome { class Device { public: - void set_device_id(uint32_t device_id) { device_id_ = device_id; } - uint32_t get_device_id() { return device_id_; } - void set_name(const char *name) { name_ = name; } - const char *get_name() { return name_; } - void set_area_id(uint32_t area_id) { area_id_ = area_id; } - uint32_t get_area_id() { return area_id_; } + void set_device_id(uint32_t device_id) { this->device_id_ = device_id; } + uint32_t get_device_id() { return this->device_id_; } + void set_name(const char *name) { this->name_ = name; } + const char *get_name() { return this->name_; } + void set_area_id(uint32_t area_id) { this->area_id_ = area_id; } + uint32_t get_area_id() { return this->area_id_; } protected: uint32_t device_id_{}; From 5697d549a82b83adba19efdb241604dcf509584e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Jun 2025 23:44:08 +0200 Subject: [PATCH 105/183] Use scheduler for api reboot --- esphome/components/api/api_server.cpp | 43 +++++++++++++++++---------- esphome/components/api/api_server.h | 2 +- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 740e4259b1..ae732fc234 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -47,6 +47,11 @@ void APIServer::setup() { } #endif + // Schedule reboot if no clients connect within timeout + if (this->reboot_timeout_ != 0) { + this->schedule_reboot_timeout_(); + } + this->socket_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0); // monitored for incoming connections if (this->socket_ == nullptr) { ESP_LOGW(TAG, "Could not create socket"); @@ -106,8 +111,6 @@ void APIServer::setup() { } #endif - this->last_connected_ = App.get_loop_component_start_time(); - #ifdef USE_ESP32_CAMERA if (esp32_camera::global_esp32_camera != nullptr && !esp32_camera::global_esp32_camera->is_internal()) { esp32_camera::global_esp32_camera->add_image_callback( @@ -121,6 +124,16 @@ void APIServer::setup() { #endif } +void APIServer::schedule_reboot_timeout_() { + this->status_set_warning(); + this->set_timeout("api_reboot", this->reboot_timeout_, []() { + if (!global_api_server->is_connected()) { + ESP_LOGE(TAG, "No client connected; rebooting"); + App.reboot(); + } + }); +} + void APIServer::loop() { // Accept new clients only if the socket exists and has incoming connections if (this->socket_ && this->socket_->ready()) { @@ -135,6 +148,12 @@ void APIServer::loop() { auto *conn = new APIConnection(std::move(sock), this); this->clients_.emplace_back(conn); conn->start(); + + // Clear warning status and cancel reboot when first client connects + if (this->clients_.size() == 1 && this->reboot_timeout_ != 0) { + this->status_clear_warning(); + this->cancel_timeout("api_reboot"); + } } } @@ -154,6 +173,12 @@ void APIServer::loop() { std::swap(this->clients_[client_index], this->clients_.back()); } this->clients_.pop_back(); + + // Schedule reboot when last client disconnects + if (this->clients_.empty() && this->reboot_timeout_ != 0) { + this->schedule_reboot_timeout_(); + } + // Don't increment client_index since we need to process the swapped element } else { // Process active client @@ -163,19 +188,7 @@ void APIServer::loop() { } } - if (this->reboot_timeout_ != 0) { - const uint32_t now = App.get_loop_component_start_time(); - if (!this->is_connected()) { - if (now - this->last_connected_ > this->reboot_timeout_) { - ESP_LOGE(TAG, "No client connected; rebooting"); - App.reboot(); - } - this->status_set_warning(); - } else { - this->last_connected_ = now; - this->status_clear_warning(); - } - } + // Reboot timeout is now handled by connection/disconnection events } void APIServer::dump_config() { diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 33412d8a68..27341dc596 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -142,6 +142,7 @@ class APIServer : public Component, public Controller { } protected: + void schedule_reboot_timeout_(); // Pointers and pointer-like types first (4 bytes each) std::unique_ptr socket_ = nullptr; Trigger *client_connected_trigger_ = new Trigger(); @@ -150,7 +151,6 @@ class APIServer : public Component, public Controller { // 4-byte aligned types uint32_t reboot_timeout_{300000}; uint32_t batch_delay_{100}; - uint32_t last_connected_{0}; // Vectors and strings (12 bytes each on 32-bit) std::vector> clients_; From 99b1b079d0435d85be70abd468f2a282a41cba7f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Jun 2025 00:03:01 +0200 Subject: [PATCH 106/183] Reduce RAM usage for scheduled tasks --- esphome/core/scheduler.cpp | 4 ++++ esphome/core/scheduler.h | 8 ++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index eed222c974..8144435163 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -319,13 +319,17 @@ bool HOT Scheduler::cancel_item_(Component *component, const std::string &name, return ret; } uint64_t Scheduler::millis_() { + // Get the current 32-bit millis value const uint32_t now = millis(); + // Check for rollover by comparing with last value if (now < this->last_millis_) { + // Detected rollover (happens every ~49.7 days) this->millis_major_++; ESP_LOGD(TAG, "Incrementing scheduler major at %" PRIu64 "ms", now + (static_cast(this->millis_major_) << 32)); } this->last_millis_ = now; + // Combine major (high 32 bits) and now (low 32 bits) into 64-bit time return now + (static_cast(this->millis_major_) << 32); } diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 872a8bd6f6..1284bcd4a7 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -29,12 +29,16 @@ class Scheduler { protected: struct SchedulerItem { + // Ordered by size to minimize padding Component *component; - std::string name; - enum Type { TIMEOUT, INTERVAL } type; uint32_t interval; + // 64-bit time to handle millis() rollover. The scheduler combines the 32-bit millis() + // with a 16-bit rollover counter to create a 64-bit time that won't roll over for + // billions of years. This ensures correct scheduling even when devices run for months. uint64_t next_execution_; + std::string name; std::function callback; + enum Type : uint8_t { TIMEOUT, INTERVAL } type; bool remove; static bool cmp(const std::unique_ptr &a, const std::unique_ptr &b); From e5e972231cda6624ea5667b738d9536a71e2a9cc Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 23 Jun 2025 10:26:31 +1200 Subject: [PATCH 107/183] Update testing --- esphome/core/config.py | 23 ++++++++++------------- tests/unit_tests/core/test_config.py | 17 +++++++++-------- 2 files changed, 19 insertions(+), 21 deletions(-) diff --git a/esphome/core/config.py b/esphome/core/config.py index 00b36e7899..641c73a292 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -125,22 +125,12 @@ def validate_ids_and_references(config: ConfigType) -> ConfigType: # Validate device hash collisions and area references device_hashes: dict[int, str] = {} - for i, device in enumerate(config[CONF_DEVICES]): + for device in config[CONF_DEVICES]: device_id: core.ID = device[CONF_ID] check_hash_collision( device_id, device_hashes, "Device", [CONF_DEVICES, device_id.id] ) - # Validate area_id reference if present - if CONF_AREA_ID in device: - area_ref_id: core.ID = device[CONF_AREA_ID] - if area_ref_id.id not in area_ids: - raise cv.Invalid( - f"Device '{device[CONF_NAME]}' has an area_id '{area_ref_id.id}'" - " that does not exist.", - path=[CONF_DEVICES, i, CONF_AREA_ID], - ) - return config @@ -200,12 +190,16 @@ DEVICE_SCHEMA = cv.Schema( ) +def validate_area_config(config: dict | str) -> dict[str, str | core.ID]: + return cv.maybe_simple_value(AREA_SCHEMA, key=CONF_NAME)(config) + + CONFIG_SCHEMA = cv.All( cv.Schema( { cv.Required(CONF_NAME): cv.valid_name, cv.Optional(CONF_FRIENDLY_NAME, ""): cv.string, - cv.Optional(CONF_AREA): cv.maybe_simple_value(AREA_SCHEMA, key=CONF_NAME), + cv.Optional(CONF_AREA): validate_area_config, cv.Optional(CONF_COMMENT): cv.string, cv.Required(CONF_BUILD_PATH): cv.string, cv.Optional(CONF_PLATFORMIO_OPTIONS, default={}): cv.Schema( @@ -260,9 +254,12 @@ CONFIG_SCHEMA = cv.All( } ), validate_hostname, - validate_ids_and_references, ) + +FINAL_VALIDATE_SCHEMA = cv.All(validate_ids_and_references) + + PRELOAD_CONFIG_SCHEMA = cv.Schema( { cv.Required(CONF_NAME): cv.valid_name, diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 6a28925dd3..372c1df7ee 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -7,7 +7,7 @@ from unittest.mock import patch import pytest -from esphome import config, config_validation as cv, yaml_util +from esphome import config, config_validation as cv, core, yaml_util from esphome.config import Config from esphome.const import CONF_AREA, CONF_AREAS, CONF_DEVICES from esphome.core import CORE @@ -67,8 +67,9 @@ def test_validate_area_config_with_string() -> None: assert "id" in result assert "name" in result assert result["name"] == "Living Room" - # ID should be based on slugified name - assert result["id"].id == "living_room" + assert isinstance(result["id"], core.ID) + assert result["id"].is_declaration + assert not result["id"].is_manual def test_validate_area_config_with_dict() -> None: @@ -157,10 +158,9 @@ def test_legacy_string_area( area = esphome_config[CONF_AREA] assert isinstance(area, dict) assert area["name"] == "Living Room" - assert area["id"].id == "living_room" - - # Check for deprecation warning - assert "Using 'area' as a string is deprecated" in caplog.text + assert isinstance(area["id"], core.ID) + assert area["id"].is_declaration + assert not area["id"].is_manual def test_area_id_collision( @@ -205,8 +205,9 @@ def test_device_with_invalid_area_id( # Check for the specific error message in stdout captured = capsys.readouterr() + print(captured.out) assert ( - "Device 'Test Device' has an area_id 'nonexistent_area' that does not exist." + "Couldn't find ID 'nonexistent_area'. Please check you have defined an ID with that name in your configuration." in captured.out ) From 7aea82a273867b97e588a012bffbe4fb63527d06 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 23 Jun 2025 14:15:10 +1200 Subject: [PATCH 108/183] Move define --- esphome/core/defines.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 043ab13f7a..62aac4382c 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -17,7 +17,6 @@ // logger #define ESPHOME_LOG_LEVEL ESPHOME_LOG_LEVEL_VERY_VERBOSE -#define USE_ESPHOME_TASK_LOG_BUFFER // Feature flags #define USE_ALARM_CONTROL_PANEL @@ -131,6 +130,8 @@ // ESP32-specific feature flags #ifdef USE_ESP32 +#define USE_ESPHOME_TASK_LOG_BUFFER + #define USE_BLUETOOTH_PROXY #define USE_CAPTIVE_PORTAL #define USE_ESP32_BLE From 6afa8141c08968ae077e0730565e3686e1d41469 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Jun 2025 09:00:46 +0200 Subject: [PATCH 109/183] Update esphome/components/logger/logger.cpp --- esphome/components/logger/logger.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index cfc059c29f..6316eb6991 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -151,7 +151,7 @@ void Logger::init_log_buffer(size_t total_buffer_size) { } #endif -#if defined(USE_LOGGER_USB_CDC) || defined(USE_ESPHOME_TASK_LOG_BUFFER) +#if defined(USE_LOGGER_USB_CDC) || defined(USE_ESP32) void Logger::loop() { #if defined(USE_LOGGER_USB_CDC) && defined(USE_ARDUINO) if (this->uart_ == UART_SELECTION_USB_CDC) { From f0369893615f66c405bb34640faa3354df3895d3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Jun 2025 09:01:01 +0200 Subject: [PATCH 110/183] Update esphome/components/logger/logger.h --- esphome/components/logger/logger.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index fda9983098..fe0e4cd636 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -107,7 +107,7 @@ class Logger : public Component { #ifdef USE_ESPHOME_TASK_LOG_BUFFER void init_log_buffer(size_t total_buffer_size); #endif -#if defined(USE_LOGGER_USB_CDC) || defined(USE_ESPHOME_TASK_LOG_BUFFER) +#if defined(USE_LOGGER_USB_CDC) || defined(USE_ESP32) void loop() override; #endif /// Manually set the baud rate for serial, set to 0 to disable. From 9f489c9f273d8e954a471a914e2103b9493f6ce3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Jun 2025 09:01:21 +0200 Subject: [PATCH 111/183] Update esphome/components/logger/logger.h --- esphome/components/logger/logger.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index fe0e4cd636..38faf73d84 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -359,7 +359,7 @@ class Logger : public Component { this->write_body_to_buffer_(ESPHOME_LOG_RESET_COLOR, RESET_COLOR_LEN, buffer, buffer_at, buffer_size); } -#ifdef USE_ESPHOME_TASK_LOG_BUFFER +#ifdef USE_ESP32 // Disable loop when task buffer is empty (with USB CDC check) inline void disable_loop_when_buffer_empty_() { // Thread safety note: This is safe even if another task calls enable_loop_soon_any_context() From ed57e7c6b000a298eb1c561b162c05a62cd71acb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Jun 2025 09:02:22 +0200 Subject: [PATCH 112/183] Update esphome/components/logger/logger.cpp --- esphome/components/logger/logger.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index 6316eb6991..a2c2aa0320 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -46,8 +46,8 @@ void HOT Logger::log_vprintf_(uint8_t level, const char *tag, int line, const ch bool message_sent = false; #ifdef USE_ESPHOME_TASK_LOG_BUFFER // For non-main tasks, queue the message for callbacks - but only if we have any callbacks registered - message_sent = this->log_buffer_->send_message_thread_safe(level, tag, - static_cast(line), current_task, format, args); + message_sent = + this->log_buffer_->send_message_thread_safe(level, tag, static_cast(line), current_task, format, args); if (message_sent) { // Enable logger loop to process the buffered message // This is safe to call from any context including ISRs From 8ec998ff30c57fc27d4e2fb0bfde630d05b6b1d2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Jun 2025 10:52:34 +0200 Subject: [PATCH 113/183] more api loop reductions --- esphome/components/api/api_connection.cpp | 2 +- esphome/components/api/api_server.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index ef791d462c..8f814f9f42 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -158,7 +158,7 @@ void APIConnection::loop() { if (!this->list_entities_iterator_.completed()) this->list_entities_iterator_.advance(); - if (!this->initial_state_iterator_.completed() && this->list_entities_iterator_.completed()) + else if (!this->initial_state_iterator_.completed()) this->initial_state_iterator_.advance(); static uint8_t max_ping_retries = 60; diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index ae732fc234..8f7add646c 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -136,7 +136,7 @@ void APIServer::schedule_reboot_timeout_() { void APIServer::loop() { // Accept new clients only if the socket exists and has incoming connections - if (this->socket_ && this->socket_->ready()) { + if (this->socket_->ready()) { while (true) { struct sockaddr_storage source_addr; socklen_t addr_len = sizeof(source_addr); From d6725fc1caf873681c4d213abb5e2ad0b09d7dc7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Jun 2025 10:54:50 +0200 Subject: [PATCH 114/183] more api loop reductions --- esphome/components/api/api_connection.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 8f814f9f42..fc6c4d4cf7 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -152,7 +152,7 @@ void APIConnection::loop() { // Process deferred batch if scheduled if (this->deferred_batch_.batch_scheduled && - App.get_loop_component_start_time() - this->deferred_batch_.batch_start_time >= this->get_batch_delay_ms_()) { + now - this->deferred_batch_.batch_start_time >= this->get_batch_delay_ms_()) { this->process_batch_(); } From e8c250a03c5ed2834f16861bbd63a084cff819fe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Jun 2025 10:59:00 +0200 Subject: [PATCH 115/183] more api loop reductions --- esphome/components/api/api_connection.cpp | 7 ------- esphome/components/api/api_server.cpp | 13 +++++++++++-- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index fc6c4d4cf7..ac729e7652 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -93,13 +93,6 @@ void APIConnection::loop() { if (this->remove_) return; - if (!network::is_connected()) { - // when network is disconnected force disconnect immediately - // don't wait for timeout - this->on_fatal_error(); - ESP_LOGW(TAG, "%s: Network unavailable; disconnecting", this->get_client_combined_info().c_str()); - return; - } if (this->next_close_) { // requested a disconnect this->helper_->close(); diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 8f7add646c..23c8ef30cd 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -159,6 +159,9 @@ void APIServer::loop() { // Process clients and remove disconnected ones in a single pass if (!this->clients_.empty()) { + // Check network connectivity once for all clients + bool network_connected = network::is_connected(); + size_t client_index = 0; while (client_index < this->clients_.size()) { auto &client = this->clients_[client_index]; @@ -181,8 +184,14 @@ void APIServer::loop() { // Don't increment client_index since we need to process the swapped element } else { - // Process active client - client->loop(); + // Process active client only if network is connected + if (network_connected) { + client->loop(); + } else { + // Force disconnect when network is unavailable + client->on_fatal_error(); + ESP_LOGW(TAG, "%s: Network unavailable; disconnecting", client->get_client_combined_info().c_str()); + } client_index++; // Move to next client } } From e767f30886f9d2b1f72e10cf5b6fc3cc89090700 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Jun 2025 10:59:49 +0200 Subject: [PATCH 116/183] more api loop reductions --- esphome/components/api/api_frame_helper.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index 7e90153091..a20c0c10c5 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -38,7 +38,7 @@ struct PacketInfo { : message_type(type), offset(off), payload_size(size), padding(0) {} }; -enum class APIError : int { +enum class APIError : uint16_t { OK = 0, WOULD_BLOCK = 1001, BAD_HANDSHAKE_PACKET_LEN = 1002, From a3a3bdc7ebb75c6406174ecefefe89332bffc323 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Jun 2025 11:02:27 +0200 Subject: [PATCH 117/183] more api loop reductions --- esphome/components/api/api_frame_helper.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index ff660f439e..e0cbe5513a 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -831,7 +831,6 @@ APIError APIPlaintextFrameHelper::init() { state_ = State::DATA; return APIError::OK; } -/// Not used for plaintext APIError APIPlaintextFrameHelper::loop() { if (state_ != State::DATA) { return APIError::BAD_STATE; From 0bc59b97de8a481b17f541099e655cb93f9830f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Jun 2025 11:06:51 +0200 Subject: [PATCH 118/183] more api loop reductions --- esphome/components/api/api_frame_helper.cpp | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index e0cbe5513a..d859aafd70 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -339,17 +339,15 @@ APIError APINoiseFrameHelper::try_read_frame_(ParsedFrame *frame) { return APIError::WOULD_BLOCK; } + if (rx_header_buf_[0] != 0x01) { + state_ = State::FAILED; + HELPER_LOG("Bad indicator byte %u", rx_header_buf_[0]); + return APIError::BAD_INDICATOR; + } // header reading done } // read body - uint8_t indicator = rx_header_buf_[0]; - if (indicator != 0x01) { - state_ = State::FAILED; - HELPER_LOG("Bad indicator byte %u", indicator); - return APIError::BAD_INDICATOR; - } - uint16_t msg_size = (((uint16_t) rx_header_buf_[1]) << 8) | rx_header_buf_[2]; if (state_ != State::DATA && msg_size > 128) { From 20405c84ac680e31d1d199b023dec5388dee8199 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Jun 2025 11:10:07 +0200 Subject: [PATCH 119/183] preen --- esphome/components/api/api_server.cpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 23c8ef30cd..97c8ffcc75 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -196,8 +196,6 @@ void APIServer::loop() { } } } - - // Reboot timeout is now handled by connection/disconnection events } void APIServer::dump_config() { From 2c315595f0cb3d05a0518e821d187d7e397d73c2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Jun 2025 11:12:04 +0200 Subject: [PATCH 120/183] preen --- esphome/components/api/api_connection.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index ac729e7652..c0ba925e5f 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -154,8 +154,8 @@ void APIConnection::loop() { else if (!this->initial_state_iterator_.completed()) this->initial_state_iterator_.advance(); - static uint8_t max_ping_retries = 60; - static uint16_t ping_retry_interval = 1000; + static constexpr uint8_t max_ping_retries = 60; + static constexpr uint16_t ping_retry_interval = 1000; if (this->sent_ping_) { // Disconnect if not responded within 2.5*keepalive if (now - this->last_traffic_ > (KEEPALIVE_TIMEOUT_MS * 5) / 2) { From 147f6012b2990838dc1e0c2c5dde37f76126a6d0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Jun 2025 11:16:34 +0200 Subject: [PATCH 121/183] preen --- esphome/components/api/api_connection.cpp | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index c0ba925e5f..2a8bd7e16d 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -158,7 +158,8 @@ void APIConnection::loop() { static constexpr uint16_t ping_retry_interval = 1000; if (this->sent_ping_) { // Disconnect if not responded within 2.5*keepalive - if (now - this->last_traffic_ > (KEEPALIVE_TIMEOUT_MS * 5) / 2) { + static constexpr uint32_t keepalive_disconnect_timeout = (KEEPALIVE_TIMEOUT_MS * 5) / 2; + if (now - this->last_traffic_ > keepalive_disconnect_timeout) { on_fatal_error(); ESP_LOGW(TAG, "%s is unresponsive; disconnecting", this->get_client_combined_info().c_str()); } @@ -168,15 +169,13 @@ void APIConnection::loop() { if (!this->sent_ping_) { this->next_ping_retry_ = now + ping_retry_interval; this->ping_retries_++; - std::string warn_str = str_sprintf("%s: Sending keepalive failed %u time(s);", - this->get_client_combined_info().c_str(), this->ping_retries_); if (this->ping_retries_ >= max_ping_retries) { on_fatal_error(); - ESP_LOGE(TAG, "%s disconnecting", warn_str.c_str()); + ESP_LOGE(TAG, "%s: Ping failed %u times", this->get_client_combined_info().c_str(), this->ping_retries_); } else if (this->ping_retries_ >= 10) { - ESP_LOGW(TAG, "%s retrying in %u ms", warn_str.c_str(), ping_retry_interval); + ESP_LOGW(TAG, "%s: Ping retry %u", this->get_client_combined_info().c_str(), this->ping_retries_); } else { - ESP_LOGD(TAG, "%s retrying in %u ms", warn_str.c_str(), ping_retry_interval); + ESP_LOGD(TAG, "%s: Ping retry %u", this->get_client_combined_info().c_str(), this->ping_retries_); } } } From 13b23f840b94b6beeee7fa0b797f895913b349a2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Jun 2025 11:17:17 +0200 Subject: [PATCH 122/183] preen --- esphome/components/api/api_connection.cpp | 3 --- 1 file changed, 3 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 2a8bd7e16d..88bf91ea94 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -90,9 +90,6 @@ APIConnection::~APIConnection() { } void APIConnection::loop() { - if (this->remove_) - return; - if (this->next_close_) { // requested a disconnect this->helper_->close(); From 047a3e0e8c585e7926f56b9625b1b45e162d52e4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Jun 2025 11:18:47 +0200 Subject: [PATCH 123/183] preen --- esphome/components/api/api_connection.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 88bf91ea94..e69c2f7cd3 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -33,6 +33,8 @@ namespace api { // Since each message could contain multiple protobuf messages when using packet batching, // this limits the number of messages processed, not the number of TCP packets. static constexpr uint8_t MAX_MESSAGES_PER_LOOP = 5; +static constexpr uint8_t MAX_PING_RETRIES = 60; +static constexpr uint16_t PING_RETRY_INTERVAL = 1000; static const char *const TAG = "api.connection"; static const int ESP32_CAMERA_STOP_STREAM = 5000; From c5ef7ebd27f8e945993e6f8c5b1a030e8c8a1e19 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Jun 2025 11:19:07 +0200 Subject: [PATCH 124/183] preen --- esphome/components/api/api_connection.cpp | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index e69c2f7cd3..814fcafb53 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -153,8 +153,6 @@ void APIConnection::loop() { else if (!this->initial_state_iterator_.completed()) this->initial_state_iterator_.advance(); - static constexpr uint8_t max_ping_retries = 60; - static constexpr uint16_t ping_retry_interval = 1000; if (this->sent_ping_) { // Disconnect if not responded within 2.5*keepalive static constexpr uint32_t keepalive_disconnect_timeout = (KEEPALIVE_TIMEOUT_MS * 5) / 2; @@ -166,9 +164,9 @@ void APIConnection::loop() { ESP_LOGVV(TAG, "Sending keepalive PING"); this->sent_ping_ = this->send_message(PingRequest()); if (!this->sent_ping_) { - this->next_ping_retry_ = now + ping_retry_interval; + this->next_ping_retry_ = now + PING_RETRY_INTERVAL; this->ping_retries_++; - if (this->ping_retries_ >= max_ping_retries) { + if (this->ping_retries_ >= MAX_PING_RETRIES) { on_fatal_error(); ESP_LOGE(TAG, "%s: Ping failed %u times", this->get_client_combined_info().c_str(), this->ping_retries_); } else if (this->ping_retries_ >= 10) { From 8d5d18064df4589569321ee91388e7158f59a16a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Jun 2025 11:19:56 +0200 Subject: [PATCH 125/183] preen --- esphome/components/api/api_connection.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 814fcafb53..057376579e 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -35,6 +35,7 @@ namespace api { static constexpr uint8_t MAX_MESSAGES_PER_LOOP = 5; static constexpr uint8_t MAX_PING_RETRIES = 60; static constexpr uint16_t PING_RETRY_INTERVAL = 1000; +static constexpr uint32_t KEEPALIVE_DISCONNECT_TIMEOUT = (KEEPALIVE_TIMEOUT_MS * 5) / 2; static const char *const TAG = "api.connection"; static const int ESP32_CAMERA_STOP_STREAM = 5000; From 02e61ef5d3b748e5cc5fc9b2923807a3edf46037 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Jun 2025 11:20:06 +0200 Subject: [PATCH 126/183] preen --- esphome/components/api/api_connection.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 057376579e..35e78e0ef5 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -156,8 +156,7 @@ void APIConnection::loop() { if (this->sent_ping_) { // Disconnect if not responded within 2.5*keepalive - static constexpr uint32_t keepalive_disconnect_timeout = (KEEPALIVE_TIMEOUT_MS * 5) / 2; - if (now - this->last_traffic_ > keepalive_disconnect_timeout) { + if (now - this->last_traffic_ > KEEPALIVE_DISCONNECT_TIMEOUT) { on_fatal_error(); ESP_LOGW(TAG, "%s is unresponsive; disconnecting", this->get_client_combined_info().c_str()); } From 19cbc8c33bfeac45923c7d92f6e09bb3e068cfc5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Jun 2025 11:21:37 +0200 Subject: [PATCH 127/183] preen --- esphome/components/api/api_connection.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 35e78e0ef5..f4eca0cad8 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -200,9 +200,9 @@ void APIConnection::loop() { if (success) { this->image_reader_.consume_data(to_send); - } - if (success && done) { - this->image_reader_.return_image(); + if (done) { + this->image_reader_.return_image(); + } } } #endif From b0c02341ff646bbd06d0f9810030f3c40e5787b5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Jun 2025 11:22:08 +0200 Subject: [PATCH 128/183] preen --- esphome/components/api/api_connection.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index f4eca0cad8..459f450ea2 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -207,11 +207,9 @@ void APIConnection::loop() { } #endif - if (state_subs_at_ != -1) { + if (state_subs_at_ >= 0) { const auto &subs = this->parent_->get_state_subs(); - if (state_subs_at_ >= (int) subs.size()) { - state_subs_at_ = -1; - } else { + if (state_subs_at_ < static_cast(subs.size())) { auto &it = subs[state_subs_at_]; SubscribeHomeAssistantStateResponse resp; resp.entity_id = it.entity_id; @@ -220,6 +218,8 @@ void APIConnection::loop() { if (this->send_message(resp)) { state_subs_at_++; } + } else { + state_subs_at_ = -1; } } } From 5898d34b0a8368a2215a79d575ceb88d886a9df5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Jun 2025 11:22:45 +0200 Subject: [PATCH 129/183] preen --- esphome/components/api/api_connection.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 459f450ea2..585f6fa200 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -196,7 +196,7 @@ void APIConnection::loop() { // bool done = 3; buffer.encode_bool(3, done); - bool success = this->send_buffer(buffer, 44); + bool success = this->send_buffer(buffer, CameraImageResponse::MESSAGE_TYPE); if (success) { this->image_reader_.consume_data(to_send); From ddbda5032bd5a5b40f90b35090bf6d4ca3f2820b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Jun 2025 11:25:24 +0200 Subject: [PATCH 130/183] preen --- esphome/components/api/api_frame_helper.cpp | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index d859aafd70..772b7e802b 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -593,11 +593,6 @@ APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) { return APIError::BAD_DATA_PACKET; } - // uint16_t type; - // uint16_t data_len; - // uint8_t *data; - // uint8_t *padding; zero or more bytes to fill up the rest of the packet - uint16_t type = (((uint16_t) msg_data[0]) << 8) | msg_data[1]; uint16_t data_len = (((uint16_t) msg_data[2]) << 8) | msg_data[3]; if (data_len > msg_size - 4) { state_ = State::FAILED; @@ -608,7 +603,7 @@ APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) { buffer->container = std::move(frame.msg); buffer->data_offset = 4; buffer->data_len = data_len; - buffer->type = type; + buffer->type = (((uint16_t) msg_data[0]) << 8) | msg_data[1]; return APIError::OK; } APIError APINoiseFrameHelper::write_protobuf_packet(uint16_t type, ProtoWriteBuffer buffer) { From b76e34fb7bda577aa5f9e5c9f22ba0bfb90f5e8e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Jun 2025 11:25:52 +0200 Subject: [PATCH 131/183] preen --- esphome/components/api/api_frame_helper.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 772b7e802b..53985a5c0e 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -593,6 +593,7 @@ APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) { return APIError::BAD_DATA_PACKET; } + uint16_t type = (((uint16_t) msg_data[0]) << 8) | msg_data[1]; uint16_t data_len = (((uint16_t) msg_data[2]) << 8) | msg_data[3]; if (data_len > msg_size - 4) { state_ = State::FAILED; @@ -603,7 +604,7 @@ APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) { buffer->container = std::move(frame.msg); buffer->data_offset = 4; buffer->data_len = data_len; - buffer->type = (((uint16_t) msg_data[0]) << 8) | msg_data[1]; + buffer->type = type; return APIError::OK; } APIError APINoiseFrameHelper::write_protobuf_packet(uint16_t type, ProtoWriteBuffer buffer) { From f67490b69b1a2d1a0b51a816261c03e43c0557e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Jun 2025 11:29:04 +0200 Subject: [PATCH 132/183] preen --- esphome/components/api/api_frame_helper.cpp | 29 +++++++++++---------- esphome/components/api/api_frame_helper.h | 2 +- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 53985a5c0e..af6dd0220d 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -66,6 +66,17 @@ const char *api_error_to_str(APIError err) { return "UNKNOWN"; } +// Default implementation for loop - handles sending buffered data +APIError APIFrameHelper::loop() { + if (!this->tx_buf_.empty()) { + APIError err = try_send_tx_buf_(); + if (err != APIError::OK && err != APIError::WOULD_BLOCK) { + return err; + } + } + return APIError::OK; // Convert WOULD_BLOCK to OK to avoid connection termination +} + // Helper method to buffer data from IOVs void APIFrameHelper::buffer_data_from_iov_(const struct iovec *iov, int iovcnt, uint16_t total_write_len) { SendBuffer buffer; @@ -287,13 +298,8 @@ APIError APINoiseFrameHelper::loop() { } } - if (!this->tx_buf_.empty()) { - APIError err = try_send_tx_buf_(); - if (err != APIError::OK && err != APIError::WOULD_BLOCK) { - return err; - } - } - return APIError::OK; // Convert WOULD_BLOCK to OK to avoid connection termination + // Use base class implementation for buffer sending + return APIFrameHelper::loop(); } /** Read a packet into the rx_buf_. If successful, stores frame data in the frame parameter @@ -829,13 +835,8 @@ APIError APIPlaintextFrameHelper::loop() { if (state_ != State::DATA) { return APIError::BAD_STATE; } - if (!this->tx_buf_.empty()) { - APIError err = try_send_tx_buf_(); - if (err != APIError::OK && err != APIError::WOULD_BLOCK) { - return err; - } - } - return APIError::OK; // Convert WOULD_BLOCK to OK to avoid connection termination + // Use base class implementation for buffer sending + return APIFrameHelper::loop(); } /** Read a packet into the rx_buf_. If successful, stores frame data in the frame parameter diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index a20c0c10c5..1e157278a1 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -74,7 +74,7 @@ class APIFrameHelper { } virtual ~APIFrameHelper() = default; virtual APIError init() = 0; - virtual APIError loop() = 0; + virtual APIError loop(); virtual APIError read_packet(ReadPacketBuffer *buffer) = 0; bool can_write_without_blocking() { return state_ == State::DATA && tx_buf_.empty(); } std::string getpeername() { return socket_->getpeername(); } From edeafd5a537f944ae8d4b8e62dc562c731ecc87c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Jun 2025 11:31:38 +0200 Subject: [PATCH 133/183] preen --- esphome/components/api/api_server.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 97c8ffcc75..046053872a 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -136,7 +136,7 @@ void APIServer::schedule_reboot_timeout_() { void APIServer::loop() { // Accept new clients only if the socket exists and has incoming connections - if (this->socket_->ready()) { + if (this->socket_ && this->socket_->ready()) { while (true) { struct sockaddr_storage source_addr; socklen_t addr_len = sizeof(source_addr); From 56a02409c8f12ef122c64a36603be2cdae82a641 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Jun 2025 11:34:11 +0200 Subject: [PATCH 134/183] preen --- esphome/components/api/api_server.cpp | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 046053872a..2bdcb3c45c 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -160,7 +160,14 @@ void APIServer::loop() { // Process clients and remove disconnected ones in a single pass if (!this->clients_.empty()) { // Check network connectivity once for all clients - bool network_connected = network::is_connected(); + if (!network::is_connected()) { + // Network is down - disconnect all clients + for (auto &client : this->clients_) { + client->on_fatal_error(); + ESP_LOGW(TAG, "%s: Network unavailable; disconnecting", client->get_client_combined_info().c_str()); + } + return; // All clients will be marked for removal, cleanup will happen next loop + } size_t client_index = 0; while (client_index < this->clients_.size()) { @@ -184,14 +191,8 @@ void APIServer::loop() { // Don't increment client_index since we need to process the swapped element } else { - // Process active client only if network is connected - if (network_connected) { - client->loop(); - } else { - // Force disconnect when network is unavailable - client->on_fatal_error(); - ESP_LOGW(TAG, "%s: Network unavailable; disconnecting", client->get_client_combined_info().c_str()); - } + // Network is connected, process the client + client->loop(); client_index++; // Move to next client } } From 6a22ea1c7d4f94e0683967f197d4da30fbdff33d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Jun 2025 11:35:41 +0200 Subject: [PATCH 135/183] preen --- esphome/components/api/api_server.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 2bdcb3c45c..ab1568c80b 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -166,7 +166,7 @@ void APIServer::loop() { client->on_fatal_error(); ESP_LOGW(TAG, "%s: Network unavailable; disconnecting", client->get_client_combined_info().c_str()); } - return; // All clients will be marked for removal, cleanup will happen next loop + // Continue to process and clean up the clients below } size_t client_index = 0; From 93245a24b57a9c0e399487cc59bf097a38c8a72a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Jun 2025 11:36:54 +0200 Subject: [PATCH 136/183] preen --- esphome/components/api/api_server.cpp | 66 ++++++++++++++------------- 1 file changed, 34 insertions(+), 32 deletions(-) diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index ab1568c80b..156cf7cc6e 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -158,43 +158,45 @@ void APIServer::loop() { } // Process clients and remove disconnected ones in a single pass - if (!this->clients_.empty()) { - // Check network connectivity once for all clients - if (!network::is_connected()) { - // Network is down - disconnect all clients - for (auto &client : this->clients_) { - client->on_fatal_error(); - ESP_LOGW(TAG, "%s: Network unavailable; disconnecting", client->get_client_combined_info().c_str()); - } - // Continue to process and clean up the clients below + if (this->clients_.empty()) { + return; + } + + // Check network connectivity once for all clients + if (!network::is_connected()) { + // Network is down - disconnect all clients + for (auto &client : this->clients_) { + client->on_fatal_error(); + ESP_LOGW(TAG, "%s: Network unavailable; disconnecting", client->get_client_combined_info().c_str()); } + // Continue to process and clean up the clients below + } - size_t client_index = 0; - while (client_index < this->clients_.size()) { - auto &client = this->clients_[client_index]; + size_t client_index = 0; + while (client_index < this->clients_.size()) { + auto &client = this->clients_[client_index]; - if (client->remove_) { - // Handle disconnection - this->client_disconnected_trigger_->trigger(client->client_info_, client->client_peername_); - ESP_LOGV(TAG, "Removing connection to %s", client->client_info_.c_str()); + if (client->remove_) { + // Handle disconnection + this->client_disconnected_trigger_->trigger(client->client_info_, client->client_peername_); + ESP_LOGV(TAG, "Removing connection to %s", client->client_info_.c_str()); - // Swap with the last element and pop (avoids expensive vector shifts) - if (client_index < this->clients_.size() - 1) { - std::swap(this->clients_[client_index], this->clients_.back()); - } - this->clients_.pop_back(); - - // Schedule reboot when last client disconnects - if (this->clients_.empty() && this->reboot_timeout_ != 0) { - this->schedule_reboot_timeout_(); - } - - // Don't increment client_index since we need to process the swapped element - } else { - // Network is connected, process the client - client->loop(); - client_index++; // Move to next client + // Swap with the last element and pop (avoids expensive vector shifts) + if (client_index < this->clients_.size() - 1) { + std::swap(this->clients_[client_index], this->clients_.back()); } + this->clients_.pop_back(); + + // Schedule reboot when last client disconnects + if (this->clients_.empty() && this->reboot_timeout_ != 0) { + this->schedule_reboot_timeout_(); + } + + // Don't increment client_index since we need to process the swapped element + } else { + // Network is connected, process the client + client->loop(); + client_index++; // Move to next client } } } From 76a59759b21ecca5eb9f173ed0ccecd8cc558535 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Jun 2025 11:37:27 +0200 Subject: [PATCH 137/183] preen --- esphome/components/api/api_server.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 156cf7cc6e..13c3ba0ec4 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -157,11 +157,11 @@ void APIServer::loop() { } } - // Process clients and remove disconnected ones in a single pass if (this->clients_.empty()) { return; } + // Process clients and remove disconnected ones in a single pass // Check network connectivity once for all clients if (!network::is_connected()) { // Network is down - disconnect all clients From 686cc58d6c3f945080365ad98f3c3fe16cbff2c6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Jun 2025 11:37:59 +0200 Subject: [PATCH 138/183] preen --- esphome/components/api/api_server.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 13c3ba0ec4..d2b9a0cfb9 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -143,7 +143,7 @@ void APIServer::loop() { auto sock = this->socket_->accept_loop_monitored((struct sockaddr *) &source_addr, &addr_len); if (!sock) break; - ESP_LOGD(TAG, "Accepted %s", sock->getpeername().c_str()); + ESP_LOGD(TAG, "Accept %s", sock->getpeername().c_str()); auto *conn = new APIConnection(std::move(sock), this); this->clients_.emplace_back(conn); From 97b26fbefed9431ab852211be0486eb249ace0b0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Jun 2025 11:38:10 +0200 Subject: [PATCH 139/183] preen --- esphome/components/api/api_server.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index d2b9a0cfb9..a79fc99a72 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -167,7 +167,7 @@ void APIServer::loop() { // Network is down - disconnect all clients for (auto &client : this->clients_) { client->on_fatal_error(); - ESP_LOGW(TAG, "%s: Network unavailable; disconnecting", client->get_client_combined_info().c_str()); + ESP_LOGW(TAG, "%s: Network down; disconnecting", client->get_client_combined_info().c_str()); } // Continue to process and clean up the clients below } From 5dc54782e5a5bfe80f307387bc9ac683e451e9f7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Jun 2025 11:38:30 +0200 Subject: [PATCH 140/183] preen --- esphome/components/api/api_server.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index a79fc99a72..ae278a424e 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -167,7 +167,7 @@ void APIServer::loop() { // Network is down - disconnect all clients for (auto &client : this->clients_) { client->on_fatal_error(); - ESP_LOGW(TAG, "%s: Network down; disconnecting", client->get_client_combined_info().c_str()); + ESP_LOGW(TAG, "%s: Network down; disconnect", client->get_client_combined_info().c_str()); } // Continue to process and clean up the clients below } @@ -179,7 +179,7 @@ void APIServer::loop() { if (client->remove_) { // Handle disconnection this->client_disconnected_trigger_->trigger(client->client_info_, client->client_peername_); - ESP_LOGV(TAG, "Removing connection to %s", client->client_info_.c_str()); + ESP_LOGV(TAG, "Remove connection %s", client->client_info_.c_str()); // Swap with the last element and pop (avoids expensive vector shifts) if (client_index < this->clients_.size() - 1) { From 170869b7dbcb166435a2048598c571515346284a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Jun 2025 11:39:25 +0200 Subject: [PATCH 141/183] preen --- esphome/components/api/api_server.cpp | 40 +++++++++++++-------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index ae278a424e..ad1eeda8ea 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -176,28 +176,28 @@ void APIServer::loop() { while (client_index < this->clients_.size()) { auto &client = this->clients_[client_index]; - if (client->remove_) { - // Handle disconnection - this->client_disconnected_trigger_->trigger(client->client_info_, client->client_peername_); - ESP_LOGV(TAG, "Remove connection %s", client->client_info_.c_str()); - - // Swap with the last element and pop (avoids expensive vector shifts) - if (client_index < this->clients_.size() - 1) { - std::swap(this->clients_[client_index], this->clients_.back()); - } - this->clients_.pop_back(); - - // Schedule reboot when last client disconnects - if (this->clients_.empty() && this->reboot_timeout_ != 0) { - this->schedule_reboot_timeout_(); - } - - // Don't increment client_index since we need to process the swapped element - } else { - // Network is connected, process the client + if (!client->remove_) { + // Common case: process active client client->loop(); - client_index++; // Move to next client + client_index++; + continue; } + + // Rare case: handle disconnection + this->client_disconnected_trigger_->trigger(client->client_info_, client->client_peername_); + ESP_LOGV(TAG, "Remove connection %s", client->client_info_.c_str()); + + // Swap with the last element and pop (avoids expensive vector shifts) + if (client_index < this->clients_.size() - 1) { + std::swap(this->clients_[client_index], this->clients_.back()); + } + this->clients_.pop_back(); + + // Schedule reboot when last client disconnects + if (this->clients_.empty() && this->reboot_timeout_ != 0) { + this->schedule_reboot_timeout_(); + } + // Don't increment client_index since we need to process the swapped element } } From 0773819778b6186f8b1e37591024154460320b9e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Jun 2025 11:45:58 +0200 Subject: [PATCH 142/183] cleanup --- .../fixtures/api_reboot_timeout.yaml | 7 ++++ tests/integration/test_api_reboot_timeout.py | 38 +++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 tests/integration/fixtures/api_reboot_timeout.yaml create mode 100644 tests/integration/test_api_reboot_timeout.py diff --git a/tests/integration/fixtures/api_reboot_timeout.yaml b/tests/integration/fixtures/api_reboot_timeout.yaml new file mode 100644 index 0000000000..114dd2fece --- /dev/null +++ b/tests/integration/fixtures/api_reboot_timeout.yaml @@ -0,0 +1,7 @@ +esphome: + name: api-reboot-test +host: +api: + reboot_timeout: 1s # Very short timeout for fast testing +logger: + level: DEBUG diff --git a/tests/integration/test_api_reboot_timeout.py b/tests/integration/test_api_reboot_timeout.py new file mode 100644 index 0000000000..9836b42025 --- /dev/null +++ b/tests/integration/test_api_reboot_timeout.py @@ -0,0 +1,38 @@ +"""Test API server reboot timeout functionality.""" + +import asyncio +import re + +import pytest + +from .types import RunCompiledFunction + + +@pytest.mark.asyncio +async def test_api_reboot_timeout( + yaml_config: str, + run_compiled: RunCompiledFunction, +) -> None: + """Test that the device reboots when no API clients connect within the timeout.""" + reboot_detected = False + reboot_pattern = re.compile(r"No client connected; rebooting") + + def check_output(line: str) -> None: + """Check output for reboot message.""" + nonlocal reboot_detected + if reboot_pattern.search(line): + reboot_detected = True + + # Run the device without connecting any API client + async with run_compiled(yaml_config, line_callback=check_output): + # Wait for up to 3 seconds for the reboot to occur + # (1s timeout + some margin for processing) + start_time = asyncio.get_event_loop().time() + while not reboot_detected: + await asyncio.sleep(0.1) + elapsed = asyncio.get_event_loop().time() - start_time + if elapsed > 3.0: + pytest.fail("Device did not reboot within expected timeout") + + # Verify that reboot was detected + assert reboot_detected, "Reboot message was not detected in output" From 0eea1c0e400a41cff69ee735ef6347cc769f0632 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Jun 2025 11:56:09 +0200 Subject: [PATCH 143/183] preen --- tests/integration/test_api_reboot_timeout.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/integration/test_api_reboot_timeout.py b/tests/integration/test_api_reboot_timeout.py index 9836b42025..51f4ab160b 100644 --- a/tests/integration/test_api_reboot_timeout.py +++ b/tests/integration/test_api_reboot_timeout.py @@ -27,10 +27,11 @@ async def test_api_reboot_timeout( async with run_compiled(yaml_config, line_callback=check_output): # Wait for up to 3 seconds for the reboot to occur # (1s timeout + some margin for processing) - start_time = asyncio.get_event_loop().time() + loop = asyncio.get_running_loop() + start_time = loop.time() while not reboot_detected: await asyncio.sleep(0.1) - elapsed = asyncio.get_event_loop().time() - start_time + elapsed = loop.time() - start_time if elapsed > 3.0: pytest.fail("Device did not reboot within expected timeout") From e3aaf3219dad32e565a5b9c165c7bef5473670bd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Jun 2025 11:58:16 +0200 Subject: [PATCH 144/183] speed up test --- .../fixtures/api_reboot_timeout.yaml | 2 +- tests/integration/test_api_reboot_timeout.py | 26 ++++++++----------- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/tests/integration/fixtures/api_reboot_timeout.yaml b/tests/integration/fixtures/api_reboot_timeout.yaml index 114dd2fece..881bb5b2fc 100644 --- a/tests/integration/fixtures/api_reboot_timeout.yaml +++ b/tests/integration/fixtures/api_reboot_timeout.yaml @@ -2,6 +2,6 @@ esphome: name: api-reboot-test host: api: - reboot_timeout: 1s # Very short timeout for fast testing + reboot_timeout: 0.5s # Very short timeout for fast testing logger: level: DEBUG diff --git a/tests/integration/test_api_reboot_timeout.py b/tests/integration/test_api_reboot_timeout.py index 51f4ab160b..7cace506b2 100644 --- a/tests/integration/test_api_reboot_timeout.py +++ b/tests/integration/test_api_reboot_timeout.py @@ -14,26 +14,22 @@ async def test_api_reboot_timeout( run_compiled: RunCompiledFunction, ) -> None: """Test that the device reboots when no API clients connect within the timeout.""" - reboot_detected = False + loop = asyncio.get_running_loop() + reboot_future = loop.create_future() reboot_pattern = re.compile(r"No client connected; rebooting") def check_output(line: str) -> None: """Check output for reboot message.""" - nonlocal reboot_detected - if reboot_pattern.search(line): - reboot_detected = True + if not reboot_future.done() and reboot_pattern.search(line): + reboot_future.set_result(True) # Run the device without connecting any API client async with run_compiled(yaml_config, line_callback=check_output): - # Wait for up to 3 seconds for the reboot to occur - # (1s timeout + some margin for processing) - loop = asyncio.get_running_loop() - start_time = loop.time() - while not reboot_detected: - await asyncio.sleep(0.1) - elapsed = loop.time() - start_time - if elapsed > 3.0: - pytest.fail("Device did not reboot within expected timeout") + # Wait for reboot with timeout + # (0.5s reboot timeout + some margin for processing) + try: + await asyncio.wait_for(reboot_future, timeout=2.0) + except asyncio.TimeoutError: + pytest.fail("Device did not reboot within expected timeout") - # Verify that reboot was detected - assert reboot_detected, "Reboot message was not detected in output" + # Test passes if we get here - reboot was detected From 971e954a545dfb2c54e18e91bb9be4e0eb5f9a01 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Jun 2025 11:59:07 +0200 Subject: [PATCH 145/183] follow logging guidelines --- esphome/components/api/api_server.cpp | 2 +- tests/integration/test_api_reboot_timeout.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index ad1eeda8ea..583837af82 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -128,7 +128,7 @@ void APIServer::schedule_reboot_timeout_() { this->status_set_warning(); this->set_timeout("api_reboot", this->reboot_timeout_, []() { if (!global_api_server->is_connected()) { - ESP_LOGE(TAG, "No client connected; rebooting"); + ESP_LOGE(TAG, "No clients; rebooting"); App.reboot(); } }); diff --git a/tests/integration/test_api_reboot_timeout.py b/tests/integration/test_api_reboot_timeout.py index 7cace506b2..dd9f5fbd1e 100644 --- a/tests/integration/test_api_reboot_timeout.py +++ b/tests/integration/test_api_reboot_timeout.py @@ -16,7 +16,7 @@ async def test_api_reboot_timeout( """Test that the device reboots when no API clients connect within the timeout.""" loop = asyncio.get_running_loop() reboot_future = loop.create_future() - reboot_pattern = re.compile(r"No client connected; rebooting") + reboot_pattern = re.compile(r"No clients; rebooting") def check_output(line: str) -> None: """Check output for reboot message.""" From 499517418d32b89570e0d5da6528218e2458d019 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Jun 2025 12:10:15 +0200 Subject: [PATCH 146/183] clang-tidy --- esphome/components/api/api_connection.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 585f6fa200..45fbe7c88e 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -149,10 +149,11 @@ void APIConnection::loop() { this->process_batch_(); } - if (!this->list_entities_iterator_.completed()) + if (!this->list_entities_iterator_.completed()) { this->list_entities_iterator_.advance(); - else if (!this->initial_state_iterator_.completed()) + } else if (!this->initial_state_iterator_.completed()) { this->initial_state_iterator_.advance(); + } if (this->sent_ping_) { // Disconnect if not responded within 2.5*keepalive From 0ec0a9e313d9323c6a3b6f4c7ce26b38150713a6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Jun 2025 12:19:21 +0200 Subject: [PATCH 147/183] missing ifdef --- esphome/components/api/api_connection.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 45fbe7c88e..e40318c34a 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -38,7 +38,9 @@ static constexpr uint16_t PING_RETRY_INTERVAL = 1000; static constexpr uint32_t KEEPALIVE_DISCONNECT_TIMEOUT = (KEEPALIVE_TIMEOUT_MS * 5) / 2; static const char *const TAG = "api.connection"; +#ifdef USE_ESP32_CAMERA static const int ESP32_CAMERA_STOP_STREAM = 5000; +#endif APIConnection::APIConnection(std::unique_ptr sock, APIServer *parent) : parent_(parent), initial_state_iterator_(this), list_entities_iterator_(this) { From d4e978369a9f376b5e072431aba356f778139e52 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 24 Jun 2025 19:56:30 +1200 Subject: [PATCH 148/183] Store reference to device on EntityBase This is so we can get the name of the device to use as part of the object id and to internally set the name for logging. --- esphome/core/entity_base.cpp | 38 ++++++++++++++++++------------------ esphome/core/entity_base.h | 15 +++++++++++--- esphome/cpp_helpers.py | 10 ++++++---- 3 files changed, 37 insertions(+), 26 deletions(-) diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index 791b6615a1..cf91e17a6a 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -11,7 +11,14 @@ const StringRef &EntityBase::get_name() const { return this->name_; } void EntityBase::set_name(const char *name) { this->name_ = StringRef(name); if (this->name_.empty()) { - this->name_ = StringRef(App.get_friendly_name()); +#ifdef USE_DEVICES + if (this->device_ != nullptr) { + this->name_ = StringRef(this->device_->get_name()); + } else +#endif + { + this->name_ = StringRef(App.get_friendly_name()); + } this->flags_.has_own_name = false; } else { this->flags_.has_own_name = true; @@ -29,16 +36,21 @@ void EntityBase::set_icon(const char *icon) { this->icon_c_str_ = icon; } // Entity Object ID std::string EntityBase::get_object_id() const { + std::string suffix = ""; +#ifdef USE_DEVICES + if (this->device_ != nullptr) { + suffix = "@" + str_sanitize(str_snake_case(this->device_->get_name())); + } +#endif // Check if `App.get_friendly_name()` is constant or dynamic. if (!this->flags_.has_own_name && App.is_name_add_mac_suffix_enabled()) { // `App.get_friendly_name()` is dynamic. - return str_sanitize(str_snake_case(App.get_friendly_name())); - } else { - // `App.get_friendly_name()` is constant. + return str_sanitize(str_snake_case(App.get_friendly_name())) + suffix; + } else { // `App.get_friendly_name()` is constant. if (this->object_id_c_str_ == nullptr) { - return ""; + return suffix; } - return this->object_id_c_str_; + return this->object_id_c_str_ + suffix; } } void EntityBase::set_object_id(const char *object_id) { @@ -47,19 +59,7 @@ void EntityBase::set_object_id(const char *object_id) { } // Calculate Object ID Hash from Entity Name -void EntityBase::calc_object_id_() { - // Check if `App.get_friendly_name()` is constant or dynamic. - if (!this->flags_.has_own_name && App.is_name_add_mac_suffix_enabled()) { - // `App.get_friendly_name()` is dynamic. - const auto object_id = str_sanitize(str_snake_case(App.get_friendly_name())); - // FNV-1 hash - this->object_id_hash_ = fnv1_hash(object_id); - } else { - // `App.get_friendly_name()` is constant. - // FNV-1 hash - this->object_id_hash_ = fnv1_hash(this->object_id_c_str_); - } -} +void EntityBase::calc_object_id_() { this->object_id_hash_ = fnv1_hash(this->get_object_id()); } uint32_t EntityBase::get_object_id_hash() { return this->object_id_hash_; } diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 4bd04a9b1c..4819b66108 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -6,6 +6,10 @@ #include "helpers.h" #include "log.h" +#ifdef USE_DEVICES +#include "device.h" +#endif + namespace esphome { enum EntityCategory : uint8_t { @@ -53,8 +57,13 @@ class EntityBase { #ifdef USE_DEVICES // Get/set this entity's device id - uint32_t get_device_id() const { return this->device_id_; } - void set_device_id(const uint32_t device_id) { this->device_id_ = device_id; } + uint32_t get_device_id() const { + if (this->device_ == nullptr) { + return 0; // No device set, return 0 + } + return this->device_->get_device_id(); + } + void set_device(Device *device) { this->device_ = device; } #endif // Check if this entity has state @@ -74,7 +83,7 @@ class EntityBase { const char *icon_c_str_{nullptr}; uint32_t object_id_hash_{}; #ifdef USE_DEVICES - uint32_t device_id_{}; + Device *device_{}; #endif // Bit-packed flags to save memory (1 byte instead of 5) diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index 8d5440f591..e50be56092 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -17,7 +17,7 @@ from esphome.core import CORE, ID, coroutine from esphome.coroutine import FakeAwaitable from esphome.cpp_generator import add, get_variable from esphome.cpp_types import App -from esphome.helpers import fnv1a_32bit_hash, sanitize, snake_case +from esphome.helpers import sanitize, snake_case from esphome.types import ConfigFragmentType, ConfigType from esphome.util import Registry, RegistryEntry @@ -99,6 +99,11 @@ async def register_parented(var, value): async def setup_entity(var, config): """Set up generic properties of an Entity""" + if CONF_DEVICE_ID in config: + device_id: ID = config[CONF_DEVICE_ID] + device = await get_variable(device_id) + add(var.set_device(device)) + add(var.set_name(config[CONF_NAME])) if not config[CONF_NAME]: add(var.set_object_id(sanitize(snake_case(CORE.friendly_name)))) @@ -111,9 +116,6 @@ async def setup_entity(var, config): add(var.set_icon(config[CONF_ICON])) if CONF_ENTITY_CATEGORY in config: add(var.set_entity_category(config[CONF_ENTITY_CATEGORY])) - if CONF_DEVICE_ID in config: - device_id: ID = config[CONF_DEVICE_ID] - add(var.set_device_id(fnv1a_32bit_hash(device_id.id))) def extract_registry_entry_config( From e370872ec1baeb48b864daee44a6a00f7aee0e23 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Jun 2025 16:13:34 +0200 Subject: [PATCH 149/183] fix conflicts --- .../alarm_control_panel/__init__.py | 2 +- esphome/components/binary_sensor/__init__.py | 2 +- esphome/components/button/__init__.py | 2 +- esphome/components/climate/__init__.py | 2 +- esphome/components/cover/__init__.py | 2 +- esphome/components/datetime/__init__.py | 2 +- esphome/components/esp32_camera/__init__.py | 2 +- esphome/components/event/__init__.py | 2 +- esphome/components/fan/__init__.py | 2 +- esphome/components/light/__init__.py | 2 +- esphome/components/lock/__init__.py | 2 +- esphome/components/media_player/__init__.py | 2 +- esphome/components/number/__init__.py | 2 +- esphome/components/select/__init__.py | 2 +- esphome/components/sensor/__init__.py | 2 +- esphome/components/switch/__init__.py | 2 +- esphome/components/text/__init__.py | 2 +- esphome/components/text_sensor/__init__.py | 2 +- esphome/components/update/__init__.py | 2 +- esphome/components/valve/__init__.py | 2 +- esphome/core/__init__.py | 4 + esphome/core/entity_base.cpp | 12 +- esphome/cpp_helpers.py | 61 ++++++++- tests/unit_tests/conftest.py | 9 ++ tests/unit_tests/test_duplicate_entities.py | 129 ++++++++++++++++++ 25 files changed, 219 insertions(+), 36 deletions(-) create mode 100644 tests/unit_tests/test_duplicate_entities.py diff --git a/esphome/components/alarm_control_panel/__init__.py b/esphome/components/alarm_control_panel/__init__.py index e88050132a..3c35076de9 100644 --- a/esphome/components/alarm_control_panel/__init__.py +++ b/esphome/components/alarm_control_panel/__init__.py @@ -190,7 +190,7 @@ ALARM_CONTROL_PANEL_CONDITION_SCHEMA = maybe_simple_id( async def setup_alarm_control_panel_core_(var, config): - await setup_entity(var, config) + await setup_entity(var, config, "alarm_control_panel") for conf in config.get(CONF_ON_STATE, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await automation.build_automation(trigger, [], conf) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index bc26c09622..b34477d30a 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -521,7 +521,7 @@ BINARY_SENSOR_SCHEMA.add_extra(cv.deprecated_schema_constant("binary_sensor")) async def setup_binary_sensor_core_(var, config): - await setup_entity(var, config) + await setup_entity(var, config, "binary_sensor") if (device_class := config.get(CONF_DEVICE_CLASS)) is not None: cg.add(var.set_device_class(device_class)) diff --git a/esphome/components/button/__init__.py b/esphome/components/button/__init__.py index 892bf62f3a..c63073dd38 100644 --- a/esphome/components/button/__init__.py +++ b/esphome/components/button/__init__.py @@ -87,7 +87,7 @@ BUTTON_SCHEMA.add_extra(cv.deprecated_schema_constant("button")) async def setup_button_core_(var, config): - await setup_entity(var, config) + await setup_entity(var, config, "button") for conf in config.get(CONF_ON_PRESS, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index 52938a17d0..ff00565abf 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -273,7 +273,7 @@ CLIMATE_SCHEMA.add_extra(cv.deprecated_schema_constant("climate")) async def setup_climate_core_(var, config): - await setup_entity(var, config) + await setup_entity(var, config, "climate") visual = config[CONF_VISUAL] if (min_temp := visual.get(CONF_MIN_TEMPERATURE)) is not None: diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index 9fe7593eab..c7aec6493b 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -154,7 +154,7 @@ COVER_SCHEMA.add_extra(cv.deprecated_schema_constant("cover")) async def setup_cover_core_(var, config): - await setup_entity(var, config) + await setup_entity(var, config, "cover") if (device_class := config.get(CONF_DEVICE_CLASS)) is not None: cg.add(var.set_device_class(device_class)) diff --git a/esphome/components/datetime/__init__.py b/esphome/components/datetime/__init__.py index 24fbf5a1ec..42b29227c3 100644 --- a/esphome/components/datetime/__init__.py +++ b/esphome/components/datetime/__init__.py @@ -133,7 +133,7 @@ def datetime_schema(class_: MockObjClass) -> cv.Schema: async def setup_datetime_core_(var, config): - await setup_entity(var, config) + await setup_entity(var, config, "datetime") if (mqtt_id := config.get(CONF_MQTT_ID)) is not None: mqtt_ = cg.new_Pvariable(mqtt_id, var) diff --git a/esphome/components/esp32_camera/__init__.py b/esphome/components/esp32_camera/__init__.py index 05522265ae..68ba1ae549 100644 --- a/esphome/components/esp32_camera/__init__.py +++ b/esphome/components/esp32_camera/__init__.py @@ -284,7 +284,7 @@ SETTERS = { async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) - await setup_entity(var, config) + await setup_entity(var, config, "camera") await cg.register_component(var, config) for key, setter in SETTERS.items(): diff --git a/esphome/components/event/__init__.py b/esphome/components/event/__init__.py index e7ab489a25..1ff0d4e3d5 100644 --- a/esphome/components/event/__init__.py +++ b/esphome/components/event/__init__.py @@ -88,7 +88,7 @@ EVENT_SCHEMA.add_extra(cv.deprecated_schema_constant("event")) async def setup_event_core_(var, config, *, event_types: list[str]): - await setup_entity(var, config) + await setup_entity(var, config, "event") for conf in config.get(CONF_ON_EVENT, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) diff --git a/esphome/components/fan/__init__.py b/esphome/components/fan/__init__.py index c6ff938cd6..bebf760b0b 100644 --- a/esphome/components/fan/__init__.py +++ b/esphome/components/fan/__init__.py @@ -225,7 +225,7 @@ def validate_preset_modes(value): async def setup_fan_core_(var, config): - await setup_entity(var, config) + await setup_entity(var, config, "fan") cg.add(var.set_restore_mode(config[CONF_RESTORE_MODE])) diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index a013029fc2..902d661eb5 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -207,7 +207,7 @@ def validate_color_temperature_channels(value): async def setup_light_core_(light_var, output_var, config): - await setup_entity(light_var, config) + await setup_entity(light_var, config, "light") cg.add(light_var.set_restore_mode(config[CONF_RESTORE_MODE])) diff --git a/esphome/components/lock/__init__.py b/esphome/components/lock/__init__.py index 0fb67e3948..aa1061de53 100644 --- a/esphome/components/lock/__init__.py +++ b/esphome/components/lock/__init__.py @@ -94,7 +94,7 @@ LOCK_SCHEMA.add_extra(cv.deprecated_schema_constant("lock")) async def _setup_lock_core(var, config): - await setup_entity(var, config) + await setup_entity(var, config, "lock") for conf in config.get(CONF_ON_LOCK, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) diff --git a/esphome/components/media_player/__init__.py b/esphome/components/media_player/__init__.py index ef76419de3..c01bd24890 100644 --- a/esphome/components/media_player/__init__.py +++ b/esphome/components/media_player/__init__.py @@ -81,7 +81,7 @@ IsAnnouncingCondition = media_player_ns.class_( async def setup_media_player_core_(var, config): - await setup_entity(var, config) + await setup_entity(var, config, "media_player") for conf in config.get(CONF_ON_STATE, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await automation.build_automation(trigger, [], conf) diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index 2567d9ffe1..65a00bfe2f 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -237,7 +237,7 @@ NUMBER_SCHEMA.add_extra(cv.deprecated_schema_constant("number")) async def setup_number_core_( var, config, *, min_value: float, max_value: float, step: float ): - await setup_entity(var, config) + await setup_entity(var, config, "number") cg.add(var.traits.set_min_value(min_value)) cg.add(var.traits.set_max_value(max_value)) diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py index e14a9351a0..c3f8abec8f 100644 --- a/esphome/components/select/__init__.py +++ b/esphome/components/select/__init__.py @@ -89,7 +89,7 @@ SELECT_SCHEMA.add_extra(cv.deprecated_schema_constant("select")) async def setup_select_core_(var, config, *, options: list[str]): - await setup_entity(var, config) + await setup_entity(var, config, "select") cg.add(var.traits.set_options(options)) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 1ad3cfabee..749b7992b8 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -787,7 +787,7 @@ async def build_filters(config): async def setup_sensor_core_(var, config): - await setup_entity(var, config) + await setup_entity(var, config, "sensor") if (device_class := config.get(CONF_DEVICE_CLASS)) is not None: cg.add(var.set_device_class(device_class)) diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index 0211c648fc..322d547e95 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -131,7 +131,7 @@ SWITCH_SCHEMA.add_extra(cv.deprecated_schema_constant("switch")) async def setup_switch_core_(var, config): - await setup_entity(var, config) + await setup_entity(var, config, "switch") if (inverted := config.get(CONF_INVERTED)) is not None: cg.add(var.set_inverted(inverted)) diff --git a/esphome/components/text/__init__.py b/esphome/components/text/__init__.py index 40b3a90d6b..fc1b3d1b05 100644 --- a/esphome/components/text/__init__.py +++ b/esphome/components/text/__init__.py @@ -94,7 +94,7 @@ async def setup_text_core_( max_length: int | None, pattern: str | None, ): - await setup_entity(var, config) + await setup_entity(var, config, "text") cg.add(var.traits.set_min_length(min_length)) cg.add(var.traits.set_max_length(max_length)) diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index c7ac17c35a..38f0ae451e 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -186,7 +186,7 @@ async def build_filters(config): async def setup_text_sensor_core_(var, config): - await setup_entity(var, config) + await setup_entity(var, config, "text_sensor") if (device_class := config.get(CONF_DEVICE_CLASS)) is not None: cg.add(var.set_device_class(device_class)) diff --git a/esphome/components/update/__init__.py b/esphome/components/update/__init__.py index 09b0698903..061dd4589f 100644 --- a/esphome/components/update/__init__.py +++ b/esphome/components/update/__init__.py @@ -87,7 +87,7 @@ UPDATE_SCHEMA.add_extra(cv.deprecated_schema_constant("update")) async def setup_update_core_(var, config): - await setup_entity(var, config) + await setup_entity(var, config, "update") if device_class_config := config.get(CONF_DEVICE_CLASS): cg.add(var.set_device_class(device_class_config)) diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index a6f1428cd2..98c96f9afc 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -132,7 +132,7 @@ VALVE_SCHEMA.add_extra(cv.deprecated_schema_constant("valve")) async def _setup_valve_core(var, config): - await setup_entity(var, config) + await setup_entity(var, config, "valve") if device_class_config := config.get(CONF_DEVICE_CLASS): cg.add(var.set_device_class(device_class_config)) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index bc98ff54db..00c1db33ee 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -522,6 +522,9 @@ class EsphomeCore: # Dict to track platform entity counts for pre-allocation # Key: platform name (e.g. "sensor", "binary_sensor"), Value: count self.platform_counts: defaultdict[str, int] = defaultdict(int) + # Track entity unique IDs to handle duplicates + # Key: (device_id, platform, object_id), Value: count of duplicates + self.unique_ids: dict[tuple[int, str, str], int] = {} # Whether ESPHome was started in verbose mode self.verbose = False # Whether ESPHome was started in quiet mode @@ -553,6 +556,7 @@ class EsphomeCore: self.loaded_integrations = set() self.component_ids = set() self.platform_counts = defaultdict(int) + self.unique_ids = {} PIN_SCHEMA_REGISTRY.reset() @property diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index cf91e17a6a..7b86130f2f 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -36,21 +36,15 @@ void EntityBase::set_icon(const char *icon) { this->icon_c_str_ = icon; } // Entity Object ID std::string EntityBase::get_object_id() const { - std::string suffix = ""; -#ifdef USE_DEVICES - if (this->device_ != nullptr) { - suffix = "@" + str_sanitize(str_snake_case(this->device_->get_name())); - } -#endif // Check if `App.get_friendly_name()` is constant or dynamic. if (!this->flags_.has_own_name && App.is_name_add_mac_suffix_enabled()) { // `App.get_friendly_name()` is dynamic. - return str_sanitize(str_snake_case(App.get_friendly_name())) + suffix; + return str_sanitize(str_snake_case(App.get_friendly_name())); } else { // `App.get_friendly_name()` is constant. if (this->object_id_c_str_ == nullptr) { - return suffix; + return ""; } - return this->object_id_c_str_ + suffix; + return this->object_id_c_str_; } } void EntityBase::set_object_id(const char *object_id) { diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index e50be56092..ee91ac6132 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -15,7 +15,7 @@ from esphome.const import ( ) from esphome.core import CORE, ID, coroutine from esphome.coroutine import FakeAwaitable -from esphome.cpp_generator import add, get_variable +from esphome.cpp_generator import MockObj, add, get_variable from esphome.cpp_types import App from esphome.helpers import sanitize, snake_case from esphome.types import ConfigFragmentType, ConfigType @@ -97,18 +97,65 @@ async def register_parented(var, value): add(var.set_parent(paren)) -async def setup_entity(var, config): - """Set up generic properties of an Entity""" +async def setup_entity(var: MockObj, config: ConfigType, platform: str) -> None: + """Set up generic properties of an Entity. + + This function handles duplicate entity names by automatically appending + a suffix (_2, _3, etc.) when multiple entities have the same object_id + within the same platform and device combination. + + Args: + var: The entity variable to set up + config: Configuration dictionary containing entity settings + platform: The platform name (e.g., "sensor", "binary_sensor") + """ + # Get device info + device_id: int = 0 if CONF_DEVICE_ID in config: - device_id: ID = config[CONF_DEVICE_ID] - device = await get_variable(device_id) + device_id_obj: ID = config[CONF_DEVICE_ID] + device: MockObj = await get_variable(device_id_obj) add(var.set_device(device)) + # Use the device's ID hash as device_id + from esphome.helpers import fnv1a_32bit_hash + + device_id = fnv1a_32bit_hash(device_id_obj.id) add(var.set_name(config[CONF_NAME])) + + # Calculate base object_id + base_object_id: str if not config[CONF_NAME]: - add(var.set_object_id(sanitize(snake_case(CORE.friendly_name)))) + # Use the friendly name if available, otherwise use the device name + if CORE.friendly_name: + base_object_id = sanitize(snake_case(CORE.friendly_name)) + else: + base_object_id = sanitize(snake_case(CORE.name)) + _LOGGER.debug( + "Entity has empty name, using '%s' as object_id base", base_object_id + ) else: - add(var.set_object_id(sanitize(snake_case(config[CONF_NAME])))) + base_object_id = sanitize(snake_case(config[CONF_NAME])) + + # Handle duplicates + # Check for duplicates + unique_key: tuple[int, str, str] = (device_id, platform, base_object_id) + if unique_key in CORE.unique_ids: + # Found duplicate, add suffix + count = CORE.unique_ids[unique_key] + 1 + CORE.unique_ids[unique_key] = count + object_id = f"{base_object_id}_{count}" + _LOGGER.info( + "Duplicate %s entity '%s' found. Renaming to '%s'", + platform, + config[CONF_NAME], + object_id, + ) + else: + # First occurrence + CORE.unique_ids[unique_key] = 1 + object_id = base_object_id + + add(var.set_object_id(object_id)) add(var.set_disabled_by_default(config[CONF_DISABLED_BY_DEFAULT])) if CONF_INTERNAL in config: add(var.set_internal(config[CONF_INTERNAL])) diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index 955869b799..aac5a642f6 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -14,6 +14,8 @@ import sys import pytest +from esphome.core import CORE + here = Path(__file__).parent # Configure location of package root @@ -21,6 +23,13 @@ package_root = here.parent.parent sys.path.insert(0, package_root.as_posix()) +@pytest.fixture(autouse=True) +def reset_core(): + """Reset CORE after each test.""" + yield + CORE.reset() + + @pytest.fixture def fixture_path() -> Path: """ diff --git a/tests/unit_tests/test_duplicate_entities.py b/tests/unit_tests/test_duplicate_entities.py new file mode 100644 index 0000000000..ab075a02fc --- /dev/null +++ b/tests/unit_tests/test_duplicate_entities.py @@ -0,0 +1,129 @@ +"""Test duplicate entity object ID handling.""" + +import pytest + +from esphome.core import CORE +from esphome.helpers import sanitize, snake_case + + +@pytest.fixture +def setup_test_device() -> None: + """Set up test device configuration.""" + CORE.name = "test-device" + CORE.friendly_name = "Test Device" + + +def test_unique_key_generation() -> None: + """Test that unique keys are generated correctly.""" + # Test with no device + key1: tuple[int, str, str] = (0, "binary_sensor", "temperature") + assert key1 == (0, "binary_sensor", "temperature") + + # Test with device + key2: tuple[int, str, str] = (12345, "sensor", "humidity") + assert key2 == (12345, "sensor", "humidity") + + +def test_duplicate_tracking() -> None: + """Test that duplicates are tracked correctly.""" + # First occurrence + key: tuple[int, str, str] = (0, "sensor", "temperature") + assert key not in CORE.unique_ids + + CORE.unique_ids[key] = 1 + assert CORE.unique_ids[key] == 1 + + # Second occurrence + count: int = CORE.unique_ids[key] + 1 + CORE.unique_ids[key] = count + assert CORE.unique_ids[key] == 2 + + +def test_object_id_sanitization() -> None: + """Test that object IDs are properly sanitized.""" + # Test various inputs + assert sanitize(snake_case("Temperature Sensor")) == "temperature_sensor" + assert sanitize(snake_case("Living Room Light!")) == "living_room_light_" + assert sanitize(snake_case("Test-Device")) == "test-device" + assert sanitize(snake_case("")) == "" + + +def test_suffix_generation() -> None: + """Test that suffixes are generated correctly.""" + base_id: str = "temperature" + + # No suffix for first occurrence + object_id_1: str = base_id + assert object_id_1 == "temperature" + + # Add suffix for duplicates + count: int = 2 + object_id_2: str = f"{base_id}_{count}" + assert object_id_2 == "temperature_2" + + count = 3 + object_id_3: str = f"{base_id}_{count}" + assert object_id_3 == "temperature_3" + + +def test_different_platforms_same_name() -> None: + """Test that same name on different platforms doesn't conflict.""" + # Simulate two entities with same name on different platforms + key1: tuple[int, str, str] = (0, "binary_sensor", "status") + key2: tuple[int, str, str] = (0, "text_sensor", "status") + + # They should be different keys + assert key1 != key2 + + # Track them separately + CORE.unique_ids[key1] = 1 + CORE.unique_ids[key2] = 1 + + # Both should be at count 1 (no conflict) + assert CORE.unique_ids[key1] == 1 + assert CORE.unique_ids[key2] == 1 + + +def test_different_devices_same_name_platform() -> None: + """Test that same name+platform on different devices doesn't conflict.""" + # Simulate two entities with same name and platform but different devices + key1: tuple[int, str, str] = (12345, "sensor", "temperature") + key2: tuple[int, str, str] = (67890, "sensor", "temperature") + + # They should be different keys + assert key1 != key2 + + # Track them separately + CORE.unique_ids[key1] = 1 + CORE.unique_ids[key2] = 1 + + # Both should be at count 1 (no conflict) + assert CORE.unique_ids[key1] == 1 + assert CORE.unique_ids[key2] == 1 + + +def test_empty_name_handling(setup_test_device: None) -> None: + """Test handling of entities with empty names.""" + # When name is empty, it should use the device name + empty_name: str = "" + base_id: str + if not empty_name: + if CORE.friendly_name: + base_id = sanitize(snake_case(CORE.friendly_name)) + else: + base_id = sanitize(snake_case(CORE.name)) + + assert base_id == "test_device" # Uses friendly name + + +def test_reset_clears_unique_ids() -> None: + """Test that CORE.reset() clears the unique_ids tracking.""" + # Add some tracked IDs + CORE.unique_ids[(0, "sensor", "test")] = 2 + CORE.unique_ids[(0, "binary_sensor", "test")] = 3 + + assert len(CORE.unique_ids) == 2 + + # Reset should clear them + CORE.reset() + assert len(CORE.unique_ids) == 0 From c3776240b632571eed36829f192f91071bbaba0b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Jun 2025 17:03:23 +0200 Subject: [PATCH 150/183] fixes --- esphome/cpp_helpers.py | 21 +- esphome/entity.py | 41 ++++ .../fixtures/duplicate_entities.yaml | 118 +++++++++++ tests/integration/test_duplicate_entities.py | 187 ++++++++++++++++++ .../test_get_base_entity_object_id.py | 140 +++++++++++++ 5 files changed, 497 insertions(+), 10 deletions(-) create mode 100644 esphome/entity.py create mode 100644 tests/integration/fixtures/duplicate_entities.yaml create mode 100644 tests/integration/test_duplicate_entities.py create mode 100644 tests/unit_tests/test_get_base_entity_object_id.py diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index ee91ac6132..a1289485ca 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -17,7 +17,7 @@ from esphome.core import CORE, ID, coroutine from esphome.coroutine import FakeAwaitable from esphome.cpp_generator import MockObj, add, get_variable from esphome.cpp_types import App -from esphome.helpers import sanitize, snake_case +from esphome.entity import get_base_entity_object_id from esphome.types import ConfigFragmentType, ConfigType from esphome.util import Registry, RegistryEntry @@ -122,19 +122,14 @@ async def setup_entity(var: MockObj, config: ConfigType, platform: str) -> None: add(var.set_name(config[CONF_NAME])) - # Calculate base object_id - base_object_id: str + # Calculate base object_id using the same logic as C++ + # This must match the C++ behavior in esphome/core/entity_base.cpp + base_object_id = get_base_entity_object_id(config[CONF_NAME], CORE.friendly_name) + if not config[CONF_NAME]: - # Use the friendly name if available, otherwise use the device name - if CORE.friendly_name: - base_object_id = sanitize(snake_case(CORE.friendly_name)) - else: - base_object_id = sanitize(snake_case(CORE.name)) _LOGGER.debug( "Entity has empty name, using '%s' as object_id base", base_object_id ) - else: - base_object_id = sanitize(snake_case(config[CONF_NAME])) # Handle duplicates # Check for duplicates @@ -156,6 +151,12 @@ async def setup_entity(var: MockObj, config: ConfigType, platform: str) -> None: object_id = base_object_id add(var.set_object_id(object_id)) + _LOGGER.debug( + "Setting object_id '%s' for entity '%s' on platform '%s'", + object_id, + config[CONF_NAME], + platform, + ) add(var.set_disabled_by_default(config[CONF_DISABLED_BY_DEFAULT])) if CONF_INTERNAL in config: add(var.set_internal(config[CONF_INTERNAL])) diff --git a/esphome/entity.py b/esphome/entity.py new file mode 100644 index 0000000000..732822d0ff --- /dev/null +++ b/esphome/entity.py @@ -0,0 +1,41 @@ +"""Entity-related helper functions.""" + +from esphome.core import CORE +from esphome.helpers import sanitize, snake_case + + +def get_base_entity_object_id(name: str, friendly_name: str | None) -> str: + """Calculate the base object ID for an entity that will be set via set_object_id(). + + This function calculates what object_id_c_str_ should be set to in C++. + + The C++ EntityBase::get_object_id() (entity_base.cpp lines 38-49) works as: + - If !has_own_name && is_name_add_mac_suffix_enabled(): + return str_sanitize(str_snake_case(App.get_friendly_name())) // Dynamic + - Else: + return object_id_c_str_ ?? "" // What we set via set_object_id() + + Since we're calculating what to pass to set_object_id(), we always need to + generate the object_id the same way, regardless of name_add_mac_suffix setting. + + Args: + name: The entity name (empty string if no name) + friendly_name: The friendly name from CORE.friendly_name + + Returns: + The base object ID to use for duplicate checking and to pass to set_object_id() + """ + + if name: + # Entity has its own name (has_own_name will be true) + base_str = name + elif friendly_name: + # Entity has empty name (has_own_name will be false) + # Calculate what the object_id should be + # C++ uses App.get_friendly_name() which returns friendly_name or device name + base_str = friendly_name + else: + # Fallback to device name + base_str = CORE.name + + return sanitize(snake_case(base_str)) diff --git a/tests/integration/fixtures/duplicate_entities.yaml b/tests/integration/fixtures/duplicate_entities.yaml new file mode 100644 index 0000000000..0f831db90d --- /dev/null +++ b/tests/integration/fixtures/duplicate_entities.yaml @@ -0,0 +1,118 @@ +esphome: + name: duplicate-entities-test + # Define devices to test multi-device duplicate handling + devices: + - id: controller_1 + name: Controller 1 + - id: controller_2 + name: Controller 2 + +host: +api: # Port will be automatically injected +logger: + +# Create duplicate entities across different scenarios + +# Scenario 1: Multiple sensors with same name on same device (should get _2, _3, _4) +sensor: + - platform: template + name: Temperature + lambda: return 1.0; + update_interval: 0.1s + + - platform: template + name: Temperature + lambda: return 2.0; + update_interval: 0.1s + + - platform: template + name: Temperature + lambda: return 3.0; + update_interval: 0.1s + + - platform: template + name: Temperature + lambda: return 4.0; + update_interval: 0.1s + + # Scenario 2: Device-specific duplicates using device_id configuration + - platform: template + name: Device Temperature + device_id: controller_1 + lambda: return 10.0; + update_interval: 0.1s + + - platform: template + name: Device Temperature + device_id: controller_1 + lambda: return 11.0; + update_interval: 0.1s + + - platform: template + name: Device Temperature + device_id: controller_1 + lambda: return 12.0; + update_interval: 0.1s + + # Different device, same name - should not conflict + - platform: template + name: Device Temperature + device_id: controller_2 + lambda: return 20.0; + update_interval: 0.1s + +# Scenario 3: Binary sensors (different platform, same name) +binary_sensor: + - platform: template + name: Temperature + lambda: return true; + + - platform: template + name: Temperature + lambda: return false; + + - platform: template + name: Temperature + lambda: return true; + + # Scenario 5: Binary sensors on devices + - platform: template + name: Device Temperature + device_id: controller_1 + lambda: return true; + + - platform: template + name: Device Temperature + device_id: controller_2 + lambda: return false; + +# Scenario 6: Test with special characters that need sanitization +text_sensor: + - platform: template + name: "Status Message!" + lambda: return {"status1"}; + update_interval: 0.1s + + - platform: template + name: "Status Message!" + lambda: return {"status2"}; + update_interval: 0.1s + + - platform: template + name: "Status Message!" + lambda: return {"status3"}; + update_interval: 0.1s + +# Scenario 7: More switch duplicates +switch: + - platform: template + name: "Power Switch" + lambda: return false; + turn_on_action: [] + turn_off_action: [] + + - platform: template + name: "Power Switch" + lambda: return true; + turn_on_action: [] + turn_off_action: [] diff --git a/tests/integration/test_duplicate_entities.py b/tests/integration/test_duplicate_entities.py new file mode 100644 index 0000000000..edbcb9799c --- /dev/null +++ b/tests/integration/test_duplicate_entities.py @@ -0,0 +1,187 @@ +"""Integration test for duplicate entity handling.""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import EntityInfo +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_duplicate_entities( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that duplicate entity names are automatically suffixed with _2, _3, _4.""" + async with run_compiled(yaml_config), api_client_connected() as client: + # Get device info + device_info = await client.device_info() + assert device_info is not None + + # Get devices + devices = device_info.devices + assert len(devices) >= 2, f"Expected at least 2 devices, got {len(devices)}" + + # Find our test devices + controller_1 = next((d for d in devices if d.name == "Controller 1"), None) + controller_2 = next((d for d in devices if d.name == "Controller 2"), None) + + assert controller_1 is not None, "Controller 1 device not found" + assert controller_2 is not None, "Controller 2 device not found" + + # Get entity list + entities = await client.list_entities_services() + all_entities: list[EntityInfo] = [] + for entity_list in entities[0]: + if hasattr(entity_list, "object_id"): + all_entities.append(entity_list) + + # Group entities by type for easier testing + sensors = [e for e in all_entities if e.__class__.__name__ == "SensorInfo"] + binary_sensors = [ + e for e in all_entities if e.__class__.__name__ == "BinarySensorInfo" + ] + text_sensors = [ + e for e in all_entities if e.__class__.__name__ == "TextSensorInfo" + ] + switches = [e for e in all_entities if e.__class__.__name__ == "SwitchInfo"] + + # Scenario 1: Check sensors with duplicate "Temperature" names + temp_sensors = [s for s in sensors if s.name == "Temperature"] + temp_object_ids = sorted([s.object_id for s in temp_sensors]) + + # Should have temperature, temperature_2, temperature_3, temperature_4 + assert len(temp_object_ids) >= 4, ( + f"Expected at least 4 temperature sensors, got {len(temp_object_ids)}" + ) + assert "temperature" in temp_object_ids, ( + "First temperature sensor should not have suffix" + ) + assert "temperature_2" in temp_object_ids, ( + "Second temperature sensor should be temperature_2" + ) + assert "temperature_3" in temp_object_ids, ( + "Third temperature sensor should be temperature_3" + ) + assert "temperature_4" in temp_object_ids, ( + "Fourth temperature sensor should be temperature_4" + ) + + # Scenario 2: Check device-specific sensors don't conflict + device_temp_sensors = [s for s in sensors if s.name == "Device Temperature"] + + # Group by device + controller_1_temps = [ + s + for s in device_temp_sensors + if getattr(s, "device_id", None) == controller_1.device_id + ] + controller_2_temps = [ + s + for s in device_temp_sensors + if getattr(s, "device_id", None) == controller_2.device_id + ] + + # Controller 1 should have device_temperature, device_temperature_2, device_temperature_3 + c1_object_ids = sorted([s.object_id for s in controller_1_temps]) + assert len(c1_object_ids) >= 3, ( + f"Expected at least 3 sensors on controller_1, got {len(c1_object_ids)}" + ) + assert "device_temperature" in c1_object_ids, ( + "First device sensor should not have suffix" + ) + assert "device_temperature_2" in c1_object_ids, ( + "Second device sensor should be device_temperature_2" + ) + assert "device_temperature_3" in c1_object_ids, ( + "Third device sensor should be device_temperature_3" + ) + + # Controller 2 should have only device_temperature (no suffix) + c2_object_ids = [s.object_id for s in controller_2_temps] + assert len(c2_object_ids) >= 1, ( + f"Expected at least 1 sensor on controller_2, got {len(c2_object_ids)}" + ) + assert "device_temperature" in c2_object_ids, ( + "Controller 2 sensor should not have suffix" + ) + + # Scenario 3: Check binary sensors (different platform, same name) + temp_binary = [b for b in binary_sensors if b.name == "Temperature"] + binary_object_ids = sorted([b.object_id for b in temp_binary]) + + # Should have temperature, temperature_2, temperature_3 (no conflict with sensor platform) + assert len(binary_object_ids) >= 3, ( + f"Expected at least 3 binary sensors, got {len(binary_object_ids)}" + ) + assert "temperature" in binary_object_ids, ( + "First binary sensor should not have suffix" + ) + assert "temperature_2" in binary_object_ids, ( + "Second binary sensor should be temperature_2" + ) + assert "temperature_3" in binary_object_ids, ( + "Third binary sensor should be temperature_3" + ) + + # Scenario 4: Check text sensors with special characters + status_sensors = [t for t in text_sensors if t.name == "Status Message!"] + status_object_ids = sorted([t.object_id for t in status_sensors]) + + # Special characters should be sanitized to _ + assert len(status_object_ids) >= 3, ( + f"Expected at least 3 status sensors, got {len(status_object_ids)}" + ) + assert "status_message_" in status_object_ids, ( + "First status sensor should be status_message_" + ) + assert "status_message__2" in status_object_ids, ( + "Second status sensor should be status_message__2" + ) + assert "status_message__3" in status_object_ids, ( + "Third status sensor should be status_message__3" + ) + + # Scenario 5: Check switches with duplicate names + power_switches = [s for s in switches if s.name == "Power Switch"] + power_object_ids = sorted([s.object_id for s in power_switches]) + + # Should have power_switch, power_switch_2 + assert len(power_object_ids) >= 2, ( + f"Expected at least 2 power switches, got {len(power_object_ids)}" + ) + assert "power_switch" in power_object_ids, ( + "First power switch should be power_switch" + ) + assert "power_switch_2" in power_object_ids, ( + "Second power switch should be power_switch_2" + ) + + # Verify we can get states for all entities (ensures they're functional) + loop = asyncio.get_running_loop() + states_future: asyncio.Future[bool] = loop.create_future() + state_count = 0 + expected_count = ( + len(sensors) + len(binary_sensors) + len(text_sensors) + len(switches) + ) + + def on_state(state) -> None: + nonlocal state_count + state_count += 1 + if state_count >= expected_count and not states_future.done(): + states_future.set_result(True) + + client.subscribe_states(on_state) + + # Wait for all entity states + try: + await asyncio.wait_for(states_future, timeout=10.0) + except asyncio.TimeoutError: + pytest.fail( + f"Did not receive all entity states within 10 seconds. " + f"Expected {expected_count}, received {state_count}" + ) diff --git a/tests/unit_tests/test_get_base_entity_object_id.py b/tests/unit_tests/test_get_base_entity_object_id.py new file mode 100644 index 0000000000..aeea862d78 --- /dev/null +++ b/tests/unit_tests/test_get_base_entity_object_id.py @@ -0,0 +1,140 @@ +"""Test get_base_entity_object_id function matches C++ behavior.""" + +from esphome.core import CORE +from esphome.entity import get_base_entity_object_id +from esphome.helpers import sanitize, snake_case + + +class TestGetBaseEntityObjectId: + """Test that get_base_entity_object_id matches C++ EntityBase::get_object_id behavior.""" + + def test_with_entity_name(self) -> None: + """Test when entity has its own name - should use entity name.""" + # Simple name + assert ( + get_base_entity_object_id("Temperature Sensor", None) + == "temperature_sensor" + ) + assert ( + get_base_entity_object_id("Temperature Sensor", "Device Name") + == "temperature_sensor" + ) + + # Name with special characters + assert ( + get_base_entity_object_id("Temp!@#$%^&*()Sensor", None) + == "temp__________sensor" + ) + assert get_base_entity_object_id("Temp-Sensor_123", None) == "temp-sensor_123" + + # Already snake_case + assert ( + get_base_entity_object_id("temperature_sensor", None) + == "temperature_sensor" + ) + + # Mixed case + assert ( + get_base_entity_object_id("TemperatureSensor", None) == "temperaturesensor" + ) + assert ( + get_base_entity_object_id("TEMPERATURE SENSOR", None) + == "temperature_sensor" + ) + + def test_empty_name_with_friendly_name(self) -> None: + """Test when entity has empty name - should use friendly name.""" + # C++ behavior: when has_own_name is false, uses App.get_friendly_name() + assert get_base_entity_object_id("", "Friendly Device") == "friendly_device" + assert ( + get_base_entity_object_id("", "Kitchen Controller") == "kitchen_controller" + ) + assert get_base_entity_object_id("", "Test-Device_123") == "test-device_123" + + # Special characters in friendly name + assert get_base_entity_object_id("", "Device!@#$%") == "device_____" + + def test_empty_name_no_friendly_name(self) -> None: + """Test when entity has empty name and no friendly name - should use device name.""" + # Save original values + original_name = getattr(CORE, "name", None) + + try: + # Test with CORE.name set + CORE.name = "device-name" + assert get_base_entity_object_id("", None) == "device-name" + + CORE.name = "Test Device" + assert get_base_entity_object_id("", None) == "test_device" + + finally: + # Restore original value + if original_name is not None: + CORE.name = original_name + + def test_edge_cases(self) -> None: + """Test edge cases.""" + # Only spaces + assert get_base_entity_object_id(" ", None) == "___" + + # Unicode characters (should be replaced) + assert get_base_entity_object_id("Température", None) == "temp_rature" + assert get_base_entity_object_id("测试", None) == "__" + + # Empty string with empty friendly name (empty friendly name is treated as None) + # Falls back to CORE.name + original_name = getattr(CORE, "name", None) + try: + CORE.name = "device" + assert get_base_entity_object_id("", "") == "device" + finally: + if original_name is not None: + CORE.name = original_name + + # Very long name (should work fine) + long_name = "a" * 100 + " " + "b" * 100 + expected = "a" * 100 + "_" + "b" * 100 + assert get_base_entity_object_id(long_name, None) == expected + + def test_matches_cpp_helpers(self) -> None: + """Test that the logic matches using snake_case and sanitize directly.""" + test_cases = [ + ("Temperature Sensor", "temperature_sensor"), + ("Living Room Light", "living_room_light"), + ("Test-Device_123", "test-device_123"), + ("Special!@#Chars", "special___chars"), + ("UPPERCASE NAME", "uppercase_name"), + ("lowercase name", "lowercase_name"), + ("Mixed Case Name", "mixed_case_name"), + (" Spaces ", "___spaces___"), + ] + + for name, expected in test_cases: + # For non-empty names, verify our function produces same result as direct snake_case + sanitize + assert get_base_entity_object_id(name, None) == sanitize(snake_case(name)) + assert get_base_entity_object_id(name, None) == expected + + # Empty name is handled specially - it doesn't just use sanitize(snake_case("")) + # Instead it falls back to friendly_name or CORE.name + assert sanitize(snake_case("")) == "" # Direct conversion gives empty string + # But our function returns a fallback + original_name = getattr(CORE, "name", None) + try: + CORE.name = "device" + assert get_base_entity_object_id("", None) == "device" # Uses device name + finally: + if original_name is not None: + CORE.name = original_name + + def test_name_add_mac_suffix_behavior(self) -> None: + """Test behavior related to name_add_mac_suffix. + + In C++, when name_add_mac_suffix is enabled and entity has no name, + get_object_id() returns str_sanitize(str_snake_case(App.get_friendly_name())) + dynamically. Our function always returns the same result since we're + calculating the base for duplicate tracking. + """ + # The function should always return the same result regardless of + # name_add_mac_suffix setting, as we're calculating the base object_id + assert get_base_entity_object_id("", "Test Device") == "test_device" + assert get_base_entity_object_id("Entity Name", "Test Device") == "entity_name" From 2f8e07302b64c81871a5c695e6e533e50f9ceabb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Jun 2025 17:10:06 +0200 Subject: [PATCH 151/183] Update esphome/core/entity_base.cpp --- esphome/core/entity_base.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index 7b86130f2f..6afd02ff65 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -40,7 +40,8 @@ std::string EntityBase::get_object_id() const { if (!this->flags_.has_own_name && App.is_name_add_mac_suffix_enabled()) { // `App.get_friendly_name()` is dynamic. return str_sanitize(str_snake_case(App.get_friendly_name())); - } else { // `App.get_friendly_name()` is constant. + } else { + // `App.get_friendly_name()` is constant. if (this->object_id_c_str_ == nullptr) { return ""; } From 8c2b141049d86a55b1d8ff7da151b7910c2f98f3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Jun 2025 17:41:40 +0200 Subject: [PATCH 152/183] cleanup --- esphome/cpp_helpers.py | 81 +-- esphome/entity.py | 100 ++- .../fixtures/duplicate_entities.yaml | 93 +++ tests/integration/test_duplicate_entities.py | 79 +++ tests/unit_tests/test_duplicate_entities.py | 129 ---- tests/unit_tests/test_entity.py | 590 ++++++++++++++++++ .../test_get_base_entity_object_id.py | 140 ----- 7 files changed, 863 insertions(+), 349 deletions(-) delete mode 100644 tests/unit_tests/test_duplicate_entities.py create mode 100644 tests/unit_tests/test_entity.py delete mode 100644 tests/unit_tests/test_get_base_entity_object_id.py diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index a1289485ca..746a006348 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -1,12 +1,6 @@ import logging from esphome.const import ( - CONF_DEVICE_ID, - CONF_DISABLED_BY_DEFAULT, - CONF_ENTITY_CATEGORY, - CONF_ICON, - CONF_INTERNAL, - CONF_NAME, CONF_SAFE_MODE, CONF_SETUP_PRIORITY, CONF_TYPE_ID, @@ -15,9 +9,11 @@ from esphome.const import ( ) from esphome.core import CORE, ID, coroutine from esphome.coroutine import FakeAwaitable -from esphome.cpp_generator import MockObj, add, get_variable +from esphome.cpp_generator import add, get_variable from esphome.cpp_types import App -from esphome.entity import get_base_entity_object_id +from esphome.entity import ( # noqa: F401 # pylint: disable=unused-import + setup_entity, # Import for backward compatibility +) from esphome.types import ConfigFragmentType, ConfigType from esphome.util import Registry, RegistryEntry @@ -97,75 +93,6 @@ async def register_parented(var, value): add(var.set_parent(paren)) -async def setup_entity(var: MockObj, config: ConfigType, platform: str) -> None: - """Set up generic properties of an Entity. - - This function handles duplicate entity names by automatically appending - a suffix (_2, _3, etc.) when multiple entities have the same object_id - within the same platform and device combination. - - Args: - var: The entity variable to set up - config: Configuration dictionary containing entity settings - platform: The platform name (e.g., "sensor", "binary_sensor") - """ - # Get device info - device_id: int = 0 - if CONF_DEVICE_ID in config: - device_id_obj: ID = config[CONF_DEVICE_ID] - device: MockObj = await get_variable(device_id_obj) - add(var.set_device(device)) - # Use the device's ID hash as device_id - from esphome.helpers import fnv1a_32bit_hash - - device_id = fnv1a_32bit_hash(device_id_obj.id) - - add(var.set_name(config[CONF_NAME])) - - # Calculate base object_id using the same logic as C++ - # This must match the C++ behavior in esphome/core/entity_base.cpp - base_object_id = get_base_entity_object_id(config[CONF_NAME], CORE.friendly_name) - - if not config[CONF_NAME]: - _LOGGER.debug( - "Entity has empty name, using '%s' as object_id base", base_object_id - ) - - # Handle duplicates - # Check for duplicates - unique_key: tuple[int, str, str] = (device_id, platform, base_object_id) - if unique_key in CORE.unique_ids: - # Found duplicate, add suffix - count = CORE.unique_ids[unique_key] + 1 - CORE.unique_ids[unique_key] = count - object_id = f"{base_object_id}_{count}" - _LOGGER.info( - "Duplicate %s entity '%s' found. Renaming to '%s'", - platform, - config[CONF_NAME], - object_id, - ) - else: - # First occurrence - CORE.unique_ids[unique_key] = 1 - object_id = base_object_id - - add(var.set_object_id(object_id)) - _LOGGER.debug( - "Setting object_id '%s' for entity '%s' on platform '%s'", - object_id, - config[CONF_NAME], - platform, - ) - add(var.set_disabled_by_default(config[CONF_DISABLED_BY_DEFAULT])) - if CONF_INTERNAL in config: - add(var.set_internal(config[CONF_INTERNAL])) - if CONF_ICON in config: - add(var.set_icon(config[CONF_ICON])) - if CONF_ENTITY_CATEGORY in config: - add(var.set_entity_category(config[CONF_ENTITY_CATEGORY])) - - def extract_registry_entry_config( registry: Registry, full_config: ConfigType, diff --git a/esphome/entity.py b/esphome/entity.py index 732822d0ff..fa7f1ab7d9 100644 --- a/esphome/entity.py +++ b/esphome/entity.py @@ -1,10 +1,26 @@ """Entity-related helper functions.""" -from esphome.core import CORE +import logging + +from esphome.const import ( + CONF_DEVICE_ID, + CONF_DISABLED_BY_DEFAULT, + CONF_ENTITY_CATEGORY, + CONF_ICON, + CONF_INTERNAL, + CONF_NAME, +) +from esphome.core import CORE, ID +from esphome.cpp_generator import MockObj, add, get_variable from esphome.helpers import sanitize, snake_case +from esphome.types import ConfigType + +_LOGGER = logging.getLogger(__name__) -def get_base_entity_object_id(name: str, friendly_name: str | None) -> str: +def get_base_entity_object_id( + name: str, friendly_name: str | None, device_name: str | None = None +) -> str: """Calculate the base object ID for an entity that will be set via set_object_id(). This function calculates what object_id_c_str_ should be set to in C++. @@ -21,6 +37,7 @@ def get_base_entity_object_id(name: str, friendly_name: str | None) -> str: Args: name: The entity name (empty string if no name) friendly_name: The friendly name from CORE.friendly_name + device_name: The device name if entity is on a sub-device Returns: The base object ID to use for duplicate checking and to pass to set_object_id() @@ -29,9 +46,12 @@ def get_base_entity_object_id(name: str, friendly_name: str | None) -> str: if name: # Entity has its own name (has_own_name will be true) base_str = name + elif device_name: + # Entity has empty name and is on a sub-device + # C++ EntityBase::set_name() uses device->get_name() when device is set + base_str = device_name elif friendly_name: # Entity has empty name (has_own_name will be false) - # Calculate what the object_id should be # C++ uses App.get_friendly_name() which returns friendly_name or device name base_str = friendly_name else: @@ -39,3 +59,77 @@ def get_base_entity_object_id(name: str, friendly_name: str | None) -> str: base_str = CORE.name return sanitize(snake_case(base_str)) + + +async def setup_entity(var: MockObj, config: ConfigType, platform: str) -> None: + """Set up generic properties of an Entity. + + This function handles duplicate entity names by automatically appending + a suffix (_2, _3, etc.) when multiple entities have the same object_id + within the same platform and device combination. + + Args: + var: The entity variable to set up + config: Configuration dictionary containing entity settings + platform: The platform name (e.g., "sensor", "binary_sensor") + """ + # Get device info + device_id: int = 0 + device_name: str | None = None + if CONF_DEVICE_ID in config: + device_id_obj: ID = config[CONF_DEVICE_ID] + device: MockObj = await get_variable(device_id_obj) + add(var.set_device(device)) + # Use the device's ID hash as device_id + from esphome.helpers import fnv1a_32bit_hash + + device_id = fnv1a_32bit_hash(device_id_obj.id) + # Get device name for object ID calculation + device_name = device_id_obj.id + + add(var.set_name(config[CONF_NAME])) + + # Calculate base object_id using the same logic as C++ + # This must match the C++ behavior in esphome/core/entity_base.cpp + base_object_id = get_base_entity_object_id( + config[CONF_NAME], CORE.friendly_name, device_name + ) + + if not config[CONF_NAME]: + _LOGGER.debug( + "Entity has empty name, using '%s' as object_id base", base_object_id + ) + + # Handle duplicates + # Check for duplicates + unique_key: tuple[int, str, str] = (device_id, platform, base_object_id) + if unique_key in CORE.unique_ids: + # Found duplicate, add suffix + count = CORE.unique_ids[unique_key] + 1 + CORE.unique_ids[unique_key] = count + object_id = f"{base_object_id}_{count}" + _LOGGER.info( + "Duplicate %s entity '%s' found. Renaming to '%s'", + platform, + config[CONF_NAME], + object_id, + ) + else: + # First occurrence + CORE.unique_ids[unique_key] = 1 + object_id = base_object_id + + add(var.set_object_id(object_id)) + _LOGGER.debug( + "Setting object_id '%s' for entity '%s' on platform '%s'", + object_id, + config[CONF_NAME], + platform, + ) + add(var.set_disabled_by_default(config[CONF_DISABLED_BY_DEFAULT])) + if CONF_INTERNAL in config: + add(var.set_internal(config[CONF_INTERNAL])) + if CONF_ICON in config: + add(var.set_icon(config[CONF_ICON])) + if CONF_ENTITY_CATEGORY in config: + add(var.set_entity_category(config[CONF_ENTITY_CATEGORY])) diff --git a/tests/integration/fixtures/duplicate_entities.yaml b/tests/integration/fixtures/duplicate_entities.yaml index 0f831db90d..17332fe4b2 100644 --- a/tests/integration/fixtures/duplicate_entities.yaml +++ b/tests/integration/fixtures/duplicate_entities.yaml @@ -86,6 +86,22 @@ binary_sensor: device_id: controller_2 lambda: return false; + # Issue #6953: Empty names on binary sensors + - platform: template + name: "" + lambda: return true; + - platform: template + name: "" + lambda: return false; + + - platform: template + name: "" + lambda: return true; + + - platform: template + name: "" + lambda: return false; + # Scenario 6: Test with special characters that need sanitization text_sensor: - platform: template @@ -116,3 +132,80 @@ switch: lambda: return true; turn_on_action: [] turn_off_action: [] + + # Scenario 8: Issue #6953 - Multiple entities with empty names + # Empty names on main device - should use device name with suffixes + - platform: template + name: "" + lambda: return false; + turn_on_action: [] + turn_off_action: [] + + - platform: template + name: "" + lambda: return true; + turn_on_action: [] + turn_off_action: [] + + - platform: template + name: "" + lambda: return false; + turn_on_action: [] + turn_off_action: [] + + # Scenario 9: Issue #6953 - Empty names on sub-devices + # Empty names on sub-device - should use sub-device name with suffixes + - platform: template + name: "" + device_id: controller_1 + lambda: return false; + turn_on_action: [] + turn_off_action: [] + + - platform: template + name: "" + device_id: controller_1 + lambda: return true; + turn_on_action: [] + turn_off_action: [] + + - platform: template + name: "" + device_id: controller_1 + lambda: return false; + turn_on_action: [] + turn_off_action: [] + + # Empty names on different sub-device + - platform: template + name: "" + device_id: controller_2 + lambda: return false; + turn_on_action: [] + turn_off_action: [] + + - platform: template + name: "" + device_id: controller_2 + lambda: return true; + turn_on_action: [] + turn_off_action: [] + + # Scenario 10: Issue #6953 - Duplicate "xyz" names + - platform: template + name: "xyz" + lambda: return false; + turn_on_action: [] + turn_off_action: [] + + - platform: template + name: "xyz" + lambda: return true; + turn_on_action: [] + turn_off_action: [] + + - platform: template + name: "xyz" + lambda: return false; + turn_on_action: [] + turn_off_action: [] diff --git a/tests/integration/test_duplicate_entities.py b/tests/integration/test_duplicate_entities.py index edbcb9799c..ba40e6bd23 100644 --- a/tests/integration/test_duplicate_entities.py +++ b/tests/integration/test_duplicate_entities.py @@ -161,6 +161,85 @@ async def test_duplicate_entities( "Second power switch should be power_switch_2" ) + # Scenario 6: Check empty names on main device (Issue #6953) + empty_binary = [b for b in binary_sensors if b.name == ""] + empty_binary_ids = sorted([b.object_id for b in empty_binary]) + + # Should use device name "duplicate-entities-test" (sanitized, not snake_case) + assert len(empty_binary_ids) >= 4, ( + f"Expected at least 4 empty name binary sensors, got {len(empty_binary_ids)}" + ) + assert "duplicate-entities-test" in empty_binary_ids, ( + "First empty binary sensor should use device name" + ) + assert "duplicate-entities-test_2" in empty_binary_ids, ( + "Second empty binary sensor should be duplicate-entities-test_2" + ) + assert "duplicate-entities-test_3" in empty_binary_ids, ( + "Third empty binary sensor should be duplicate-entities-test_3" + ) + assert "duplicate-entities-test_4" in empty_binary_ids, ( + "Fourth empty binary sensor should be duplicate-entities-test_4" + ) + + # Scenario 7: Check empty names on sub-devices (Issue #6953) + empty_switches = [s for s in switches if s.name == ""] + + # Group by device + c1_empty_switches = [ + s + for s in empty_switches + if getattr(s, "device_id", None) == controller_1.device_id + ] + c2_empty_switches = [ + s + for s in empty_switches + if getattr(s, "device_id", None) == controller_2.device_id + ] + main_empty_switches = [ + s + for s in empty_switches + if getattr(s, "device_id", None) + not in [controller_1.device_id, controller_2.device_id] + ] + + # Controller 1 empty switches should use "controller_1" + c1_empty_ids = sorted([s.object_id for s in c1_empty_switches]) + assert len(c1_empty_ids) >= 3, ( + f"Expected at least 3 empty switches on controller_1, got {len(c1_empty_ids)}" + ) + assert "controller_1" in c1_empty_ids, "First should be controller_1" + assert "controller_1_2" in c1_empty_ids, "Second should be controller_1_2" + assert "controller_1_3" in c1_empty_ids, "Third should be controller_1_3" + + # Controller 2 empty switches + c2_empty_ids = sorted([s.object_id for s in c2_empty_switches]) + assert len(c2_empty_ids) >= 2, ( + f"Expected at least 2 empty switches on controller_2, got {len(c2_empty_ids)}" + ) + assert "controller_2" in c2_empty_ids, "First should be controller_2" + assert "controller_2_2" in c2_empty_ids, "Second should be controller_2_2" + + # Main device empty switches + main_empty_ids = sorted([s.object_id for s in main_empty_switches]) + assert len(main_empty_ids) >= 3, ( + f"Expected at least 3 empty switches on main device, got {len(main_empty_ids)}" + ) + assert "duplicate-entities-test" in main_empty_ids + assert "duplicate-entities-test_2" in main_empty_ids + assert "duplicate-entities-test_3" in main_empty_ids + + # Scenario 8: Check "xyz" duplicates (Issue #6953) + xyz_switches = [s for s in switches if s.name == "xyz"] + xyz_ids = sorted([s.object_id for s in xyz_switches]) + + assert len(xyz_ids) >= 3, ( + f"Expected at least 3 xyz switches, got {len(xyz_ids)}" + ) + assert "xyz" in xyz_ids, "First xyz switch should be xyz" + assert "xyz_2" in xyz_ids, "Second xyz switch should be xyz_2" + assert "xyz_3" in xyz_ids, "Third xyz switch should be xyz_3" + # Verify we can get states for all entities (ensures they're functional) loop = asyncio.get_running_loop() states_future: asyncio.Future[bool] = loop.create_future() diff --git a/tests/unit_tests/test_duplicate_entities.py b/tests/unit_tests/test_duplicate_entities.py deleted file mode 100644 index ab075a02fc..0000000000 --- a/tests/unit_tests/test_duplicate_entities.py +++ /dev/null @@ -1,129 +0,0 @@ -"""Test duplicate entity object ID handling.""" - -import pytest - -from esphome.core import CORE -from esphome.helpers import sanitize, snake_case - - -@pytest.fixture -def setup_test_device() -> None: - """Set up test device configuration.""" - CORE.name = "test-device" - CORE.friendly_name = "Test Device" - - -def test_unique_key_generation() -> None: - """Test that unique keys are generated correctly.""" - # Test with no device - key1: tuple[int, str, str] = (0, "binary_sensor", "temperature") - assert key1 == (0, "binary_sensor", "temperature") - - # Test with device - key2: tuple[int, str, str] = (12345, "sensor", "humidity") - assert key2 == (12345, "sensor", "humidity") - - -def test_duplicate_tracking() -> None: - """Test that duplicates are tracked correctly.""" - # First occurrence - key: tuple[int, str, str] = (0, "sensor", "temperature") - assert key not in CORE.unique_ids - - CORE.unique_ids[key] = 1 - assert CORE.unique_ids[key] == 1 - - # Second occurrence - count: int = CORE.unique_ids[key] + 1 - CORE.unique_ids[key] = count - assert CORE.unique_ids[key] == 2 - - -def test_object_id_sanitization() -> None: - """Test that object IDs are properly sanitized.""" - # Test various inputs - assert sanitize(snake_case("Temperature Sensor")) == "temperature_sensor" - assert sanitize(snake_case("Living Room Light!")) == "living_room_light_" - assert sanitize(snake_case("Test-Device")) == "test-device" - assert sanitize(snake_case("")) == "" - - -def test_suffix_generation() -> None: - """Test that suffixes are generated correctly.""" - base_id: str = "temperature" - - # No suffix for first occurrence - object_id_1: str = base_id - assert object_id_1 == "temperature" - - # Add suffix for duplicates - count: int = 2 - object_id_2: str = f"{base_id}_{count}" - assert object_id_2 == "temperature_2" - - count = 3 - object_id_3: str = f"{base_id}_{count}" - assert object_id_3 == "temperature_3" - - -def test_different_platforms_same_name() -> None: - """Test that same name on different platforms doesn't conflict.""" - # Simulate two entities with same name on different platforms - key1: tuple[int, str, str] = (0, "binary_sensor", "status") - key2: tuple[int, str, str] = (0, "text_sensor", "status") - - # They should be different keys - assert key1 != key2 - - # Track them separately - CORE.unique_ids[key1] = 1 - CORE.unique_ids[key2] = 1 - - # Both should be at count 1 (no conflict) - assert CORE.unique_ids[key1] == 1 - assert CORE.unique_ids[key2] == 1 - - -def test_different_devices_same_name_platform() -> None: - """Test that same name+platform on different devices doesn't conflict.""" - # Simulate two entities with same name and platform but different devices - key1: tuple[int, str, str] = (12345, "sensor", "temperature") - key2: tuple[int, str, str] = (67890, "sensor", "temperature") - - # They should be different keys - assert key1 != key2 - - # Track them separately - CORE.unique_ids[key1] = 1 - CORE.unique_ids[key2] = 1 - - # Both should be at count 1 (no conflict) - assert CORE.unique_ids[key1] == 1 - assert CORE.unique_ids[key2] == 1 - - -def test_empty_name_handling(setup_test_device: None) -> None: - """Test handling of entities with empty names.""" - # When name is empty, it should use the device name - empty_name: str = "" - base_id: str - if not empty_name: - if CORE.friendly_name: - base_id = sanitize(snake_case(CORE.friendly_name)) - else: - base_id = sanitize(snake_case(CORE.name)) - - assert base_id == "test_device" # Uses friendly name - - -def test_reset_clears_unique_ids() -> None: - """Test that CORE.reset() clears the unique_ids tracking.""" - # Add some tracked IDs - CORE.unique_ids[(0, "sensor", "test")] = 2 - CORE.unique_ids[(0, "binary_sensor", "test")] = 3 - - assert len(CORE.unique_ids) == 2 - - # Reset should clear them - CORE.reset() - assert len(CORE.unique_ids) == 0 diff --git a/tests/unit_tests/test_entity.py b/tests/unit_tests/test_entity.py new file mode 100644 index 0000000000..6cdf5369ae --- /dev/null +++ b/tests/unit_tests/test_entity.py @@ -0,0 +1,590 @@ +"""Test get_base_entity_object_id function matches C++ behavior.""" + +from collections.abc import Generator +from typing import Any + +import pytest + +from esphome import entity +from esphome.const import CONF_DEVICE_ID, CONF_DISABLED_BY_DEFAULT, CONF_ICON, CONF_NAME +from esphome.core import CORE, ID +from esphome.cpp_generator import MockObj +from esphome.entity import get_base_entity_object_id, setup_entity +from esphome.helpers import sanitize, snake_case + + +@pytest.fixture(autouse=True) +def restore_core_state() -> Generator[None, None, None]: + """Save and restore CORE state for tests.""" + original_name = CORE.name + original_friendly_name = CORE.friendly_name + yield + CORE.name = original_name + CORE.friendly_name = original_friendly_name + + +def test_with_entity_name() -> None: + """Test when entity has its own name - should use entity name.""" + # Simple name + assert get_base_entity_object_id("Temperature Sensor", None) == "temperature_sensor" + assert ( + get_base_entity_object_id("Temperature Sensor", "Device Name") + == "temperature_sensor" + ) + # Even with device name, entity name takes precedence + assert ( + get_base_entity_object_id("Temperature Sensor", "Device Name", "Sub Device") + == "temperature_sensor" + ) + + # Name with special characters + assert ( + get_base_entity_object_id("Temp!@#$%^&*()Sensor", None) + == "temp__________sensor" + ) + assert get_base_entity_object_id("Temp-Sensor_123", None) == "temp-sensor_123" + + # Already snake_case + assert get_base_entity_object_id("temperature_sensor", None) == "temperature_sensor" + + # Mixed case + assert get_base_entity_object_id("TemperatureSensor", None) == "temperaturesensor" + assert get_base_entity_object_id("TEMPERATURE SENSOR", None) == "temperature_sensor" + + +def test_empty_name_with_device_name() -> None: + """Test when entity has empty name and is on a sub-device - should use device name.""" + # C++ behavior: when has_own_name is false and device is set, uses device->get_name() + assert ( + get_base_entity_object_id("", "Friendly Device", "Sub Device 1") + == "sub_device_1" + ) + assert ( + get_base_entity_object_id("", "Kitchen Controller", "controller_1") + == "controller_1" + ) + assert get_base_entity_object_id("", None, "Test-Device_123") == "test-device_123" + + +def test_empty_name_with_friendly_name() -> None: + """Test when entity has empty name and no device - should use friendly name.""" + # C++ behavior: when has_own_name is false, uses App.get_friendly_name() + assert get_base_entity_object_id("", "Friendly Device") == "friendly_device" + assert get_base_entity_object_id("", "Kitchen Controller") == "kitchen_controller" + assert get_base_entity_object_id("", "Test-Device_123") == "test-device_123" + + # Special characters in friendly name + assert get_base_entity_object_id("", "Device!@#$%") == "device_____" + + +def test_empty_name_no_friendly_name() -> None: + """Test when entity has empty name and no friendly name - should use device name.""" + # Test with CORE.name set + CORE.name = "device-name" + assert get_base_entity_object_id("", None) == "device-name" + + CORE.name = "Test Device" + assert get_base_entity_object_id("", None) == "test_device" + + +def test_edge_cases() -> None: + """Test edge cases.""" + # Only spaces + assert get_base_entity_object_id(" ", None) == "___" + + # Unicode characters (should be replaced) + assert get_base_entity_object_id("Température", None) == "temp_rature" + assert get_base_entity_object_id("测试", None) == "__" + + # Empty string with empty friendly name (empty friendly name is treated as None) + # Falls back to CORE.name + CORE.name = "device" + assert get_base_entity_object_id("", "") == "device" + + # Very long name (should work fine) + long_name = "a" * 100 + " " + "b" * 100 + expected = "a" * 100 + "_" + "b" * 100 + assert get_base_entity_object_id(long_name, None) == expected + + +def test_matches_cpp_helpers() -> None: + """Test that the logic matches using snake_case and sanitize directly.""" + test_cases = [ + ("Temperature Sensor", "temperature_sensor"), + ("Living Room Light", "living_room_light"), + ("Test-Device_123", "test-device_123"), + ("Special!@#Chars", "special___chars"), + ("UPPERCASE NAME", "uppercase_name"), + ("lowercase name", "lowercase_name"), + ("Mixed Case Name", "mixed_case_name"), + (" Spaces ", "___spaces___"), + ] + + for name, expected in test_cases: + # For non-empty names, verify our function produces same result as direct snake_case + sanitize + assert get_base_entity_object_id(name, None) == sanitize(snake_case(name)) + assert get_base_entity_object_id(name, None) == expected + + # Empty name is handled specially - it doesn't just use sanitize(snake_case("")) + # Instead it falls back to friendly_name or CORE.name + assert sanitize(snake_case("")) == "" # Direct conversion gives empty string + # But our function returns a fallback + CORE.name = "device" + assert get_base_entity_object_id("", None) == "device" # Uses device name + + +def test_name_add_mac_suffix_behavior() -> None: + """Test behavior related to name_add_mac_suffix. + + In C++, when name_add_mac_suffix is enabled and entity has no name, + get_object_id() returns str_sanitize(str_snake_case(App.get_friendly_name())) + dynamically. Our function always returns the same result since we're + calculating the base for duplicate tracking. + """ + # The function should always return the same result regardless of + # name_add_mac_suffix setting, as we're calculating the base object_id + assert get_base_entity_object_id("", "Test Device") == "test_device" + assert get_base_entity_object_id("Entity Name", "Test Device") == "entity_name" + + +def test_priority_order() -> None: + """Test the priority order: entity name > device name > friendly name > CORE.name.""" + CORE.name = "core-device" + + # 1. Entity name has highest priority + assert ( + get_base_entity_object_id("Entity Name", "Friendly Name", "Device Name") + == "entity_name" + ) + + # 2. Device name is next priority (when entity name is empty) + assert ( + get_base_entity_object_id("", "Friendly Name", "Device Name") == "device_name" + ) + + # 3. Friendly name is next (when entity and device names are empty) + assert get_base_entity_object_id("", "Friendly Name", None) == "friendly_name" + + # 4. CORE.name is last resort + assert get_base_entity_object_id("", None, None) == "core-device" + + +def test_real_world_examples() -> None: + """Test real-world entity naming scenarios.""" + # Common ESPHome entity names + test_cases = [ + # name, friendly_name, device_name, expected + ("Living Room Light", None, None, "living_room_light"), + ("", "Kitchen Controller", None, "kitchen_controller"), + ( + "", + "ESP32 Device", + "controller_1", + "controller_1", + ), # Device name takes precedence + ("GPIO2 Button", None, None, "gpio2_button"), + ("WiFi Signal", "My Device", None, "wifi_signal"), + ("", None, "esp32_node", "esp32_node"), + ("Front Door Sensor", "Home Assistant", "door_controller", "front_door_sensor"), + ] + + for name, friendly_name, device_name, expected in test_cases: + result = get_base_entity_object_id(name, friendly_name, device_name) + assert result == expected, ( + f"Failed for {name=}, {friendly_name=}, {device_name=}: {result=}, {expected=}" + ) + + +def test_issue_6953_scenarios() -> None: + """Test specific scenarios from issue #6953.""" + # Scenario 1: Multiple empty names on main device with name_add_mac_suffix + # The Python code calculates the base, C++ might append MAC suffix dynamically + CORE.name = "device-name" + CORE.friendly_name = "Friendly Device" + + # All empty names should resolve to same base + assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device" + assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device" + assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device" + + # Scenario 2: Empty names on sub-devices + assert ( + get_base_entity_object_id("", "Main Device", "controller_1") == "controller_1" + ) + assert ( + get_base_entity_object_id("", "Main Device", "controller_2") == "controller_2" + ) + + # Scenario 3: xyz duplicates + assert get_base_entity_object_id("xyz", None) == "xyz" + assert get_base_entity_object_id("xyz", "Device") == "xyz" + + +# Tests for setup_entity function + + +@pytest.fixture +def setup_test_environment() -> Generator[list[str], None, None]: + """Set up test environment for setup_entity tests.""" + # Reset CORE state + CORE.reset() + CORE.name = "test-device" + CORE.friendly_name = "Test Device" + # Store original add function + + original_add = entity.add + # Track what gets added + added_expressions = [] + + def mock_add(expression: Any) -> Any: + added_expressions.append(str(expression)) + return original_add(expression) + + # Patch add function in entity module + entity.add = mock_add + yield added_expressions + # Clean up + entity.add = original_add + CORE.reset() + + +def extract_object_id_from_expressions(expressions: list[str]) -> str | None: + """Extract the object ID that was set from the generated expressions.""" + for expr in expressions: + # Look for set_object_id calls + if ".set_object_id(" in expr: + # Extract the ID from something like: var.set_object_id("temperature_2") + start = expr.find('"') + 1 + end = expr.rfind('"') + if start > 0 and end > start: + return expr[start:end] + return None + + +@pytest.mark.asyncio +async def test_setup_entity_no_duplicates(setup_test_environment: list[str]) -> None: + """Test setup_entity with unique names.""" + + added_expressions = setup_test_environment + + # Create mock entities + var1 = MockObj("sensor1") + var2 = MockObj("sensor2") + + # Set up first entity + config1 = { + CONF_NAME: "Temperature", + CONF_DISABLED_BY_DEFAULT: False, + } + await setup_entity(var1, config1, "sensor") + + # Get object ID from first entity + object_id1 = extract_object_id_from_expressions(added_expressions) + assert object_id1 == "temperature" + + # Clear for next entity + added_expressions.clear() + + # Set up second entity with different name + config2 = { + CONF_NAME: "Humidity", + CONF_DISABLED_BY_DEFAULT: False, + } + await setup_entity(var2, config2, "sensor") + + # Get object ID from second entity + object_id2 = extract_object_id_from_expressions(added_expressions) + assert object_id2 == "humidity" + + +@pytest.mark.asyncio +async def test_setup_entity_with_duplicates(setup_test_environment: list[str]) -> None: + """Test setup_entity with duplicate names.""" + + added_expressions = setup_test_environment + + # Create mock entities + entities = [MockObj(f"sensor{i}") for i in range(4)] + + # Set up entities with same name + config = { + CONF_NAME: "Temperature", + CONF_DISABLED_BY_DEFAULT: False, + } + + object_ids = [] + for var in entities: + added_expressions.clear() + await setup_entity(var, config, "sensor") + object_id = extract_object_id_from_expressions(added_expressions) + object_ids.append(object_id) + + # Check that object IDs were set with proper suffixes + assert object_ids[0] == "temperature" + assert object_ids[1] == "temperature_2" + assert object_ids[2] == "temperature_3" + assert object_ids[3] == "temperature_4" + + +@pytest.mark.asyncio +async def test_setup_entity_different_platforms( + setup_test_environment: list[str], +) -> None: + """Test that same name on different platforms doesn't conflict.""" + + added_expressions = setup_test_environment + + # Create mock entities + sensor = MockObj("sensor1") + binary_sensor = MockObj("binary_sensor1") + text_sensor = MockObj("text_sensor1") + + config = { + CONF_NAME: "Status", + CONF_DISABLED_BY_DEFAULT: False, + } + + # Set up entities on different platforms + platforms = [ + (sensor, "sensor"), + (binary_sensor, "binary_sensor"), + (text_sensor, "text_sensor"), + ] + + object_ids = [] + for var, platform in platforms: + added_expressions.clear() + await setup_entity(var, config, platform) + object_id = extract_object_id_from_expressions(added_expressions) + object_ids.append(object_id) + + # All should get base object ID without suffix + assert all(obj_id == "status" for obj_id in object_ids) + + +@pytest.mark.asyncio +async def test_setup_entity_with_devices(setup_test_environment: list[str]) -> None: + """Test that same name on different devices doesn't conflict.""" + + added_expressions = setup_test_environment + + # Create mock devices + device1_id = ID("device1", type="Device") + device2_id = ID("device2", type="Device") + + device1 = MockObj("device1_obj") + device2 = MockObj("device2_obj") + + # Mock get_variable to return our devices + original_get_variable = entity.get_variable + + async def mock_get_variable(device_id: ID) -> MockObj: + if device_id == device1_id: + return device1 + elif device_id == device2_id: + return device2 + return await original_get_variable(device_id) + + entity.get_variable = mock_get_variable + + try: + # Create sensors with same name on different devices + sensor1 = MockObj("sensor1") + sensor2 = MockObj("sensor2") + + config1 = { + CONF_NAME: "Temperature", + CONF_DEVICE_ID: device1_id, + CONF_DISABLED_BY_DEFAULT: False, + } + + config2 = { + CONF_NAME: "Temperature", + CONF_DEVICE_ID: device2_id, + CONF_DISABLED_BY_DEFAULT: False, + } + + # Get object IDs + object_ids = [] + for var, config in [(sensor1, config1), (sensor2, config2)]: + added_expressions.clear() + await setup_entity(var, config, "sensor") + object_id = extract_object_id_from_expressions(added_expressions) + object_ids.append(object_id) + + # Both should get base object ID without suffix (different devices) + assert object_ids[0] == "temperature" + assert object_ids[1] == "temperature" + + finally: + entity.get_variable = original_get_variable + + +@pytest.mark.asyncio +async def test_setup_entity_empty_name(setup_test_environment: list[str]) -> None: + """Test setup_entity with empty entity name.""" + + added_expressions = setup_test_environment + + var = MockObj("sensor1") + + config = { + CONF_NAME: "", + CONF_DISABLED_BY_DEFAULT: False, + } + + await setup_entity(var, config, "sensor") + + object_id = extract_object_id_from_expressions(added_expressions) + # Should use friendly name + assert object_id == "test_device" + + +@pytest.mark.asyncio +async def test_setup_entity_empty_name_duplicates( + setup_test_environment: list[str], +) -> None: + """Test setup_entity with multiple empty names.""" + + added_expressions = setup_test_environment + + entities = [MockObj(f"sensor{i}") for i in range(3)] + + config = { + CONF_NAME: "", + CONF_DISABLED_BY_DEFAULT: False, + } + + object_ids = [] + for var in entities: + added_expressions.clear() + await setup_entity(var, config, "sensor") + object_id = extract_object_id_from_expressions(added_expressions) + object_ids.append(object_id) + + # Should use device name with suffixes + assert object_ids[0] == "test_device" + assert object_ids[1] == "test_device_2" + assert object_ids[2] == "test_device_3" + + +@pytest.mark.asyncio +async def test_setup_entity_special_characters( + setup_test_environment: list[str], +) -> None: + """Test setup_entity with names containing special characters.""" + + added_expressions = setup_test_environment + + entities = [MockObj(f"sensor{i}") for i in range(3)] + + config = { + CONF_NAME: "Temperature Sensor!", + CONF_DISABLED_BY_DEFAULT: False, + } + + object_ids = [] + for var in entities: + added_expressions.clear() + await setup_entity(var, config, "sensor") + object_id = extract_object_id_from_expressions(added_expressions) + object_ids.append(object_id) + + # Special characters should be sanitized + assert object_ids[0] == "temperature_sensor_" + assert object_ids[1] == "temperature_sensor__2" + assert object_ids[2] == "temperature_sensor__3" + + +@pytest.mark.asyncio +async def test_setup_entity_with_icon(setup_test_environment: list[str]) -> None: + """Test setup_entity sets icon correctly.""" + + added_expressions = setup_test_environment + + var = MockObj("sensor1") + + config = { + CONF_NAME: "Temperature", + CONF_DISABLED_BY_DEFAULT: False, + CONF_ICON: "mdi:thermometer", + } + + await setup_entity(var, config, "sensor") + + # Check icon was set + icon_set = any( + ".set_icon(" in expr and "mdi:thermometer" in expr for expr in added_expressions + ) + assert icon_set + + +@pytest.mark.asyncio +async def test_setup_entity_disabled_by_default( + setup_test_environment: list[str], +) -> None: + """Test setup_entity sets disabled_by_default correctly.""" + + added_expressions = setup_test_environment + + var = MockObj("sensor1") + + config = { + CONF_NAME: "Temperature", + CONF_DISABLED_BY_DEFAULT: True, + } + + await setup_entity(var, config, "sensor") + + # Check disabled_by_default was set + disabled_set = any( + ".set_disabled_by_default(true)" in expr.lower() for expr in added_expressions + ) + assert disabled_set + + +@pytest.mark.asyncio +async def test_setup_entity_mixed_duplicates(setup_test_environment: list[str]) -> None: + """Test complex duplicate scenario with multiple platforms and devices.""" + + added_expressions = setup_test_environment + + # Track results + results = [] + + # 3 sensors named "Status" + for i in range(3): + added_expressions.clear() + var = MockObj(f"sensor_status_{i}") + await setup_entity( + var, {CONF_NAME: "Status", CONF_DISABLED_BY_DEFAULT: False}, "sensor" + ) + object_id = extract_object_id_from_expressions(added_expressions) + results.append(("sensor", object_id)) + + # 2 binary_sensors named "Status" + for i in range(2): + added_expressions.clear() + var = MockObj(f"binary_sensor_status_{i}") + await setup_entity( + var, {CONF_NAME: "Status", CONF_DISABLED_BY_DEFAULT: False}, "binary_sensor" + ) + object_id = extract_object_id_from_expressions(added_expressions) + results.append(("binary_sensor", object_id)) + + # 1 text_sensor named "Status" + added_expressions.clear() + var = MockObj("text_sensor_status") + await setup_entity( + var, {CONF_NAME: "Status", CONF_DISABLED_BY_DEFAULT: False}, "text_sensor" + ) + object_id = extract_object_id_from_expressions(added_expressions) + results.append(("text_sensor", object_id)) + + # Check results - each platform has its own namespace + assert results[0] == ("sensor", "status") # sensor + assert results[1] == ("sensor", "status_2") # sensor + assert results[2] == ("sensor", "status_3") # sensor + assert results[3] == ("binary_sensor", "status") # binary_sensor (new namespace) + assert results[4] == ("binary_sensor", "status_2") # binary_sensor + assert results[5] == ("text_sensor", "status") # text_sensor (new namespace) diff --git a/tests/unit_tests/test_get_base_entity_object_id.py b/tests/unit_tests/test_get_base_entity_object_id.py deleted file mode 100644 index aeea862d78..0000000000 --- a/tests/unit_tests/test_get_base_entity_object_id.py +++ /dev/null @@ -1,140 +0,0 @@ -"""Test get_base_entity_object_id function matches C++ behavior.""" - -from esphome.core import CORE -from esphome.entity import get_base_entity_object_id -from esphome.helpers import sanitize, snake_case - - -class TestGetBaseEntityObjectId: - """Test that get_base_entity_object_id matches C++ EntityBase::get_object_id behavior.""" - - def test_with_entity_name(self) -> None: - """Test when entity has its own name - should use entity name.""" - # Simple name - assert ( - get_base_entity_object_id("Temperature Sensor", None) - == "temperature_sensor" - ) - assert ( - get_base_entity_object_id("Temperature Sensor", "Device Name") - == "temperature_sensor" - ) - - # Name with special characters - assert ( - get_base_entity_object_id("Temp!@#$%^&*()Sensor", None) - == "temp__________sensor" - ) - assert get_base_entity_object_id("Temp-Sensor_123", None) == "temp-sensor_123" - - # Already snake_case - assert ( - get_base_entity_object_id("temperature_sensor", None) - == "temperature_sensor" - ) - - # Mixed case - assert ( - get_base_entity_object_id("TemperatureSensor", None) == "temperaturesensor" - ) - assert ( - get_base_entity_object_id("TEMPERATURE SENSOR", None) - == "temperature_sensor" - ) - - def test_empty_name_with_friendly_name(self) -> None: - """Test when entity has empty name - should use friendly name.""" - # C++ behavior: when has_own_name is false, uses App.get_friendly_name() - assert get_base_entity_object_id("", "Friendly Device") == "friendly_device" - assert ( - get_base_entity_object_id("", "Kitchen Controller") == "kitchen_controller" - ) - assert get_base_entity_object_id("", "Test-Device_123") == "test-device_123" - - # Special characters in friendly name - assert get_base_entity_object_id("", "Device!@#$%") == "device_____" - - def test_empty_name_no_friendly_name(self) -> None: - """Test when entity has empty name and no friendly name - should use device name.""" - # Save original values - original_name = getattr(CORE, "name", None) - - try: - # Test with CORE.name set - CORE.name = "device-name" - assert get_base_entity_object_id("", None) == "device-name" - - CORE.name = "Test Device" - assert get_base_entity_object_id("", None) == "test_device" - - finally: - # Restore original value - if original_name is not None: - CORE.name = original_name - - def test_edge_cases(self) -> None: - """Test edge cases.""" - # Only spaces - assert get_base_entity_object_id(" ", None) == "___" - - # Unicode characters (should be replaced) - assert get_base_entity_object_id("Température", None) == "temp_rature" - assert get_base_entity_object_id("测试", None) == "__" - - # Empty string with empty friendly name (empty friendly name is treated as None) - # Falls back to CORE.name - original_name = getattr(CORE, "name", None) - try: - CORE.name = "device" - assert get_base_entity_object_id("", "") == "device" - finally: - if original_name is not None: - CORE.name = original_name - - # Very long name (should work fine) - long_name = "a" * 100 + " " + "b" * 100 - expected = "a" * 100 + "_" + "b" * 100 - assert get_base_entity_object_id(long_name, None) == expected - - def test_matches_cpp_helpers(self) -> None: - """Test that the logic matches using snake_case and sanitize directly.""" - test_cases = [ - ("Temperature Sensor", "temperature_sensor"), - ("Living Room Light", "living_room_light"), - ("Test-Device_123", "test-device_123"), - ("Special!@#Chars", "special___chars"), - ("UPPERCASE NAME", "uppercase_name"), - ("lowercase name", "lowercase_name"), - ("Mixed Case Name", "mixed_case_name"), - (" Spaces ", "___spaces___"), - ] - - for name, expected in test_cases: - # For non-empty names, verify our function produces same result as direct snake_case + sanitize - assert get_base_entity_object_id(name, None) == sanitize(snake_case(name)) - assert get_base_entity_object_id(name, None) == expected - - # Empty name is handled specially - it doesn't just use sanitize(snake_case("")) - # Instead it falls back to friendly_name or CORE.name - assert sanitize(snake_case("")) == "" # Direct conversion gives empty string - # But our function returns a fallback - original_name = getattr(CORE, "name", None) - try: - CORE.name = "device" - assert get_base_entity_object_id("", None) == "device" # Uses device name - finally: - if original_name is not None: - CORE.name = original_name - - def test_name_add_mac_suffix_behavior(self) -> None: - """Test behavior related to name_add_mac_suffix. - - In C++, when name_add_mac_suffix is enabled and entity has no name, - get_object_id() returns str_sanitize(str_snake_case(App.get_friendly_name())) - dynamically. Our function always returns the same result since we're - calculating the base for duplicate tracking. - """ - # The function should always return the same result regardless of - # name_add_mac_suffix setting, as we're calculating the base object_id - assert get_base_entity_object_id("", "Test Device") == "test_device" - assert get_base_entity_object_id("Entity Name", "Test Device") == "entity_name" From 418e248e5eca848d06f4bad8483d3edaa7a533b6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Jun 2025 17:51:05 +0200 Subject: [PATCH 153/183] cleanup --- esphome/entity.py | 3 +- tests/unit_tests/test_entity.py | 170 ++++++++++++++++---------------- 2 files changed, 88 insertions(+), 85 deletions(-) diff --git a/esphome/entity.py b/esphome/entity.py index fa7f1ab7d9..3fa2d62b4d 100644 --- a/esphome/entity.py +++ b/esphome/entity.py @@ -12,7 +12,7 @@ from esphome.const import ( ) from esphome.core import CORE, ID from esphome.cpp_generator import MockObj, add, get_variable -from esphome.helpers import sanitize, snake_case +from esphome.helpers import fnv1a_32bit_hash, sanitize, snake_case from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -81,7 +81,6 @@ async def setup_entity(var: MockObj, config: ConfigType, platform: str) -> None: device: MockObj = await get_variable(device_id_obj) add(var.set_device(device)) # Use the device's ID hash as device_id - from esphome.helpers import fnv1a_32bit_hash device_id = fnv1a_32bit_hash(device_id_obj.id) # Get device name for object ID calculation diff --git a/tests/unit_tests/test_entity.py b/tests/unit_tests/test_entity.py index 6cdf5369ae..3033b52a65 100644 --- a/tests/unit_tests/test_entity.py +++ b/tests/unit_tests/test_entity.py @@ -1,6 +1,7 @@ """Test get_base_entity_object_id function matches C++ behavior.""" from collections.abc import Generator +import re from typing import Any import pytest @@ -107,9 +108,9 @@ def test_edge_cases() -> None: assert get_base_entity_object_id(long_name, None) == expected -def test_matches_cpp_helpers() -> None: - """Test that the logic matches using snake_case and sanitize directly.""" - test_cases = [ +@pytest.mark.parametrize( + ("name", "expected"), + [ ("Temperature Sensor", "temperature_sensor"), ("Living Room Light", "living_room_light"), ("Test-Device_123", "test-device_123"), @@ -118,13 +119,17 @@ def test_matches_cpp_helpers() -> None: ("lowercase name", "lowercase_name"), ("Mixed Case Name", "mixed_case_name"), (" Spaces ", "___spaces___"), - ] + ], +) +def test_matches_cpp_helpers(name: str, expected: str) -> None: + """Test that the logic matches using snake_case and sanitize directly.""" + # For non-empty names, verify our function produces same result as direct snake_case + sanitize + assert get_base_entity_object_id(name, None) == sanitize(snake_case(name)) + assert get_base_entity_object_id(name, None) == expected - for name, expected in test_cases: - # For non-empty names, verify our function produces same result as direct snake_case + sanitize - assert get_base_entity_object_id(name, None) == sanitize(snake_case(name)) - assert get_base_entity_object_id(name, None) == expected +def test_empty_name_fallback() -> None: + """Test empty name handling which falls back to friendly_name or CORE.name.""" # Empty name is handled specially - it doesn't just use sanitize(snake_case("")) # Instead it falls back to friendly_name or CORE.name assert sanitize(snake_case("")) == "" # Direct conversion gives empty string @@ -169,10 +174,9 @@ def test_priority_order() -> None: assert get_base_entity_object_id("", None, None) == "core-device" -def test_real_world_examples() -> None: - """Test real-world entity naming scenarios.""" - # Common ESPHome entity names - test_cases = [ +@pytest.mark.parametrize( + ("name", "friendly_name", "device_name", "expected"), + [ # name, friendly_name, device_name, expected ("Living Room Light", None, None, "living_room_light"), ("", "Kitchen Controller", None, "kitchen_controller"), @@ -186,13 +190,14 @@ def test_real_world_examples() -> None: ("WiFi Signal", "My Device", None, "wifi_signal"), ("", None, "esp32_node", "esp32_node"), ("Front Door Sensor", "Home Assistant", "door_controller", "front_door_sensor"), - ] - - for name, friendly_name, device_name, expected in test_cases: - result = get_base_entity_object_id(name, friendly_name, device_name) - assert result == expected, ( - f"Failed for {name=}, {friendly_name=}, {device_name=}: {result=}, {expected=}" - ) + ], +) +def test_real_world_examples( + name: str, friendly_name: str | None, device_name: str | None, expected: str +) -> None: + """Test real-world entity naming scenarios.""" + result = get_base_entity_object_id(name, friendly_name, device_name) + assert result == expected def test_issue_6953_scenarios() -> None: @@ -226,15 +231,14 @@ def test_issue_6953_scenarios() -> None: @pytest.fixture def setup_test_environment() -> Generator[list[str], None, None]: """Set up test environment for setup_entity tests.""" - # Reset CORE state - CORE.reset() + # Set CORE state for tests CORE.name = "test-device" CORE.friendly_name = "Test Device" # Store original add function original_add = entity.add # Track what gets added - added_expressions = [] + added_expressions: list[str] = [] def mock_add(expression: Any) -> Any: added_expressions.append(str(expression)) @@ -245,19 +249,16 @@ def setup_test_environment() -> Generator[list[str], None, None]: yield added_expressions # Clean up entity.add = original_add - CORE.reset() def extract_object_id_from_expressions(expressions: list[str]) -> str | None: """Extract the object ID that was set from the generated expressions.""" for expr in expressions: - # Look for set_object_id calls - if ".set_object_id(" in expr: - # Extract the ID from something like: var.set_object_id("temperature_2") - start = expr.find('"') + 1 - end = expr.rfind('"') - if start > 0 and end > start: - return expr[start:end] + # Look for set_object_id calls with regex to handle various formats + # Matches: var.set_object_id("temperature_2") or var.set_object_id('temperature_2') + match = re.search(r'\.set_object_id\(["\'](.*?)["\']\)', expr) + if match: + return match.group(1) return None @@ -312,7 +313,7 @@ async def test_setup_entity_with_duplicates(setup_test_environment: list[str]) - CONF_DISABLED_BY_DEFAULT: False, } - object_ids = [] + object_ids: list[str] = [] for var in entities: added_expressions.clear() await setup_entity(var, config, "sensor") @@ -351,7 +352,7 @@ async def test_setup_entity_different_platforms( (text_sensor, "text_sensor"), ] - object_ids = [] + object_ids: list[str] = [] for var, platform in platforms: added_expressions.clear() await setup_entity(var, config, platform) @@ -362,62 +363,67 @@ async def test_setup_entity_different_platforms( assert all(obj_id == "status" for obj_id in object_ids) -@pytest.mark.asyncio -async def test_setup_entity_with_devices(setup_test_environment: list[str]) -> None: - """Test that same name on different devices doesn't conflict.""" +@pytest.fixture +def mock_get_variable() -> Generator[dict[ID, MockObj], None, None]: + """Mock get_variable to return test devices.""" + devices = {} + original_get_variable = entity.get_variable + async def _mock_get_variable(device_id: ID) -> MockObj: + if device_id in devices: + return devices[device_id] + return await original_get_variable(device_id) + + entity.get_variable = _mock_get_variable + yield devices + # Clean up + entity.get_variable = original_get_variable + + +@pytest.mark.asyncio +async def test_setup_entity_with_devices( + setup_test_environment: list[str], mock_get_variable: dict[ID, MockObj] +) -> None: + """Test that same name on different devices doesn't conflict.""" added_expressions = setup_test_environment # Create mock devices device1_id = ID("device1", type="Device") device2_id = ID("device2", type="Device") - device1 = MockObj("device1_obj") device2 = MockObj("device2_obj") - # Mock get_variable to return our devices - original_get_variable = entity.get_variable + # Register devices with the mock + mock_get_variable[device1_id] = device1 + mock_get_variable[device2_id] = device2 - async def mock_get_variable(device_id: ID) -> MockObj: - if device_id == device1_id: - return device1 - elif device_id == device2_id: - return device2 - return await original_get_variable(device_id) + # Create sensors with same name on different devices + sensor1 = MockObj("sensor1") + sensor2 = MockObj("sensor2") - entity.get_variable = mock_get_variable + config1 = { + CONF_NAME: "Temperature", + CONF_DEVICE_ID: device1_id, + CONF_DISABLED_BY_DEFAULT: False, + } - try: - # Create sensors with same name on different devices - sensor1 = MockObj("sensor1") - sensor2 = MockObj("sensor2") + config2 = { + CONF_NAME: "Temperature", + CONF_DEVICE_ID: device2_id, + CONF_DISABLED_BY_DEFAULT: False, + } - config1 = { - CONF_NAME: "Temperature", - CONF_DEVICE_ID: device1_id, - CONF_DISABLED_BY_DEFAULT: False, - } + # Get object IDs + object_ids: list[str] = [] + for var, config in [(sensor1, config1), (sensor2, config2)]: + added_expressions.clear() + await setup_entity(var, config, "sensor") + object_id = extract_object_id_from_expressions(added_expressions) + object_ids.append(object_id) - config2 = { - CONF_NAME: "Temperature", - CONF_DEVICE_ID: device2_id, - CONF_DISABLED_BY_DEFAULT: False, - } - - # Get object IDs - object_ids = [] - for var, config in [(sensor1, config1), (sensor2, config2)]: - added_expressions.clear() - await setup_entity(var, config, "sensor") - object_id = extract_object_id_from_expressions(added_expressions) - object_ids.append(object_id) - - # Both should get base object ID without suffix (different devices) - assert object_ids[0] == "temperature" - assert object_ids[1] == "temperature" - - finally: - entity.get_variable = original_get_variable + # Both should get base object ID without suffix (different devices) + assert object_ids[0] == "temperature" + assert object_ids[1] == "temperature" @pytest.mark.asyncio @@ -455,7 +461,7 @@ async def test_setup_entity_empty_name_duplicates( CONF_DISABLED_BY_DEFAULT: False, } - object_ids = [] + object_ids: list[str] = [] for var in entities: added_expressions.clear() await setup_entity(var, config, "sensor") @@ -483,7 +489,7 @@ async def test_setup_entity_special_characters( CONF_DISABLED_BY_DEFAULT: False, } - object_ids = [] + object_ids: list[str] = [] for var in entities: added_expressions.clear() await setup_entity(var, config, "sensor") @@ -513,10 +519,9 @@ async def test_setup_entity_with_icon(setup_test_environment: list[str]) -> None await setup_entity(var, config, "sensor") # Check icon was set - icon_set = any( - ".set_icon(" in expr and "mdi:thermometer" in expr for expr in added_expressions + assert any( + 'sensor1.set_icon("mdi:thermometer")' in expr for expr in added_expressions ) - assert icon_set @pytest.mark.asyncio @@ -537,10 +542,9 @@ async def test_setup_entity_disabled_by_default( await setup_entity(var, config, "sensor") # Check disabled_by_default was set - disabled_set = any( - ".set_disabled_by_default(true)" in expr.lower() for expr in added_expressions + assert any( + "sensor1.set_disabled_by_default(true)" in expr for expr in added_expressions ) - assert disabled_set @pytest.mark.asyncio @@ -550,7 +554,7 @@ async def test_setup_entity_mixed_duplicates(setup_test_environment: list[str]) added_expressions = setup_test_environment # Track results - results = [] + results: list[tuple[str, str]] = [] # 3 sensors named "Status" for i in range(3): From d89ee2df423c0132fc13a0214dc0addc8fff7bb6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Jun 2025 17:52:13 +0200 Subject: [PATCH 154/183] Update esphome/core/application.h --- esphome/core/application.h | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index 160a7b35ca..17270ca459 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -109,7 +109,6 @@ class Application { this->name_ = name; this->friendly_name_ = friendly_name; } - // area is now handled through the areas system this->comment_ = comment; this->compilation_time_ = compilation_time; } From ac0b0b652ead8f911a077c4f3620f7853d90fb3c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Jun 2025 17:55:58 +0200 Subject: [PATCH 155/183] cleanup --- tests/integration/test_duplicate_entities.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/integration/test_duplicate_entities.py b/tests/integration/test_duplicate_entities.py index ba40e6bd23..9b30d2db5a 100644 --- a/tests/integration/test_duplicate_entities.py +++ b/tests/integration/test_duplicate_entities.py @@ -37,8 +37,7 @@ async def test_duplicate_entities( entities = await client.list_entities_services() all_entities: list[EntityInfo] = [] for entity_list in entities[0]: - if hasattr(entity_list, "object_id"): - all_entities.append(entity_list) + all_entities.append(entity_list) # Group entities by type for easier testing sensors = [e for e in all_entities if e.__class__.__name__ == "SensorInfo"] @@ -242,7 +241,7 @@ async def test_duplicate_entities( # Verify we can get states for all entities (ensures they're functional) loop = asyncio.get_running_loop() - states_future: asyncio.Future[bool] = loop.create_future() + states_future: asyncio.Future[None] = loop.create_future() state_count = 0 expected_count = ( len(sensors) + len(binary_sensors) + len(text_sensors) + len(switches) @@ -252,7 +251,7 @@ async def test_duplicate_entities( nonlocal state_count state_count += 1 if state_count >= expected_count and not states_future.done(): - states_future.set_result(True) + states_future.set_result(None) client.subscribe_states(on_state) From 66201be5ca9febebf1d31238fca7bd28ed1e175e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Jun 2025 18:00:10 +0200 Subject: [PATCH 156/183] preen --- tests/unit_tests/core/test_config.py | 11 ++--------- tests/unit_tests/test_entity.py | 5 ++++- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 372c1df7ee..55cc1f3027 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -28,13 +28,6 @@ def yaml_file(tmp_path: Path) -> Callable[[str], str]: return _yaml_file -@pytest.fixture(autouse=True) -def reset_core(): - """Reset CORE after each test.""" - yield - CORE.reset() - - def load_config_from_yaml( yaml_file: Callable[[str], str], yaml_content: str ) -> Config | None: @@ -61,7 +54,7 @@ def load_config_from_fixture( def test_validate_area_config_with_string() -> None: """Test that string area config is converted to structured format.""" - result: dict[str, Any] = validate_area_config("Living Room") + result = validate_area_config("Living Room") assert isinstance(result, dict) assert "id" in result @@ -80,7 +73,7 @@ def test_validate_area_config_with_dict() -> None: "name": "Test Area", } - result: dict[str, Any] = validate_area_config(input_config) + result = validate_area_config(input_config) assert result == input_config assert result["id"] == area_id diff --git a/tests/unit_tests/test_entity.py b/tests/unit_tests/test_entity.py index 3033b52a65..1b0c648be4 100644 --- a/tests/unit_tests/test_entity.py +++ b/tests/unit_tests/test_entity.py @@ -13,6 +13,9 @@ from esphome.cpp_generator import MockObj from esphome.entity import get_base_entity_object_id, setup_entity from esphome.helpers import sanitize, snake_case +# Pre-compiled regex pattern for extracting object IDs from expressions +OBJECT_ID_PATTERN = re.compile(r'\.set_object_id\(["\'](.*?)["\']\)') + @pytest.fixture(autouse=True) def restore_core_state() -> Generator[None, None, None]: @@ -256,7 +259,7 @@ def extract_object_id_from_expressions(expressions: list[str]) -> str | None: for expr in expressions: # Look for set_object_id calls with regex to handle various formats # Matches: var.set_object_id("temperature_2") or var.set_object_id('temperature_2') - match = re.search(r'\.set_object_id\(["\'](.*?)["\']\)', expr) + match = OBJECT_ID_PATTERN.search(expr) if match: return match.group(1) return None From ac3598f12af5468bd07dc178767f0393bc8c3a51 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Jun 2025 18:07:58 +0200 Subject: [PATCH 157/183] cleanup --- tests/unit_tests/core/test_config.py | 1 - tests/unit_tests/test_entity.py | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 55cc1f3027..ba8436b7a7 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -198,7 +198,6 @@ def test_device_with_invalid_area_id( # Check for the specific error message in stdout captured = capsys.readouterr() - print(captured.out) assert ( "Couldn't find ID 'nonexistent_area'. Please check you have defined an ID with that name in your configuration." in captured.out diff --git a/tests/unit_tests/test_entity.py b/tests/unit_tests/test_entity.py index 1b0c648be4..62ce7406ff 100644 --- a/tests/unit_tests/test_entity.py +++ b/tests/unit_tests/test_entity.py @@ -259,8 +259,7 @@ def extract_object_id_from_expressions(expressions: list[str]) -> str | None: for expr in expressions: # Look for set_object_id calls with regex to handle various formats # Matches: var.set_object_id("temperature_2") or var.set_object_id('temperature_2') - match = OBJECT_ID_PATTERN.search(expr) - if match: + if match := OBJECT_ID_PATTERN.search(expr): return match.group(1) return None From 48f291143485d47b3ed208ede0310409a749a9b7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Jun 2025 22:18:29 +0200 Subject: [PATCH 158/183] raise --- esphome/core/__init__.py | 6 +- esphome/entity.py | 22 +++--- tests/unit_tests/test_entity.py | 129 +++++++++++++++++--------------- 3 files changed, 83 insertions(+), 74 deletions(-) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 00c1db33ee..45487e1bb9 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -523,8 +523,8 @@ class EsphomeCore: # Key: platform name (e.g. "sensor", "binary_sensor"), Value: count self.platform_counts: defaultdict[str, int] = defaultdict(int) # Track entity unique IDs to handle duplicates - # Key: (device_id, platform, object_id), Value: count of duplicates - self.unique_ids: dict[tuple[int, str, str], int] = {} + # Set of (device_id, platform, object_id) tuples + self.unique_ids: set[tuple[int, str, str]] = set() # Whether ESPHome was started in verbose mode self.verbose = False # Whether ESPHome was started in quiet mode @@ -556,7 +556,7 @@ class EsphomeCore: self.loaded_integrations = set() self.component_ids = set() self.platform_counts = defaultdict(int) - self.unique_ids = {} + self.unique_ids = set() PIN_SCHEMA_REGISTRY.reset() @property diff --git a/esphome/entity.py b/esphome/entity.py index 3fa2d62b4d..528a640b9e 100644 --- a/esphome/entity.py +++ b/esphome/entity.py @@ -99,23 +99,21 @@ async def setup_entity(var: MockObj, config: ConfigType, platform: str) -> None: "Entity has empty name, using '%s' as object_id base", base_object_id ) - # Handle duplicates # Check for duplicates unique_key: tuple[int, str, str] = (device_id, platform, base_object_id) if unique_key in CORE.unique_ids: - # Found duplicate, add suffix - count = CORE.unique_ids[unique_key] + 1 - CORE.unique_ids[unique_key] = count - object_id = f"{base_object_id}_{count}" - _LOGGER.info( - "Duplicate %s entity '%s' found. Renaming to '%s'", - platform, - config[CONF_NAME], - object_id, + # Found duplicate - fail validation + from esphome.config_validation import Invalid + + entity_name = config[CONF_NAME] or base_object_id + device_prefix = f" on device '{device_name}'" if device_name else "" + raise Invalid( + f"Duplicate {platform} entity with name '{entity_name}' found{device_prefix}. " + f"Each entity on a device must have a unique name within its platform." ) else: - # First occurrence - CORE.unique_ids[unique_key] = 1 + # First occurrence - register it + CORE.unique_ids.add(unique_key) object_id = base_object_id add(var.set_object_id(object_id)) diff --git a/tests/unit_tests/test_entity.py b/tests/unit_tests/test_entity.py index 62ce7406ff..6477e98e13 100644 --- a/tests/unit_tests/test_entity.py +++ b/tests/unit_tests/test_entity.py @@ -7,6 +7,7 @@ from typing import Any import pytest from esphome import entity +from esphome.config_validation import Invalid from esphome.const import CONF_DEVICE_ID, CONF_DISABLED_BY_DEFAULT, CONF_ICON, CONF_NAME from esphome.core import CORE, ID from esphome.cpp_generator import MockObj @@ -302,8 +303,7 @@ async def test_setup_entity_no_duplicates(setup_test_environment: list[str]) -> @pytest.mark.asyncio async def test_setup_entity_with_duplicates(setup_test_environment: list[str]) -> None: - """Test setup_entity with duplicate names.""" - + """Test setup_entity with duplicate names raises validation error.""" added_expressions = setup_test_environment # Create mock entities @@ -315,18 +315,21 @@ async def test_setup_entity_with_duplicates(setup_test_environment: list[str]) - CONF_DISABLED_BY_DEFAULT: False, } - object_ids: list[str] = [] - for var in entities: - added_expressions.clear() - await setup_entity(var, config, "sensor") - object_id = extract_object_id_from_expressions(added_expressions) - object_ids.append(object_id) + # First entity should succeed + await setup_entity(entities[0], config, "sensor") + object_id = extract_object_id_from_expressions(added_expressions) + assert object_id == "temperature" - # Check that object IDs were set with proper suffixes - assert object_ids[0] == "temperature" - assert object_ids[1] == "temperature_2" - assert object_ids[2] == "temperature_3" - assert object_ids[3] == "temperature_4" + # Clear CORE unique_ids before second test to ensure clean state + CORE.unique_ids.clear() + # Add back the first one + CORE.unique_ids.add((0, "sensor", "temperature")) + + # Second entity with same name should raise Invalid + with pytest.raises( + Invalid, match=r"Duplicate sensor entity with name 'Temperature' found" + ): + await setup_entity(entities[1], config, "sensor") @pytest.mark.asyncio @@ -452,8 +455,7 @@ async def test_setup_entity_empty_name(setup_test_environment: list[str]) -> Non async def test_setup_entity_empty_name_duplicates( setup_test_environment: list[str], ) -> None: - """Test setup_entity with multiple empty names.""" - + """Test setup_entity with multiple empty names raises validation error.""" added_expressions = setup_test_environment entities = [MockObj(f"sensor{i}") for i in range(3)] @@ -463,17 +465,20 @@ async def test_setup_entity_empty_name_duplicates( CONF_DISABLED_BY_DEFAULT: False, } - object_ids: list[str] = [] - for var in entities: - added_expressions.clear() - await setup_entity(var, config, "sensor") - object_id = extract_object_id_from_expressions(added_expressions) - object_ids.append(object_id) + # First entity should succeed + await setup_entity(entities[0], config, "sensor") + object_id = extract_object_id_from_expressions(added_expressions) + assert object_id == "test_device" - # Should use device name with suffixes - assert object_ids[0] == "test_device" - assert object_ids[1] == "test_device_2" - assert object_ids[2] == "test_device_3" + # Clear and restore unique_ids for clean test + CORE.unique_ids.clear() + CORE.unique_ids.add((0, "sensor", "test_device")) + + # Second entity with empty name should raise Invalid + with pytest.raises( + Invalid, match=r"Duplicate sensor entity with name 'test_device' found" + ): + await setup_entity(entities[1], config, "sensor") @pytest.mark.asyncio @@ -484,24 +489,18 @@ async def test_setup_entity_special_characters( added_expressions = setup_test_environment - entities = [MockObj(f"sensor{i}") for i in range(3)] + var = MockObj("sensor1") config = { CONF_NAME: "Temperature Sensor!", CONF_DISABLED_BY_DEFAULT: False, } - object_ids: list[str] = [] - for var in entities: - added_expressions.clear() - await setup_entity(var, config, "sensor") - object_id = extract_object_id_from_expressions(added_expressions) - object_ids.append(object_id) + await setup_entity(var, config, "sensor") + object_id = extract_object_id_from_expressions(added_expressions) # Special characters should be sanitized - assert object_ids[0] == "temperature_sensor_" - assert object_ids[1] == "temperature_sensor__2" - assert object_ids[2] == "temperature_sensor__3" + assert object_id == "temperature_sensor_" @pytest.mark.asyncio @@ -558,27 +557,39 @@ async def test_setup_entity_mixed_duplicates(setup_test_environment: list[str]) # Track results results: list[tuple[str, str]] = [] - # 3 sensors named "Status" - for i in range(3): - added_expressions.clear() - var = MockObj(f"sensor_status_{i}") - await setup_entity( - var, {CONF_NAME: "Status", CONF_DISABLED_BY_DEFAULT: False}, "sensor" - ) - object_id = extract_object_id_from_expressions(added_expressions) - results.append(("sensor", object_id)) + # First sensor named "Status" should succeed + added_expressions.clear() + var = MockObj("sensor_status_0") + await setup_entity( + var, {CONF_NAME: "Status", CONF_DISABLED_BY_DEFAULT: False}, "sensor" + ) + object_id = extract_object_id_from_expressions(added_expressions) + results.append(("sensor", object_id)) - # 2 binary_sensors named "Status" - for i in range(2): - added_expressions.clear() - var = MockObj(f"binary_sensor_status_{i}") - await setup_entity( - var, {CONF_NAME: "Status", CONF_DISABLED_BY_DEFAULT: False}, "binary_sensor" - ) - object_id = extract_object_id_from_expressions(added_expressions) - results.append(("binary_sensor", object_id)) + # Clear and restore unique_ids for test + CORE.unique_ids.clear() + CORE.unique_ids.add((0, "sensor", "status")) - # 1 text_sensor named "Status" + # Second sensor with same name should fail + with pytest.raises( + Invalid, match=r"Duplicate sensor entity with name 'Status' found" + ): + await setup_entity( + MockObj("sensor_status_1"), + {CONF_NAME: "Status", CONF_DISABLED_BY_DEFAULT: False}, + "sensor", + ) + + # Binary sensor with same name should succeed (different platform) + added_expressions.clear() + var = MockObj("binary_sensor_status_0") + await setup_entity( + var, {CONF_NAME: "Status", CONF_DISABLED_BY_DEFAULT: False}, "binary_sensor" + ) + object_id = extract_object_id_from_expressions(added_expressions) + results.append(("binary_sensor", object_id)) + + # Text sensor with same name should succeed (different platform) added_expressions.clear() var = MockObj("text_sensor_status") await setup_entity( @@ -589,8 +600,8 @@ async def test_setup_entity_mixed_duplicates(setup_test_environment: list[str]) # Check results - each platform has its own namespace assert results[0] == ("sensor", "status") # sensor - assert results[1] == ("sensor", "status_2") # sensor - assert results[2] == ("sensor", "status_3") # sensor - assert results[3] == ("binary_sensor", "status") # binary_sensor (new namespace) - assert results[4] == ("binary_sensor", "status_2") # binary_sensor - assert results[5] == ("text_sensor", "status") # text_sensor (new namespace) + assert results[1] == ( + "binary_sensor", + "status", + ) # binary_sensor (different platform) + assert results[2] == ("text_sensor", "status") # text_sensor (different platform) From 5ad1af69e483e0c6428437da1fef4727f85b9966 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Jun 2025 22:57:10 +0200 Subject: [PATCH 159/183] migrate --- .../alarm_control_panel/__init__.py | 6 +- esphome/components/binary_sensor/__init__.py | 6 +- esphome/components/button/__init__.py | 6 +- esphome/components/climate/__init__.py | 6 +- esphome/components/cover/__init__.py | 6 +- esphome/components/datetime/__init__.py | 5 +- esphome/components/esp32_camera/__init__.py | 2 +- esphome/components/event/__init__.py | 6 +- esphome/components/fan/__init__.py | 6 +- esphome/components/light/__init__.py | 5 +- esphome/components/lock/__init__.py | 6 +- esphome/components/media_player/__init__.py | 6 +- esphome/components/number/__init__.py | 6 +- esphome/components/select/__init__.py | 6 +- esphome/components/sensor/__init__.py | 5 +- esphome/components/switch/__init__.py | 6 +- esphome/components/text/__init__.py | 6 +- esphome/components/text_sensor/__init__.py | 6 +- esphome/components/update/__init__.py | 6 +- esphome/components/valve/__init__.py | 6 +- esphome/core/entity_helpers.py | 169 +++++++++++++++++- esphome/cpp_helpers.py | 3 - esphome/entity.py | 132 -------------- .../test_entity_helpers.py} | 19 +- 24 files changed, 269 insertions(+), 167 deletions(-) delete mode 100644 esphome/entity.py rename tests/unit_tests/{test_entity.py => core/test_entity_helpers.py} (97%) diff --git a/esphome/components/alarm_control_panel/__init__.py b/esphome/components/alarm_control_panel/__init__.py index 3c35076de9..2fbf17656a 100644 --- a/esphome/components/alarm_control_panel/__init__.py +++ b/esphome/components/alarm_control_panel/__init__.py @@ -14,8 +14,8 @@ from esphome.const import ( CONF_WEB_SERVER, ) from esphome.core import CORE, coroutine_with_priority +from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass -from esphome.cpp_helpers import setup_entity CODEOWNERS = ["@grahambrown11", "@hwstar"] IS_PLATFORM_COMPONENT = True @@ -149,6 +149,10 @@ _ALARM_CONTROL_PANEL_SCHEMA = ( ) +# Add duplicate entity validation +_ALARM_CONTROL_PANEL_SCHEMA.add_extra(entity_duplicate_validator("alarm_control_panel")) + + def alarm_control_panel_schema( class_: MockObjClass, *, diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index b34477d30a..0711fb2971 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -60,8 +60,8 @@ from esphome.const import ( DEVICE_CLASS_WINDOW, ) from esphome.core import CORE, coroutine_with_priority +from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass -from esphome.cpp_helpers import setup_entity from esphome.util import Registry CODEOWNERS = ["@esphome/core"] @@ -491,6 +491,10 @@ _BINARY_SENSOR_SCHEMA = ( ) +# Add duplicate entity validation +_BINARY_SENSOR_SCHEMA.add_extra(entity_duplicate_validator("binary_sensor")) + + def binary_sensor_schema( class_: MockObjClass = cv.UNDEFINED, *, diff --git a/esphome/components/button/__init__.py b/esphome/components/button/__init__.py index c63073dd38..c1b47e2a74 100644 --- a/esphome/components/button/__init__.py +++ b/esphome/components/button/__init__.py @@ -18,8 +18,8 @@ from esphome.const import ( DEVICE_CLASS_UPDATE, ) from esphome.core import CORE, coroutine_with_priority +from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass -from esphome.cpp_helpers import setup_entity CODEOWNERS = ["@esphome/core"] IS_PLATFORM_COMPONENT = True @@ -61,6 +61,10 @@ _BUTTON_SCHEMA = ( ) +# Add duplicate entity validation +_BUTTON_SCHEMA.add_extra(entity_duplicate_validator("button")) + + def button_schema( class_: MockObjClass, *, diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index ff00565abf..8f4298c156 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -48,8 +48,8 @@ from esphome.const import ( CONF_WEB_SERVER, ) from esphome.core import CORE, coroutine_with_priority +from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass -from esphome.cpp_helpers import setup_entity IS_PLATFORM_COMPONENT = True @@ -247,6 +247,10 @@ _CLIMATE_SCHEMA = ( ) +# Add duplicate entity validation +_CLIMATE_SCHEMA.add_extra(entity_duplicate_validator("climate")) + + def climate_schema( class_: MockObjClass, *, diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index c7aec6493b..8fbf9ece97 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -33,8 +33,8 @@ from esphome.const import ( DEVICE_CLASS_WINDOW, ) from esphome.core import CORE, coroutine_with_priority +from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass -from esphome.cpp_helpers import setup_entity IS_PLATFORM_COMPONENT = True @@ -126,6 +126,10 @@ _COVER_SCHEMA = ( ) +# Add duplicate entity validation +_COVER_SCHEMA.add_extra(entity_duplicate_validator("cover")) + + def cover_schema( class_: MockObjClass, *, diff --git a/esphome/components/datetime/__init__.py b/esphome/components/datetime/__init__.py index 42b29227c3..bb061a8148 100644 --- a/esphome/components/datetime/__init__.py +++ b/esphome/components/datetime/__init__.py @@ -22,8 +22,8 @@ from esphome.const import ( CONF_YEAR, ) from esphome.core import CORE, coroutine_with_priority +from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass -from esphome.cpp_helpers import setup_entity CODEOWNERS = ["@rfdarter", "@jesserockz"] @@ -84,6 +84,9 @@ _DATETIME_SCHEMA = cv.ENTITY_BASE_SCHEMA.extend( .extend(cv.MQTT_COMMAND_COMPONENT_SCHEMA) ).add_extra(_validate_time_present) +# Add duplicate entity validation +_DATETIME_SCHEMA.add_extra(entity_duplicate_validator("datetime")) + def date_schema(class_: MockObjClass) -> cv.Schema: schema = cv.Schema( diff --git a/esphome/components/esp32_camera/__init__.py b/esphome/components/esp32_camera/__init__.py index 68ba1ae549..cfca0ed6fc 100644 --- a/esphome/components/esp32_camera/__init__.py +++ b/esphome/components/esp32_camera/__init__.py @@ -19,7 +19,7 @@ from esphome.const import ( CONF_VSYNC_PIN, ) from esphome.core import CORE -from esphome.cpp_helpers import setup_entity +from esphome.core.entity_helpers import setup_entity DEPENDENCIES = ["esp32"] diff --git a/esphome/components/event/__init__.py b/esphome/components/event/__init__.py index 1ff0d4e3d5..39a51f16df 100644 --- a/esphome/components/event/__init__.py +++ b/esphome/components/event/__init__.py @@ -18,8 +18,8 @@ from esphome.const import ( DEVICE_CLASS_MOTION, ) from esphome.core import CORE, coroutine_with_priority +from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass -from esphome.cpp_helpers import setup_entity CODEOWNERS = ["@nohat"] IS_PLATFORM_COMPONENT = True @@ -59,6 +59,10 @@ _EVENT_SCHEMA = ( ) +# Add duplicate entity validation +_EVENT_SCHEMA.add_extra(entity_duplicate_validator("event")) + + def event_schema( class_: MockObjClass = cv.UNDEFINED, *, diff --git a/esphome/components/fan/__init__.py b/esphome/components/fan/__init__.py index bebf760b0b..9bd1ce2e4d 100644 --- a/esphome/components/fan/__init__.py +++ b/esphome/components/fan/__init__.py @@ -32,7 +32,7 @@ from esphome.const import ( CONF_WEB_SERVER, ) from esphome.core import CORE, coroutine_with_priority -from esphome.cpp_helpers import setup_entity +from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity IS_PLATFORM_COMPONENT = True @@ -161,6 +161,10 @@ _FAN_SCHEMA = ( ) +# Add duplicate entity validation +_FAN_SCHEMA.add_extra(entity_duplicate_validator("fan")) + + def fan_schema( class_: cg.Pvariable, *, diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index 902d661eb5..c6997ccd6d 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -38,8 +38,8 @@ from esphome.const import ( CONF_WHITE, ) from esphome.core import CORE, coroutine_with_priority +from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass -from esphome.cpp_helpers import setup_entity from .automation import LIGHT_STATE_SCHEMA from .effects import ( @@ -110,6 +110,9 @@ LIGHT_SCHEMA = ( ) ) +# Add duplicate entity validation +LIGHT_SCHEMA.add_extra(entity_duplicate_validator("light")) + BINARY_LIGHT_SCHEMA = LIGHT_SCHEMA.extend( { cv.Optional(CONF_EFFECTS): validate_effects(BINARY_EFFECTS), diff --git a/esphome/components/lock/__init__.py b/esphome/components/lock/__init__.py index aa1061de53..c0718d5d41 100644 --- a/esphome/components/lock/__init__.py +++ b/esphome/components/lock/__init__.py @@ -14,8 +14,8 @@ from esphome.const import ( CONF_WEB_SERVER, ) from esphome.core import CORE, coroutine_with_priority +from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass -from esphome.cpp_helpers import setup_entity CODEOWNERS = ["@esphome/core"] IS_PLATFORM_COMPONENT = True @@ -67,6 +67,10 @@ _LOCK_SCHEMA = ( ) +# Add duplicate entity validation +_LOCK_SCHEMA.add_extra(entity_duplicate_validator("lock")) + + def lock_schema( class_: MockObjClass = cv.UNDEFINED, *, diff --git a/esphome/components/media_player/__init__.py b/esphome/components/media_player/__init__.py index c01bd24890..04d01f5913 100644 --- a/esphome/components/media_player/__init__.py +++ b/esphome/components/media_player/__init__.py @@ -11,9 +11,9 @@ from esphome.const import ( CONF_VOLUME, ) from esphome.core import CORE +from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.coroutine import coroutine_with_priority from esphome.cpp_generator import MockObjClass -from esphome.cpp_helpers import setup_entity CODEOWNERS = ["@jesserockz"] @@ -143,6 +143,9 @@ _MEDIA_PLAYER_SCHEMA = cv.ENTITY_BASE_SCHEMA.extend( } ) +# Add duplicate entity validation +_MEDIA_PLAYER_SCHEMA.add_extra(entity_duplicate_validator("media_player")) + def media_player_schema( class_: MockObjClass, @@ -166,7 +169,6 @@ def media_player_schema( MEDIA_PLAYER_SCHEMA = media_player_schema(MediaPlayer) MEDIA_PLAYER_SCHEMA.add_extra(cv.deprecated_schema_constant("media_player")) - MEDIA_PLAYER_ACTION_SCHEMA = automation.maybe_simple_id( cv.Schema( { diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index 65a00bfe2f..ec3c263f8f 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -76,8 +76,8 @@ from esphome.const import ( DEVICE_CLASS_WIND_SPEED, ) from esphome.core import CORE, coroutine_with_priority +from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass -from esphome.cpp_helpers import setup_entity CODEOWNERS = ["@esphome/core"] DEVICE_CLASSES = [ @@ -207,6 +207,10 @@ _NUMBER_SCHEMA = ( ) +# Add duplicate entity validation +_NUMBER_SCHEMA.add_extra(entity_duplicate_validator("number")) + + def number_schema( class_: MockObjClass, *, diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py index c3f8abec8f..a5464d18d5 100644 --- a/esphome/components/select/__init__.py +++ b/esphome/components/select/__init__.py @@ -17,8 +17,8 @@ from esphome.const import ( CONF_WEB_SERVER, ) from esphome.core import CORE, coroutine_with_priority +from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass -from esphome.cpp_helpers import setup_entity CODEOWNERS = ["@esphome/core"] IS_PLATFORM_COMPONENT = True @@ -65,6 +65,10 @@ _SELECT_SCHEMA = ( ) +# Add duplicate entity validation +_SELECT_SCHEMA.add_extra(entity_duplicate_validator("select")) + + def select_schema( class_: MockObjClass, *, diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 749b7992b8..99b19d4c8b 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -101,8 +101,8 @@ from esphome.const import ( ENTITY_CATEGORY_CONFIG, ) from esphome.core import CORE, coroutine_with_priority +from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass -from esphome.cpp_helpers import setup_entity from esphome.util import Registry CODEOWNERS = ["@esphome/core"] @@ -318,6 +318,9 @@ _SENSOR_SCHEMA = ( ) ) +# Add duplicate entity validation +_SENSOR_SCHEMA.add_extra(entity_duplicate_validator("sensor")) + def sensor_schema( class_: MockObjClass = cv.UNDEFINED, diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index 322d547e95..b5fb88c5e4 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -20,8 +20,8 @@ from esphome.const import ( DEVICE_CLASS_SWITCH, ) from esphome.core import CORE, coroutine_with_priority +from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass -from esphome.cpp_helpers import setup_entity CODEOWNERS = ["@esphome/core"] IS_PLATFORM_COMPONENT = True @@ -91,6 +91,10 @@ _SWITCH_SCHEMA = ( ) +# Add duplicate entity validation +_SWITCH_SCHEMA.add_extra(entity_duplicate_validator("switch")) + + def switch_schema( class_: MockObjClass, *, diff --git a/esphome/components/text/__init__.py b/esphome/components/text/__init__.py index fc1b3d1b05..ae416b44d7 100644 --- a/esphome/components/text/__init__.py +++ b/esphome/components/text/__init__.py @@ -14,8 +14,8 @@ from esphome.const import ( CONF_WEB_SERVER, ) from esphome.core import CORE, coroutine_with_priority +from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass -from esphome.cpp_helpers import setup_entity CODEOWNERS = ["@mauritskorse"] IS_PLATFORM_COMPONENT = True @@ -58,6 +58,10 @@ _TEXT_SCHEMA = ( ) +# Add duplicate entity validation +_TEXT_SCHEMA.add_extra(entity_duplicate_validator("text")) + + def text_schema( class_: MockObjClass = cv.UNDEFINED, *, diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 38f0ae451e..8d91bed566 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -21,8 +21,8 @@ from esphome.const import ( DEVICE_CLASS_TIMESTAMP, ) from esphome.core import CORE, coroutine_with_priority +from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass -from esphome.cpp_helpers import setup_entity from esphome.util import Registry DEVICE_CLASSES = [ @@ -153,6 +153,10 @@ _TEXT_SENSOR_SCHEMA = ( ) +# Add duplicate entity validation +_TEXT_SENSOR_SCHEMA.add_extra(entity_duplicate_validator("text_sensor")) + + def text_sensor_schema( class_: MockObjClass = cv.UNDEFINED, *, diff --git a/esphome/components/update/__init__.py b/esphome/components/update/__init__.py index 061dd4589f..48ac2acebf 100644 --- a/esphome/components/update/__init__.py +++ b/esphome/components/update/__init__.py @@ -15,8 +15,8 @@ from esphome.const import ( ENTITY_CATEGORY_CONFIG, ) from esphome.core import CORE, coroutine_with_priority +from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass -from esphome.cpp_helpers import setup_entity CODEOWNERS = ["@jesserockz"] IS_PLATFORM_COMPONENT = True @@ -58,6 +58,10 @@ _UPDATE_SCHEMA = ( ) +# Add duplicate entity validation +_UPDATE_SCHEMA.add_extra(entity_duplicate_validator("update")) + + def update_schema( class_: MockObjClass = cv.UNDEFINED, *, diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index 98c96f9afc..6acef3189c 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -22,8 +22,8 @@ from esphome.const import ( DEVICE_CLASS_WATER, ) from esphome.core import CORE, coroutine_with_priority +from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass -from esphome.cpp_helpers import setup_entity IS_PLATFORM_COMPONENT = True @@ -103,6 +103,10 @@ _VALVE_SCHEMA = ( ) +# Add duplicate entity validation +_VALVE_SCHEMA.add_extra(entity_duplicate_validator("valve")) + + def valve_schema( class_: MockObjClass = cv.UNDEFINED, *, diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 7f6a9b48ab..21ba9cc032 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -1,5 +1,115 @@ -from esphome.const import CONF_ID +from collections.abc import Callable +import logging + +from esphome.const import ( + CONF_DEVICE_ID, + CONF_DISABLED_BY_DEFAULT, + CONF_ENTITY_CATEGORY, + CONF_ICON, + CONF_ID, + CONF_INTERNAL, + CONF_NAME, +) +from esphome.core import CORE, ID +from esphome.cpp_generator import MockObj, add, get_variable import esphome.final_validate as fv +from esphome.helpers import sanitize, snake_case +from esphome.types import ConfigType + +_LOGGER = logging.getLogger(__name__) + + +def get_base_entity_object_id( + name: str, friendly_name: str | None, device_name: str | None = None +) -> str: + """Calculate the base object ID for an entity that will be set via set_object_id(). + + This function calculates what object_id_c_str_ should be set to in C++. + + The C++ EntityBase::get_object_id() (entity_base.cpp lines 38-49) works as: + - If !has_own_name && is_name_add_mac_suffix_enabled(): + return str_sanitize(str_snake_case(App.get_friendly_name())) // Dynamic + - Else: + return object_id_c_str_ ?? "" // What we set via set_object_id() + + Since we're calculating what to pass to set_object_id(), we always need to + generate the object_id the same way, regardless of name_add_mac_suffix setting. + + Args: + name: The entity name (empty string if no name) + friendly_name: The friendly name from CORE.friendly_name + device_name: The device name if entity is on a sub-device + + Returns: + The base object ID to use for duplicate checking and to pass to set_object_id() + """ + + if name: + # Entity has its own name (has_own_name will be true) + base_str = name + elif device_name: + # Entity has empty name and is on a sub-device + # C++ EntityBase::set_name() uses device->get_name() when device is set + base_str = device_name + elif friendly_name: + # Entity has empty name (has_own_name will be false) + # C++ uses App.get_friendly_name() which returns friendly_name or device name + base_str = friendly_name + else: + # Fallback to device name + base_str = CORE.name + + return sanitize(snake_case(base_str)) + + +async def setup_entity(var: MockObj, config: ConfigType, platform: str) -> None: + """Set up generic properties of an Entity. + + This function sets up the common entity properties like name, icon, + entity category, etc. + + Args: + var: The entity variable to set up + config: Configuration dictionary containing entity settings + platform: The platform name (e.g., "sensor", "binary_sensor") + """ + # Get device info + device_name: str | None = None + if CONF_DEVICE_ID in config: + device_id_obj: ID = config[CONF_DEVICE_ID] + device: MockObj = await get_variable(device_id_obj) + add(var.set_device(device)) + # Get device name for object ID calculation + device_name = device_id_obj.id + + add(var.set_name(config[CONF_NAME])) + + # Calculate base object_id using the same logic as C++ + # This must match the C++ behavior in esphome/core/entity_base.cpp + base_object_id = get_base_entity_object_id( + config[CONF_NAME], CORE.friendly_name, device_name + ) + + if not config[CONF_NAME]: + _LOGGER.debug( + "Entity has empty name, using '%s' as object_id base", base_object_id + ) + + # Set the object ID + add(var.set_object_id(base_object_id)) + _LOGGER.debug( + "Setting object_id '%s' for entity '%s' on platform '%s'", + base_object_id, + config[CONF_NAME], + platform, + ) + add(var.set_disabled_by_default(config[CONF_DISABLED_BY_DEFAULT])) + if CONF_INTERNAL in config: + add(var.set_internal(config[CONF_INTERNAL])) + if CONF_ICON in config: + add(var.set_icon(config[CONF_ICON])) + if CONF_ENTITY_CATEGORY in config: + add(var.set_entity_category(config[CONF_ENTITY_CATEGORY])) def inherit_property_from(property_to_inherit, parent_id_property, transform=None): @@ -54,3 +164,60 @@ def inherit_property_from(property_to_inherit, parent_id_property, transform=Non return config return inherit_property + + +def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigType]: + """Create a validator function to check for duplicate entity names. + + This validator is meant to be used with schema.add_extra() for entity base schemas. + + Args: + platform: The platform name (e.g., "sensor", "binary_sensor") + + Returns: + A validator function that checks for duplicate names + """ + + def validator(config: ConfigType) -> ConfigType: + if CONF_NAME not in config: + # No name to validate + return config + + # Get the entity name and device info + entity_name = config[CONF_NAME] + device_id = 0 # Main device by default + device_name = None + + if CONF_DEVICE_ID in config: + device_config = config[CONF_DEVICE_ID] + if hasattr(device_config, "id"): + device_id = hash(device_config.id) + # Try to get device name from CORE if available + for dev in getattr(CORE, "devices", []): + if hasattr(dev, "id") and dev.id == device_config.id: + device_name = getattr(dev, "name", None) + break + + # Calculate the base object ID + base_object_id = get_base_entity_object_id( + entity_name, CORE.friendly_name, device_name + ) + + # Check for duplicates + unique_key = (device_id, platform, base_object_id) + if unique_key in CORE.unique_ids: + # Import here to avoid circular dependency + import esphome.config_validation as cv + + entity_name_display = entity_name or base_object_id + device_prefix = f" on device '{device_name}'" if device_name else "" + raise cv.Invalid( + f"Duplicate {platform} entity with name '{entity_name_display}' found{device_prefix}. " + f"Each entity on a device must have a unique name within its platform." + ) + + # Add to tracking set + CORE.unique_ids.add(unique_key) + return config + + return validator diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index 746a006348..3f64be6154 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -11,9 +11,6 @@ from esphome.core import CORE, ID, coroutine from esphome.coroutine import FakeAwaitable from esphome.cpp_generator import add, get_variable from esphome.cpp_types import App -from esphome.entity import ( # noqa: F401 # pylint: disable=unused-import - setup_entity, # Import for backward compatibility -) from esphome.types import ConfigFragmentType, ConfigType from esphome.util import Registry, RegistryEntry diff --git a/esphome/entity.py b/esphome/entity.py deleted file mode 100644 index 528a640b9e..0000000000 --- a/esphome/entity.py +++ /dev/null @@ -1,132 +0,0 @@ -"""Entity-related helper functions.""" - -import logging - -from esphome.const import ( - CONF_DEVICE_ID, - CONF_DISABLED_BY_DEFAULT, - CONF_ENTITY_CATEGORY, - CONF_ICON, - CONF_INTERNAL, - CONF_NAME, -) -from esphome.core import CORE, ID -from esphome.cpp_generator import MockObj, add, get_variable -from esphome.helpers import fnv1a_32bit_hash, sanitize, snake_case -from esphome.types import ConfigType - -_LOGGER = logging.getLogger(__name__) - - -def get_base_entity_object_id( - name: str, friendly_name: str | None, device_name: str | None = None -) -> str: - """Calculate the base object ID for an entity that will be set via set_object_id(). - - This function calculates what object_id_c_str_ should be set to in C++. - - The C++ EntityBase::get_object_id() (entity_base.cpp lines 38-49) works as: - - If !has_own_name && is_name_add_mac_suffix_enabled(): - return str_sanitize(str_snake_case(App.get_friendly_name())) // Dynamic - - Else: - return object_id_c_str_ ?? "" // What we set via set_object_id() - - Since we're calculating what to pass to set_object_id(), we always need to - generate the object_id the same way, regardless of name_add_mac_suffix setting. - - Args: - name: The entity name (empty string if no name) - friendly_name: The friendly name from CORE.friendly_name - device_name: The device name if entity is on a sub-device - - Returns: - The base object ID to use for duplicate checking and to pass to set_object_id() - """ - - if name: - # Entity has its own name (has_own_name will be true) - base_str = name - elif device_name: - # Entity has empty name and is on a sub-device - # C++ EntityBase::set_name() uses device->get_name() when device is set - base_str = device_name - elif friendly_name: - # Entity has empty name (has_own_name will be false) - # C++ uses App.get_friendly_name() which returns friendly_name or device name - base_str = friendly_name - else: - # Fallback to device name - base_str = CORE.name - - return sanitize(snake_case(base_str)) - - -async def setup_entity(var: MockObj, config: ConfigType, platform: str) -> None: - """Set up generic properties of an Entity. - - This function handles duplicate entity names by automatically appending - a suffix (_2, _3, etc.) when multiple entities have the same object_id - within the same platform and device combination. - - Args: - var: The entity variable to set up - config: Configuration dictionary containing entity settings - platform: The platform name (e.g., "sensor", "binary_sensor") - """ - # Get device info - device_id: int = 0 - device_name: str | None = None - if CONF_DEVICE_ID in config: - device_id_obj: ID = config[CONF_DEVICE_ID] - device: MockObj = await get_variable(device_id_obj) - add(var.set_device(device)) - # Use the device's ID hash as device_id - - device_id = fnv1a_32bit_hash(device_id_obj.id) - # Get device name for object ID calculation - device_name = device_id_obj.id - - add(var.set_name(config[CONF_NAME])) - - # Calculate base object_id using the same logic as C++ - # This must match the C++ behavior in esphome/core/entity_base.cpp - base_object_id = get_base_entity_object_id( - config[CONF_NAME], CORE.friendly_name, device_name - ) - - if not config[CONF_NAME]: - _LOGGER.debug( - "Entity has empty name, using '%s' as object_id base", base_object_id - ) - - # Check for duplicates - unique_key: tuple[int, str, str] = (device_id, platform, base_object_id) - if unique_key in CORE.unique_ids: - # Found duplicate - fail validation - from esphome.config_validation import Invalid - - entity_name = config[CONF_NAME] or base_object_id - device_prefix = f" on device '{device_name}'" if device_name else "" - raise Invalid( - f"Duplicate {platform} entity with name '{entity_name}' found{device_prefix}. " - f"Each entity on a device must have a unique name within its platform." - ) - else: - # First occurrence - register it - CORE.unique_ids.add(unique_key) - object_id = base_object_id - - add(var.set_object_id(object_id)) - _LOGGER.debug( - "Setting object_id '%s' for entity '%s' on platform '%s'", - object_id, - config[CONF_NAME], - platform, - ) - add(var.set_disabled_by_default(config[CONF_DISABLED_BY_DEFAULT])) - if CONF_INTERNAL in config: - add(var.set_internal(config[CONF_INTERNAL])) - if CONF_ICON in config: - add(var.set_icon(config[CONF_ICON])) - if CONF_ENTITY_CATEGORY in config: - add(var.set_entity_category(config[CONF_ENTITY_CATEGORY])) diff --git a/tests/unit_tests/test_entity.py b/tests/unit_tests/core/test_entity_helpers.py similarity index 97% rename from tests/unit_tests/test_entity.py rename to tests/unit_tests/core/test_entity_helpers.py index 6477e98e13..1a0d4d20a9 100644 --- a/tests/unit_tests/test_entity.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -6,12 +6,11 @@ from typing import Any import pytest -from esphome import entity from esphome.config_validation import Invalid from esphome.const import CONF_DEVICE_ID, CONF_DISABLED_BY_DEFAULT, CONF_ICON, CONF_NAME -from esphome.core import CORE, ID +from esphome.core import CORE, ID, entity_helpers +from esphome.core.entity_helpers import get_base_entity_object_id, setup_entity from esphome.cpp_generator import MockObj -from esphome.entity import get_base_entity_object_id, setup_entity from esphome.helpers import sanitize, snake_case # Pre-compiled regex pattern for extracting object IDs from expressions @@ -240,7 +239,7 @@ def setup_test_environment() -> Generator[list[str], None, None]: CORE.friendly_name = "Test Device" # Store original add function - original_add = entity.add + original_add = entity_helpers.add # Track what gets added added_expressions: list[str] = [] @@ -248,11 +247,11 @@ def setup_test_environment() -> Generator[list[str], None, None]: added_expressions.append(str(expression)) return original_add(expression) - # Patch add function in entity module - entity.add = mock_add + # Patch add function in entity_helpers module + entity_helpers.add = mock_add yield added_expressions # Clean up - entity.add = original_add + entity_helpers.add = original_add def extract_object_id_from_expressions(expressions: list[str]) -> str | None: @@ -372,17 +371,17 @@ async def test_setup_entity_different_platforms( def mock_get_variable() -> Generator[dict[ID, MockObj], None, None]: """Mock get_variable to return test devices.""" devices = {} - original_get_variable = entity.get_variable + original_get_variable = entity_helpers.get_variable async def _mock_get_variable(device_id: ID) -> MockObj: if device_id in devices: return devices[device_id] return await original_get_variable(device_id) - entity.get_variable = _mock_get_variable + entity_helpers.get_variable = _mock_get_variable yield devices # Clean up - entity.get_variable = original_get_variable + entity_helpers.get_variable = original_get_variable @pytest.mark.asyncio From 10bf05ab0dff5004c8636a87ab6fda031e02a1f7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Jun 2025 22:59:46 +0200 Subject: [PATCH 160/183] migrate --- esphome/core/entity_helpers.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 21ba9cc032..2928f07edf 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -1,6 +1,7 @@ from collections.abc import Callable import logging +import esphome.config_validation as cv from esphome.const import ( CONF_DEVICE_ID, CONF_DISABLED_BY_DEFAULT, @@ -206,9 +207,6 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy # Check for duplicates unique_key = (device_id, platform, base_object_id) if unique_key in CORE.unique_ids: - # Import here to avoid circular dependency - import esphome.config_validation as cv - entity_name_display = entity_name or base_object_id device_prefix = f" on device '{device_name}'" if device_name else "" raise cv.Invalid( From 536e45668f23cfe0b44464182cc188f0b6621e55 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Jun 2025 23:09:08 +0200 Subject: [PATCH 161/183] migrate --- esphome/core/__init__.py | 2 +- esphome/core/entity_helpers.py | 27 +++++++++------------------ 2 files changed, 10 insertions(+), 19 deletions(-) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 45487e1bb9..bb7c16c5ed 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -524,7 +524,7 @@ class EsphomeCore: self.platform_counts: defaultdict[str, int] = defaultdict(int) # Track entity unique IDs to handle duplicates # Set of (device_id, platform, object_id) tuples - self.unique_ids: set[tuple[int, str, str]] = set() + self.unique_ids: set[tuple[str, str, str]] = set() # Whether ESPHome was started in verbose mode self.verbose = False # Whether ESPHome was started in quiet mode diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 2928f07edf..c95acebbf9 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -186,31 +186,22 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy # Get the entity name and device info entity_name = config[CONF_NAME] - device_id = 0 # Main device by default - device_name = None + device_id = "" # Empty string for main device if CONF_DEVICE_ID in config: - device_config = config[CONF_DEVICE_ID] - if hasattr(device_config, "id"): - device_id = hash(device_config.id) - # Try to get device name from CORE if available - for dev in getattr(CORE, "devices", []): - if hasattr(dev, "id") and dev.id == device_config.id: - device_name = getattr(dev, "name", None) - break + device_id_obj = config[CONF_DEVICE_ID] + # Use the device ID string directly for uniqueness + device_id = device_id_obj.id - # Calculate the base object ID - base_object_id = get_base_entity_object_id( - entity_name, CORE.friendly_name, device_name - ) + # For duplicate detection, just use the sanitized name + name_key = sanitize(snake_case(entity_name)) # Check for duplicates - unique_key = (device_id, platform, base_object_id) + unique_key = (device_id, platform, name_key) if unique_key in CORE.unique_ids: - entity_name_display = entity_name or base_object_id - device_prefix = f" on device '{device_name}'" if device_name else "" + device_prefix = f" on device '{device_id}'" if device_id else "" raise cv.Invalid( - f"Duplicate {platform} entity with name '{entity_name_display}' found{device_prefix}. " + f"Duplicate {platform} entity with name '{entity_name}' found{device_prefix}. " f"Each entity on a device must have a unique name within its platform." ) From 602456db406ac461f2f1fbe748a3a9baf80ed053 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Jun 2025 23:13:45 +0200 Subject: [PATCH 162/183] cleanup --- tests/unit_tests/core/conftest.py | 18 ++ tests/unit_tests/core/test_config.py | 12 -- tests/unit_tests/core/test_entity_helpers.py | 164 ++++++------------- 3 files changed, 72 insertions(+), 122 deletions(-) create mode 100644 tests/unit_tests/core/conftest.py diff --git a/tests/unit_tests/core/conftest.py b/tests/unit_tests/core/conftest.py new file mode 100644 index 0000000000..60d6738ce9 --- /dev/null +++ b/tests/unit_tests/core/conftest.py @@ -0,0 +1,18 @@ +"""Shared fixtures for core unit tests.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + + +@pytest.fixture +def yaml_file(tmp_path: Path) -> Callable[[str], str]: + """Create a temporary YAML file for testing.""" + + def _yaml_file(content: str) -> str: + yaml_path = tmp_path / "test.yaml" + yaml_path.write_text(content) + return str(yaml_path) + + return _yaml_file diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index ba8436b7a7..c98dd01f19 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -16,18 +16,6 @@ from esphome.core.config import Area, validate_area_config FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" / "core" / "config" -@pytest.fixture -def yaml_file(tmp_path: Path) -> Callable[[str], str]: - """Create a temporary YAML file for testing.""" - - def _yaml_file(content: str) -> str: - yaml_path = tmp_path / "test.yaml" - yaml_path.write_text(content) - return str(yaml_path) - - return _yaml_file - - def load_config_from_yaml( yaml_file: Callable[[str], str], yaml_content: str ) -> Config | None: diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index 1a0d4d20a9..475d8a3b54 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -300,37 +300,6 @@ async def test_setup_entity_no_duplicates(setup_test_environment: list[str]) -> assert object_id2 == "humidity" -@pytest.mark.asyncio -async def test_setup_entity_with_duplicates(setup_test_environment: list[str]) -> None: - """Test setup_entity with duplicate names raises validation error.""" - added_expressions = setup_test_environment - - # Create mock entities - entities = [MockObj(f"sensor{i}") for i in range(4)] - - # Set up entities with same name - config = { - CONF_NAME: "Temperature", - CONF_DISABLED_BY_DEFAULT: False, - } - - # First entity should succeed - await setup_entity(entities[0], config, "sensor") - object_id = extract_object_id_from_expressions(added_expressions) - assert object_id == "temperature" - - # Clear CORE unique_ids before second test to ensure clean state - CORE.unique_ids.clear() - # Add back the first one - CORE.unique_ids.add((0, "sensor", "temperature")) - - # Second entity with same name should raise Invalid - with pytest.raises( - Invalid, match=r"Duplicate sensor entity with name 'Temperature' found" - ): - await setup_entity(entities[1], config, "sensor") - - @pytest.mark.asyncio async def test_setup_entity_different_platforms( setup_test_environment: list[str], @@ -450,36 +419,6 @@ async def test_setup_entity_empty_name(setup_test_environment: list[str]) -> Non assert object_id == "test_device" -@pytest.mark.asyncio -async def test_setup_entity_empty_name_duplicates( - setup_test_environment: list[str], -) -> None: - """Test setup_entity with multiple empty names raises validation error.""" - added_expressions = setup_test_environment - - entities = [MockObj(f"sensor{i}") for i in range(3)] - - config = { - CONF_NAME: "", - CONF_DISABLED_BY_DEFAULT: False, - } - - # First entity should succeed - await setup_entity(entities[0], config, "sensor") - object_id = extract_object_id_from_expressions(added_expressions) - assert object_id == "test_device" - - # Clear and restore unique_ids for clean test - CORE.unique_ids.clear() - CORE.unique_ids.add((0, "sensor", "test_device")) - - # Second entity with empty name should raise Invalid - with pytest.raises( - Invalid, match=r"Duplicate sensor entity with name 'test_device' found" - ): - await setup_entity(entities[1], config, "sensor") - - @pytest.mark.asyncio async def test_setup_entity_special_characters( setup_test_environment: list[str], @@ -547,60 +486,65 @@ async def test_setup_entity_disabled_by_default( ) -@pytest.mark.asyncio -async def test_setup_entity_mixed_duplicates(setup_test_environment: list[str]) -> None: - """Test complex duplicate scenario with multiple platforms and devices.""" +def test_entity_duplicate_validator() -> None: + """Test the entity_duplicate_validator function.""" + from esphome.core.entity_helpers import entity_duplicate_validator - added_expressions = setup_test_environment - - # Track results - results: list[tuple[str, str]] = [] - - # First sensor named "Status" should succeed - added_expressions.clear() - var = MockObj("sensor_status_0") - await setup_entity( - var, {CONF_NAME: "Status", CONF_DISABLED_BY_DEFAULT: False}, "sensor" - ) - object_id = extract_object_id_from_expressions(added_expressions) - results.append(("sensor", object_id)) - - # Clear and restore unique_ids for test + # Reset CORE unique_ids for clean test CORE.unique_ids.clear() - CORE.unique_ids.add((0, "sensor", "status")) - # Second sensor with same name should fail + # Create validator for sensor platform + validator = entity_duplicate_validator("sensor") + + # First entity should pass + config1 = {CONF_NAME: "Temperature"} + validated1 = validator(config1) + assert validated1 == config1 + assert ("", "sensor", "temperature") in CORE.unique_ids + + # Second entity with different name should pass + config2 = {CONF_NAME: "Humidity"} + validated2 = validator(config2) + assert validated2 == config2 + assert ("", "sensor", "humidity") in CORE.unique_ids + + # Duplicate entity should fail + config3 = {CONF_NAME: "Temperature"} with pytest.raises( - Invalid, match=r"Duplicate sensor entity with name 'Status' found" + Invalid, match=r"Duplicate sensor entity with name 'Temperature' found" ): - await setup_entity( - MockObj("sensor_status_1"), - {CONF_NAME: "Status", CONF_DISABLED_BY_DEFAULT: False}, - "sensor", - ) + validator(config3) - # Binary sensor with same name should succeed (different platform) - added_expressions.clear() - var = MockObj("binary_sensor_status_0") - await setup_entity( - var, {CONF_NAME: "Status", CONF_DISABLED_BY_DEFAULT: False}, "binary_sensor" - ) - object_id = extract_object_id_from_expressions(added_expressions) - results.append(("binary_sensor", object_id)) - # Text sensor with same name should succeed (different platform) - added_expressions.clear() - var = MockObj("text_sensor_status") - await setup_entity( - var, {CONF_NAME: "Status", CONF_DISABLED_BY_DEFAULT: False}, "text_sensor" - ) - object_id = extract_object_id_from_expressions(added_expressions) - results.append(("text_sensor", object_id)) +def test_entity_duplicate_validator_with_devices() -> None: + """Test entity_duplicate_validator with devices.""" + from esphome.core.entity_helpers import entity_duplicate_validator - # Check results - each platform has its own namespace - assert results[0] == ("sensor", "status") # sensor - assert results[1] == ( - "binary_sensor", - "status", - ) # binary_sensor (different platform) - assert results[2] == ("text_sensor", "status") # text_sensor (different platform) + # Reset CORE unique_ids for clean test + CORE.unique_ids.clear() + + # Create validator for sensor platform + validator = entity_duplicate_validator("sensor") + + # Create mock device IDs + device1 = ID("device1", type="Device") + device2 = ID("device2", type="Device") + + # Same name on different devices should pass + config1 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device1} + validated1 = validator(config1) + assert validated1 == config1 + assert ("device1", "sensor", "temperature") in CORE.unique_ids + + config2 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device2} + validated2 = validator(config2) + assert validated2 == config2 + assert ("device2", "sensor", "temperature") in CORE.unique_ids + + # Duplicate on same device should fail + config3 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device1} + with pytest.raises( + Invalid, + match=r"Duplicate sensor entity with name 'Temperature' found on device 'device1'", + ): + validator(config3) From 192158ef1ad7101759cef86a63155400e09193de Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Jun 2025 23:22:18 +0200 Subject: [PATCH 163/183] cleanup --- tests/unit_tests/core/__init__.py | 0 tests/unit_tests/core/common.py | 33 ++++++ tests/unit_tests/core/test_config.py | 63 +++++------ tests/unit_tests/core/test_entity_helpers.py | 108 ++++++++++++++++++- 4 files changed, 166 insertions(+), 38 deletions(-) create mode 100644 tests/unit_tests/core/__init__.py create mode 100644 tests/unit_tests/core/common.py diff --git a/tests/unit_tests/core/__init__.py b/tests/unit_tests/core/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/core/common.py b/tests/unit_tests/core/common.py new file mode 100644 index 0000000000..1848d5397b --- /dev/null +++ b/tests/unit_tests/core/common.py @@ -0,0 +1,33 @@ +"""Common test utilities for core unit tests.""" + +from collections.abc import Callable +from pathlib import Path +from unittest.mock import patch + +from esphome import config, yaml_util +from esphome.config import Config +from esphome.core import CORE + + +def load_config_from_yaml( + yaml_file: Callable[[str], str], yaml_content: str +) -> Config | None: + """Load configuration from YAML content.""" + yaml_path = yaml_file(yaml_content) + parsed_yaml = yaml_util.load_yaml(yaml_path) + + # Mock yaml_util.load_yaml to return our parsed content + with ( + patch.object(yaml_util, "load_yaml", return_value=parsed_yaml), + patch.object(CORE, "config_path", yaml_path), + ): + return config.read_config({}) + + +def load_config_from_fixture( + yaml_file: Callable[[str], str], fixture_name: str, fixtures_dir: Path +) -> Config | None: + """Load configuration from a fixture file.""" + fixture_path = fixtures_dir / fixture_name + yaml_content = fixture_path.read_text() + return load_config_from_yaml(yaml_file, yaml_content) diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index c98dd01f19..46e3b513d7 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -3,43 +3,18 @@ from collections.abc import Callable from pathlib import Path from typing import Any -from unittest.mock import patch import pytest -from esphome import config, config_validation as cv, core, yaml_util -from esphome.config import Config +from esphome import config_validation as cv, core from esphome.const import CONF_AREA, CONF_AREAS, CONF_DEVICES -from esphome.core import CORE from esphome.core.config import Area, validate_area_config +from .common import load_config_from_fixture + FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" / "core" / "config" -def load_config_from_yaml( - yaml_file: Callable[[str], str], yaml_content: str -) -> Config | None: - """Load configuration from YAML content.""" - yaml_path = yaml_file(yaml_content) - parsed_yaml = yaml_util.load_yaml(yaml_path) - - # Mock yaml_util.load_yaml to return our parsed content - with ( - patch.object(yaml_util, "load_yaml", return_value=parsed_yaml), - patch.object(CORE, "config_path", yaml_path), - ): - return config.read_config({}) - - -def load_config_from_fixture( - yaml_file: Callable[[str], str], fixture_name: str -) -> Config | None: - """Load configuration from a fixture file.""" - fixture_path = FIXTURES_DIR / fixture_name - yaml_content = fixture_path.read_text() - return load_config_from_yaml(yaml_file, yaml_content) - - def test_validate_area_config_with_string() -> None: """Test that string area config is converted to structured format.""" result = validate_area_config("Living Room") @@ -70,7 +45,7 @@ def test_validate_area_config_with_dict() -> None: def test_device_with_valid_area_id(yaml_file: Callable[[str], str]) -> None: """Test that device with valid area_id works correctly.""" - result = load_config_from_fixture(yaml_file, "valid_area_device.yaml") + result = load_config_from_fixture(yaml_file, "valid_area_device.yaml", FIXTURES_DIR) assert result is not None esphome_config = result["esphome"] @@ -93,7 +68,9 @@ def test_device_with_valid_area_id(yaml_file: Callable[[str], str]) -> None: def test_multiple_areas_and_devices(yaml_file: Callable[[str], str]) -> None: """Test multiple areas and devices configuration.""" - result = load_config_from_fixture(yaml_file, "multiple_areas_devices.yaml") + result = load_config_from_fixture( + yaml_file, "multiple_areas_devices.yaml", FIXTURES_DIR + ) assert result is not None esphome_config = result["esphome"] @@ -129,7 +106,9 @@ def test_legacy_string_area( yaml_file: Callable[[str], str], caplog: pytest.LogCaptureFixture ) -> None: """Test legacy string area configuration with deprecation warning.""" - result = load_config_from_fixture(yaml_file, "legacy_string_area.yaml") + result = load_config_from_fixture( + yaml_file, "legacy_string_area.yaml", FIXTURES_DIR + ) assert result is not None esphome_config = result["esphome"] @@ -148,7 +127,7 @@ def test_area_id_collision( yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] ) -> None: """Test that duplicate area IDs are detected.""" - result = load_config_from_fixture(yaml_file, "area_id_collision.yaml") + result = load_config_from_fixture(yaml_file, "area_id_collision.yaml", FIXTURES_DIR) assert result is None # Check for the specific error message in stdout @@ -159,7 +138,9 @@ def test_area_id_collision( def test_device_without_area(yaml_file: Callable[[str], str]) -> None: """Test that devices without area_id work correctly.""" - result = load_config_from_fixture(yaml_file, "device_without_area.yaml") + result = load_config_from_fixture( + yaml_file, "device_without_area.yaml", FIXTURES_DIR + ) assert result is not None esphome_config = result["esphome"] @@ -181,7 +162,9 @@ def test_device_with_invalid_area_id( yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] ) -> None: """Test that device with non-existent area_id fails validation.""" - result = load_config_from_fixture(yaml_file, "device_invalid_area.yaml") + result = load_config_from_fixture( + yaml_file, "device_invalid_area.yaml", FIXTURES_DIR + ) assert result is None # Check for the specific error message in stdout @@ -196,7 +179,9 @@ def test_device_id_hash_collision( yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] ) -> None: """Test that device IDs with hash collisions are detected.""" - result = load_config_from_fixture(yaml_file, "device_id_collision.yaml") + result = load_config_from_fixture( + yaml_file, "device_id_collision.yaml", FIXTURES_DIR + ) assert result is None # Check for the specific error message about hash collision @@ -212,7 +197,9 @@ def test_area_id_hash_collision( yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] ) -> None: """Test that area IDs with hash collisions are detected.""" - result = load_config_from_fixture(yaml_file, "area_id_hash_collision.yaml") + result = load_config_from_fixture( + yaml_file, "area_id_hash_collision.yaml", FIXTURES_DIR + ) assert result is None # Check for the specific error message about hash collision @@ -228,7 +215,9 @@ def test_device_duplicate_id( yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] ) -> None: """Test that duplicate device IDs are detected by IDPassValidationStep.""" - result = load_config_from_fixture(yaml_file, "device_duplicate_id.yaml") + result = load_config_from_fixture( + yaml_file, "device_duplicate_id.yaml", FIXTURES_DIR + ) assert result is None # Check for the specific error message from IDPassValidationStep diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index 475d8a3b54..ffb155cc2d 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -1,6 +1,7 @@ """Test get_base_entity_object_id function matches C++ behavior.""" -from collections.abc import Generator +from collections.abc import Callable, Generator +from pathlib import Path import re from typing import Any @@ -13,9 +14,13 @@ from esphome.core.entity_helpers import get_base_entity_object_id, setup_entity from esphome.cpp_generator import MockObj from esphome.helpers import sanitize, snake_case +from .common import load_config_from_yaml + # Pre-compiled regex pattern for extracting object IDs from expressions OBJECT_ID_PATTERN = re.compile(r'\.set_object_id\(["\'](.*?)["\']\)') +FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" / "core" / "entity_helpers" + @pytest.fixture(autouse=True) def restore_core_state() -> Generator[None, None, None]: @@ -548,3 +553,104 @@ def test_entity_duplicate_validator_with_devices() -> None: match=r"Duplicate sensor entity with name 'Temperature' found on device 'device1'", ): validator(config3) + + +def test_duplicate_entity_yaml_validation( + yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] +) -> None: + """Test that duplicate entity names are caught during YAML config validation.""" + yaml_content = """ +esphome: + name: test-duplicate + +esp32: + board: esp32dev + +sensor: + - platform: template + name: "Temperature" + lambda: return 21.0; + - platform: template + name: "Temperature" # Duplicate - should fail + lambda: return 22.0; +""" + result = load_config_from_yaml(yaml_file, yaml_content) + assert result is None + + # Check for the duplicate entity error message + captured = capsys.readouterr() + assert "Duplicate sensor entity with name 'Temperature' found" in captured.out + + +def test_duplicate_entity_with_devices_yaml_validation( + yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] +) -> None: + """Test duplicate entity validation with devices.""" + yaml_content = """ +esphome: + name: test-duplicate-devices + devices: + - id: device1 + name: "Device 1" + - id: device2 + name: "Device 2" + +esp32: + board: esp32dev + +sensor: + # Same name on different devices - should pass + - platform: template + device_id: device1 + name: "Temperature" + lambda: return 21.0; + - platform: template + device_id: device2 + name: "Temperature" + lambda: return 22.0; + # Duplicate on same device - should fail + - platform: template + device_id: device1 + name: "Temperature" + lambda: return 23.0; +""" + result = load_config_from_yaml(yaml_file, yaml_content) + assert result is None + + # Check for the duplicate entity error message with device + captured = capsys.readouterr() + assert ( + "Duplicate sensor entity with name 'Temperature' found on device 'device1'" + in captured.out + ) + + +def test_entity_different_platforms_yaml_validation( + yaml_file: Callable[[str], str], +) -> None: + """Test that same entity name on different platforms is allowed.""" + yaml_content = """ +esphome: + name: test-different-platforms + +esp32: + board: esp32dev + +sensor: + - platform: template + name: "Status" + lambda: return 1.0; + +binary_sensor: + - platform: template + name: "Status" # Same name, different platform - should pass + lambda: return true; + +text_sensor: + - platform: template + name: "Status" # Same name, different platform - should pass + lambda: return {"OK"}; +""" + result = load_config_from_yaml(yaml_file, yaml_content) + # This should succeed + assert result is not None From 30f4e782db3ddb94649fc1eadbfbaeb3569dab35 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Jun 2025 23:23:35 +0200 Subject: [PATCH 164/183] cleanup --- .../core/entity_helpers/duplicate_entity.yaml | 13 ++++++++++ .../duplicate_entity_with_devices.yaml | 26 +++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 tests/unit_tests/fixtures/core/entity_helpers/duplicate_entity.yaml create mode 100644 tests/unit_tests/fixtures/core/entity_helpers/duplicate_entity_with_devices.yaml diff --git a/tests/unit_tests/fixtures/core/entity_helpers/duplicate_entity.yaml b/tests/unit_tests/fixtures/core/entity_helpers/duplicate_entity.yaml new file mode 100644 index 0000000000..2a8dad66c9 --- /dev/null +++ b/tests/unit_tests/fixtures/core/entity_helpers/duplicate_entity.yaml @@ -0,0 +1,13 @@ +esphome: + name: test-duplicate + +esp32: + board: esp32dev + +sensor: + - platform: template + name: "Temperature" + lambda: return 21.0; + - platform: template + name: "Temperature" # Duplicate - should fail + lambda: return 22.0; diff --git a/tests/unit_tests/fixtures/core/entity_helpers/duplicate_entity_with_devices.yaml b/tests/unit_tests/fixtures/core/entity_helpers/duplicate_entity_with_devices.yaml new file mode 100644 index 0000000000..42e16231a5 --- /dev/null +++ b/tests/unit_tests/fixtures/core/entity_helpers/duplicate_entity_with_devices.yaml @@ -0,0 +1,26 @@ +esphome: + name: test-duplicate-devices + devices: + - id: device1 + name: "Device 1" + - id: device2 + name: "Device 2" + +esp32: + board: esp32dev + +sensor: + # Same name on different devices - should pass + - platform: template + device_id: device1 + name: "Temperature" + lambda: return 21.0; + - platform: template + device_id: device2 + name: "Temperature" + lambda: return 22.0; + # Duplicate on same device - should fail + - platform: template + device_id: device1 + name: "Temperature" + lambda: return 23.0; From ca0f3ba262acc9b338ee9cdde95cf1a24e4e7601 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Jun 2025 23:23:59 +0200 Subject: [PATCH 165/183] cleanup --- .../entity_different_platforms.yaml | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 tests/unit_tests/fixtures/core/entity_helpers/entity_different_platforms.yaml diff --git a/tests/unit_tests/fixtures/core/entity_helpers/entity_different_platforms.yaml b/tests/unit_tests/fixtures/core/entity_helpers/entity_different_platforms.yaml new file mode 100644 index 0000000000..00181c52c4 --- /dev/null +++ b/tests/unit_tests/fixtures/core/entity_helpers/entity_different_platforms.yaml @@ -0,0 +1,20 @@ +esphome: + name: test-different-platforms + +esp32: + board: esp32dev + +sensor: + - platform: template + name: "Status" + lambda: return 1.0; + +binary_sensor: + - platform: template + name: "Status" # Same name, different platform - should pass + lambda: return true; + +text_sensor: + - platform: template + name: "Status" # Same name, different platform - should pass + lambda: return {"OK"}; From 0a5f09402527a9809c8bf4f76759cb04453a0eeb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Jun 2025 23:25:46 +0200 Subject: [PATCH 166/183] cleanup --- tests/unit_tests/core/test_entity_helpers.py | 77 ++------------------ 1 file changed, 8 insertions(+), 69 deletions(-) diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index ffb155cc2d..e166eeedee 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -14,7 +14,7 @@ from esphome.core.entity_helpers import get_base_entity_object_id, setup_entity from esphome.cpp_generator import MockObj from esphome.helpers import sanitize, snake_case -from .common import load_config_from_yaml +from .common import load_config_from_fixture # Pre-compiled regex pattern for extracting object IDs from expressions OBJECT_ID_PATTERN = re.compile(r'\.set_object_id\(["\'](.*?)["\']\)') @@ -559,22 +559,7 @@ def test_duplicate_entity_yaml_validation( yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] ) -> None: """Test that duplicate entity names are caught during YAML config validation.""" - yaml_content = """ -esphome: - name: test-duplicate - -esp32: - board: esp32dev - -sensor: - - platform: template - name: "Temperature" - lambda: return 21.0; - - platform: template - name: "Temperature" # Duplicate - should fail - lambda: return 22.0; -""" - result = load_config_from_yaml(yaml_file, yaml_content) + result = load_config_from_fixture(yaml_file, "duplicate_entity.yaml", FIXTURES_DIR) assert result is None # Check for the duplicate entity error message @@ -586,35 +571,9 @@ def test_duplicate_entity_with_devices_yaml_validation( yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] ) -> None: """Test duplicate entity validation with devices.""" - yaml_content = """ -esphome: - name: test-duplicate-devices - devices: - - id: device1 - name: "Device 1" - - id: device2 - name: "Device 2" - -esp32: - board: esp32dev - -sensor: - # Same name on different devices - should pass - - platform: template - device_id: device1 - name: "Temperature" - lambda: return 21.0; - - platform: template - device_id: device2 - name: "Temperature" - lambda: return 22.0; - # Duplicate on same device - should fail - - platform: template - device_id: device1 - name: "Temperature" - lambda: return 23.0; -""" - result = load_config_from_yaml(yaml_file, yaml_content) + result = load_config_from_fixture( + yaml_file, "duplicate_entity_with_devices.yaml", FIXTURES_DIR + ) assert result is None # Check for the duplicate entity error message with device @@ -629,28 +588,8 @@ def test_entity_different_platforms_yaml_validation( yaml_file: Callable[[str], str], ) -> None: """Test that same entity name on different platforms is allowed.""" - yaml_content = """ -esphome: - name: test-different-platforms - -esp32: - board: esp32dev - -sensor: - - platform: template - name: "Status" - lambda: return 1.0; - -binary_sensor: - - platform: template - name: "Status" # Same name, different platform - should pass - lambda: return true; - -text_sensor: - - platform: template - name: "Status" # Same name, different platform - should pass - lambda: return {"OK"}; -""" - result = load_config_from_yaml(yaml_file, yaml_content) + result = load_config_from_fixture( + yaml_file, "entity_different_platforms.yaml", FIXTURES_DIR + ) # This should succeed assert result is not None From 41eceb72ef3fa5cdd1626e56590044fd3a8e1cf1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Jun 2025 23:28:06 +0200 Subject: [PATCH 167/183] preen --- esphome/components/alarm_control_panel/__init__.py | 1 - esphome/components/binary_sensor/__init__.py | 1 - esphome/components/button/__init__.py | 1 - esphome/components/climate/__init__.py | 1 - esphome/components/cover/__init__.py | 1 - esphome/components/datetime/__init__.py | 1 - esphome/components/event/__init__.py | 1 - esphome/components/fan/__init__.py | 1 - esphome/components/light/__init__.py | 1 - esphome/components/lock/__init__.py | 1 - esphome/components/media_player/__init__.py | 1 - esphome/components/number/__init__.py | 1 - esphome/components/select/__init__.py | 1 - esphome/components/sensor/__init__.py | 1 - esphome/components/switch/__init__.py | 1 - esphome/components/text/__init__.py | 1 - esphome/components/text_sensor/__init__.py | 1 - esphome/components/update/__init__.py | 1 - esphome/components/valve/__init__.py | 1 - 19 files changed, 19 deletions(-) diff --git a/esphome/components/alarm_control_panel/__init__.py b/esphome/components/alarm_control_panel/__init__.py index 2fbf17656a..6d37d53a4c 100644 --- a/esphome/components/alarm_control_panel/__init__.py +++ b/esphome/components/alarm_control_panel/__init__.py @@ -149,7 +149,6 @@ _ALARM_CONTROL_PANEL_SCHEMA = ( ) -# Add duplicate entity validation _ALARM_CONTROL_PANEL_SCHEMA.add_extra(entity_duplicate_validator("alarm_control_panel")) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 0711fb2971..fd9551b850 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -491,7 +491,6 @@ _BINARY_SENSOR_SCHEMA = ( ) -# Add duplicate entity validation _BINARY_SENSOR_SCHEMA.add_extra(entity_duplicate_validator("binary_sensor")) diff --git a/esphome/components/button/__init__.py b/esphome/components/button/__init__.py index c1b47e2a74..ed2670a5c5 100644 --- a/esphome/components/button/__init__.py +++ b/esphome/components/button/__init__.py @@ -61,7 +61,6 @@ _BUTTON_SCHEMA = ( ) -# Add duplicate entity validation _BUTTON_SCHEMA.add_extra(entity_duplicate_validator("button")) diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index 8f4298c156..9530ecdcca 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -247,7 +247,6 @@ _CLIMATE_SCHEMA = ( ) -# Add duplicate entity validation _CLIMATE_SCHEMA.add_extra(entity_duplicate_validator("climate")) diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index 8fbf9ece97..cd97a38ecc 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -126,7 +126,6 @@ _COVER_SCHEMA = ( ) -# Add duplicate entity validation _COVER_SCHEMA.add_extra(entity_duplicate_validator("cover")) diff --git a/esphome/components/datetime/__init__.py b/esphome/components/datetime/__init__.py index bb061a8148..4788810965 100644 --- a/esphome/components/datetime/__init__.py +++ b/esphome/components/datetime/__init__.py @@ -84,7 +84,6 @@ _DATETIME_SCHEMA = cv.ENTITY_BASE_SCHEMA.extend( .extend(cv.MQTT_COMMAND_COMPONENT_SCHEMA) ).add_extra(_validate_time_present) -# Add duplicate entity validation _DATETIME_SCHEMA.add_extra(entity_duplicate_validator("datetime")) diff --git a/esphome/components/event/__init__.py b/esphome/components/event/__init__.py index 39a51f16df..3aff96a48e 100644 --- a/esphome/components/event/__init__.py +++ b/esphome/components/event/__init__.py @@ -59,7 +59,6 @@ _EVENT_SCHEMA = ( ) -# Add duplicate entity validation _EVENT_SCHEMA.add_extra(entity_duplicate_validator("event")) diff --git a/esphome/components/fan/__init__.py b/esphome/components/fan/__init__.py index 9bd1ce2e4d..0b1d39575d 100644 --- a/esphome/components/fan/__init__.py +++ b/esphome/components/fan/__init__.py @@ -161,7 +161,6 @@ _FAN_SCHEMA = ( ) -# Add duplicate entity validation _FAN_SCHEMA.add_extra(entity_duplicate_validator("fan")) diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index c6997ccd6d..7ab899edb2 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -110,7 +110,6 @@ LIGHT_SCHEMA = ( ) ) -# Add duplicate entity validation LIGHT_SCHEMA.add_extra(entity_duplicate_validator("light")) BINARY_LIGHT_SCHEMA = LIGHT_SCHEMA.extend( diff --git a/esphome/components/lock/__init__.py b/esphome/components/lock/__init__.py index c0718d5d41..e62d9f3e2b 100644 --- a/esphome/components/lock/__init__.py +++ b/esphome/components/lock/__init__.py @@ -67,7 +67,6 @@ _LOCK_SCHEMA = ( ) -# Add duplicate entity validation _LOCK_SCHEMA.add_extra(entity_duplicate_validator("lock")) diff --git a/esphome/components/media_player/__init__.py b/esphome/components/media_player/__init__.py index 04d01f5913..ccded1deb2 100644 --- a/esphome/components/media_player/__init__.py +++ b/esphome/components/media_player/__init__.py @@ -143,7 +143,6 @@ _MEDIA_PLAYER_SCHEMA = cv.ENTITY_BASE_SCHEMA.extend( } ) -# Add duplicate entity validation _MEDIA_PLAYER_SCHEMA.add_extra(entity_duplicate_validator("media_player")) diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index ec3c263f8f..4beed57188 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -207,7 +207,6 @@ _NUMBER_SCHEMA = ( ) -# Add duplicate entity validation _NUMBER_SCHEMA.add_extra(entity_duplicate_validator("number")) diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py index a5464d18d5..ed1f6c020d 100644 --- a/esphome/components/select/__init__.py +++ b/esphome/components/select/__init__.py @@ -65,7 +65,6 @@ _SELECT_SCHEMA = ( ) -# Add duplicate entity validation _SELECT_SCHEMA.add_extra(entity_duplicate_validator("select")) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 99b19d4c8b..ea74361d51 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -318,7 +318,6 @@ _SENSOR_SCHEMA = ( ) ) -# Add duplicate entity validation _SENSOR_SCHEMA.add_extra(entity_duplicate_validator("sensor")) diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index b5fb88c5e4..c09675069f 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -91,7 +91,6 @@ _SWITCH_SCHEMA = ( ) -# Add duplicate entity validation _SWITCH_SCHEMA.add_extra(entity_duplicate_validator("switch")) diff --git a/esphome/components/text/__init__.py b/esphome/components/text/__init__.py index ae416b44d7..8362e09ac0 100644 --- a/esphome/components/text/__init__.py +++ b/esphome/components/text/__init__.py @@ -58,7 +58,6 @@ _TEXT_SCHEMA = ( ) -# Add duplicate entity validation _TEXT_SCHEMA.add_extra(entity_duplicate_validator("text")) diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 8d91bed566..abb2dcae6c 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -153,7 +153,6 @@ _TEXT_SENSOR_SCHEMA = ( ) -# Add duplicate entity validation _TEXT_SENSOR_SCHEMA.add_extra(entity_duplicate_validator("text_sensor")) diff --git a/esphome/components/update/__init__.py b/esphome/components/update/__init__.py index 48ac2acebf..758267f412 100644 --- a/esphome/components/update/__init__.py +++ b/esphome/components/update/__init__.py @@ -58,7 +58,6 @@ _UPDATE_SCHEMA = ( ) -# Add duplicate entity validation _UPDATE_SCHEMA.add_extra(entity_duplicate_validator("update")) diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index 6acef3189c..cb27546120 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -103,7 +103,6 @@ _VALVE_SCHEMA = ( ) -# Add duplicate entity validation _VALVE_SCHEMA.add_extra(entity_duplicate_validator("valve")) From 591ec36f4a1360f1930f0c3a941f08a8d386c79c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Jun 2025 23:37:58 +0200 Subject: [PATCH 168/183] fixes --- .../fixtures/duplicate_entities.yaml | 211 -------------- ...plicate_entities_on_different_devices.yaml | 154 +++++++++++ tests/integration/test_duplicate_entities.py | 260 +++++++----------- 3 files changed, 248 insertions(+), 377 deletions(-) delete mode 100644 tests/integration/fixtures/duplicate_entities.yaml create mode 100644 tests/integration/fixtures/duplicate_entities_on_different_devices.yaml diff --git a/tests/integration/fixtures/duplicate_entities.yaml b/tests/integration/fixtures/duplicate_entities.yaml deleted file mode 100644 index 17332fe4b2..0000000000 --- a/tests/integration/fixtures/duplicate_entities.yaml +++ /dev/null @@ -1,211 +0,0 @@ -esphome: - name: duplicate-entities-test - # Define devices to test multi-device duplicate handling - devices: - - id: controller_1 - name: Controller 1 - - id: controller_2 - name: Controller 2 - -host: -api: # Port will be automatically injected -logger: - -# Create duplicate entities across different scenarios - -# Scenario 1: Multiple sensors with same name on same device (should get _2, _3, _4) -sensor: - - platform: template - name: Temperature - lambda: return 1.0; - update_interval: 0.1s - - - platform: template - name: Temperature - lambda: return 2.0; - update_interval: 0.1s - - - platform: template - name: Temperature - lambda: return 3.0; - update_interval: 0.1s - - - platform: template - name: Temperature - lambda: return 4.0; - update_interval: 0.1s - - # Scenario 2: Device-specific duplicates using device_id configuration - - platform: template - name: Device Temperature - device_id: controller_1 - lambda: return 10.0; - update_interval: 0.1s - - - platform: template - name: Device Temperature - device_id: controller_1 - lambda: return 11.0; - update_interval: 0.1s - - - platform: template - name: Device Temperature - device_id: controller_1 - lambda: return 12.0; - update_interval: 0.1s - - # Different device, same name - should not conflict - - platform: template - name: Device Temperature - device_id: controller_2 - lambda: return 20.0; - update_interval: 0.1s - -# Scenario 3: Binary sensors (different platform, same name) -binary_sensor: - - platform: template - name: Temperature - lambda: return true; - - - platform: template - name: Temperature - lambda: return false; - - - platform: template - name: Temperature - lambda: return true; - - # Scenario 5: Binary sensors on devices - - platform: template - name: Device Temperature - device_id: controller_1 - lambda: return true; - - - platform: template - name: Device Temperature - device_id: controller_2 - lambda: return false; - - # Issue #6953: Empty names on binary sensors - - platform: template - name: "" - lambda: return true; - - platform: template - name: "" - lambda: return false; - - - platform: template - name: "" - lambda: return true; - - - platform: template - name: "" - lambda: return false; - -# Scenario 6: Test with special characters that need sanitization -text_sensor: - - platform: template - name: "Status Message!" - lambda: return {"status1"}; - update_interval: 0.1s - - - platform: template - name: "Status Message!" - lambda: return {"status2"}; - update_interval: 0.1s - - - platform: template - name: "Status Message!" - lambda: return {"status3"}; - update_interval: 0.1s - -# Scenario 7: More switch duplicates -switch: - - platform: template - name: "Power Switch" - lambda: return false; - turn_on_action: [] - turn_off_action: [] - - - platform: template - name: "Power Switch" - lambda: return true; - turn_on_action: [] - turn_off_action: [] - - # Scenario 8: Issue #6953 - Multiple entities with empty names - # Empty names on main device - should use device name with suffixes - - platform: template - name: "" - lambda: return false; - turn_on_action: [] - turn_off_action: [] - - - platform: template - name: "" - lambda: return true; - turn_on_action: [] - turn_off_action: [] - - - platform: template - name: "" - lambda: return false; - turn_on_action: [] - turn_off_action: [] - - # Scenario 9: Issue #6953 - Empty names on sub-devices - # Empty names on sub-device - should use sub-device name with suffixes - - platform: template - name: "" - device_id: controller_1 - lambda: return false; - turn_on_action: [] - turn_off_action: [] - - - platform: template - name: "" - device_id: controller_1 - lambda: return true; - turn_on_action: [] - turn_off_action: [] - - - platform: template - name: "" - device_id: controller_1 - lambda: return false; - turn_on_action: [] - turn_off_action: [] - - # Empty names on different sub-device - - platform: template - name: "" - device_id: controller_2 - lambda: return false; - turn_on_action: [] - turn_off_action: [] - - - platform: template - name: "" - device_id: controller_2 - lambda: return true; - turn_on_action: [] - turn_off_action: [] - - # Scenario 10: Issue #6953 - Duplicate "xyz" names - - platform: template - name: "xyz" - lambda: return false; - turn_on_action: [] - turn_off_action: [] - - - platform: template - name: "xyz" - lambda: return true; - turn_on_action: [] - turn_off_action: [] - - - platform: template - name: "xyz" - lambda: return false; - turn_on_action: [] - turn_off_action: [] diff --git a/tests/integration/fixtures/duplicate_entities_on_different_devices.yaml b/tests/integration/fixtures/duplicate_entities_on_different_devices.yaml new file mode 100644 index 0000000000..ecc502ad28 --- /dev/null +++ b/tests/integration/fixtures/duplicate_entities_on_different_devices.yaml @@ -0,0 +1,154 @@ +esphome: + name: duplicate-entities-test + # Define devices to test multi-device duplicate handling + devices: + - id: controller_1 + name: Controller 1 + - id: controller_2 + name: Controller 2 + - id: controller_3 + name: Controller 3 + +host: +api: # Port will be automatically injected +logger: + +# Test that duplicate entity names are allowed on different devices + +# Scenario 1: Same sensor name on different devices (allowed) +sensor: + - platform: template + name: Temperature + device_id: controller_1 + lambda: return 21.0; + update_interval: 0.1s + + - platform: template + name: Temperature + device_id: controller_2 + lambda: return 22.0; + update_interval: 0.1s + + - platform: template + name: Temperature + device_id: controller_3 + lambda: return 23.0; + update_interval: 0.1s + + # Main device sensor (no device_id) + - platform: template + name: Temperature + lambda: return 20.0; + update_interval: 0.1s + + # Different sensor with unique name + - platform: template + name: Humidity + lambda: return 60.0; + update_interval: 0.1s + +# Scenario 2: Same binary sensor name on different devices (allowed) +binary_sensor: + - platform: template + name: Status + device_id: controller_1 + lambda: return true; + + - platform: template + name: Status + device_id: controller_2 + lambda: return false; + + - platform: template + name: Status + lambda: return true; # Main device + + # Different platform can have same name as sensor + - platform: template + name: Temperature + lambda: return true; + +# Scenario 3: Same text sensor name on different devices +text_sensor: + - platform: template + name: Device Info + device_id: controller_1 + lambda: return {"Controller 1 Active"}; + update_interval: 0.1s + + - platform: template + name: Device Info + device_id: controller_2 + lambda: return {"Controller 2 Active"}; + update_interval: 0.1s + + - platform: template + name: Device Info + lambda: return {"Main Device Active"}; + update_interval: 0.1s + +# Scenario 4: Same switch name on different devices +switch: + - platform: template + name: Power + device_id: controller_1 + lambda: return false; + turn_on_action: [] + turn_off_action: [] + + - platform: template + name: Power + device_id: controller_2 + lambda: return true; + turn_on_action: [] + turn_off_action: [] + + - platform: template + name: Power + device_id: controller_3 + lambda: return false; + turn_on_action: [] + turn_off_action: [] + + # Unique switch on main device + - platform: template + name: Main Power + lambda: return true; + turn_on_action: [] + turn_off_action: [] + +# Scenario 5: Empty names on different devices (should use device name) +button: + - platform: template + name: "" + device_id: controller_1 + on_press: [] + + - platform: template + name: "" + device_id: controller_2 + on_press: [] + + - platform: template + name: "" + on_press: [] # Main device + +# Scenario 6: Special characters in names +number: + - platform: template + name: "Temperature Setpoint!" + device_id: controller_1 + min_value: 10.0 + max_value: 30.0 + step: 0.1 + lambda: return 21.0; + set_action: [] + + - platform: template + name: "Temperature Setpoint!" + device_id: controller_2 + min_value: 10.0 + max_value: 30.0 + step: 0.1 + lambda: return 22.0; + set_action: [] diff --git a/tests/integration/test_duplicate_entities.py b/tests/integration/test_duplicate_entities.py index 9b30d2db5a..2fdfad979a 100644 --- a/tests/integration/test_duplicate_entities.py +++ b/tests/integration/test_duplicate_entities.py @@ -1,4 +1,4 @@ -"""Integration test for duplicate entity handling.""" +"""Integration test for duplicate entity handling with new validation.""" from __future__ import annotations @@ -11,12 +11,12 @@ from .types import APIClientConnectedFactory, RunCompiledFunction @pytest.mark.asyncio -async def test_duplicate_entities( +async def test_duplicate_entities_on_different_devices( yaml_config: str, run_compiled: RunCompiledFunction, api_client_connected: APIClientConnectedFactory, ) -> None: - """Test that duplicate entity names are automatically suffixed with _2, _3, _4.""" + """Test that duplicate entity names are allowed on different devices.""" async with run_compiled(yaml_config), api_client_connected() as client: # Get device info device_info = await client.device_info() @@ -24,14 +24,16 @@ async def test_duplicate_entities( # Get devices devices = device_info.devices - assert len(devices) >= 2, f"Expected at least 2 devices, got {len(devices)}" + assert len(devices) >= 3, f"Expected at least 3 devices, got {len(devices)}" # Find our test devices controller_1 = next((d for d in devices if d.name == "Controller 1"), None) controller_2 = next((d for d in devices if d.name == "Controller 2"), None) + controller_3 = next((d for d in devices if d.name == "Controller 3"), None) assert controller_1 is not None, "Controller 1 device not found" assert controller_2 is not None, "Controller 2 device not found" + assert controller_3 is not None, "Controller 3 device not found" # Get entity list entities = await client.list_entities_services() @@ -48,203 +50,129 @@ async def test_duplicate_entities( e for e in all_entities if e.__class__.__name__ == "TextSensorInfo" ] switches = [e for e in all_entities if e.__class__.__name__ == "SwitchInfo"] + buttons = [e for e in all_entities if e.__class__.__name__ == "ButtonInfo"] + numbers = [e for e in all_entities if e.__class__.__name__ == "NumberInfo"] - # Scenario 1: Check sensors with duplicate "Temperature" names + # Scenario 1: Check sensors with same "Temperature" name on different devices temp_sensors = [s for s in sensors if s.name == "Temperature"] - temp_object_ids = sorted([s.object_id for s in temp_sensors]) - - # Should have temperature, temperature_2, temperature_3, temperature_4 - assert len(temp_object_ids) >= 4, ( - f"Expected at least 4 temperature sensors, got {len(temp_object_ids)}" - ) - assert "temperature" in temp_object_ids, ( - "First temperature sensor should not have suffix" - ) - assert "temperature_2" in temp_object_ids, ( - "Second temperature sensor should be temperature_2" - ) - assert "temperature_3" in temp_object_ids, ( - "Third temperature sensor should be temperature_3" - ) - assert "temperature_4" in temp_object_ids, ( - "Fourth temperature sensor should be temperature_4" + assert len(temp_sensors) == 4, ( + f"Expected exactly 4 temperature sensors, got {len(temp_sensors)}" ) - # Scenario 2: Check device-specific sensors don't conflict - device_temp_sensors = [s for s in sensors if s.name == "Device Temperature"] + # Verify each sensor is on a different device + temp_device_ids = set() + temp_object_ids = set() - # Group by device - controller_1_temps = [ - s - for s in device_temp_sensors - if getattr(s, "device_id", None) == controller_1.device_id - ] - controller_2_temps = [ - s - for s in device_temp_sensors - if getattr(s, "device_id", None) == controller_2.device_id - ] + for sensor in temp_sensors: + device_id = getattr(sensor, "device_id", None) + temp_device_ids.add(device_id) + temp_object_ids.add(sensor.object_id) - # Controller 1 should have device_temperature, device_temperature_2, device_temperature_3 - c1_object_ids = sorted([s.object_id for s in controller_1_temps]) - assert len(c1_object_ids) >= 3, ( - f"Expected at least 3 sensors on controller_1, got {len(c1_object_ids)}" - ) - assert "device_temperature" in c1_object_ids, ( - "First device sensor should not have suffix" - ) - assert "device_temperature_2" in c1_object_ids, ( - "Second device sensor should be device_temperature_2" - ) - assert "device_temperature_3" in c1_object_ids, ( - "Third device sensor should be device_temperature_3" + # All should have object_id "temperature" (no suffix) + assert sensor.object_id == "temperature", ( + f"Expected object_id 'temperature', got '{sensor.object_id}'" + ) + + # Should have 4 different device IDs (including None for main device) + assert len(temp_device_ids) == 4, ( + f"Temperature sensors should be on different devices, got {temp_device_ids}" ) - # Controller 2 should have only device_temperature (no suffix) - c2_object_ids = [s.object_id for s in controller_2_temps] - assert len(c2_object_ids) >= 1, ( - f"Expected at least 1 sensor on controller_2, got {len(c2_object_ids)}" - ) - assert "device_temperature" in c2_object_ids, ( - "Controller 2 sensor should not have suffix" + # Scenario 2: Check binary sensors "Status" on different devices + status_binary = [b for b in binary_sensors if b.name == "Status"] + assert len(status_binary) == 3, ( + f"Expected exactly 3 status binary sensors, got {len(status_binary)}" ) - # Scenario 3: Check binary sensors (different platform, same name) + # All should have object_id "status" + for binary in status_binary: + assert binary.object_id == "status", ( + f"Expected object_id 'status', got '{binary.object_id}'" + ) + + # Scenario 3: Check that sensor and binary_sensor can have same name temp_binary = [b for b in binary_sensors if b.name == "Temperature"] - binary_object_ids = sorted([b.object_id for b in temp_binary]) + assert len(temp_binary) == 1, ( + f"Expected exactly 1 temperature binary sensor, got {len(temp_binary)}" + ) + assert temp_binary[0].object_id == "temperature" - # Should have temperature, temperature_2, temperature_3 (no conflict with sensor platform) - assert len(binary_object_ids) >= 3, ( - f"Expected at least 3 binary sensors, got {len(binary_object_ids)}" - ) - assert "temperature" in binary_object_ids, ( - "First binary sensor should not have suffix" - ) - assert "temperature_2" in binary_object_ids, ( - "Second binary sensor should be temperature_2" - ) - assert "temperature_3" in binary_object_ids, ( - "Third binary sensor should be temperature_3" + # Scenario 4: Check text sensors "Device Info" on different devices + info_text = [t for t in text_sensors if t.name == "Device Info"] + assert len(info_text) == 3, ( + f"Expected exactly 3 device info text sensors, got {len(info_text)}" ) - # Scenario 4: Check text sensors with special characters - status_sensors = [t for t in text_sensors if t.name == "Status Message!"] - status_object_ids = sorted([t.object_id for t in status_sensors]) + # All should have object_id "device_info" + for text in info_text: + assert text.object_id == "device_info", ( + f"Expected object_id 'device_info', got '{text.object_id}'" + ) - # Special characters should be sanitized to _ - assert len(status_object_ids) >= 3, ( - f"Expected at least 3 status sensors, got {len(status_object_ids)}" - ) - assert "status_message_" in status_object_ids, ( - "First status sensor should be status_message_" - ) - assert "status_message__2" in status_object_ids, ( - "Second status sensor should be status_message__2" - ) - assert "status_message__3" in status_object_ids, ( - "Third status sensor should be status_message__3" + # Scenario 5: Check switches "Power" on different devices + power_switches = [s for s in switches if s.name == "Power"] + assert len(power_switches) == 3, ( + f"Expected exactly 3 power switches, got {len(power_switches)}" ) - # Scenario 5: Check switches with duplicate names - power_switches = [s for s in switches if s.name == "Power Switch"] - power_object_ids = sorted([s.object_id for s in power_switches]) + # All should have object_id "power" + for switch in power_switches: + assert switch.object_id == "power", ( + f"Expected object_id 'power', got '{switch.object_id}'" + ) - # Should have power_switch, power_switch_2 - assert len(power_object_ids) >= 2, ( - f"Expected at least 2 power switches, got {len(power_object_ids)}" + # Scenario 6: Check empty name buttons (should use device name) + empty_buttons = [b for b in buttons if b.name == ""] + assert len(empty_buttons) == 3, ( + f"Expected exactly 3 empty name buttons, got {len(empty_buttons)}" ) - assert "power_switch" in power_object_ids, ( - "First power switch should be power_switch" - ) - assert "power_switch_2" in power_object_ids, ( - "Second power switch should be power_switch_2" - ) - - # Scenario 6: Check empty names on main device (Issue #6953) - empty_binary = [b for b in binary_sensors if b.name == ""] - empty_binary_ids = sorted([b.object_id for b in empty_binary]) - - # Should use device name "duplicate-entities-test" (sanitized, not snake_case) - assert len(empty_binary_ids) >= 4, ( - f"Expected at least 4 empty name binary sensors, got {len(empty_binary_ids)}" - ) - assert "duplicate-entities-test" in empty_binary_ids, ( - "First empty binary sensor should use device name" - ) - assert "duplicate-entities-test_2" in empty_binary_ids, ( - "Second empty binary sensor should be duplicate-entities-test_2" - ) - assert "duplicate-entities-test_3" in empty_binary_ids, ( - "Third empty binary sensor should be duplicate-entities-test_3" - ) - assert "duplicate-entities-test_4" in empty_binary_ids, ( - "Fourth empty binary sensor should be duplicate-entities-test_4" - ) - - # Scenario 7: Check empty names on sub-devices (Issue #6953) - empty_switches = [s for s in switches if s.name == ""] # Group by device - c1_empty_switches = [ - s - for s in empty_switches - if getattr(s, "device_id", None) == controller_1.device_id + c1_buttons = [ + b + for b in empty_buttons + if getattr(b, "device_id", 0) == controller_1.device_id ] - c2_empty_switches = [ - s - for s in empty_switches - if getattr(s, "device_id", None) == controller_2.device_id - ] - main_empty_switches = [ - s - for s in empty_switches - if getattr(s, "device_id", None) - not in [controller_1.device_id, controller_2.device_id] + c2_buttons = [ + b + for b in empty_buttons + if getattr(b, "device_id", 0) == controller_2.device_id ] - # Controller 1 empty switches should use "controller_1" - c1_empty_ids = sorted([s.object_id for s in c1_empty_switches]) - assert len(c1_empty_ids) >= 3, ( - f"Expected at least 3 empty switches on controller_1, got {len(c1_empty_ids)}" - ) - assert "controller_1" in c1_empty_ids, "First should be controller_1" - assert "controller_1_2" in c1_empty_ids, "Second should be controller_1_2" - assert "controller_1_3" in c1_empty_ids, "Third should be controller_1_3" + # For main device, device_id is 0 + main_buttons = [b for b in empty_buttons if getattr(b, "device_id", 0) == 0] - # Controller 2 empty switches - c2_empty_ids = sorted([s.object_id for s in c2_empty_switches]) - assert len(c2_empty_ids) >= 2, ( - f"Expected at least 2 empty switches on controller_2, got {len(c2_empty_ids)}" + # Check object IDs for empty name entities + assert len(c1_buttons) == 1 and c1_buttons[0].object_id == "controller_1" + assert len(c2_buttons) == 1 and c2_buttons[0].object_id == "controller_2" + assert ( + len(main_buttons) == 1 + and main_buttons[0].object_id == "duplicate-entities-test" ) - assert "controller_2" in c2_empty_ids, "First should be controller_2" - assert "controller_2_2" in c2_empty_ids, "Second should be controller_2_2" - # Main device empty switches - main_empty_ids = sorted([s.object_id for s in main_empty_switches]) - assert len(main_empty_ids) >= 3, ( - f"Expected at least 3 empty switches on main device, got {len(main_empty_ids)}" + # Scenario 7: Check special characters in number names + temp_numbers = [n for n in numbers if n.name == "Temperature Setpoint!"] + assert len(temp_numbers) == 2, ( + f"Expected exactly 2 temperature setpoint numbers, got {len(temp_numbers)}" ) - assert "duplicate-entities-test" in main_empty_ids - assert "duplicate-entities-test_2" in main_empty_ids - assert "duplicate-entities-test_3" in main_empty_ids - # Scenario 8: Check "xyz" duplicates (Issue #6953) - xyz_switches = [s for s in switches if s.name == "xyz"] - xyz_ids = sorted([s.object_id for s in xyz_switches]) - - assert len(xyz_ids) >= 3, ( - f"Expected at least 3 xyz switches, got {len(xyz_ids)}" - ) - assert "xyz" in xyz_ids, "First xyz switch should be xyz" - assert "xyz_2" in xyz_ids, "Second xyz switch should be xyz_2" - assert "xyz_3" in xyz_ids, "Third xyz switch should be xyz_3" + # Special characters should be sanitized to _ in object_id + for number in temp_numbers: + assert number.object_id == "temperature_setpoint_", ( + f"Expected object_id 'temperature_setpoint_', got '{number.object_id}'" + ) # Verify we can get states for all entities (ensures they're functional) loop = asyncio.get_running_loop() states_future: asyncio.Future[None] = loop.create_future() state_count = 0 expected_count = ( - len(sensors) + len(binary_sensors) + len(text_sensors) + len(switches) + len(sensors) + + len(binary_sensors) + + len(text_sensors) + + len(switches) + + len(buttons) + + len(numbers) ) def on_state(state) -> None: From ddbe17d3f6a2c235706290b560a750e814e758b6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Jun 2025 23:40:16 +0200 Subject: [PATCH 169/183] fixes --- esphome/core/__init__.py | 2 +- tests/integration/test_duplicate_entities.py | 17 ++++------------- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index bb7c16c5ed..368e2affe9 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -523,7 +523,7 @@ class EsphomeCore: # Key: platform name (e.g. "sensor", "binary_sensor"), Value: count self.platform_counts: defaultdict[str, int] = defaultdict(int) # Track entity unique IDs to handle duplicates - # Set of (device_id, platform, object_id) tuples + # Set of (device_id, platform, sanitized_name) tuples self.unique_ids: set[tuple[str, str, str]] = set() # Whether ESPHome was started in verbose mode self.verbose = False diff --git a/tests/integration/test_duplicate_entities.py b/tests/integration/test_duplicate_entities.py index 2fdfad979a..99968204d4 100644 --- a/tests/integration/test_duplicate_entities.py +++ b/tests/integration/test_duplicate_entities.py @@ -64,8 +64,7 @@ async def test_duplicate_entities_on_different_devices( temp_object_ids = set() for sensor in temp_sensors: - device_id = getattr(sensor, "device_id", None) - temp_device_ids.add(device_id) + temp_device_ids.add(sensor.device_id) temp_object_ids.add(sensor.object_id) # All should have object_id "temperature" (no suffix) @@ -128,19 +127,11 @@ async def test_duplicate_entities_on_different_devices( ) # Group by device - c1_buttons = [ - b - for b in empty_buttons - if getattr(b, "device_id", 0) == controller_1.device_id - ] - c2_buttons = [ - b - for b in empty_buttons - if getattr(b, "device_id", 0) == controller_2.device_id - ] + c1_buttons = [b for b in empty_buttons if b.device_id == controller_1.device_id] + c2_buttons = [b for b in empty_buttons if b.device_id == controller_2.device_id] # For main device, device_id is 0 - main_buttons = [b for b in empty_buttons if getattr(b, "device_id", 0) == 0] + main_buttons = [b for b in empty_buttons if b.device_id == 0] # Check object IDs for empty name entities assert len(c1_buttons) == 1 and c1_buttons[0].object_id == "controller_1" From 83613726d159c98bb3b8d0479ac64e6c354ca4d3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 Jun 2025 00:04:07 +0200 Subject: [PATCH 170/183] fix --- tests/integration/conftest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 525e3541b3..8f5f77ca52 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -203,6 +203,7 @@ async def compile_esphome( loop = asyncio.get_running_loop() def _read_config_and_get_binary(): + CORE.reset() # Reset CORE state between test runs CORE.config_path = str(config_path) config = esphome.config.read_config( {"command": "compile", "config": str(config_path)} From 8b25b1eee67180f29102684e7b1366283ec8ac6d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 Jun 2025 00:18:28 +0200 Subject: [PATCH 171/183] update tests now that duplicate names are validated --- tests/components/ade7880/common.yaml | 38 +++++++++---------- .../alarm_control_panel/common.yaml | 2 +- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/tests/components/ade7880/common.yaml b/tests/components/ade7880/common.yaml index 0aa388a325..48c22c8485 100644 --- a/tests/components/ade7880/common.yaml +++ b/tests/components/ade7880/common.yaml @@ -12,12 +12,12 @@ sensor: frequency: 60Hz phase_a: name: Channel A - voltage: Voltage - current: Current - active_power: Active Power - power_factor: Power Factor - forward_active_energy: Forward Active Energy - reverse_active_energy: Reverse Active Energy + voltage: Channel A Voltage + current: Channel A Current + active_power: Channel A Active Power + power_factor: Channel A Power Factor + forward_active_energy: Channel A Forward Active Energy + reverse_active_energy: Channel A Reverse Active Energy calibration: current_gain: 3116628 voltage_gain: -757178 @@ -25,12 +25,12 @@ sensor: phase_angle: 188 phase_b: name: Channel B - voltage: Voltage - current: Current - active_power: Active Power - power_factor: Power Factor - forward_active_energy: Forward Active Energy - reverse_active_energy: Reverse Active Energy + voltage: Channel B Voltage + current: Channel B Current + active_power: Channel B Active Power + power_factor: Channel B Power Factor + forward_active_energy: Channel B Forward Active Energy + reverse_active_energy: Channel B Reverse Active Energy calibration: current_gain: 3133655 voltage_gain: -755235 @@ -38,12 +38,12 @@ sensor: phase_angle: 188 phase_c: name: Channel C - voltage: Voltage - current: Current - active_power: Active Power - power_factor: Power Factor - forward_active_energy: Forward Active Energy - reverse_active_energy: Reverse Active Energy + voltage: Channel C Voltage + current: Channel C Current + active_power: Channel C Active Power + power_factor: Channel C Power Factor + forward_active_energy: Channel C Forward Active Energy + reverse_active_energy: Channel C Reverse Active Energy calibration: current_gain: 3111158 voltage_gain: -743813 @@ -51,6 +51,6 @@ sensor: phase_angle: 180 neutral: name: Neutral - current: Current + current: Neutral Current calibration: current_gain: 3189 diff --git a/tests/components/alarm_control_panel/common.yaml b/tests/components/alarm_control_panel/common.yaml index 5b8ae5a282..142bf3c7e6 100644 --- a/tests/components/alarm_control_panel/common.yaml +++ b/tests/components/alarm_control_panel/common.yaml @@ -26,7 +26,7 @@ alarm_control_panel: ESP_LOGD("TEST", "State change %s", LOG_STR_ARG(alarm_control_panel_state_to_string(id(alarmcontrolpanel1)->get_state()))); - platform: template id: alarmcontrolpanel2 - name: Alarm Panel + name: Alarm Panel 2 codes: - "1234" requires_code_to_arm: true From 1f48e2b01fc820683db2578e70cb5d5a9a7b2a0e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 Jun 2025 00:18:28 +0200 Subject: [PATCH 172/183] update tests now that duplicate names are validated --- tests/components/ade7880/common.yaml | 38 +++++++++---------- .../alarm_control_panel/common.yaml | 2 +- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/tests/components/ade7880/common.yaml b/tests/components/ade7880/common.yaml index 0aa388a325..48c22c8485 100644 --- a/tests/components/ade7880/common.yaml +++ b/tests/components/ade7880/common.yaml @@ -12,12 +12,12 @@ sensor: frequency: 60Hz phase_a: name: Channel A - voltage: Voltage - current: Current - active_power: Active Power - power_factor: Power Factor - forward_active_energy: Forward Active Energy - reverse_active_energy: Reverse Active Energy + voltage: Channel A Voltage + current: Channel A Current + active_power: Channel A Active Power + power_factor: Channel A Power Factor + forward_active_energy: Channel A Forward Active Energy + reverse_active_energy: Channel A Reverse Active Energy calibration: current_gain: 3116628 voltage_gain: -757178 @@ -25,12 +25,12 @@ sensor: phase_angle: 188 phase_b: name: Channel B - voltage: Voltage - current: Current - active_power: Active Power - power_factor: Power Factor - forward_active_energy: Forward Active Energy - reverse_active_energy: Reverse Active Energy + voltage: Channel B Voltage + current: Channel B Current + active_power: Channel B Active Power + power_factor: Channel B Power Factor + forward_active_energy: Channel B Forward Active Energy + reverse_active_energy: Channel B Reverse Active Energy calibration: current_gain: 3133655 voltage_gain: -755235 @@ -38,12 +38,12 @@ sensor: phase_angle: 188 phase_c: name: Channel C - voltage: Voltage - current: Current - active_power: Active Power - power_factor: Power Factor - forward_active_energy: Forward Active Energy - reverse_active_energy: Reverse Active Energy + voltage: Channel C Voltage + current: Channel C Current + active_power: Channel C Active Power + power_factor: Channel C Power Factor + forward_active_energy: Channel C Forward Active Energy + reverse_active_energy: Channel C Reverse Active Energy calibration: current_gain: 3111158 voltage_gain: -743813 @@ -51,6 +51,6 @@ sensor: phase_angle: 180 neutral: name: Neutral - current: Current + current: Neutral Current calibration: current_gain: 3189 diff --git a/tests/components/alarm_control_panel/common.yaml b/tests/components/alarm_control_panel/common.yaml index 5b8ae5a282..142bf3c7e6 100644 --- a/tests/components/alarm_control_panel/common.yaml +++ b/tests/components/alarm_control_panel/common.yaml @@ -26,7 +26,7 @@ alarm_control_panel: ESP_LOGD("TEST", "State change %s", LOG_STR_ARG(alarm_control_panel_state_to_string(id(alarmcontrolpanel1)->get_state()))); - platform: template id: alarmcontrolpanel2 - name: Alarm Panel + name: Alarm Panel 2 codes: - "1234" requires_code_to_arm: true From 509a704410055bd5fcdad83f727d38c9e3fdcfa5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 Jun 2025 00:19:32 +0200 Subject: [PATCH 173/183] update tests now that duplicate names are validated --- tests/components/binary_sensor_map/common.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/components/binary_sensor_map/common.yaml b/tests/components/binary_sensor_map/common.yaml index 8ffdd1f379..2fed5ae515 100644 --- a/tests/components/binary_sensor_map/common.yaml +++ b/tests/components/binary_sensor_map/common.yaml @@ -26,7 +26,7 @@ binary_sensor: sensor: - platform: binary_sensor_map - name: Binary Sensor Map + name: Binary Sensor Map Group type: group channels: - binary_sensor: bin1 @@ -36,7 +36,7 @@ sensor: - binary_sensor: bin3 value: 100.0 - platform: binary_sensor_map - name: Binary Sensor Map + name: Binary Sensor Map Sum type: sum channels: - binary_sensor: bin1 @@ -46,7 +46,7 @@ sensor: - binary_sensor: bin3 value: 100.0 - platform: binary_sensor_map - name: Binary Sensor Map + name: Binary Sensor Map Bayesian type: bayesian prior: 0.4 observations: From bf359cb8e3d3166b49889d34b4b525557136dd0a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 Jun 2025 00:20:51 +0200 Subject: [PATCH 174/183] update tests now that duplicate names are validated --- tests/components/dallas_temp/common.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/dallas_temp/common.yaml b/tests/components/dallas_temp/common.yaml index 2f846ca278..fb51f4818e 100644 --- a/tests/components/dallas_temp/common.yaml +++ b/tests/components/dallas_temp/common.yaml @@ -5,7 +5,7 @@ one_wire: sensor: - platform: dallas_temp address: 0x1C0000031EDD2A28 - name: Dallas Temperature + name: Dallas Temperature 1 resolution: 9 - platform: dallas_temp - name: Dallas Temperature + name: Dallas Temperature 2 From 599993d1a5a282e7aad1f64dcd4accd77b639aad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 Jun 2025 00:22:51 +0200 Subject: [PATCH 175/183] update tests now that duplicate names are validated --- esphome/components/demo/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/demo/__init__.py b/esphome/components/demo/__init__.py index 0a56073284..2af0c18c18 100644 --- a/esphome/components/demo/__init__.py +++ b/esphome/components/demo/__init__.py @@ -455,7 +455,7 @@ CONFIG_SCHEMA = cv.Schema( CONF_NAME: "Demo Plain Sensor", }, { - CONF_NAME: "Demo Temperature Sensor", + CONF_NAME: "Demo Temperature Sensor 1", CONF_UNIT_OF_MEASUREMENT: UNIT_CELSIUS, CONF_ICON: ICON_THERMOMETER, CONF_ACCURACY_DECIMALS: 1, @@ -463,7 +463,7 @@ CONFIG_SCHEMA = cv.Schema( CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, { - CONF_NAME: "Demo Temperature Sensor", + CONF_NAME: "Demo Temperature Sensor 2", CONF_UNIT_OF_MEASUREMENT: UNIT_CELSIUS, CONF_ICON: ICON_THERMOMETER, CONF_ACCURACY_DECIMALS: 1, From 27347b2088a1216d233a939289844870f00f3af7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 Jun 2025 00:34:04 +0200 Subject: [PATCH 176/183] update tests now that duplicate names are validated --- tests/components/heatpumpir/common.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/components/heatpumpir/common.yaml b/tests/components/heatpumpir/common.yaml index 2df195c5de..d740f31518 100644 --- a/tests/components/heatpumpir/common.yaml +++ b/tests/components/heatpumpir/common.yaml @@ -7,20 +7,20 @@ climate: protocol: mitsubishi_heavy_zm horizontal_default: left vertical_default: up - name: HeatpumpIR Climate + name: HeatpumpIR Climate Mitsubishi min_temperature: 18 max_temperature: 30 - platform: heatpumpir protocol: daikin horizontal_default: mleft vertical_default: mup - name: HeatpumpIR Climate + name: HeatpumpIR Climate Daikin min_temperature: 18 max_temperature: 30 - platform: heatpumpir protocol: panasonic_altdke horizontal_default: mright vertical_default: mdown - name: HeatpumpIR Climate + name: HeatpumpIR Climate Panasonic min_temperature: 18 max_temperature: 30 From 71fbcbceaf4a76ac95938e474f6761b67168e77c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 Jun 2025 00:34:27 +0200 Subject: [PATCH 177/183] update tests now that duplicate names are validated --- tests/components/light/common.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index a224dbe8bc..d4f64dcdea 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -114,7 +114,7 @@ light: warm_white_color_temperature: 500 mireds - platform: rgb id: test_rgb_light_initial_state - name: RGB Light + name: RGB Light Initial State red: test_ledc_1 green: test_ledc_2 blue: test_ledc_3 From d2fc3e749cc58a49b37e73c0b0791e731b597e7f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 Jun 2025 00:34:50 +0200 Subject: [PATCH 178/183] update tests now that duplicate names are validated --- tests/components/ltr390/common.yaml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/components/ltr390/common.yaml b/tests/components/ltr390/common.yaml index 2eebe9d1c3..e5e331e7ba 100644 --- a/tests/components/ltr390/common.yaml +++ b/tests/components/ltr390/common.yaml @@ -6,13 +6,13 @@ i2c: sensor: - platform: ltr390 uv: - name: LTR390 UV + name: LTR390 UV 1 uv_index: - name: LTR390 UVI + name: LTR390 UVI 1 light: - name: LTR390 Light + name: LTR390 Light 1 ambient_light: - name: LTR390 ALS + name: LTR390 ALS 1 gain: X3 resolution: 18 window_correction_factor: 1.0 @@ -20,13 +20,13 @@ sensor: update_interval: 60s - platform: ltr390 uv: - name: LTR390 UV + name: LTR390 UV 2 uv_index: - name: LTR390 UVI + name: LTR390 UVI 2 light: - name: LTR390 Light + name: LTR390 Light 2 ambient_light: - name: LTR390 ALS + name: LTR390 ALS 2 gain: ambient_light: X9 uv: X3 From 1fd8ebf38625f4e73ef66777e1ed4a33902426c2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 Jun 2025 00:35:38 +0200 Subject: [PATCH 179/183] update tests now that duplicate names are validated --- tests/components/remote_transmitter/common-buttons.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index 1fb7ef6dbe..29f48d995d 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -115,7 +115,7 @@ button: address: 0x00 command: 0x0B - platform: template - name: RC5 + name: RC5 Raw on_press: remote_transmitter.transmit_raw: code: [1000, -1000] From 4bdd08887ed3d7159581fd12ae32acde03208807 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 Jun 2025 00:50:18 +0200 Subject: [PATCH 180/183] use a common that does not have dupes on dev --- tests/components/lvgl/common.yaml | 14 +++++++------- tests/components/opentherm/common.yaml | 2 +- tests/components/packages/test.esp32-ard.yaml | 2 +- tests/components/packages/test.esp32-idf.yaml | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/components/lvgl/common.yaml b/tests/components/lvgl/common.yaml index 59602414a7..a035900386 100644 --- a/tests/components/lvgl/common.yaml +++ b/tests/components/lvgl/common.yaml @@ -24,33 +24,33 @@ sensor: widget: lv_arc - platform: lvgl widget: slider_id - name: LVGL Slider + name: LVGL Slider Sensor - platform: lvgl widget: bar_id id: lvgl_bar_sensor - name: LVGL Bar + name: LVGL Bar Sensor - platform: lvgl widget: spinbox_id - name: LVGL Spinbox + name: LVGL Spinbox Sensor number: - platform: lvgl widget: slider_id - name: LVGL Slider + name: LVGL Slider Number update_on_release: true restore_value: true - platform: lvgl widget: lv_arc id: lvgl_arc_number - name: LVGL Arc + name: LVGL Arc Number - platform: lvgl widget: bar_id id: lvgl_bar_number - name: LVGL Bar + name: LVGL Bar Number - platform: lvgl widget: spinbox_id id: lvgl_spinbox_number - name: LVGL Spinbox + name: LVGL Spinbox Number light: - platform: lvgl diff --git a/tests/components/opentherm/common.yaml b/tests/components/opentherm/common.yaml index 5edacc6f17..1e58a04bf0 100644 --- a/tests/components/opentherm/common.yaml +++ b/tests/components/opentherm/common.yaml @@ -170,4 +170,4 @@ switch: otc_active: name: "Boiler Outside temperature compensation active" ch2_active: - name: "Boiler Central Heating 2 active" + name: "Boiler Central Heating 2 active status" diff --git a/tests/components/packages/test.esp32-ard.yaml b/tests/components/packages/test.esp32-ard.yaml index d35c27d997..d882116c10 100644 --- a/tests/components/packages/test.esp32-ard.yaml +++ b/tests/components/packages/test.esp32-ard.yaml @@ -5,7 +5,7 @@ packages: - !include package.yaml - github://esphome/esphome/tests/components/template/common.yaml@dev - url: https://github.com/esphome/esphome - file: tests/components/binary_sensor_map/common.yaml + file: tests/components/absolute_humidity/common.yaml ref: dev refresh: 1d diff --git a/tests/components/packages/test.esp32-idf.yaml b/tests/components/packages/test.esp32-idf.yaml index 9f1484d1fd..720a5777c2 100644 --- a/tests/components/packages/test.esp32-idf.yaml +++ b/tests/components/packages/test.esp32-idf.yaml @@ -7,7 +7,7 @@ packages: shorthand: github://esphome/esphome/tests/components/template/common.yaml@dev github: url: https://github.com/esphome/esphome - file: tests/components/binary_sensor_map/common.yaml + file: tests/components/absolute_humidity/common.yaml ref: dev refresh: 1d From 23774ae03b0d6253951922204767a952354017ef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 Jun 2025 14:17:05 +0200 Subject: [PATCH 181/183] Reduce memory required for sensor entities --- esphome/components/sensor/sensor.cpp | 18 ++++++++++------ esphome/components/sensor/sensor.h | 18 +++++++++++----- .../fixtures/host_mode_with_sensor.yaml | 3 +++ tests/integration/test_host_mode_sensor.py | 21 +++++++++++++++++++ 4 files changed, 49 insertions(+), 11 deletions(-) diff --git a/esphome/components/sensor/sensor.cpp b/esphome/components/sensor/sensor.cpp index 6d6cff0400..7dab63b026 100644 --- a/esphome/components/sensor/sensor.cpp +++ b/esphome/components/sensor/sensor.cpp @@ -23,16 +23,22 @@ std::string state_class_to_string(StateClass state_class) { Sensor::Sensor() : state(NAN), raw_state(NAN) {} int8_t Sensor::get_accuracy_decimals() { - if (this->accuracy_decimals_.has_value()) - return *this->accuracy_decimals_; + if (this->sensor_flags_.has_accuracy_override) + return this->accuracy_decimals_; return 0; } -void Sensor::set_accuracy_decimals(int8_t accuracy_decimals) { this->accuracy_decimals_ = accuracy_decimals; } +void Sensor::set_accuracy_decimals(int8_t accuracy_decimals) { + this->accuracy_decimals_ = accuracy_decimals; + this->sensor_flags_.has_accuracy_override = true; +} -void Sensor::set_state_class(StateClass state_class) { this->state_class_ = state_class; } +void Sensor::set_state_class(StateClass state_class) { + this->state_class_ = state_class; + this->sensor_flags_.has_state_class_override = true; +} StateClass Sensor::get_state_class() { - if (this->state_class_.has_value()) - return *this->state_class_; + if (this->sensor_flags_.has_state_class_override) + return this->state_class_; return StateClass::STATE_CLASS_NONE; } diff --git a/esphome/components/sensor/sensor.h b/esphome/components/sensor/sensor.h index 456e876497..3fb6e5522b 100644 --- a/esphome/components/sensor/sensor.h +++ b/esphome/components/sensor/sensor.h @@ -80,9 +80,9 @@ class Sensor : public EntityBase, public EntityBase_DeviceClass, public EntityBa * state changes to the database when they are published, even if the state is the * same as before. */ - bool get_force_update() const { return force_update_; } + bool get_force_update() const { return sensor_flags_.force_update; } /// Set force update mode. - void set_force_update(bool force_update) { force_update_ = force_update; } + void set_force_update(bool force_update) { sensor_flags_.force_update = force_update; } /// Add a filter to the filter chain. Will be appended to the back. void add_filter(Filter *filter); @@ -155,9 +155,17 @@ class Sensor : public EntityBase, public EntityBase_DeviceClass, public EntityBa Filter *filter_list_{nullptr}; ///< Store all active filters. - optional accuracy_decimals_; ///< Accuracy in decimals override - optional state_class_{STATE_CLASS_NONE}; ///< State class override - bool force_update_{false}; ///< Force update mode + // Group small members together to avoid padding + int8_t accuracy_decimals_{-1}; ///< Accuracy in decimals (-1 = not set) + StateClass state_class_{STATE_CLASS_NONE}; ///< State class (STATE_CLASS_NONE = not set) + + // Bit-packed flags for sensor-specific settings + struct SensorFlags { + uint8_t has_accuracy_override : 1; + uint8_t has_state_class_override : 1; + uint8_t force_update : 1; + uint8_t reserved : 5; // Reserved for future use + } sensor_flags_{}; }; } // namespace sensor diff --git a/tests/integration/fixtures/host_mode_with_sensor.yaml b/tests/integration/fixtures/host_mode_with_sensor.yaml index fecd0b435b..0ac495f3b1 100644 --- a/tests/integration/fixtures/host_mode_with_sensor.yaml +++ b/tests/integration/fixtures/host_mode_with_sensor.yaml @@ -8,5 +8,8 @@ sensor: name: Test Sensor id: test_sensor unit_of_measurement: °C + accuracy_decimals: 2 + state_class: measurement + force_update: true lambda: return 42.0; update_interval: 0.1s diff --git a/tests/integration/test_host_mode_sensor.py b/tests/integration/test_host_mode_sensor.py index f0c938da1c..049f7db619 100644 --- a/tests/integration/test_host_mode_sensor.py +++ b/tests/integration/test_host_mode_sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio +import aioesphomeapi from aioesphomeapi import EntityState import pytest @@ -47,3 +48,23 @@ async def test_host_mode_with_sensor( # Verify the sensor state assert test_sensor_state.state == 42.0 assert len(states) > 0, "No states received" + + # Verify the optimized fields are working correctly + # Get entity info to check accuracy_decimals, state_class, etc. + entities, _ = await client.list_entities_services() + sensor_info: aioesphomeapi.SensorInfo | None = None + for entity in entities: + if isinstance(entity, aioesphomeapi.SensorInfo): + sensor_info = entity + break + + assert sensor_info is not None, "Sensor entity info not found" + assert sensor_info.accuracy_decimals == 2, ( + f"Expected accuracy_decimals=2, got {sensor_info.accuracy_decimals}" + ) + assert sensor_info.state_class == 1, ( + f"Expected state_class=1 (measurement), got {sensor_info.state_class}" + ) + assert sensor_info.force_update is True, ( + f"Expected force_update=True, got {sensor_info.force_update}" + ) From 7d984335023257bdea0b82580d6e268903c41450 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 Jun 2025 14:23:59 +0200 Subject: [PATCH 182/183] Update tests/integration/test_host_mode_sensor.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/integration/test_host_mode_sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration/test_host_mode_sensor.py b/tests/integration/test_host_mode_sensor.py index 049f7db619..f12e53b244 100644 --- a/tests/integration/test_host_mode_sensor.py +++ b/tests/integration/test_host_mode_sensor.py @@ -62,8 +62,8 @@ async def test_host_mode_with_sensor( assert sensor_info.accuracy_decimals == 2, ( f"Expected accuracy_decimals=2, got {sensor_info.accuracy_decimals}" ) - assert sensor_info.state_class == 1, ( - f"Expected state_class=1 (measurement), got {sensor_info.state_class}" + assert sensor_info.state_class == aioesphomeapi.StateClass.MEASUREMENT, ( + f"Expected state_class=StateClass.MEASUREMENT, got {sensor_info.state_class}" ) assert sensor_info.force_update is True, ( f"Expected force_update=True, got {sensor_info.force_update}" From 17396d67de3eff8aa83565b7ba55a97aa701747a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 Jun 2025 14:32:38 +0200 Subject: [PATCH 183/183] revert --- tests/integration/test_host_mode_sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration/test_host_mode_sensor.py b/tests/integration/test_host_mode_sensor.py index f12e53b244..049f7db619 100644 --- a/tests/integration/test_host_mode_sensor.py +++ b/tests/integration/test_host_mode_sensor.py @@ -62,8 +62,8 @@ async def test_host_mode_with_sensor( assert sensor_info.accuracy_decimals == 2, ( f"Expected accuracy_decimals=2, got {sensor_info.accuracy_decimals}" ) - assert sensor_info.state_class == aioesphomeapi.StateClass.MEASUREMENT, ( - f"Expected state_class=StateClass.MEASUREMENT, got {sensor_info.state_class}" + assert sensor_info.state_class == 1, ( + f"Expected state_class=1 (measurement), got {sensor_info.state_class}" ) assert sensor_info.force_update is True, ( f"Expected force_update=True, got {sensor_info.force_update}"