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: