Merge remote-tracking branch 'origin/dev' into esp8266-native-library-backend

This commit is contained in:
J. Nick Koston
2026-08-27 15:52:58 -05:00
14 changed files with 383 additions and 178 deletions
+13 -7
View File
@@ -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 \
+1 -1
View File
@@ -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
@@ -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,
+44 -28
View File
@@ -153,6 +153,50 @@ inline std::span<const uint8_t> server_pdu_payload(std::span<const uint8_t> 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<typename T> 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<uint32_t>(get_data<uint16_t>(data, buffer_offset)) << 16 |
static_cast<uint32_t>(get_data<uint16_t>(data, buffer_offset + 2));
}
if (sizeof(T) == sizeof(uint64_t)) {
return static_cast<uint64_t>(get_data<uint32_t>(data, buffer_offset)) << 32 |
(static_cast<uint64_t>(get_data<uint32_t>(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<const uint8_t> 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<uint16_t> client_pdu_start_address(std::span<const uint8_t> pdu) {
if (pdu.size() < 3 || is_function_code_unknown_length(pdu[0]))
return std::nullopt;
const auto fc = static_cast<FunctionCode>(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<uint16_t>(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<uint64_t>(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<typename T> 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<uint32_t>(get_data<uint16_t>(data, buffer_offset)) << 16 |
static_cast<uint32_t>(get_data<uint16_t>(data, buffer_offset + 2));
}
if (sizeof(T) == sizeof(uint64_t)) {
return static_cast<uint64_t>(get_data<uint32_t>(data, buffer_offset)) << 32 |
(static_cast<uint64_t>(get_data<uint32_t>(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<typename T> T get_data(const std::vector<uint8_t> &data, size_t buffer_offset) {
return get_data<T>(data.data(), buffer_offset);
}
@@ -23,60 +23,106 @@ void WriterDevice::warn_write_buffer_deprecated(const LogString *platform, uint1
bool WriterDevice::send_raw_frame_deprecated(std::span<const uint8_t> 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<const uint8_t> 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<const uint8_t> 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<const uint8_t> 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<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) {
this->notify_online_(request_pdu);
}
void ControllerDevice::on_error(std::span<const uint8_t> 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<uint8_t>(exception_code));
this->notify_online_(request_pdu); // an exception is still a legitimate reply -> device is online
}
void WriterDevice::on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> 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<const uint8_t> 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<uint8_t>(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<const uint8_t> 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<const uint8_t> 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<const uint8_t> 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<const uint8_t> request_pdu) {
void ControllerDevice::on_not_sent(std::span<const uint8_t> 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<const uint8_t> 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<const uint8_t>(*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<uint8_t>(this->range_.register_type),
this->range_.start_address, this->range_.register_count);
}
return accepted;
}
void PollingDevice::on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> 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<const uint8_t> 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<ModbusCommandItem> &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<RegisterRange> 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<uint8_t>(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<const uint8_t> 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
@@ -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 <list>
@@ -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<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override;
void on_error(std::span<const uint8_t> request_pdu, modbus::ExceptionCode exception_code) override;
void on_sent(std::span<const uint8_t> request_pdu) override;
@@ -246,90 +254,82 @@ class WriterDevice final : protected modbus::ModbusClientDevice {
bool on_no_response(std::span<const uint8_t> request_pdu) override;
void notify_online_(std::span<const uint8_t> request_pdu);
/// Function code / register address decoded from a request PDU ([fc, addr_hi, addr_lo, ...]).
static int fc_of(std::span<const uint8_t> pdu) { return pdu.empty() ? 0 : (pdu[0] & modbus::FUNCTION_CODE_MASK); }
static int addr_of(std::span<const uint8_t> pdu) {
return pdu.size() >= 3 ? modbus::helpers::get_data<uint16_t>(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<const uint16_t> values) {
this->dispatched_ = true;
return modbus::ModbusClientDevice::write_multiple_registers(address, values);
}
bool write_multiple_coils(uint16_t address, std::span<const bool> 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<const uint8_t> 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<const uint8_t> 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<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override;
void on_error(std::span<const uint8_t> 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<const uint16_t> values) {
this->device_.set_dispatched();
return this->device_.write_multiple_registers(address, values);
}
bool write_multiple_coils(uint16_t address, std::span<const bool> 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<const uint8_t> 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<const uint8_t> 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<const uint8_t> request_pdu, std::span<const uint8_t> 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<const uint8_t> 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<ModbusCommandItem> 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<PollingDevice> 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<std::unique_ptr<ModbusCommandItem>> 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
+5 -1
View File
@@ -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
+1 -1
View File
@@ -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:
-14
View File
@@ -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) {
+3 -21
View File
@@ -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
+1 -1
View File
@@ -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
+43
View File
@@ -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
+68
View File
@@ -1,4 +1,5 @@
#include <gtest/gtest.h>
#include <cmath>
#include <cstring>
#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
@@ -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