From a8c7279b3bfc886f21bb138206a9ea768a0e5efe Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:30:53 -0500 Subject: [PATCH 1/6] Bump platformdirs from 4.11.3 to 4.11.4 (#18835) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index de00f07836..da100ad0cd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,7 @@ bleak==3.0.2 smpclient==7.2.0 requests==2.34.2 py7zr==1.1.3 -platformdirs==4.11.3 # native esp-idf toolchain global cache dir +platformdirs==4.11.4 # native esp-idf toolchain global cache dir ninja==1.13.0 # native esp8266 arduino toolchain build driver filelock==3.32.4 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg From 9598449b6b6af8471ffae16c02e4e25656e2ba51 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:31:11 -0500 Subject: [PATCH 2/6] Bump CodSpeedHQ/action from 5.0.3 to 5.2.1 (#18834) Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2762faa4d..cbf6e070b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -536,7 +536,7 @@ jobs: apt-get install -y libc6-dbg - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@4296e51e7041e24dadb86d1d6e8b9320d223dbe8 # v5.0.3 + uses: CodSpeedHQ/action@373d6868929f444bc08d901fd0eb0ad52a8875ea # v5.2.1 with: run: | . venv/bin/activate From 25d5cd3e14db638a9b7a2907eaf889fc4177c443 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Thu, 27 Aug 2026 12:38:13 -0700 Subject: [PATCH 3/6] [modbus_controller] Poll through PollingDevice; deprecate ModbusCommandItem (#18071) Co-authored-by: Claude Co-authored-by: J. Nick Koston --- .../components/modbus/modbus_definitions.h | 4 +- esphome/components/modbus/modbus_helpers.h | 72 +++++--- .../modbus_controller/modbus_controller.cpp | 135 +++++++++++---- .../modbus_controller/modbus_controller.h | 162 ++++++++++-------- .../command_payload_test.cpp | 7 + 5 files changed, 248 insertions(+), 132 deletions(-) diff --git a/esphome/components/modbus/modbus_definitions.h b/esphome/components/modbus/modbus_definitions.h index 64f7210585..83f314352f 100644 --- a/esphome/components/modbus/modbus_definitions.h +++ b/esphome/components/modbus/modbus_definitions.h @@ -20,7 +20,9 @@ const uint8_t FUNCTION_CODE_USER_DEFINED_SPACE_2_END = 110; // 0x6E enum class FunctionCode : uint8_t { INVALID = 0x00, // 0x00 is not a valid function code (even for custom functions). - CUSTOM = 0x00, // The CUSTOM alias should be removed in future. + // Remove before 2027.3.0 + CUSTOM ESPDEPRECATED("0x00 is not a function code; use FunctionCode::INVALID for the sentinel. Removed in 2027.3.0", + "2026.9.0") = 0x00, READ_COILS = 0x01, READ_DISCRETE_INPUTS = 0x02, READ_HOLDING_REGISTERS = 0x03, diff --git a/esphome/components/modbus/modbus_helpers.h b/esphome/components/modbus/modbus_helpers.h index b2454e6f14..b04df1923f 100644 --- a/esphome/components/modbus/modbus_helpers.h +++ b/esphome/components/modbus/modbus_helpers.h @@ -153,6 +153,50 @@ inline std::span server_pdu_payload(std::span pdu) inline uint8_t client_frame_data_offset(const uint8_t *, size_t) { return 2; } +/** Extract data from modbus response buffer + * @param T one of supported integer data types int_8,int_16,int_32,int_64 + * @param data modbus response buffer (uint8_t) + * @param buffer_offset offset in bytes. + * @return value of type T extracted from buffer + */ +template T get_data(const uint8_t *data, size_t buffer_offset) { + if (sizeof(T) == sizeof(uint8_t)) { + return T(data[buffer_offset]); + } + if (sizeof(T) == sizeof(uint16_t)) { + return T((uint16_t(data[buffer_offset + 0]) << 8) | (uint16_t(data[buffer_offset + 1]) << 0)); + } + if (sizeof(T) == sizeof(uint32_t)) { + return static_cast(get_data(data, buffer_offset)) << 16 | + static_cast(get_data(data, buffer_offset + 2)); + } + if (sizeof(T) == sizeof(uint64_t)) { + return static_cast(get_data(data, buffer_offset)) << 32 | + (static_cast(get_data(data, buffer_offset + 4))); + } + static_assert(sizeof(T) == sizeof(uint8_t) || sizeof(T) == sizeof(uint16_t) || sizeof(T) == sizeof(uint32_t) || + sizeof(T) == sizeof(uint64_t), + "Unsupported type size in get_data; only 1, 2, 4, or 8-byte integer types are supported."); + return T{}; +} + +/// Function code of a PDU, exception flag masked; 0 for an empty PDU. +inline uint8_t pdu_function_code(std::span pdu) { + return pdu.empty() ? 0 : (pdu[0] & FUNCTION_CODE_MASK); +} + +/// Start address of a standard client request PDU ([fc, addr_hi, addr_lo, ...]). Empty when the PDU is +/// too short or its function code has no known layout - custom-frame bytes are not misread as an address. +inline std::optional client_pdu_start_address(std::span pdu) { + if (pdu.size() < 3 || is_function_code_unknown_length(pdu[0])) + return std::nullopt; + const auto fc = static_cast(pdu[0]); + // The file-record PDUs are known-length but carry a byte count, not a start address. + if (fc == FunctionCode::READ_FILE_RECORD || fc == FunctionCode::WRITE_FILE_RECORD) + return std::nullopt; + return get_data(pdu.data(), 1); +} + enum class SensorValueType : uint8_t { RAW = 0x00, // variable length U_WORD = 0x1, // 1 Register unsigned @@ -256,34 +300,6 @@ inline uint64_t qword_from_hex_str(const std::string &value, uint8_t pos) { return static_cast(dword_from_hex_str(value, pos)) << 32 | dword_from_hex_str(value, pos + 4); } -// Extract data from modbus response buffer -/** Extract data from modbus response buffer - * @param T one of supported integer data types int_8,int_16,int_32,int_64 - * @param data modbus response buffer (uint8_t) - * @param buffer_offset offset in bytes. - * @return value of type T extracted from buffer - */ -template T get_data(const uint8_t *data, size_t buffer_offset) { - if (sizeof(T) == sizeof(uint8_t)) { - return T(data[buffer_offset]); - } - if (sizeof(T) == sizeof(uint16_t)) { - return T((uint16_t(data[buffer_offset + 0]) << 8) | (uint16_t(data[buffer_offset + 1]) << 0)); - } - if (sizeof(T) == sizeof(uint32_t)) { - return static_cast(get_data(data, buffer_offset)) << 16 | - static_cast(get_data(data, buffer_offset + 2)); - } - if (sizeof(T) == sizeof(uint64_t)) { - return static_cast(get_data(data, buffer_offset)) << 32 | - (static_cast(get_data(data, buffer_offset + 4))); - } - static_assert(sizeof(T) == sizeof(uint8_t) || sizeof(T) == sizeof(uint16_t) || sizeof(T) == sizeof(uint32_t) || - sizeof(T) == sizeof(uint64_t), - "Unsupported type size in get_data; only 1, 2, 4, or 8-byte integer types are supported."); - return T{}; -} - template T get_data(const std::vector &data, size_t buffer_offset) { return get_data(data.data(), buffer_offset); } diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index 9d7b719e15..8801c33d8c 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -23,60 +23,106 @@ void WriterDevice::warn_write_buffer_deprecated(const LogString *platform, uint1 bool WriterDevice::send_raw_frame_deprecated(std::span frame) { if (frame.empty()) return false; - this->dispatched_ = true; return this->parent_->queue_pdu(frame[0], frame.subspan(1), this); } -void WriterDevice::set_controller(ModbusController *controller) { +void ControllerDevice::set_controller(ModbusController *controller) { this->controller_ = controller; this->set_parent(controller->hub()); this->set_address(controller->device_address()); } -void WriterDevice::notify_online_(std::span request_pdu) { - if (this->controller_ != nullptr) - this->controller_->set_online(true, fc_of(request_pdu), addr_of(request_pdu)); +// A request whose layout carries no start address (a custom PDU) reports -1; 0 stays a real address. +static int trigger_address(std::span request_pdu) { + const auto addr = modbus::helpers::client_pdu_start_address(request_pdu); + return addr.has_value() ? *addr : -1; +} + +void ControllerDevice::notify_online_(std::span request_pdu) { + if (this->controller_ != nullptr) { + this->controller_->set_online(true, modbus::helpers::pdu_function_code(request_pdu), trigger_address(request_pdu)); + } +} + +void ControllerDevice::on_response(std::span request_pdu, std::span response_pdu) { + this->notify_online_(request_pdu); +} + +void ControllerDevice::on_error(std::span request_pdu, modbus::ExceptionCode exception_code) { + ESP_LOGW(TAG, "Modbus error function code: 0x%X register %d exception: %d", + modbus::helpers::pdu_function_code(request_pdu), trigger_address(request_pdu), + static_cast(exception_code)); + this->notify_online_(request_pdu); // an exception is still a legitimate reply -> device is online } void WriterDevice::on_response(std::span request_pdu, std::span response_pdu) { - this->notify_online_(request_pdu); + ControllerDevice::on_response(request_pdu, response_pdu); this->dispatch_response_(request_pdu, response_pdu, std::nullopt); } void WriterDevice::on_error(std::span request_pdu, modbus::ExceptionCode exception_code) { - ESP_LOGW(TAG, "Modbus error function code: 0x%X register 0x%X exception: %d", fc_of(request_pdu), - addr_of(request_pdu), static_cast(exception_code)); - this->notify_online_(request_pdu); // an exception is still a legitimate reply -> device is online + ControllerDevice::on_error(request_pdu, exception_code); this->dispatch_response_(request_pdu, {}, exception_code); } // Fired once per wire transmission (including hub re-queues from a retry), so the on_command_sent trigger // reflects when the frame actually went out, not when it was queued. -void WriterDevice::on_sent(std::span request_pdu) { - if (this->controller_ != nullptr) - this->controller_->command_sent(fc_of(request_pdu), addr_of(request_pdu)); -} - -void WriterDevice::on_not_sent(std::span request_pdu) { - // Only the offline teardown reaches this (a supersede retires silently), so the frame is genuinely - // lost; a dropped write was already published optimistically, so surface it. - if (modbus::helpers::is_function_code_write(fc_of(request_pdu))) { - ESP_LOGW(TAG, "Write not sent: function 0x%X register 0x%X", fc_of(request_pdu), addr_of(request_pdu)); - } else { - ESP_LOGD(TAG, "Request not sent: function 0x%X register 0x%X", fc_of(request_pdu), addr_of(request_pdu)); +void ControllerDevice::on_sent(std::span request_pdu) { + if (this->controller_ != nullptr) { + this->controller_->command_sent(modbus::helpers::pdu_function_code(request_pdu), trigger_address(request_pdu)); } } -bool WriterDevice::on_no_response(std::span request_pdu) { +void ControllerDevice::on_not_sent(std::span request_pdu) { + const uint8_t fc = modbus::helpers::pdu_function_code(request_pdu); + const int addr = trigger_address(request_pdu); + // Only the offline teardown reaches this (a supersede retires silently), so the frame is genuinely + // lost; a dropped write was already published optimistically, so surface it. + if (modbus::helpers::is_function_code_write(fc)) { + ESP_LOGW(TAG, "Write not sent: function 0x%X register %d", fc, addr); + } else { + ESP_LOGD(TAG, "Request not sent: function 0x%X register %d", fc, addr); + } +} + +bool ControllerDevice::on_no_response(std::span request_pdu) { if (this->controller_ == nullptr) return false; this->controller_->increment_non_response_count(); if (this->controller_->can_send()) return true; // the hub re-queues the frame it is holding; on_sent fires again on the retry - this->controller_->set_online(false, fc_of(request_pdu), addr_of(request_pdu)); + this->controller_->set_online(false, modbus::helpers::pdu_function_code(request_pdu), trigger_address(request_pdu)); return false; } +PollingDevice::PollingDevice(ModbusController &controller, RegisterRange &&range) + : ControllerDevice(&controller), range_(std::move(range)) {} + +bool PollingDevice::queue(modbus::CommandOptions options) { + bool accepted; + if (this->range_.custom_pdu != nullptr) { + accepted = this->queue_pdu(std::span(*this->range_.custom_pdu), options); + } else { + accepted = this->read_entities(this->range_.register_type, this->range_.start_address, this->range_.register_count, + options); + } + if (accepted) { + ESP_LOGV(TAG, "Poll queued type=%u 0x%X %d", static_cast(this->range_.register_type), + this->range_.start_address, this->range_.register_count); + } + return accepted; +} + +void PollingDevice::on_response(std::span request_pdu, std::span response_pdu) { + this->notify_online_(request_pdu); + auto data = modbus::helpers::server_pdu_payload(response_pdu); + for (auto *sensor : this->range_.sensors) + sensor->parse_and_publish(data); +} + +// ModbusCommandItem's machinery stays as-is until its removal in 2027.3.0; silence its self-references. +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" ModbusCommandItem::ModbusCommandItem(ModbusController &controller, modbus::ModbusClientHub *parent, uint8_t address, RegisterRange &&range) : modbus::ModbusClientDevice(parent, address), @@ -207,6 +253,8 @@ bool ModbusCommandItem::on_no_response(std::span request_pdu) { return false; } +#pragma GCC diagnostic pop + void ModbusController::set_online(bool online, int function_code, int register_address) { if (online) { this->cmd_non_responses_ = 0; @@ -228,6 +276,8 @@ void ModbusController::set_online(bool online, int function_code, int register_a } } +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" void ModbusController::queue_command(ModbusCommandItem command) { this->sweep_completed_one_shots_(); // reclaim finished one-shots before adding a new one // Duplicates are the caller's to manage; the controller only holds the item until its terminal callback. @@ -262,6 +312,8 @@ void ModbusController::sweep_completed_one_shots_() { [](const std::unique_ptr &item) { return item->pending_removal; }); } +#pragma GCC diagnostic pop + void ModbusController::update() { this->sweep_completed_one_shots_(); // reclaim one-shots deferred out of their own callbacks if (this->module_offline_) { @@ -270,11 +322,11 @@ void ModbusController::update() { if (offline_retry_due(this->update_counter_, this->module_offline_at_, this->offline_skip_updates_)) { ESP_LOGV(TAG, "Module offline - retrying"); this->cmd_non_responses_ = 0; // allow the probe through can_send() - for (auto &cmd : this->polling_command_items_) { + for (auto &poll : this->polling_devices_) { // Probes carry the read-side options too, so a recovering device resumes streaming on the // probe itself rather than waiting for the next update_interval. - if (!cmd.send(this->read_options_)) { - ESP_LOGD(TAG, "Probe refused by hub for range 0x%X", cmd.register_address()); + if (!poll.queue(this->read_options_)) { + ESP_LOGD(TAG, "Probe refused by hub for range 0x%X", poll.register_address()); } } } else { @@ -285,12 +337,12 @@ void ModbusController::update() { } if (this->can_send()) { - for (auto &cmd : this->polling_command_items_) { - ESP_LOGVV(TAG, "Updating range 0x%X", cmd.register_address()); + for (auto &poll : this->polling_devices_) { + ESP_LOGVV(TAG, "Updating range 0x%X", poll.register_address()); // read_options_ carries the controller's continuous flag (the offline probe above sends it too). // A refusal is already logged by the hub; note the affected range for controller-level diagnostics. - if (!cmd.send(this->read_options_)) { - ESP_LOGD(TAG, "Poll refused by hub for range 0x%X", cmd.register_address()); + if (!poll.queue(this->read_options_)) { + ESP_LOGD(TAG, "Poll refused by hub for range 0x%X", poll.register_address()); } } } @@ -308,6 +360,10 @@ void ModbusController::create_polling_commands_() { // force_new_range ahead of the rest, then address - so the walk is not purely address-ordered. // Each keeps the address it was configured with; what is resolved here is its `offset`, the position // of its data within the response of whichever range it ends up in. + // One range per sensor is a strict upper bound: each walk step closes at most one range, plus one + // closed after the walk. Sized to that bound so no push is ever silently dropped, then handed on by move. + FixedVector ranges; + ranges.init(this->sensorset_.size()); RegisterRange r = {}; bool have_range = false; // Set while the open range belongs to a force_new_range sensor: a range the user asked to keep @@ -390,7 +446,7 @@ void ModbusController::create_polling_commands_() { if (!join) { if (have_range) { ESP_LOGV(TAG, "Add range 0x%X %d", r.start_address, r.register_count); - this->create_polling_command_(std::move(r)); + ranges.push_back(std::move(r)); } r = {}; range_bytes = curr->get_register_size(); @@ -401,6 +457,8 @@ void ModbusController::create_polling_commands_() { r.start_address = curr->start_address; r.register_count = curr->register_count; r.register_type = curr->register_type; + if (curr->register_type == modbus::EntityType::CUSTOM) + r.custom_pdu = &curr->custom_pdu; have_range = true; } @@ -412,11 +470,13 @@ void ModbusController::create_polling_commands_() { } if (have_range) { ESP_LOGV(TAG, "Add last range 0x%X %d", r.start_address, r.register_count); - this->create_polling_command_(std::move(r)); + ranges.push_back(std::move(r)); + } + // Staged in a setup-time vector so the device storage can be sized exactly (see polling_devices_). + this->polling_devices_.init(ranges.size()); + for (auto &range : ranges) { + this->polling_devices_.emplace_back(*this, std::move(range)); } - // Reclaim growth slack; safe here because nothing has registered with the hub yet (see the - // lifetime note on polling_command_items_). - this->polling_command_items_.shrink_to_fit(); } void ModbusController::dump_config() { @@ -435,13 +495,15 @@ void ModbusController::dump_config() { it->get_register_size()); } ESP_LOGCONFIG(TAG, "ranges"); - for (auto &it : this->polling_command_items_) { + for (auto &it : this->polling_devices_) { ESP_LOGCONFIG(TAG, " Range type=%u start=0x%X count=%d", static_cast(it.register_type()), it.register_address(), it.register_count()); } #endif } +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" void ModbusController::on_write_register_response(EntityType register_type, uint16_t start_address, std::span data) { // A well-formed write ACK echoes address and value, but a truncated PDU yields a short/empty span. @@ -598,5 +660,6 @@ bool ModbusCommandItem::send(modbus::CommandOptions options) { } return accepted; } +#pragma GCC diagnostic pop } // namespace esphome::modbus_controller diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index 1f36d5a7c8..490efbde0b 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -4,6 +4,7 @@ #include "esphome/components/modbus/modbus.h" #include "esphome/components/modbus/modbus_helpers.h" +#include "esphome/core/helpers.h" #include "esphome/core/automation.h" #include @@ -230,15 +231,22 @@ struct RegisterRange { modbus::EntityType register_type; uint8_t register_count; SensorSet sensors; // all sensors of this range + /// A custom range polls this PDU, referenced from the sensor that opened the range. + const SmallInlineBuffer<8> *custom_pdu{nullptr}; }; -/// A hub device owned by a writer entity (switch/number/select/output) through WriterEntity. -/// Centralises the feedback to the controller - online/offline tracking, retry counting and the -/// on_command_sent trigger - and records every dispatch, so a write lambda can tell "I sent it myself" -/// from "use the default write". The hub base is inherited protected, so the public members below are -/// the entity's whole request API and nothing can bypass the recording or re-target the device. -class WriterDevice final : protected modbus::ModbusClientDevice { +/// The shared feedback half of a controller-owned hub device: online/offline tracking, retry counting +/// and the on_command_sent trigger all route to the controller from here. The hub base is inherited +/// protected, so a subclass chooses exactly what request API it exposes. +class ControllerDevice : protected modbus::ModbusClientDevice { + public: + // Public: only the owner can reach this instance, so reachability is the access gate. + void set_controller(ModbusController *controller); + protected: + ControllerDevice() = default; // WriterEntity's member is wired later via set_controller() + explicit ControllerDevice(ModbusController *controller) { this->set_controller(controller); } + void on_response(std::span request_pdu, std::span response_pdu) override; void on_error(std::span request_pdu, modbus::ExceptionCode exception_code) override; void on_sent(std::span request_pdu) override; @@ -246,90 +254,82 @@ class WriterDevice final : protected modbus::ModbusClientDevice { bool on_no_response(std::span request_pdu) override; void notify_online_(std::span request_pdu); - /// Function code / register address decoded from a request PDU ([fc, addr_hi, addr_lo, ...]). - static int fc_of(std::span pdu) { return pdu.empty() ? 0 : (pdu[0] & modbus::FUNCTION_CODE_MASK); } - static int addr_of(std::span pdu) { - return pdu.size() >= 3 ? modbus::helpers::get_data(pdu.data(), 1) : 0; - } - /// Declared before controller_ so they land in the padding after ModbusClientDevice::custom_response_warned_ - /// instead of adding a word to every entity that owns a device. - /// dispatched_: a frame was queued since the last clear_dispatched_(). - /// write_buffer_deprecated_warned_: warn-once for the legacy write_lambda buffer parameter. + /// Write-path state owned by WriterEntity's forwarders, stored here so both bools land in the base's + /// tail padding instead of adding a word to every writer entity. The warn flag leaves in 2027.3.0. bool dispatched_{false}; bool write_buffer_deprecated_warned_{false}; ModbusController *controller_{nullptr}; +}; +/// The write side of a ControllerDevice, owned by the writer entities through WriterEntity, whose +/// forwarders re-expose exactly the request API a write lambda may use and record every dispatch. +class WriterDevice final : public ControllerDevice { public: - /// Whether a frame was queued to the hub since the last clear_dispatched_(). - bool dispatched() const { return this->dispatched_; } + using modbus::ModbusClientDevice::clear_tx_queue_for_device; + using modbus::ModbusClientDevice::queue_pdu; + using modbus::ModbusClientDevice::write_multiple_coils; + using modbus::ModbusClientDevice::write_multiple_registers; + using modbus::ModbusClientDevice::write_single_coil; + using modbus::ModbusClientDevice::write_single_register; - bool write_single_register(uint16_t address, uint16_t value) { - this->dispatched_ = true; - return modbus::ModbusClientDevice::write_single_register(address, value); - } - bool write_single_coil(uint16_t address, bool value) { - this->dispatched_ = true; - return modbus::ModbusClientDevice::write_single_coil(address, value); - } - bool write_multiple_registers(uint16_t address, std::span values) { - this->dispatched_ = true; - return modbus::ModbusClientDevice::write_multiple_registers(address, values); - } - bool write_multiple_coils(uint16_t address, std::span values) { - this->dispatched_ = true; - return modbus::ModbusClientDevice::write_multiple_coils(address, values); - } - bool write_multiple_coils(uint16_t address, modbus::PackedBits bits) { - this->dispatched_ = true; - return modbus::ModbusClientDevice::write_multiple_coils(address, bits); - } - bool queue_pdu(std::span pdu, modbus::CommandOptions options = {}) { - this->dispatched_ = true; - return modbus::ModbusClientDevice::queue_pdu(pdu, options); - } /// Send a legacy raw frame (address + function code + data) to the frame's own address. /// Serves only the deprecated write_lambda buffer path. Remove before 2027.3.0. bool send_raw_frame_deprecated(std::span frame); - void clear_tx_queue_for_device() { modbus::ModbusClientDevice::clear_tx_queue_for_device(); } - - // Entity plumbing, public because the owning WriterEntity holds the only reachable instance (device_ is - // protected there and the hub sees just the masked base) - reachability is the access gate, not a friend. - void set_controller(ModbusController *controller); + bool dispatched() const { return this->dispatched_; } + void set_dispatched() { this->dispatched_ = true; } void clear_dispatched() { this->dispatched_ = false; } /// Warn once per entity that filling the write_lambda buffer parameter is deprecated (the entity is now the /// command - call a write helper / queue_pdu() on `item` instead). The buffer parameter is removed in 2027.3.0. void warn_write_buffer_deprecated(const LogString *platform, uint16_t address); + + protected: + // Only the write side forwards to the typed callbacks (for item->queue_pdu() replies): a poll parses + // its own response, and dispatching its errors would trip the base unhandled-custom-response warning. + void on_response(std::span request_pdu, std::span response_pdu) override; + void on_error(std::span request_pdu, modbus::ExceptionCode exception_code) override; }; /// Gives a writer entity the write API of the WriterDevice it owns. The device is a member, not a base: /// the mixin declares no virtual function, so an entity mixing it in gains no second vtable and all the /// writer platforms share the single WriterDevice vtable instead of each emitting its own copy. -/// The forwarders keep `item->write_*()` working unchanged inside a write_lambda. +/// The forwarders keep `item->write_*()` working unchanged inside a write_lambda, and record every +/// dispatch, so the write path can tell "the lambda sent it itself" from "use the default write". class WriterEntity { public: + /// Whether the lambda called a request helper since the last clear_dispatched_(). Deliberately records + /// the call, not the hub's accept/refuse: a refused lambda write must not fall through to the default write. bool dispatched() const { return this->device_.dispatched(); } bool write_single_register(uint16_t address, uint16_t value) { + this->device_.set_dispatched(); return this->device_.write_single_register(address, value); } - bool write_single_coil(uint16_t address, bool value) { return this->device_.write_single_coil(address, value); } + bool write_single_coil(uint16_t address, bool value) { + this->device_.set_dispatched(); + return this->device_.write_single_coil(address, value); + } bool write_multiple_registers(uint16_t address, std::span values) { + this->device_.set_dispatched(); return this->device_.write_multiple_registers(address, values); } bool write_multiple_coils(uint16_t address, std::span values) { + this->device_.set_dispatched(); return this->device_.write_multiple_coils(address, values); } bool write_multiple_coils(uint16_t address, modbus::PackedBits bits) { + this->device_.set_dispatched(); return this->device_.write_multiple_coils(address, bits); } bool queue_pdu(std::span pdu, modbus::CommandOptions options = {}) { + this->device_.set_dispatched(); return this->device_.queue_pdu(pdu, options); } void clear_tx_queue_for_device() { this->device_.clear_tx_queue_for_device(); } protected: bool send_raw_frame_deprecated_(std::span frame) { + this->device_.set_dispatched(); return this->device_.send_raw_frame_deprecated(frame); } void set_controller_(ModbusController *controller) { this->device_.set_controller(controller); } @@ -338,13 +338,40 @@ class WriterEntity { this->device_.warn_write_buffer_deprecated(platform, address); } + private: + // Private so a derived entity cannot reach the device except through the recording forwarders above. WriterDevice device_; }; +/// A persistent hub device that polls one register range - the read-side mirror of WriterDevice. +/// Owned by the controller, one per range; the response is parsed straight to the range's sensors. +class PollingDevice final : public ControllerDevice { + public: + PollingDevice(ModbusController &controller, RegisterRange &&range); + + /// Queue this range's read (or its sensor's custom PDU) on the hub. False = refused, no callback follows. + bool queue(modbus::CommandOptions options = {}); + + uint16_t register_address() const { return this->range_.start_address; } + uint16_t register_count() const { return this->range_.register_count; } + EntityType register_type() const { return this->range_.register_type; } + + protected: + void on_response(std::span request_pdu, std::span response_pdu) override; + + RegisterRange range_; +}; + /// A single modbus command. Each command is its own ModbusClientDevice: it sends its frame to the hub /// and the hub routes the response back to this object's on_modbus_* callbacks, so the controller no /// longer has to match responses to a FIFO queue. -class ModbusCommandItem : public modbus::ModbusClientDevice { +// The deprecated class references other deprecated names. Remove before 2027.3.0. +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +class ESPDEPRECATED( + "One-shot writes go through the entity write helpers (WriterDevice) or the modbus_client actions, and " + "polling runs through PollingDevice. Removed in 2027.3.0", + "2026.9.0") ModbusCommandItem : public modbus::ModbusClientDevice { public: /// Empty command with no controller connection (kept for source compatibility with value-type usage). ModbusCommandItem(ModbusController &controller, modbus::ModbusClientHub *parent, uint8_t address) @@ -489,6 +516,7 @@ class ModbusCommandItem : public modbus::ModbusClientDevice { const SmallInlineBuffer<8> *custom_pdu_{nullptr}; ModbusController *controller_{nullptr}; }; +#pragma GCC diagnostic pop /// Whether an offline probe is due this update cycle: every offline_skip_updates + 1 cycles, /// anchored at the cycle the device went offline. Pure so the cadence (including update_counter @@ -522,14 +550,24 @@ class ModbusController final : public PollingComponent { modbus::ModbusClientHub *hub() const { return this->hub_; } uint8_t device_address() const { return this->address_; } +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" /// Queues a one-shot modbus command (writes, custom commands); taken by value, so std::move to avoid a copy. + /// Remove with ModbusCommandItem before 2027.3.0. + ESPDEPRECATED("Use the entity write helpers or the modbus_client actions instead. Removed in 2027.3.0", "2026.9.0") void queue_command(ModbusCommandItem command); /// Flags a finished one-shot command for removal. Called by the command as the last action of its own /// callback, so the item is not destroyed here (send() and the hub still touch it) but swept later. + /// Remove with ModbusCommandItem before 2027.3.0. + ESPDEPRECATED("Serves only ModbusCommandItem's own callbacks. Removed in 2027.3.0", "2026.9.0") void unqueue_command(const ModbusCommandItem *command); +#pragma GCC diagnostic pop /// Registers a sensor with the controller. Called by esphomes code generator void add_sensor_item(SensorItem *item) { sensorset_.insert(item); } /// Handles a write command acknowledgement (used by write command on_data_func handlers). + /// Remove with ModbusCommandItem before 2027.3.0. + ESPDEPRECATED("Write acknowledgements are handled by the writing entity's own device. Removed in 2027.3.0", + "2026.9.0") void on_write_register_response(EntityType register_type, uint16_t start_address, std::span data); /// Update the online/offline state after a response or a run of timeouts, firing the callbacks. void set_online(bool online, int function_code, int register_address); @@ -568,32 +606,22 @@ class ModbusController final : public PollingComponent { const modbus::CommandOptions &read_options() const { return this->read_options_; } protected: - /// parse sensormap_ and create range of sequential addresses - /// Group the registered sensors into contiguous ranges and create one polling command per range. + /// Group the registered sensors into contiguous ranges and create one PollingDevice per range. void create_polling_commands_(); - /// build one persistent polling command from a range and add it to polling_command_items_ - void create_polling_command_(RegisterRange &&range) { - // A custom range polls the first sensor's custom_pdu (referenced, not copied); the sensor constructor - // decodes the real function code. The response still dispatches to every sensor in the range. - if (range.register_type == EntityType::CUSTOM && !range.sensors.empty()) { - auto &cmd = this->polling_command_items_.emplace_back(*this, this->hub_, this->address_, *range.sensors.begin()); - cmd.sensors = std::move(range.sensors); - } else { - this->polling_command_items_.emplace_back(*this, this->hub_, this->address_, std::move(range)); - } - } /// The hub this controller's commands/entities send through, and the modbus address they target. modbus::ModbusClientHub *hub_{nullptr}; uint8_t address_{0}; /// Collection of all sensors for this component SensorSet sensorset_; - /// One persistent command per register range, each its own ModbusClientDevice. Built once in setup() - /// (create_polling_commands_ feeds each range straight in; the vector may reallocate as it grows, which - /// is safe because no command has registered with the hub yet) and never appended to afterward, so the - /// hub's device pointers stay valid once commands start sending. - std::vector polling_command_items_{}; + /// One persistent PollingDevice per register range. Built once in setup() with the exact count + /// (FixedVector never reallocates), so the hub's device pointers stay valid once polls start sending. + FixedVector polling_devices_; /// Dynamically queued one-shot commands (writes, custom commands). std::list keeps stable addresses. + /// Remove with ModbusCommandItem before 2027.3.0. +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" std::list> one_shot_command_items_; +#pragma GCC diagnostic pop /// Erases one-shot commands flagged by unqueue_command(). Safe even when reached from inside a hub /// callback (via an on_online/on_offline/on_command_sent automation that queues a command): the /// destructor detaches via clear_tx_queue_for_device(), which the hub allows from callbacks, and the diff --git a/tests/components/modbus_controller/command_payload_test.cpp b/tests/components/modbus_controller/command_payload_test.cpp index a0a59f5106..b9a1930ed0 100644 --- a/tests/components/modbus_controller/command_payload_test.cpp +++ b/tests/components/modbus_controller/command_payload_test.cpp @@ -5,6 +5,11 @@ #include "esphome/components/modbus_controller/modbus_controller.h" +// These tests pin the behaviour of the deprecated ModbusCommandItem until its removal. +// Remove with ModbusCommandItem before 2027.3.0. +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + namespace esphome::modbus_controller::testing { // The coil write factory packs into an exact-size payload. Pinned at one past the protocol maximum @@ -29,3 +34,5 @@ TEST(ModbusCommandPayload, CoilWritePacksLsbFirstWithZeroPad) { } } // namespace esphome::modbus_controller::testing + +#pragma GCC diagnostic pop From 16d0fa11658dab4664e0ef91e95a1ee61ed8b54e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 Aug 2026 15:28:04 -0500 Subject: [PATCH 4/6] [core] Add step_to_accuracy_decimals benchmarks (#18826) --- tests/benchmarks/core/bench_helpers.cpp | 43 ++++++++++++++++ tests/components/core/test_helpers.cpp | 68 +++++++++++++++++++++++++ 2 files changed, 111 insertions(+) diff --git a/tests/benchmarks/core/bench_helpers.cpp b/tests/benchmarks/core/bench_helpers.cpp index 1ce9101ff6..4a1f3c5bcc 100644 --- a/tests/benchmarks/core/bench_helpers.cpp +++ b/tests/benchmarks/core/bench_helpers.cpp @@ -363,4 +363,47 @@ static void Snprintf_Uint32_Large(benchmark::State &state) { } BENCHMARK(Snprintf_Uint32_Large); +// --- step_to_accuracy_decimals() --- +// Called from climate traits and web_server for every number/climate step. + +static void StepToAccuracyDecimals_Tenth(benchmark::State &state) { + for (auto _ : state) { + int result = 0; + for (int i = 0; i < kInnerIterations; i++) { + result += step_to_accuracy_decimals(0.1f); + } + benchmark::DoNotOptimize(result); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(StepToAccuracyDecimals_Tenth); + +static void StepToAccuracyDecimals_Whole(benchmark::State &state) { + for (auto _ : state) { + int result = 0; + for (int i = 0; i < kInnerIterations; i++) { + result += step_to_accuracy_decimals(1.0f); + } + benchmark::DoNotOptimize(result); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(StepToAccuracyDecimals_Whole); + +static void StepToAccuracyDecimals_Mixed(benchmark::State &state) { + static constexpr float steps[] = { + 0.001f, 0.01f, 0.05f, 0.1f, 0.25f, 0.5f, 1.0f, 2.5f, 5.0f, 10.0f, + }; + static constexpr int num_steps = sizeof(steps) / sizeof(steps[0]); + for (auto _ : state) { + int result = 0; + for (int i = 0; i < kInnerIterations; i++) { + result += step_to_accuracy_decimals(steps[i % num_steps]); + } + benchmark::DoNotOptimize(result); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(StepToAccuracyDecimals_Mixed); + } // namespace esphome::benchmarks diff --git a/tests/components/core/test_helpers.cpp b/tests/components/core/test_helpers.cpp index 3767b24d86..a031dcb36f 100644 --- a/tests/components/core/test_helpers.cpp +++ b/tests/components/core/test_helpers.cpp @@ -1,4 +1,5 @@ #include +#include #include #include "esphome/core/alloc_helpers.h" @@ -280,4 +281,71 @@ TEST(Base64, Rfc4648Vectors) { } } +// --- step_to_accuracy_decimals() --- + +TEST(StepToAccuracyDecimals, TypicalSteps) { + EXPECT_EQ(step_to_accuracy_decimals(0.001f), 3); + EXPECT_EQ(step_to_accuracy_decimals(0.005f), 3); + EXPECT_EQ(step_to_accuracy_decimals(0.01f), 2); + EXPECT_EQ(step_to_accuracy_decimals(0.025f), 3); + EXPECT_EQ(step_to_accuracy_decimals(0.05f), 2); + EXPECT_EQ(step_to_accuracy_decimals(0.1f), 1); + EXPECT_EQ(step_to_accuracy_decimals(0.25f), 2); + EXPECT_EQ(step_to_accuracy_decimals(0.5f), 1); + EXPECT_EQ(step_to_accuracy_decimals(1.5f), 1); + EXPECT_EQ(step_to_accuracy_decimals(2.5f), 1); +} + +TEST(StepToAccuracyDecimals, WholeSteps) { + EXPECT_EQ(step_to_accuracy_decimals(1.0f), 0); + EXPECT_EQ(step_to_accuracy_decimals(2.0f), 0); + EXPECT_EQ(step_to_accuracy_decimals(5.0f), 0); + EXPECT_EQ(step_to_accuracy_decimals(10.0f), 0); + EXPECT_EQ(step_to_accuracy_decimals(100.0f), 0); + EXPECT_EQ(step_to_accuracy_decimals(1000.0f), 0); +} + +TEST(StepToAccuracyDecimals, FiveSignificantDigits) { + EXPECT_EQ(step_to_accuracy_decimals(1.23456f), 4); + EXPECT_EQ(step_to_accuracy_decimals(12.345f), 3); + EXPECT_EQ(step_to_accuracy_decimals(123.45f), 2); + EXPECT_EQ(step_to_accuracy_decimals(1234.5f), 1); + EXPECT_EQ(step_to_accuracy_decimals(12345.0f), 0); + EXPECT_EQ(step_to_accuracy_decimals(0.33333f), 5); + EXPECT_EQ(step_to_accuracy_decimals(0.0001f), 4); +} + +TEST(StepToAccuracyDecimals, TrailingZerosDropped) { + EXPECT_EQ(step_to_accuracy_decimals(0.3f), 1); + EXPECT_EQ(step_to_accuracy_decimals(0.7f), 1); + EXPECT_EQ(step_to_accuracy_decimals(0.125f), 3); + EXPECT_EQ(step_to_accuracy_decimals(0.0625f), 4); +} + +TEST(StepToAccuracyDecimals, RoundsUpToWholeNumber) { + // Rounds to five significant digits first, so this becomes 10 with no decimals. + EXPECT_EQ(step_to_accuracy_decimals(9.999999f), 0); +} + +TEST(StepToAccuracyDecimals, OutsideFixedNotationRange) { + // %.5g prints these in exponent form, so the count comes from parsing "1e-05" or "1.2346e+05". + EXPECT_EQ(step_to_accuracy_decimals(0.00001f), 0); + EXPECT_EQ(step_to_accuracy_decimals(0.000125f), 6); + EXPECT_EQ(step_to_accuracy_decimals(123456.0f), 8); + EXPECT_EQ(step_to_accuracy_decimals(1000000.0f), 0); +} + +TEST(StepToAccuracyDecimals, SignIgnored) { + EXPECT_EQ(step_to_accuracy_decimals(-0.1f), 1); + EXPECT_EQ(step_to_accuracy_decimals(-0.25f), 2); + EXPECT_EQ(step_to_accuracy_decimals(-1.0f), 0); +} + +TEST(StepToAccuracyDecimals, NonFiniteAndZero) { + EXPECT_EQ(step_to_accuracy_decimals(0.0f), 0); + EXPECT_EQ(step_to_accuracy_decimals(NAN), 0); + EXPECT_EQ(step_to_accuracy_decimals(INFINITY), 0); + EXPECT_EQ(step_to_accuracy_decimals(-INFINITY), 0); +} + } // namespace esphome::core::testing From a9a66baeba25455369db6ec32d7f09053be70f05 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 Aug 2026 15:29:23 -0500 Subject: [PATCH 5/6] [ci] Compress the compile-test image with zstd (#18812) --- .github/workflows/ci-docker.yml | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 42be51cdd9..829bdd5f98 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -119,16 +119,22 @@ jobs: # pushed image) keeps it working for fork PRs, which never push to ghcr.io. - name: Export image for compile-test if: matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker' - run: docker save "ghcr.io/esphome/esphome-amd64:${{ steps.tag.outputs.tag }}" | gzip > compile-test-image.tar.gz + # zstd over gzip: docker save is on the critical path for every + # compile-test job, and zstd -T0 is multithreaded (export 50s -> 9s). + # docker load auto-detects the format; its time is layer extraction, + # not decompression, so it is unchanged. shell: bash adds pipefail so + # a failed docker save cannot upload a truncated artifact. + shell: bash + run: docker save "ghcr.io/esphome/esphome-amd64:${{ steps.tag.outputs.tag }}" | zstd -T0 -3 > compile-test-image.tar.zst - name: Upload compile-test image artifact if: matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - # The tar is already gzipped, so upload it as-is. archive: false skips - # the redundant zip and makes the file name the artifact name (the - # `name` input is ignored in that mode). - path: compile-test-image.tar.gz + # The tar is already compressed, so upload it as-is. archive: false + # skips the redundant zip and makes the file name the artifact name + # (the `name` input is ignored in that mode). + path: compile-test-image.tar.zst retention-days: 1 archive: false @@ -206,9 +212,9 @@ jobs: - name: Download image artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: compile-test-image.tar.gz + name: compile-test-image.tar.zst - name: Load image - run: docker load --input compile-test-image.tar.gz + run: docker load --input compile-test-image.tar.zst - name: Compile ${{ matrix.id }} run: | docker run --rm \ From 7782bc11c6a08a35d401e9264e2818a72a0ab02b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 Aug 2026 15:29:43 -0500 Subject: [PATCH 6/6] [core] Remove make_name_with_suffix std::string overloads (#18828) --- esphome/components/mqtt/mqtt_client.cpp | 6 +++++- esphome/config_validation.py | 2 +- esphome/core/helpers.cpp | 14 -------------- esphome/core/helpers.h | 24 +++--------------------- 4 files changed, 9 insertions(+), 37 deletions(-) diff --git a/esphome/components/mqtt/mqtt_client.cpp b/esphome/components/mqtt/mqtt_client.cpp index 1127c36dc6..2ecab47904 100644 --- a/esphome/components/mqtt/mqtt_client.cpp +++ b/esphome/components/mqtt/mqtt_client.cpp @@ -41,7 +41,11 @@ MQTTClientComponent::MQTTClientComponent() { global_mqtt_client = this; char mac_addr[MAC_ADDRESS_BUFFER_SIZE]; get_mac_address_into_buffer(mac_addr); - this->credentials_.client_id = make_name_with_suffix(App.get_name(), '-', mac_addr, MAC_ADDRESS_BUFFER_SIZE - 1); + const StringRef &name = App.get_name(); + char client_id[MAX_NAME_WITH_SUFFIX_SIZE]; + size_t len = make_name_with_suffix_to(client_id, sizeof(client_id), name.c_str(), name.size(), '-', mac_addr, + MAC_ADDRESS_BUFFER_SIZE - 1); + this->credentials_.client_id.assign(client_id, len); } // Connection diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 904cbd1919..aff39201e8 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -1462,7 +1462,7 @@ def hostname(value): Maximum length is 63 characters per RFC 1035. Note: If this limit is changed, update MAX_NAME_WITH_SUFFIX_SIZE in - esphome/core/helpers.cpp to accommodate the new maximum length. + esphome/core/helpers.h to accommodate the new maximum length. """ value = string(value) if re.match(r"^[a-z0-9-]{1,63}$", value, re.IGNORECASE) is not None: diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 71e3c87e1e..6bfe5c9e3c 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -268,9 +268,6 @@ char *str_sanitize_to(char *buffer, size_t buffer_size, const char *str) { // str_sanitize, str_snprintf, str_sprintf moved to alloc_helpers.cpp -// Maximum size for name with suffix: 120 (max friendly name) + 1 (separator) + 6 (MAC suffix) + 1 (null term) -static constexpr size_t MAX_NAME_WITH_SUFFIX_SIZE = 128; - size_t make_name_with_suffix_to(char *buffer, size_t buffer_size, const char *name, size_t name_len, char sep, const char *suffix_ptr, size_t suffix_len) { size_t total_len = name_len + 1 + suffix_len; @@ -291,17 +288,6 @@ size_t make_name_with_suffix_to(char *buffer, size_t buffer_size, const char *na return total_len; } -std::string make_name_with_suffix(const char *name, size_t name_len, char sep, const char *suffix_ptr, - size_t suffix_len) { - char buffer[MAX_NAME_WITH_SUFFIX_SIZE]; - size_t len = make_name_with_suffix_to(buffer, sizeof(buffer), name, name_len, sep, suffix_ptr, suffix_len); - return std::string(buffer, len); -} - -std::string make_name_with_suffix(const std::string &name, char sep, const char *suffix_ptr, size_t suffix_len) { - return make_name_with_suffix(name.c_str(), name.size(), sep, suffix_ptr, suffix_len); -} - // Parsing & formatting size_t parse_hex(const char *str, size_t length, uint8_t *data, size_t count) { diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 9fdc088ecb..1ccc833048 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1153,28 +1153,10 @@ inline size_t buf_append_str(char *buf, size_t size, size_t pos, const char *str } #endif -/// Concatenate a name with a separator and suffix using an efficient stack-based approach. -/// This avoids multiple heap allocations during string construction. -/// Maximum name length supported is 120 characters for friendly names. -/// @param name The base name string -/// @param sep The separator character (e.g., '-', ' ', or '.') -/// @param suffix_ptr Pointer to the suffix characters -/// @param suffix_len Length of the suffix -/// @return The concatenated string: name + sep + suffix -std::string make_name_with_suffix(const std::string &name, char sep, const char *suffix_ptr, size_t suffix_len); +/// Maximum size for name with suffix: 120 (max friendly name) + 1 (separator) + 6 (MAC suffix) + 1 (null term) +static constexpr size_t MAX_NAME_WITH_SUFFIX_SIZE = 128; -/// Optimized string concatenation: name + separator + suffix (const char* overload) -/// Uses a fixed stack buffer to avoid heap allocations. -/// @param name The base name string -/// @param name_len Length of the name -/// @param sep Single character separator -/// @param suffix_ptr Pointer to the suffix characters -/// @param suffix_len Length of the suffix -/// @return The concatenated string: name + sep + suffix -std::string make_name_with_suffix(const char *name, size_t name_len, char sep, const char *suffix_ptr, - size_t suffix_len); - -/// Zero-allocation version: format name + separator + suffix directly into buffer. +/// Format name + separator + suffix directly into buffer without heap allocation. /// @param buffer Output buffer (must have space for result + null terminator) /// @param buffer_size Size of the output buffer /// @param name The base name string