Merge remote-tracking branch 'origin/dev' into web-server-logs

# Conflicts:
#	esphome/__main__.py
#	tests/unit_tests/test_main.py
This commit is contained in:
J. Nick Koston
2026-06-21 15:03:00 -05:00
37 changed files with 1258 additions and 845 deletions

View File

@@ -375,7 +375,7 @@ void APIConnection::finalize_iterator_sync_() {
void APIConnection::process_iterator_batch_(ComponentIterator &iterator) { void APIConnection::process_iterator_batch_(ComponentIterator &iterator) {
size_t initial_size = this->deferred_batch_.size(); size_t initial_size = this->deferred_batch_.size();
size_t max_batch = this->get_max_batch_size_(); size_t max_batch = MAX_INITIAL_PER_BATCH;
while (!iterator.completed() && (this->deferred_batch_.size() - initial_size) < max_batch) { while (!iterator.completed() && (this->deferred_batch_.size() - initial_size) < max_batch) {
iterator.advance(); iterator.advance();
} }
@@ -418,16 +418,6 @@ uint16_t APIConnection::fill_and_encode_entity_info(EntityBase *entity, InfoResp
// Set common fields that are shared by all entity types // Set common fields that are shared by all entity types
msg.key = entity->get_object_id_hash(); msg.key = entity->get_object_id_hash();
// API 1.14+ clients compute object_id client-side from the entity name
// For older clients, we must send object_id for backward compatibility
// See: https://github.com/esphome/backlog/issues/76
// TODO: Remove this backward compat code before 2026.7.0 - all clients should support API 1.14 by then
// Buffer must remain in scope until encode_to_buffer is called
char object_id_buf[OBJECT_ID_MAX_LEN];
if (!conn->client_supports_api_version(1, 14)) {
msg.object_id = entity->get_object_id_to(object_id_buf);
}
if (entity->has_own_name()) { if (entity->has_own_name()) {
msg.name = entity->get_name(); msg.name = entity->get_name();
} }

View File

@@ -43,10 +43,7 @@ class APIServer;
// Keepalive timeout in milliseconds // Keepalive timeout in milliseconds
static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000; static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000;
// Maximum number of entities to process in a single batch during initial state/info sending // Maximum number of entities to process in a single batch during initial state/info sending
// API 1.14+ clients compute object_id client-side, so messages are smaller and we can fit more per batch static constexpr size_t MAX_INITIAL_PER_BATCH = 34;
// TODO: Remove MAX_INITIAL_PER_BATCH_LEGACY before 2026.7.0 - all clients should support API 1.14 by then
static constexpr size_t MAX_INITIAL_PER_BATCH_LEGACY = 24; // For clients < API 1.14 (includes object_id)
static constexpr size_t MAX_INITIAL_PER_BATCH = 34; // For clients >= API 1.14 (no object_id)
// Verify MAX_MESSAGES_PER_BATCH (defined in api_frame_helper.h) can hold the initial batch // Verify MAX_MESSAGES_PER_BATCH (defined in api_frame_helper.h) can hold the initial batch
static_assert(MAX_MESSAGES_PER_BATCH >= MAX_INITIAL_PER_BATCH, static_assert(MAX_MESSAGES_PER_BATCH >= MAX_INITIAL_PER_BATCH,
"MAX_MESSAGES_PER_BATCH must be >= MAX_INITIAL_PER_BATCH"); "MAX_MESSAGES_PER_BATCH must be >= MAX_INITIAL_PER_BATCH");
@@ -481,13 +478,6 @@ class APIConnection final : public APIServerConnectionBase {
inline bool check_voice_assistant_api_connection_() const; inline bool check_voice_assistant_api_connection_() const;
#endif #endif
// Get the max batch size based on client API version
// API 1.14+ clients don't receive object_id, so messages are smaller and more fit per batch
// TODO: Remove this method before 2026.7.0 and use MAX_INITIAL_PER_BATCH directly
size_t get_max_batch_size_() const {
return this->client_supports_api_version(1, 14) ? MAX_INITIAL_PER_BATCH : MAX_INITIAL_PER_BATCH_LEGACY;
}
// Send keepalive ping or disconnect unresponsive client. // Send keepalive ping or disconnect unresponsive client.
// Cold path — extracted from loop() to reduce instruction cache pressure. // Cold path — extracted from loop() to reduce instruction cache pressure.
void __attribute__((noinline)) check_keepalive_(uint32_t now); void __attribute__((noinline)) check_keepalive_(uint32_t now);

View File

@@ -324,7 +324,7 @@ def _validate(config):
" clk:\n" " clk:\n"
" mode: %s\n" " mode: %s\n"
" pin: %s\n" " pin: %s\n"
"Removal scheduled for 2026.7.0.", "Removal scheduled for 2026.9.0.",
config[CONF_CLK_MODE], config[CONF_CLK_MODE],
mode, mode,
pin, pin,

View File

@@ -14,7 +14,11 @@ DEPENDENCIES = ["uart"]
modbus_ns = cg.esphome_ns.namespace("modbus") modbus_ns = cg.esphome_ns.namespace("modbus")
Modbus = modbus_ns.class_("Modbus", cg.Component, uart.UARTDevice) Modbus = modbus_ns.class_("Modbus", cg.Component, uart.UARTDevice)
ModbusServer = modbus_ns.class_("ModbusServerHub", Modbus)
ModbusClient = modbus_ns.class_("ModbusClientHub", Modbus)
ModbusDevice = modbus_ns.class_("ModbusDevice") ModbusDevice = modbus_ns.class_("ModbusDevice")
ModbusClientDevice = modbus_ns.class_("ModbusClientDevice")
ModbusServerDevice = modbus_ns.class_("ModbusServerDevice")
MULTI_CONF = True MULTI_CONF = True
CONF_ROLE = "role" CONF_ROLE = "role"
@@ -22,29 +26,43 @@ CONF_MODBUS_ID = "modbus_id"
CONF_SEND_WAIT_TIME = "send_wait_time" CONF_SEND_WAIT_TIME = "send_wait_time"
CONF_TURNAROUND_TIME = "turnaround_time" CONF_TURNAROUND_TIME = "turnaround_time"
ModbusRole = modbus_ns.enum("ModbusRole") MODBUS_ROLES = ["client", "server"]
MODBUS_ROLES = {
"client": ModbusRole.CLIENT,
"server": ModbusRole.SERVER,
}
CONFIG_SCHEMA = ( CONFIG_SCHEMA = cv.typed_schema(
cv.Schema(
{ {
cv.GenerateID(): cv.declare_id(Modbus), "client": cv.Schema(
cv.Optional(CONF_ROLE, default="client"): cv.enum(MODBUS_ROLES), {
cv.GenerateID(): cv.declare_id(ModbusClient),
cv.Optional(CONF_FLOW_CONTROL_PIN): pins.gpio_output_pin_schema, cv.Optional(CONF_FLOW_CONTROL_PIN): pins.gpio_output_pin_schema,
cv.Optional( cv.Optional(
CONF_SEND_WAIT_TIME, default="250ms" CONF_SEND_WAIT_TIME, default="2000ms"
): cv.positive_time_period_milliseconds, ): cv.positive_time_period_milliseconds,
cv.Optional( cv.Optional(
CONF_TURNAROUND_TIME, default="100ms" CONF_TURNAROUND_TIME, default="600ms"
): cv.positive_time_period_milliseconds, ): cv.positive_time_period_milliseconds,
cv.Optional(CONF_DISABLE_CRC, default=False): cv.boolean, # Remove before 2026.10.0
cv.Optional(CONF_DISABLE_CRC): cv.invalid(
"'disable_crc' has been removed. The parser no longer requires it — remove this option."
),
} }
) )
.extend(cv.COMPONENT_SCHEMA) .extend(cv.COMPONENT_SCHEMA)
.extend(uart.UART_DEVICE_SCHEMA) .extend(uart.UART_DEVICE_SCHEMA),
"server": cv.Schema(
{
cv.GenerateID(): cv.declare_id(ModbusServer),
cv.Optional(CONF_FLOW_CONTROL_PIN): pins.gpio_output_pin_schema,
# Remove before 2026.10.0
cv.Optional(CONF_DISABLE_CRC): cv.invalid(
"'disable_crc' has been removed. The parser no longer requires it — remove this option."
),
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(uart.UART_DEVICE_SCHEMA),
},
key=CONF_ROLE,
default_type="client",
) )
@@ -55,19 +73,19 @@ async def to_code(config):
await uart.register_uart_device(var, config) await uart.register_uart_device(var, config)
cg.add(var.set_role(config[CONF_ROLE]))
if CONF_FLOW_CONTROL_PIN in config: if CONF_FLOW_CONTROL_PIN in config:
pin = await gpio_pin_expression(config[CONF_FLOW_CONTROL_PIN]) pin = await gpio_pin_expression(config[CONF_FLOW_CONTROL_PIN])
cg.add(var.set_flow_control_pin(pin)) cg.add(var.set_flow_control_pin(pin))
if config[CONF_ROLE] == "client":
cg.add(var.set_send_wait_time(config[CONF_SEND_WAIT_TIME])) cg.add(var.set_send_wait_time(config[CONF_SEND_WAIT_TIME]))
cg.add(var.set_turnaround_time(config[CONF_TURNAROUND_TIME])) cg.add(var.set_turnaround_time(config[CONF_TURNAROUND_TIME]))
cg.add(var.set_disable_crc(config[CONF_DISABLE_CRC]))
def modbus_device_schema(default_address): def modbus_device_schema(default_address, role: Literal["client", "server"] = "client"):
hub_type = ModbusClient if role == "client" else ModbusServer
schema = { schema = {
cv.GenerateID(CONF_MODBUS_ID): cv.use_id(Modbus), cv.GenerateID(CONF_MODBUS_ID): cv.use_id(hub_type),
} }
if default_address is None: if default_address is None:
schema[cv.Required(CONF_ADDRESS)] = cv.hex_uint8_t schema[cv.Required(CONF_ADDRESS)] = cv.hex_uint8_t
@@ -98,8 +116,18 @@ def final_validate_modbus_device(
) )
async def register_modbus_device(var, config): async def register_modbus_client_device(var, config):
parent = await cg.get_variable(config[CONF_MODBUS_ID])
cg.add(var.set_parent(parent))
cg.add(var.set_address(config[CONF_ADDRESS]))
async def register_modbus_server_device(var, config):
parent = await cg.get_variable(config[CONF_MODBUS_ID]) parent = await cg.get_variable(config[CONF_MODBUS_ID])
cg.add(var.set_parent(parent)) cg.add(var.set_parent(parent))
cg.add(var.set_address(config[CONF_ADDRESS])) cg.add(var.set_address(config[CONF_ADDRESS]))
cg.add(parent.register_device(var)) cg.add(parent.register_device(var))
async def register_modbus_device(var, config):
return await register_modbus_client_device(var, config)

View File

@@ -37,9 +37,36 @@ void Modbus::setup() {
} }
void Modbus::loop() { void Modbus::loop() {
// First process all available incoming data. // Receive any available bytes from UART
this->receive_and_parse_modbus_bytes_(); this->receive_bytes_();
// Parse bytes into frames and process them
this->parse_modbus_frames();
}
void ModbusClientHub::loop() {
// Call base class to receive bytes and parse frames
this->Modbus::loop();
// If we're past the send_wait_time timeout and response buffer doesn't have the start of the expected response
if (this->waiting_for_response_.has_value()) {
ModbusDeviceCommand &wfr = this->waiting_for_response_.value();
uint8_t expected_address = wfr.frame.data.get()[0];
if (this->last_receive_check_ - this->last_send_ > this->last_send_tx_offset_ + this->send_wait_time_ &&
(this->rx_buffer_.empty() || this->rx_buffer_[0] != expected_address)) {
ESP_LOGW(TAG, "Stop waiting for response from %" PRIu8 " %" PRIu32 "ms after last send", expected_address,
this->last_receive_check_ - this->last_send_);
if (wfr.device)
wfr.device->on_modbus_no_response();
this->waiting_for_response_.reset();
}
}
// If there's no response pending and there's commands in the buffer
this->send_next_frame_();
}
bool Modbus::timeout_() {
// If the response frame is finished (including interframe delay) - we timeout. // If the response frame is finished (including interframe delay) - we timeout.
// The long_rx_buffer_delay accounts for long responses (larger than the UART rx_full_threshold) to avoid timeouts // The long_rx_buffer_delay accounts for long responses (larger than the UART rx_full_threshold) to avoid timeouts
// when the buffer is filling the back half of the response // when the buffer is filling the back half of the response
@@ -47,250 +74,307 @@ void Modbus::loop() {
(uint16_t) this->frame_delay_ms_, (uint16_t) this->frame_delay_ms_,
(uint16_t) (this->rx_buffer_.size() >= this->parent_->get_rx_full_threshold() ? this->long_rx_buffer_delay_ms_ (uint16_t) (this->rx_buffer_.size() >= this->parent_->get_rx_full_threshold() ? this->long_rx_buffer_delay_ms_
: 0)); : 0));
return this->last_receive_check_ - this->last_modbus_byte_ > timeout;
}
int32_t Modbus::tx_delay_remaining() {
// We use millis() here and elsewhere instead of App.get_loop_component_start_time() to avoid stale timestamps // We use millis() here and elsewhere instead of App.get_loop_component_start_time() to avoid stale timestamps
// It's critical in all timestamp comparisons that the left timestamp comes before the right one in time // It's critical in all timestamp comparisons that the left timestamp comes before the right one in time
// If we use a cached value in place of millis() and last_modbus_byte_ is updated inside our loop // If we use a cached value in place of millis() and last_modbus_byte_ is updated inside our loop
// then the comparison is backwards (small negative which wraps to large positive) and will cause a false timeout // then the comparison is backwards (small negative which wraps to large positive) and will cause a false timeout
// So in this component we don't use any cached timestamp values to avoid these annoying bugs // So in this component we don't use any cached timestamp values to avoid these annoying bugs
if (millis() - this->last_modbus_byte_ > timeout) { const uint32_t now = millis();
this->clear_rx_buffer_(LOG_STR("timeout after partial response"), true); return std::max({(int32_t) 0,
(int32_t) (this->last_send_tx_offset_ + this->frame_delay_ms_ - (now - this->last_send_)),
(int32_t) (this->frame_delay_ms_ - (now - this->last_modbus_byte_))});
} }
// If we're past the send_wait_time timeout and response buffer doesn't have the start of the expected response int32_t ModbusClientHub::tx_delay_remaining() {
if (this->waiting_for_response_ != 0 && const uint32_t now = millis();
millis() - this->last_send_ > this->last_send_tx_offset_ + this->send_wait_time_ && return std::max({(int32_t) 0,
(this->rx_buffer_.empty() || this->rx_buffer_[0] != this->waiting_for_response_)) { (int32_t) (this->last_send_tx_offset_ + this->frame_delay_ms_ + this->turnaround_delay_ms_ -
ESP_LOGW(TAG, "Stop waiting for response from %" PRIu8 " %" PRIu32 "ms after last send", (now - this->last_send_)),
this->waiting_for_response_, millis() - this->last_send_); (int32_t) (this->frame_delay_ms_ + this->turnaround_delay_ms_ - (now - this->last_modbus_byte_))});
this->waiting_for_response_ = 0;
}
// If there's no response pending and there's commands in the buffer
this->send_next_frame_();
} }
bool Modbus::tx_blocked() { bool Modbus::tx_blocked() {
const uint32_t now = millis(); // We block transmission in any of these cases:
// We block transmission in any of these case:
// 1. There are bytes in the UART Rx buffer // 1. There are bytes in the UART Rx buffer
// 2. There are bytes in our Rx buffer // 2. There are bytes in our Rx buffer
// 3. We're waiting for a response // 3. The last sent byte isn't more than tx_delay ms ago (i.e. wait to tell receivers that our previous Tx is done)
// 4. The last sent byte isn't more than frame_delay ms ago (i.e. wait to tell receivers that our previous Tx is done) // 4. The last received byte isn't more than tx_delay ms ago (i.e. wait to be sure there isn't more Rx coming)
// 5. The last received byte isn't more than frame_delay ms ago (i.e. wait to be sure there isn't more Rx coming) // N.B. We allow a small delay (MODBUS_TX_MAX_DELAY_MS) to avoid looping on small delays. This gets handled by
// 6. If we're a client - also wait for the turnaround delay, to give the servers time to process the previous message // send_frame_.
return this->available() || !this->rx_buffer_.empty() || (this->waiting_for_response_ != 0) || return this->available() || !this->rx_buffer_.empty() || this->tx_delay_remaining() > MODBUS_TX_MAX_DELAY_MS;
(now - this->last_send_ < this->last_send_tx_offset_ + this->frame_delay_ms_ +
(this->role == ModbusRole::CLIENT ? this->turnaround_delay_ms_ : 0)) ||
(now - this->last_modbus_byte_ <
this->frame_delay_ms_ + (this->role == ModbusRole::CLIENT ? this->turnaround_delay_ms_ : 0));
} }
bool Modbus::tx_buffer_empty() { return this->tx_buffer_.empty(); } bool ModbusClientHub::tx_blocked() {
// We block transmission in any of these case:
void Modbus::receive_and_parse_modbus_bytes_() { // 1. We're waiting for a response
// Read all available bytes in batches to reduce UART call overhead. // 2. Any of the base class tx_blocked conditions
size_t avail = this->available(); return (this->waiting_for_response_.has_value()) || this->Modbus::tx_blocked();
uint8_t buf[64]; }
while (avail > 0) {
size_t to_read = std::min(avail, sizeof(buf)); bool ModbusClientHub::tx_buffer_empty() { return this->tx_buffer_.empty(); }
if (!this->read_array(buf, to_read)) {
break; void Modbus::receive_bytes_() {
this->last_receive_check_ = millis();
size_t bytes = this->available();
if (bytes) {
size_t buffer_size = this->rx_buffer_.size();
this->last_modbus_byte_ = this->last_receive_check_;
this->rx_buffer_.resize(buffer_size + bytes);
if (!this->read_array(this->rx_buffer_.data() + buffer_size, bytes)) {
this->rx_buffer_.resize(buffer_size);
return;
}
if (buffer_size == 0) {
ESP_LOGV(TAG, "Received first byte %" PRIu8 " (0X%x) of %zu bytes %" PRIu32 "ms after last send",
this->rx_buffer_[0], this->rx_buffer_[0], this->rx_buffer_.size(), millis() - this->last_send_);
}
}
}
void ModbusClientHub::parse_modbus_frames() {
if (!this->rx_buffer_.empty()) {
size_t size;
do {
size = this->rx_buffer_.size();
if (!this->parse_modbus_server_frame_())
this->clear_rx_buffer_(LOG_STR("parse failed"), true);
} while (!this->rx_buffer_.empty() && size > this->rx_buffer_.size());
if (this->timeout_())
this->clear_rx_buffer_(LOG_STR("timeout after partial response"), true);
}
}
void ModbusServerHub::parse_modbus_frames() {
while (!this->rx_buffer_.empty()) {
size_t size = this->rx_buffer_.size();
ESP_LOGVV(TAG, "Parsing frames buffer size = %" PRIu32, size);
bool retry_as_client = false;
if (this->expecting_peer_response_ != 0) {
if (!this->parse_modbus_server_frame_()) {
ESP_LOGV(TAG, "Stop expecting peer response from %" PRIu8 " due to parse failure, and retry parse",
this->expecting_peer_response_);
this->expecting_peer_response_ = 0;
retry_as_client = true;
} else if (this->timeout_() && size == this->rx_buffer_.size()) {
// If we timed out and the above parse attempt did not consume data, stop expecting a response
ESP_LOGV(TAG,
"Stop expecting peer response from %" PRIu8 " due to timeout after partial response, and retry parse",
this->expecting_peer_response_);
this->expecting_peer_response_ = 0;
retry_as_client = true;
} }
avail -= to_read;
for (size_t i = 0; i < to_read; i++) {
if (this->rx_buffer_.empty()) {
ESP_LOGV(TAG, "Received first byte %" PRIu8 " (0X%x) %" PRIu32 "ms after last send", buf[i], buf[i],
millis() - this->last_send_);
} else { } else {
ESP_LOGVV(TAG, "Received byte %" PRIu8 " (0X%x) %" PRIu32 "ms after last send", buf[i], buf[i], if (!this->parse_modbus_client_frame_())
millis() - this->last_send_);
}
// If the bytes in the rx buffer do not parse, clear out the buffer
if (!this->parse_modbus_byte_(buf[i])) {
this->clear_rx_buffer_(LOG_STR("parse failed"), true); this->clear_rx_buffer_(LOG_STR("parse failed"), true);
} }
this->last_modbus_byte_ = millis(); // Stop if the buffer didn't shrink (no frame consumed) and no mode switch triggered a retry
} if (!retry_as_client && size <= this->rx_buffer_.size())
break;
} }
if (this->timeout_())
this->clear_rx_buffer_(LOG_STR("timeout after partial response"), true);
} }
bool Modbus::parse_modbus_byte_(uint8_t byte) { uint16_t Modbus::find_custom_frame_end_(uint16_t min_length) const {
size_t at = this->rx_buffer_.size(); // Custom functions could be any length - we have to rely on the CRC to determine completeness.
this->rx_buffer_.push_back(byte); // If a CRC match is never found, the buffer will eventually overflow and be cleared.
const uint8_t *raw = &this->rx_buffer_[0]; const uint8_t *raw = &this->rx_buffer_[0];
const size_t size = this->rx_buffer_.size();
for (uint16_t len = min_length; len <= std::min(size, size_t(MAX_FRAME_SIZE)); len++) {
if (crc16(raw, len) == 0)
return len;
}
return 0;
}
// Byte 0: modbus address (match all) bool Modbus::parse_modbus_server_frame_() {
if (at == 0) size_t size = this->rx_buffer_.size();
return true; uint16_t frame_length = helpers::server_frame_length(this->rx_buffer_.data(), this->rx_buffer_.size());
// Byte 1: function code
if (at == 1) if (size < frame_length)
return true;
// Byte 2: Size (with modbus rtu function code 4/3)
// See also https://en.wikipedia.org/wiki/Modbus
if (at == 2)
return true; return true;
uint8_t address = raw[0]; uint8_t address = this->rx_buffer_[0];
uint8_t function_code = raw[1]; uint8_t function_code = this->rx_buffer_[1];
uint8_t data_len = raw[2];
uint8_t data_offset = 3;
// Per https://modbus.org/docs/Modbus_Application_Protocol_V1_1b3.pdf Ch 5 User-Defined function codes
if (((function_code >= FUNCTION_CODE_USER_DEFINED_SPACE_1_INIT) &&
(function_code <= FUNCTION_CODE_USER_DEFINED_SPACE_1_END)) ||
((function_code >= FUNCTION_CODE_USER_DEFINED_SPACE_2_INIT) &&
(function_code <= FUNCTION_CODE_USER_DEFINED_SPACE_2_END))) {
// Handle user-defined function, since we don't know how big this ought to be,
// ideally we should delegate the entire length detection to whatever handler is
// installed, but wait, there is the CRC, and if we get a hit there is a good
// chance that this is a complete message ... admittedly there is a small chance is
// isn't but that is quite small given the purpose of the CRC in the first place
data_len = at - 2;
data_offset = 1;
uint16_t computed_crc = crc16(raw, data_offset + data_len);
uint16_t remote_crc = uint16_t(raw[data_offset + data_len]) | (uint16_t(raw[data_offset + data_len + 1]) << 8);
if (computed_crc != remote_crc)
return true;
if (helpers::is_function_code_custom(function_code)) {
frame_length = this->find_custom_frame_end_(frame_length);
if (frame_length == 0)
return size < MAX_FRAME_SIZE; // Continue to parse until we hit max size
ESP_LOGD(TAG, "User-defined function %02X found", function_code); ESP_LOGD(TAG, "User-defined function %02X found", function_code);
} else { } else {
// data starts at 2 and length is 4 for read registers commands if (crc16(&this->rx_buffer_[0], frame_length) != 0)
if (this->role == ModbusRole::SERVER) {
if (function_code == ModbusFunctionCode::READ_COILS ||
function_code == ModbusFunctionCode::READ_DISCRETE_INPUTS ||
function_code == ModbusFunctionCode::READ_HOLDING_REGISTERS ||
function_code == ModbusFunctionCode::READ_INPUT_REGISTERS ||
function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER) {
data_offset = 2;
data_len = 4;
} else if (function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) {
if (at < 6) {
return true;
}
data_offset = 2;
// starting address (2 bytes) + quantity of registers (2 bytes) + byte count itself (1 byte) + actual byte count
data_len = 2 + 2 + 1 + raw[6];
}
} else {
// the response for write command mirrors the requests and data starts at offset 2 instead of 3 for read commands
if (function_code == ModbusFunctionCode::WRITE_SINGLE_COIL ||
function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER ||
function_code == ModbusFunctionCode::WRITE_MULTIPLE_COILS ||
function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) {
data_offset = 2;
data_len = 4;
}
}
// Error ( msb indicates error )
// response format: Byte[0] = device address, Byte[1] function code | 0x80 , Byte[2] exception code, Byte[3-4] crc
if ((function_code & FUNCTION_CODE_EXCEPTION_MASK) == FUNCTION_CODE_EXCEPTION_MASK) {
data_offset = 2;
data_len = 1;
}
// Byte data_offset..data_offset+data_len-1: Data
if (at < data_offset + data_len)
return true;
// Byte 3+data_len: CRC_LO (over all bytes)
if (at == data_offset + data_len)
return true;
// Byte data_offset+len+1: CRC_HI (over all bytes)
uint16_t computed_crc = crc16(raw, data_offset + data_len);
uint16_t remote_crc = uint16_t(raw[data_offset + data_len]) | (uint16_t(raw[data_offset + data_len + 1]) << 8);
if (computed_crc != remote_crc) {
if (this->disable_crc_) {
ESP_LOGD(TAG, "CRC check failed %" PRIu32 "ms after last send; ignoring", millis() - this->last_send_);
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE
char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)];
#endif
ESP_LOGVV(TAG, " (%02X != %02X) %s", computed_crc, remote_crc,
format_hex_pretty_to(hex_buf, this->rx_buffer_.data(), this->rx_buffer_.size()));
} else {
ESP_LOGW(TAG, "CRC check failed %" PRIu32 "ms after last send", millis() - this->last_send_);
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE
char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)];
#endif
ESP_LOGVV(TAG, " (%02X != %02X) %s", computed_crc, remote_crc,
format_hex_pretty_to(hex_buf, this->rx_buffer_.data(), this->rx_buffer_.size()));
return false; return false;
} }
// Process before clearing: process_modbus_server_frame (receiving a response or peer message) never sends a reply
// synchronously. We can safely point directly into rx_buffer_ and avoid a copy.
uint8_t data_offset = helpers::server_frame_data_offset(this->rx_buffer_.data(), this->rx_buffer_.size());
const uint8_t *data = this->rx_buffer_.data() + data_offset;
uint16_t data_len = frame_length - 2 - data_offset;
this->process_modbus_server_frame(address, function_code, data, data_len);
this->clear_rx_buffer_(LOG_STR("parse succeeded"), false, frame_length);
return true;
}
bool ModbusServerHub::parse_modbus_client_frame_() {
size_t size = this->rx_buffer_.size();
uint16_t frame_length = helpers::client_frame_length(this->rx_buffer_.data(), this->rx_buffer_.size());
if (size < frame_length)
return true;
uint8_t address = this->rx_buffer_[0];
uint8_t function_code = this->rx_buffer_[1];
if (helpers::is_function_code_custom(function_code)) {
frame_length = this->find_custom_frame_end_(frame_length);
if (frame_length == 0)
return size < MAX_FRAME_SIZE; // Continue to parse until we hit max size
ESP_LOGD(TAG, "User-defined function %02X found", function_code);
} else {
if (crc16(&this->rx_buffer_[0], frame_length) != 0)
return false;
}
// Clear before processing: process_modbus_client_frame_ dispatches to a server device which sends
// a response immediately. We need to clear the rx buffer first so the response doesn't snag tx_blocked.
// This requires copying the frame data to a local buffer beforehand.
uint8_t data_offset = helpers::client_frame_data_offset(this->rx_buffer_.data(), this->rx_buffer_.size());
uint16_t data_len = frame_length - 2 - data_offset;
uint8_t data[MAX_FRAME_SIZE] = {};
std::memcpy(data, this->rx_buffer_.data() + data_offset, data_len);
this->clear_rx_buffer_(LOG_STR("parse succeeded"), false, frame_length);
this->process_modbus_client_frame_(address, function_code, data, data_len);
return true;
}
void ModbusClientHub::process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *data,
uint16_t len) {
if (!this->waiting_for_response_.has_value()) {
ESP_LOGW(TAG,
"Received unexpected frame from address %" PRIu8 ", function code 0x%X, %" PRIu32 "ms after last send",
address, function_code, this->last_modbus_byte_ - this->last_send_);
return;
} else { // We are waiting for a response
// Check if the response matches the expected address and function code
ModbusDeviceCommand &wfr = this->waiting_for_response_.value();
uint8_t expected_address = wfr.frame.data.get()[0];
uint8_t expected_function_code = wfr.frame.data.get()[1];
if (expected_address != address || expected_function_code != (function_code & FUNCTION_CODE_MASK)) {
ESP_LOGW(TAG,
"Received incorrect frame address %" PRIu8 " <> %" PRIu8 " or function code 0x%X <> 0x%X, %" PRIu32
"ms after last send",
address, expected_address, (function_code & FUNCTION_CODE_MASK), expected_function_code,
this->last_modbus_byte_ - this->last_send_);
// Invalidate the waiting device so it won't process this response.
if (wfr.device)
wfr.device->on_modbus_no_response();
wfr.interrupted = true;
wfr.device = nullptr;
return;
}
if (wfr.interrupted) {
ESP_LOGW(TAG,
"Ignoring response from %" PRIu8 " - transmission interrupted by previous unexpected response, %" PRIu32
"ms after last send",
address, this->last_modbus_byte_ - this->last_send_);
return;
} else { // We have a valid device waiting for this response
ModbusClientDevice *device = wfr.device;
this->waiting_for_response_.reset();
// Is it an error response?
if (helpers::is_function_code_exception(function_code)) {
uint8_t exception = len > 0 ? data[0] : 0;
ESP_LOGW(TAG,
"Error function code: 0x%X exception: %" PRIu8 ", address: %" PRIu8 ", %" PRIu32 "ms after last send",
function_code, exception, address, this->last_modbus_byte_ - this->last_send_);
if (device)
device->on_modbus_error(function_code & FUNCTION_CODE_MASK, exception);
} else if (device) { // Not an error response
// on_modbus_data is existing public API taking const std::vector<uint8_t>&
device->on_modbus_data(std::vector<uint8_t>(data, data + len));
} else { // Not an error response, but no device to respond to
ESP_LOGV(TAG, "Ignoring response from %" PRIu8 " - no callback device set, %" PRIu32 "ms after last send",
address, this->last_modbus_byte_ - this->last_send_);
} }
} }
std::vector<uint8_t> data(this->rx_buffer_.begin() + data_offset, this->rx_buffer_.begin() + data_offset + data_len); }
}
void ModbusServerHub::process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *, uint16_t) {
for (auto *device : this->devices_) {
if (device->address_ == address) {
ESP_LOGE(TAG, "Unexpected response from address %" PRIu8 ", which is mapped to this device.", address);
}
}
if (this->expecting_peer_response_ == address) {
ESP_LOGV(TAG, "Expected response from peer %" PRIu8 " received", address);
} else {
ESP_LOGV(TAG, "Unexpected response from peer %" PRIu8 " received", address);
}
// This always resets, even if the address doesn't match.
// If an unexpected response is received, we can't trust that a correct response will follow (it shouldn't).
this->expecting_peer_response_ = 0;
}
void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t function_code, const uint8_t *data,
uint16_t len) {
bool found = false; bool found = false;
for (auto *device : this->devices_) { for (auto *device : this->devices_) {
if (device->address_ == address) { if (device->address_ == address) {
found = true; found = true;
if (this->role == ModbusRole::SERVER) {
if (function_code == ModbusFunctionCode::READ_HOLDING_REGISTERS || if (static_cast<ModbusFunctionCode>(function_code) == ModbusFunctionCode::READ_HOLDING_REGISTERS ||
function_code == ModbusFunctionCode::READ_INPUT_REGISTERS) { static_cast<ModbusFunctionCode>(function_code) == ModbusFunctionCode::READ_INPUT_REGISTERS) {
device->on_modbus_read_registers(function_code, uint16_t(data[1]) | (uint16_t(data[0]) << 8), device->on_modbus_read_registers(function_code, helpers::get_data<uint16_t>(data, 0),
uint16_t(data[3]) | (uint16_t(data[2]) << 8)); helpers::get_data<uint16_t>(data, 2));
} else if (function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER || } else if (static_cast<ModbusFunctionCode>(function_code) == ModbusFunctionCode::WRITE_SINGLE_REGISTER ||
function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { static_cast<ModbusFunctionCode>(function_code) == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) {
device->on_modbus_write_registers(function_code, data); device->on_modbus_write_registers(function_code, std::vector<uint8_t>(data, data + len));
}
} else { // We're a client
// Is it an error response?
if ((function_code & FUNCTION_CODE_EXCEPTION_MASK) == FUNCTION_CODE_EXCEPTION_MASK) {
uint8_t exception = raw[2];
ESP_LOGW(TAG,
"Error function code: 0x%X exception: %" PRIu8 ", address: %" PRIu8 ", %" PRIu32
"ms after last send",
function_code, exception, address, millis() - this->last_send_);
if (this->waiting_for_response_ == address) {
device->on_modbus_error(function_code & FUNCTION_CODE_MASK, exception);
} else { } else {
// Ignore modbus exception not related to a pending command ESP_LOGW(TAG, "Unsupported function code %" PRIu8, function_code);
ESP_LOGD(TAG, "Ignoring error - not expecting a response from %" PRIu8 "", address); device->send_error(function_code, ModbusExceptionCode::ILLEGAL_FUNCTION);
}
} else { // Not an error response
if (this->waiting_for_response_ == address) {
device->on_modbus_data(data);
} else {
// Ignore modbus response not related to a pending command
ESP_LOGW(TAG, "Ignoring response - not expecting a response from %" PRIu8 ", %" PRIu32 "ms after last send",
address, millis() - this->last_send_);
}
}
} }
} }
} }
if (!found && this->role == ModbusRole::CLIENT) { if (!found) {
ESP_LOGW(TAG, "Got frame from unknown address %" PRIu8 ", %" PRIu32 "ms after last send", address, this->expecting_peer_response_ = address;
millis() - this->last_send_); ESP_LOGV(TAG, "Request to peer %" PRIu8 " received", address);
}
} }
this->clear_rx_buffer_(LOG_STR("parse succeeded")); bool Modbus::send_frame_(const ModbusFrame &frame) {
if (this->tx_blocked()) {
if (this->waiting_for_response_ == address) ESP_LOGE(TAG, "Attempted to send while transmission blocked");
this->waiting_for_response_ = 0; return false;
}
return true; if (frame.size > MAX_FRAME_SIZE) {
ESP_LOGE(TAG, "Attempted to send frame larger than max frame size of %" PRIu16 " bytes", MAX_FRAME_SIZE);
return false;
} }
void Modbus::send_next_frame_() { const int32_t tx_delay_remaining = this->tx_delay_remaining();
if (this->tx_buffer_.empty()) if (tx_delay_remaining > 0) {
return; delay(tx_delay_remaining);
if (this->tx_blocked())
return;
const ModbusDeviceCommand &frame = this->tx_buffer_.front();
if (this->role == ModbusRole::CLIENT) {
this->waiting_for_response_ = frame.data.get()[0];
} }
if (this->flow_control_pin_ != nullptr) { if (this->flow_control_pin_ != nullptr) {
@@ -304,123 +388,190 @@ void Modbus::send_next_frame_() {
this->last_send_tx_offset_ = frame.size * MODBUS_BITS_PER_CHAR * MS_PER_SEC / this->parent_->get_baud_rate() + 1; this->last_send_tx_offset_ = frame.size * MODBUS_BITS_PER_CHAR * MS_PER_SEC / this->parent_->get_baud_rate() + 1;
} }
uint32_t now = millis();
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)];
#endif #endif
ESP_LOGV(TAG, "Write: %s %" PRIu32 "ms after last send", format_hex_pretty_to(hex_buf, frame.data.get(), frame.size), ESP_LOGV(TAG, "Write: %s %" PRIu32 "ms after last send, %" PRIu32 "ms after last receive",
millis() - this->last_send_); format_hex_pretty_to(hex_buf, frame.data.get(), frame.size), now - this->last_send_,
this->last_send_ = millis(); now - this->last_modbus_byte_);
this->last_send_ = now;
return true;
}
void ModbusClientHub::send_next_frame_() {
if (this->tx_buffer_.empty()) {
return;
}
if (this->tx_blocked()) {
return;
}
ModbusDeviceCommand &command = this->tx_buffer_.front();
if (this->send_frame_(command.frame)) {
this->waiting_for_response_ = std::move(command);
} else {
if (command.device)
command.device->on_modbus_not_sent();
}
this->tx_buffer_.pop_front(); this->tx_buffer_.pop_front();
if (!this->tx_buffer_.empty()) { if (!this->tx_buffer_.empty()) {
ESP_LOGV(TAG, "Write queue contains %zu items.", this->tx_buffer_.size()); ESP_LOGV(TAG, "Write queue contains %zu items.", this->tx_buffer_.size());
} }
} }
void Modbus::dump_config() { void ModbusClientHub::dump_config() {
ESP_LOGCONFIG(TAG, ESP_LOGCONFIG(TAG,
"Modbus:\n" "Modbus:\n"
" Send Wait Time: %d ms\n" " Send Wait Time: %" PRIu16 " ms\n"
" Turnaround Time: %d ms\n" " Turnaround Time: %" PRIu16 " ms\n"
" Frame Delay: %d ms\n" " Frame Delay: %" PRIu16 " ms\n"
" Long Rx Buffer Delay: %d ms\n" " Long Rx Buffer Delay: %" PRIu16 " ms",
" CRC Disabled: %s",
this->send_wait_time_, this->turnaround_delay_ms_, this->frame_delay_ms_, this->send_wait_time_, this->turnaround_delay_ms_, this->frame_delay_ms_,
this->long_rx_buffer_delay_ms_, YESNO(this->disable_crc_)); this->long_rx_buffer_delay_ms_);
LOG_PIN(" Flow Control Pin: ", this->flow_control_pin_); LOG_PIN(" Flow Control Pin: ", this->flow_control_pin_);
} }
void ModbusServerHub::dump_config() {
ESP_LOGCONFIG(TAG,
"Modbus:\n"
" Frame Delay: %" PRIu16 " ms\n"
" Long Rx Buffer Delay: %" PRIu16 " ms",
this->frame_delay_ms_, this->long_rx_buffer_delay_ms_);
LOG_PIN(" Flow Control Pin: ", this->flow_control_pin_);
}
float Modbus::get_setup_priority() const { float Modbus::get_setup_priority() const {
// After UART bus // After UART bus
return setup_priority::BUS - 1.0f; return setup_priority::BUS - 1.0f;
} }
void Modbus::send(uint8_t address, uint8_t function_code, uint16_t start_address, uint16_t number_of_entities, void ModbusServerHub::send(uint8_t address, uint8_t function_code, const std::vector<uint8_t> &payload) {
uint8_t payload_len, const uint8_t *payload) { const uint16_t len = static_cast<uint16_t>(2 + payload.size());
static const size_t MAX_VALUES = 128; if (len > MAX_RAW_SIZE) {
ESP_LOGE(TAG, "Server send frame too large (%" PRIu16 " bytes)", len);
return;
}
uint8_t raw_frame[MAX_RAW_SIZE];
raw_frame[0] = address;
raw_frame[1] = function_code;
std::memcpy(raw_frame + 2, payload.data(), payload.size());
this->send_raw_(raw_frame, len);
}
// Only check max number of registers for standard function codes // Raw send for client: pushes to tx queue. Everything except the CRC must be contained in payload.
// Some devices use non standard codes like 0x43 void ModbusClientHub::queue_raw_(uint8_t address, const uint8_t *pdu, uint16_t pdu_len, ModbusClientDevice *device) {
if (number_of_entities > MAX_VALUES && function_code <= ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { if (pdu_len == 0) {
ESP_LOGE(TAG, "send too many values %d max=%zu", number_of_entities, MAX_VALUES); if (device)
device->on_modbus_not_sent();
return; return;
} }
uint8_t data[MAX_FRAME_SIZE];
size_t pos = 0;
data[pos++] = address;
data[pos++] = function_code;
if (this->role == ModbusRole::CLIENT) {
data[pos++] = start_address >> 8;
data[pos++] = start_address >> 0;
if (function_code != ModbusFunctionCode::WRITE_SINGLE_COIL &&
function_code != ModbusFunctionCode::WRITE_SINGLE_REGISTER) {
data[pos++] = number_of_entities >> 8;
data[pos++] = number_of_entities >> 0;
}
}
if (payload != nullptr) {
if (this->role == ModbusRole::SERVER || function_code == ModbusFunctionCode::WRITE_MULTIPLE_COILS ||
function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { // Write multiple
data[pos++] = payload_len; // Byte count is required for write
} else {
payload_len = 2; // Write single register or coil
}
if (payload_len + pos + 2 > MAX_FRAME_SIZE) { // Check if payload fits (accounting for CRC)
ESP_LOGE(TAG, "Payload too large to send: %d bytes", payload_len);
return;
}
for (int i = 0; i < payload_len; i++) {
data[pos++] = payload[i];
}
}
this->queue_raw_(data, pos);
}
// Helper function for lambdas
// Send raw command. Except CRC everything must be contained in payload
void Modbus::send_raw(const std::vector<uint8_t> &payload) {
if (payload.empty()) {
return;
}
// Frame size: payload + CRC(2)
if (payload.size() + 2 > MAX_FRAME_SIZE) {
ESP_LOGE(TAG, "Attempted to send frame larger than max frame size of %d bytes", MAX_FRAME_SIZE);
return;
}
// Use stack buffer - Modbus frames are small and bounded
uint8_t data[MAX_FRAME_SIZE];
std::memcpy(data, payload.data(), payload.size());
this->queue_raw_(data, payload.size());
}
// Assume data and length is valid and append CRC, then queue for sending. Used internally to avoid unnecessary copying
// of data into vectors
void Modbus::queue_raw_(const uint8_t *data, uint16_t len) {
if (this->tx_buffer_.size() < MODBUS_TX_BUFFER_SIZE) { if (this->tx_buffer_.size() < MODBUS_TX_BUFFER_SIZE) {
this->tx_buffer_.emplace_back(data, len); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)];
#endif
ESP_LOGV(TAG, "Adding frame to tx queue: %" PRIu8 ":%s", address, format_hex_pretty_to(hex_buf, pdu, pdu_len));
this->tx_buffer_.emplace_back(device, address, pdu, pdu_len);
} else { } else {
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_ERROR #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_ERROR
char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)];
#endif #endif
ESP_LOGE(TAG, "Write buffer full, dropped: %s", format_hex_pretty_to(hex_buf, data, len)); ESP_LOGE(TAG, "Write buffer full, dropped: %" PRIu8 ":%s", address, format_hex_pretty_to(hex_buf, pdu, pdu_len));
if (device)
device->on_modbus_not_sent();
} }
} }
void Modbus::clear_rx_buffer_(const LogString *reason, bool warn) { void ModbusClientHub::clear_tx_queue_for_address(uint8_t address, bool clear_sent) {
size_t at = this->rx_buffer_.size(); // Remove any pending commands for this address from the tx buffer
if (at > 0) { auto &tx_buffer = this->tx_buffer_;
tx_buffer.erase(std::remove_if(tx_buffer.begin(), tx_buffer.end(),
[address](const ModbusDeviceCommand &cmd) { return cmd.frame.data[0] == address; }),
tx_buffer.end());
if (clear_sent && this->waiting_for_response_.has_value() && this->waiting_for_response_.value().device) {
if (this->waiting_for_response_.value().frame.data[0] == address) {
ESP_LOGV(TAG, "Clearing waiting for response for address %" PRIu8, address);
// Invalidate the waiting device so it won't process a response.
this->waiting_for_response_.value().device = nullptr;
}
}
}
void ModbusClientHub::clear_tx_queue_for_device(ModbusClientDevice *device) {
// Remove any pending commands for this address from the tx buffer
auto &tx_buffer = this->tx_buffer_;
tx_buffer.erase(std::remove_if(tx_buffer.begin(), tx_buffer.end(),
[device](const ModbusDeviceCommand &cmd) { return cmd.device == device; }),
tx_buffer.end());
if (this->waiting_for_response_.has_value() && this->waiting_for_response_.value().device) {
if (this->waiting_for_response_.value().device == device) {
ESP_LOGV(TAG, "Clearing waiting for response");
// Invalidate the waiting device so it won't process a response.
this->waiting_for_response_.value().device = nullptr;
}
}
}
void ModbusClientHub::send_raw(const std::vector<uint8_t> &payload, ModbusClientDevice *device) {
if (payload.size() < 2) {
if (device)
device->on_modbus_not_sent();
return;
}
this->queue_raw_(payload[0], payload.data() + 1, static_cast<uint16_t>(payload.size() - 1), device);
}
// Send raw command for server replies immediately. Except CRC everything must be contained in payload
void ModbusServerHub::send_raw_(const uint8_t *payload, uint16_t len) {
if (len == 0) {
return;
}
if (len > MAX_RAW_SIZE) {
ESP_LOGE(TAG, "Server send frame too large (%" PRIu16 " bytes)", len);
return;
}
// In the rare case that the server is blocked (frame delay has not elapsed), we delay the send.
// This should only happen at low baud rates with long frame delays.
if (this->tx_blocked()) {
// Stash the raw payload in a single member buffer so the deferred callback can rebuild the frame
// without a heap allocation. Only one server reply is ever in flight, and the named timeout ensures
// only one deferred send is pending, so a single buffer is sufficient.
std::memcpy(this->deferred_payload_.data(), payload, len);
this->deferred_payload_len_ = len;
this->set_timeout("deferred_send", this->tx_delay_remaining(), [this]() {
ModbusFrame frame(this->deferred_payload_[0], this->deferred_payload_.data() + 1,
this->deferred_payload_len_ - 1);
this->send_frame_(frame);
});
} else {
ModbusFrame frame(payload[0], payload + 1, len - 1);
this->send_frame_(frame);
}
}
void Modbus::clear_rx_buffer_(const LogString *reason, bool warn, size_t bytes_to_clear) {
size_t bytes = this->rx_buffer_.size();
if (bytes_to_clear > 0 && bytes >= bytes_to_clear)
bytes = bytes_to_clear;
if (bytes > 0) {
if (warn) { if (warn) {
ESP_LOGW(TAG, "Clearing buffer of %zu bytes - %s %" PRIu32 "ms after last send", at, LOG_STR_ARG(reason), ESP_LOGW(TAG, "Clearing buffer of %zu bytes - %s %" PRIu32 "ms after last send", bytes, LOG_STR_ARG(reason),
millis() - this->last_send_); millis() - this->last_send_);
} else { } else {
ESP_LOGV(TAG, "Clearing buffer of %zu bytes - %s %" PRIu32 "ms after last send", at, LOG_STR_ARG(reason), ESP_LOGV(TAG, "Clearing buffer of %zu bytes - %s %" PRIu32 "ms after last send", bytes, LOG_STR_ARG(reason),
millis() - this->last_send_); millis() - this->last_send_);
} }
if (bytes == this->rx_buffer_.size()) {
this->rx_buffer_.clear(); this->rx_buffer_.clear();
} else {
this->rx_buffer_.erase(this->rx_buffer_.begin(), this->rx_buffer_.begin() + bytes);
}
} }
} }

View File

@@ -4,33 +4,32 @@
#include "esphome/components/uart/uart.h" #include "esphome/components/uart/uart.h"
#include "esphome/components/modbus/modbus_definitions.h" #include "esphome/components/modbus/modbus_definitions.h"
#include "esphome/components/modbus/modbus_helpers.h"
#include <array>
#include <cstring> #include <cstring>
#include <memory> #include <memory>
#include <vector> #include <vector>
#include <queue> #include <deque>
#include <optional>
namespace esphome::modbus { namespace esphome::modbus {
static constexpr uint16_t MODBUS_TX_BUFFER_SIZE = 15; static constexpr uint16_t MODBUS_TX_BUFFER_SIZE = 15;
static constexpr uint16_t MODBUS_TX_MAX_DELAY_MS = 5;
enum ModbusRole { struct ModbusFrame {
CLIENT,
SERVER,
};
class ModbusDevice;
struct ModbusDeviceCommand {
// Frame with exact-size allocation to avoid std::vector overhead // Frame with exact-size allocation to avoid std::vector overhead
std::unique_ptr<uint8_t[]> data; std::unique_ptr<uint8_t[]> data;
uint16_t size; // Modbus RTU max is 256 bytes uint16_t size; // Modbus RTU max is 256 bytes
ModbusDeviceCommand(const uint8_t *src, uint16_t len) : data(std::make_unique<uint8_t[]>(len + 2)), size(len + 2) { ModbusFrame(uint8_t address, const uint8_t *pdu, uint16_t pdu_len)
std::memcpy(this->data.get(), src, len); : data(std::make_unique<uint8_t[]>(pdu_len + 3)), size(pdu_len + 3) {
auto crc = crc16(data.get(), len); data[0] = address;
data[len + 0] = crc >> 0; memcpy(data.get() + 1, pdu, pdu_len);
data[len + 1] = crc >> 8; auto crc = crc16(data.get(), pdu_len + 1);
data[pdu_len + 1] = crc >> 0;
data[pdu_len + 2] = crc >> 8;
} }
}; };
@@ -39,86 +38,197 @@ class Modbus : public uart::UARTDevice, public Component {
Modbus() = default; Modbus() = default;
void setup() override; void setup() override;
void loop() override; void loop() override;
void dump_config() override;
void register_device(ModbusDevice *device) { this->devices_.push_back(device); }
float get_setup_priority() const override; float get_setup_priority() const override;
bool tx_buffer_empty(); virtual bool tx_blocked();
bool tx_blocked();
void send(uint8_t address, uint8_t function_code, uint16_t start_address, uint16_t number_of_entities,
uint8_t payload_len = 0, const uint8_t *payload = nullptr);
void send_raw(const std::vector<uint8_t> &payload);
void set_role(ModbusRole role) { this->role = role; }
void set_flow_control_pin(GPIOPin *flow_control_pin) { this->flow_control_pin_ = flow_control_pin; } void set_flow_control_pin(GPIOPin *flow_control_pin) { this->flow_control_pin_ = flow_control_pin; }
void set_send_wait_time(uint16_t time_in_ms) { this->send_wait_time_ = time_in_ms; }
void set_turnaround_time(uint16_t time_in_ms) { this->turnaround_delay_ms_ = time_in_ms; }
void set_disable_crc(bool disable_crc) { this->disable_crc_ = disable_crc; }
ModbusRole role;
protected: protected:
bool parse_modbus_byte_(uint8_t byte); void receive_bytes_();
void receive_and_parse_modbus_bytes_(); bool timeout_();
void clear_rx_buffer_(const LogString *reason, bool warn = false); virtual int32_t tx_delay_remaining();
void send_next_frame_(); virtual void parse_modbus_frames() = 0;
void queue_raw_(const uint8_t *data, uint16_t len); bool parse_modbus_server_frame_();
virtual void process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *data,
uint16_t len) = 0;
void clear_rx_buffer_(const LogString *reason, bool warn = false, size_t bytes_to_clear = 0);
bool send_frame_(const ModbusFrame &frame);
// Scans forward from min_length to find a frame boundary by CRC match for custom function codes.
// Returns the matched frame length, or 0 if no valid CRC was found within MAX_FRAME_SIZE.
uint16_t find_custom_frame_end_(uint16_t min_length) const;
uint32_t last_modbus_byte_{0}; uint32_t last_modbus_byte_{0};
uint32_t last_receive_check_{0};
uint32_t last_send_{0}; uint32_t last_send_{0};
uint32_t last_send_tx_offset_{0}; uint32_t last_send_tx_offset_{0};
uint16_t frame_delay_ms_{5}; uint16_t frame_delay_ms_{5};
uint16_t long_rx_buffer_delay_ms_{0}; uint16_t long_rx_buffer_delay_ms_{0};
uint16_t send_wait_time_{250};
uint16_t turnaround_delay_ms_{100};
uint8_t waiting_for_response_{0};
bool disable_crc_{false};
GPIOPin *flow_control_pin_{nullptr}; GPIOPin *flow_control_pin_{nullptr};
std::vector<uint8_t> rx_buffer_; std::vector<uint8_t> rx_buffer_;
std::vector<ModbusDevice *> devices_; };
class ModbusClientDevice;
class ModbusServerDevice;
struct ModbusDeviceCommand {
ModbusClientDevice *device;
ModbusFrame frame;
bool interrupted{false};
ModbusDeviceCommand(ModbusClientDevice *device, uint8_t address, const uint8_t *src, uint16_t len)
: device(device), frame(address, src, len) {}
};
class ModbusClientHub : public Modbus {
public:
ModbusClientHub() = default;
void dump_config() override;
void loop() override;
void set_send_wait_time(uint16_t time_in_ms) { this->send_wait_time_ = time_in_ms; }
void set_turnaround_time(uint16_t time_in_ms) { this->turnaround_delay_ms_ = time_in_ms; }
bool tx_buffer_empty();
bool tx_blocked() override;
ESPDEPRECATED("Use send_pdu() with create_client_pdu() instead. Removed in 2026.10.0", "2026.4.0")
void send(uint8_t address, uint8_t function_code, uint16_t start_address, uint16_t number_of_entities,
uint8_t payload_len = 0, const uint8_t *payload = nullptr, ModbusClientDevice *device = nullptr) {
this->send_pdu(address,
helpers::create_client_pdu((ModbusFunctionCode) function_code, start_address, number_of_entities,
payload, payload_len),
device);
};
void send_pdu(uint8_t address, const StaticVector<uint8_t, MAX_PDU_SIZE> &pdu, ModbusClientDevice *device = nullptr) {
this->queue_raw_(address, pdu.data(), pdu.size(), device);
}
void send_raw(const std::vector<uint8_t> &payload, ModbusClientDevice *device = nullptr);
void clear_tx_queue_for_address(uint8_t address, bool clear_sent = true);
void clear_tx_queue_for_device(ModbusClientDevice *device);
protected:
int32_t tx_delay_remaining() override;
void parse_modbus_frames() override;
// Parsers need to handle standard (ModbusFunctionCode) and custom (uint8_t) function codes, so we use uint8_t here.
void process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len) override;
void send_next_frame_();
void queue_raw_(uint8_t address, const uint8_t *pdu, uint16_t pdu_len, ModbusClientDevice *device = nullptr);
uint16_t send_wait_time_{2000};
uint16_t turnaround_delay_ms_{0};
std::optional<ModbusDeviceCommand> waiting_for_response_;
// std::deque is appropriate here since we need a FIFO buffer, and we can't know ahead of time how many // std::deque is appropriate here since we need a FIFO buffer, and we can't know ahead of time how many
// requests will be queued. Each modbus component may queue multiple requests, and the sequence of scheduling // requests will be queued. Each modbus component may queue multiple requests, and the sequence of scheduling
// may change at run time. // may change at run time.
std::deque<ModbusDeviceCommand> tx_buffer_; std::deque<ModbusDeviceCommand> tx_buffer_;
}; };
class ModbusDevice { class ModbusServerHub : public Modbus {
public: public:
void set_parent(Modbus *parent) { parent_ = parent; } ModbusServerHub() = default;
void set_address(uint8_t address) { address_ = address; } void dump_config() override;
virtual void on_modbus_data(const std::vector<uint8_t> &data) = 0; void send(uint8_t address, uint8_t function_code, const std::vector<uint8_t> &payload);
virtual void on_modbus_error(uint8_t function_code, uint8_t exception_code) {} ESPDEPRECATED("Use ModbusServerDevice::send_raw instead. Removed in 2026.10.0", "2026.4.0")
virtual void on_modbus_read_registers(uint8_t function_code, uint16_t start_address, uint16_t number_of_registers){}; void send_raw(const std::vector<uint8_t> &payload) {
virtual void on_modbus_write_registers(uint8_t function_code, const std::vector<uint8_t> &data){}; this->send_raw_(payload.data(), static_cast<uint16_t>(payload.size()));
void send(uint8_t function, uint16_t start_address, uint16_t number_of_entities, uint8_t payload_len = 0, };
const uint8_t *payload = nullptr) { void register_device(ModbusServerDevice *device) { this->devices_.push_back(device); }
this->parent_->send(this->address_, function, start_address, number_of_entities, payload_len, payload);
}
void send_raw(const std::vector<uint8_t> &payload) { this->parent_->send_raw(payload); }
void send_error(uint8_t function_code, ModbusExceptionCode exception_code) {
std::vector<uint8_t> error_response;
error_response.reserve(3);
error_response.push_back(this->address_);
error_response.push_back(function_code | FUNCTION_CODE_EXCEPTION_MASK);
error_response.push_back(static_cast<uint8_t>(exception_code));
this->send_raw(error_response);
}
// If more than one device is connected block sending a new command before a response is received
ESPDEPRECATED("Use ready_for_immediate_send() instead. Removed in 2026.9.0", "2026.3.0")
bool waiting_for_response() { return !ready_for_immediate_send(); }
bool ready_for_immediate_send() { return parent_->tx_buffer_empty() && !parent_->tx_blocked(); }
protected: protected:
friend Modbus; friend class ModbusServerDevice;
Modbus *parent_; void parse_modbus_frames() override;
uint8_t address_; bool parse_modbus_client_frame_();
// Parsers need to handle standard (ModbusFunctionCode) and custom (uint8_t) function codes, so we use uint8_t here.
void process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len) override;
void process_modbus_client_frame_(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len);
void send_raw_(const uint8_t *payload, uint16_t len);
uint8_t expecting_peer_response_{0};
std::vector<ModbusServerDevice *> devices_;
// Holds the raw payload of a single reply deferred for sending when tx was blocked at send time.
// Only one server reply can be in flight at once, so a single fixed buffer avoids heap allocation.
std::array<uint8_t, MAX_RAW_SIZE> deferred_payload_;
uint16_t deferred_payload_len_{0};
};
class ModbusClientDevice {
public:
ModbusClientDevice() = default;
ModbusClientDevice(ModbusClientHub *parent, uint8_t address) : parent_(parent), address_(address) {}
virtual ~ModbusClientDevice() {
if (this->parent_ != nullptr)
this->clear_tx_queue_for_device();
}
ModbusClientDevice(const ModbusClientDevice &) = delete;
ModbusClientDevice &operator=(const ModbusClientDevice &) = delete;
ModbusClientDevice(ModbusClientDevice &&) = delete;
ModbusClientDevice &operator=(ModbusClientDevice &&) = delete;
void set_parent(ModbusClientHub *parent) { this->parent_ = parent; }
void set_address(uint8_t address) { this->address_ = address; }
virtual void on_modbus_data(const std::vector<uint8_t> &data) {}
virtual void on_modbus_error(uint8_t function_code, uint8_t exception_code) {}
virtual void on_modbus_not_sent() {}
virtual void on_modbus_no_response() {}
void send(uint8_t function, uint16_t start_address, uint16_t number_of_entities, uint8_t payload_len = 0,
const uint8_t *payload = nullptr) {
this->parent_->send_pdu(this->address_,
helpers::create_client_pdu((ModbusFunctionCode) function, start_address, number_of_entities,
payload, payload_len),
this);
}
void send_pdu(const StaticVector<uint8_t, MAX_PDU_SIZE> &pdu) { this->parent_->send_pdu(this->address_, pdu, this); }
void send_raw(const std::vector<uint8_t> &payload) { this->parent_->send_raw(payload, this); }
inline void clear_tx_queue_for_address(bool clear_sent = true) {
this->parent_->clear_tx_queue_for_address(this->address_, clear_sent);
}
inline void clear_tx_queue_for_device() { this->parent_->clear_tx_queue_for_device(this); }
// If more than one device is connected block sending a new command before a response is received
ESPDEPRECATED("Use ready_for_immediate_send() instead. Removed in 2026.9.0", "2026.3.0")
bool waiting_for_response() { return !this->ready_for_immediate_send(); }
bool ready_for_immediate_send() { return this->parent_->tx_buffer_empty() && !this->parent_->tx_blocked(); }
protected:
ModbusClientHub *parent_{nullptr};
uint8_t address_{0};
};
// This is for compatibility with external components using the former class name
using ModbusDevice = ModbusClientDevice;
class ModbusServerDevice {
public:
ModbusServerDevice() = default;
ModbusServerDevice(ModbusServerHub *parent, uint8_t address) : parent_(parent), address_(address) {}
virtual ~ModbusServerDevice() = default;
ModbusServerDevice(const ModbusServerDevice &) = delete;
ModbusServerDevice &operator=(const ModbusServerDevice &) = delete;
ModbusServerDevice(ModbusServerDevice &&) = delete;
ModbusServerDevice &operator=(ModbusServerDevice &&) = delete;
void set_parent(ModbusServerHub *parent) { this->parent_ = parent; }
void set_address(uint8_t address) { this->address_ = address; }
virtual void on_modbus_read_registers(uint8_t function_code, uint16_t start_address, uint16_t number_of_registers){};
virtual void on_modbus_write_registers(uint8_t function_code, const std::vector<uint8_t> &data){};
void send(uint8_t function, const std::vector<uint8_t> &payload) {
this->parent_->send(this->address_, function, payload);
}
void send_raw(const std::vector<uint8_t> &payload) {
this->parent_->send_raw_(payload.data(), static_cast<uint16_t>(payload.size()));
}
void send_error(uint8_t function_code, ModbusExceptionCode exception_code) {
uint8_t error_response[3] = {this->address_, uint8_t(function_code | FUNCTION_CODE_EXCEPTION_MASK),
static_cast<uint8_t>(exception_code)};
this->parent_->send_raw_(error_response, 3);
}
protected:
friend ModbusServerHub;
ModbusServerHub *parent_{nullptr};
uint8_t address_{0};
}; };
} // namespace esphome::modbus } // namespace esphome::modbus

View File

@@ -14,7 +14,8 @@ const uint8_t FUNCTION_CODE_USER_DEFINED_SPACE_2_INIT = 100; // 0x64
const uint8_t FUNCTION_CODE_USER_DEFINED_SPACE_2_END = 110; // 0x6E const uint8_t FUNCTION_CODE_USER_DEFINED_SPACE_2_END = 110; // 0x6E
enum class ModbusFunctionCode : uint8_t { enum class ModbusFunctionCode : uint8_t {
CUSTOM = 0x00, INVALID = 0x00, // 0x00 is not a valid function code (even for custom functions).
CUSTOM = 0x00, // The CUSTOM alias should be removed in future.
READ_COILS = 0x01, READ_COILS = 0x01,
READ_DISCRETE_INPUTS = 0x02, READ_DISCRETE_INPUTS = 0x02,
READ_HOLDING_REGISTERS = 0x03, READ_HOLDING_REGISTERS = 0x03,
@@ -35,19 +36,11 @@ enum class ModbusFunctionCode : uint8_t {
READ_FIFO_QUEUE = 0x18, // not implemented READ_FIFO_QUEUE = 0x18, // not implemented
}; };
/*Allow comparison operators between ModbusFunctionCode and uint8_t*/ /*Allow direct comparison operators between ModbusFunctionCode and uint8_t*/
inline bool operator==(ModbusFunctionCode lhs, uint8_t rhs) { return static_cast<uint8_t>(lhs) == rhs; } inline bool operator==(ModbusFunctionCode lhs, uint8_t rhs) { return static_cast<uint8_t>(lhs) == rhs; }
inline bool operator==(uint8_t lhs, ModbusFunctionCode rhs) { return lhs == static_cast<uint8_t>(rhs); } inline bool operator==(uint8_t lhs, ModbusFunctionCode rhs) { return lhs == static_cast<uint8_t>(rhs); }
inline bool operator!=(ModbusFunctionCode lhs, uint8_t rhs) { return !(static_cast<uint8_t>(lhs) == rhs); } inline bool operator!=(ModbusFunctionCode lhs, uint8_t rhs) { return !(static_cast<uint8_t>(lhs) == rhs); }
inline bool operator!=(uint8_t lhs, ModbusFunctionCode rhs) { return !(lhs == static_cast<uint8_t>(rhs)); } inline bool operator!=(uint8_t lhs, ModbusFunctionCode rhs) { return !(lhs == static_cast<uint8_t>(rhs)); }
inline bool operator<(ModbusFunctionCode lhs, uint8_t rhs) { return static_cast<uint8_t>(lhs) < rhs; }
inline bool operator<(uint8_t lhs, ModbusFunctionCode rhs) { return lhs < static_cast<uint8_t>(rhs); }
inline bool operator<=(ModbusFunctionCode lhs, uint8_t rhs) { return static_cast<uint8_t>(lhs) <= rhs; }
inline bool operator<=(uint8_t lhs, ModbusFunctionCode rhs) { return lhs <= static_cast<uint8_t>(rhs); }
inline bool operator>(ModbusFunctionCode lhs, uint8_t rhs) { return static_cast<uint8_t>(lhs) > rhs; }
inline bool operator>(uint8_t lhs, ModbusFunctionCode rhs) { return lhs > static_cast<uint8_t>(rhs); }
inline bool operator>=(ModbusFunctionCode lhs, uint8_t rhs) { return static_cast<uint8_t>(lhs) >= rhs; }
inline bool operator>=(uint8_t lhs, ModbusFunctionCode rhs) { return lhs >= static_cast<uint8_t>(rhs); }
// 4.3 MODBUS Data model // 4.3 MODBUS Data model
enum class ModbusRegisterType : uint8_t { enum class ModbusRegisterType : uint8_t {
@@ -75,12 +68,21 @@ enum class ModbusExceptionCode : uint8_t {
}; };
// 6.12 16 (0x10) Write Multiple registers: // 6.12 16 (0x10) Write Multiple registers:
const uint8_t MAX_NUM_OF_REGISTERS_TO_WRITE = 123; // 0x7B static constexpr uint16_t MAX_NUM_OF_REGISTERS_TO_WRITE = 123; // 0x7B
// 6.1 01 (0x01) Read Coils
// 6.2 02 (0x02) Read Discrete Inputs
static constexpr uint16_t MAX_NUM_OF_COILS_TO_READ = 2000; // 0x7D0
static constexpr uint16_t MAX_NUM_OF_DISCRETE_INPUTS_TO_READ = 2000; // 0x7D0
// 6.3 03 (0x03) Read Holding Registers // 6.3 03 (0x03) Read Holding Registers
// 6.4 04 (0x04) Read Input Registers // 6.4 04 (0x04) Read Input Registers
const uint8_t MAX_NUM_OF_REGISTERS_TO_READ = 125; // 0x7D static constexpr uint8_t MAX_NUM_OF_REGISTERS_TO_READ = 125; // 0x7D
// Smallest possible frame is 4 bytes (custom function with no data): address(1) + function(1) + CRC(2)
static constexpr uint16_t MIN_FRAME_SIZE = 4;
static constexpr uint16_t MAX_PDU_SIZE = 253; // Max PDU size is 256 - address(1) - CRC(2) = 253
static constexpr uint16_t MAX_RAW_SIZE = 254; // Max RAW size is 255 - CRC(2) = 254
static constexpr uint16_t MAX_FRAME_SIZE = 256; static constexpr uint16_t MAX_FRAME_SIZE = 256;
/// End of Modbus definitions /// End of Modbus definitions
} // namespace esphome::modbus } // namespace esphome::modbus

View File

@@ -1,10 +1,83 @@
#include "modbus_helpers.h" #include "modbus_helpers.h"
#include "esphome/core/log.h" #include "esphome/core/log.h"
#include <algorithm>
namespace esphome::modbus::helpers { namespace esphome::modbus::helpers {
static const char *const TAG = "modbus_helpers"; static const char *const TAG = "modbus_helpers";
uint16_t server_frame_length(const uint8_t *frame, size_t size) {
if (size < 2)
return MIN_FRAME_SIZE;
if (is_function_code_exception(frame[1])) {
return 5; // address(1) + function(1) + exception(1) + CRC(2)
}
switch (static_cast<ModbusFunctionCode>(frame[1])) {
case ModbusFunctionCode::READ_COILS:
case ModbusFunctionCode::READ_DISCRETE_INPUTS:
case ModbusFunctionCode::READ_HOLDING_REGISTERS:
case ModbusFunctionCode::READ_INPUT_REGISTERS:
// address(1) + function(1) + byte count(1) + data + CRC(2)
return 5 + (size > 2 ? std::min(frame[2], uint8_t(MAX_NUM_OF_REGISTERS_TO_READ * 2)) : 0);
case ModbusFunctionCode::WRITE_SINGLE_COIL:
case ModbusFunctionCode::WRITE_SINGLE_REGISTER:
case ModbusFunctionCode::WRITE_MULTIPLE_COILS:
case ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS:
return 8; // address(1) + function(1) + output/register address(2) + value(2) + CRC(2)
// Unsupported function codes. Included here to prevent parser failures. Excluding Serial Line specific functions.
case ModbusFunctionCode::READ_FILE_RECORD:
case ModbusFunctionCode::WRITE_FILE_RECORD:
// address(1) + function(1) + byte count(1) + data + CRC(2)
return 5 + (size > 2 ? std::min(frame[2], uint8_t(MAX_FRAME_SIZE - 5)) : 0);
case ModbusFunctionCode::MASK_WRITE_REGISTER:
return 10; // address(1) + function(1) + reference address(2) + AND mask(2) + OR mask(2) + CRC(2)
case ModbusFunctionCode::READ_WRITE_MULTIPLE_REGISTERS:
// address(1) + function(1) + byte count(1) + data + CRC(2)
return 5 + (size > 2 ? std::min(frame[2], uint8_t(MAX_NUM_OF_REGISTERS_TO_READ * 2)) : 0);
case ModbusFunctionCode::READ_FIFO_QUEUE:
// address(1) + function(1) + fifo address(2) CRC(2)
return 6;
default:
return MIN_FRAME_SIZE; // unknown length
}
}
uint16_t client_frame_length(const uint8_t *frame, size_t size) {
if (size < 2)
return MIN_FRAME_SIZE;
switch (static_cast<ModbusFunctionCode>(frame[1])) {
case ModbusFunctionCode::READ_COILS:
case ModbusFunctionCode::READ_DISCRETE_INPUTS:
case ModbusFunctionCode::READ_HOLDING_REGISTERS:
case ModbusFunctionCode::READ_INPUT_REGISTERS:
// address(1) + function(1) + start address(2) + quantity(2) + CRC(2)
case ModbusFunctionCode::WRITE_SINGLE_COIL:
case ModbusFunctionCode::WRITE_SINGLE_REGISTER:
return 8; // address(1) + function(1) + output/register address(2) + value(2) + CRC(2)
case ModbusFunctionCode::WRITE_MULTIPLE_COILS:
case ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS:
// address(1) + function(1) + start address(2) + quantity(2) + byte count(1) + data + CRC(2)
return 9 + (size > 6 ? std::min(frame[6], uint8_t(MAX_NUM_OF_REGISTERS_TO_WRITE * 2)) : 0);
// Unsupported function codes. Included here to prevent parser failures. Excluding Serial Line specific functions.
case ModbusFunctionCode::READ_FILE_RECORD:
case ModbusFunctionCode::WRITE_FILE_RECORD:
// address(1) + function(1) + byte count(1) + data + CRC(2)
return 5 + (size > 2 ? std::min(frame[2], uint8_t(MAX_FRAME_SIZE - 5)) : 0);
case ModbusFunctionCode::MASK_WRITE_REGISTER:
return 10; // address(1) + function(1) + reference address(2) + AND mask(2) + OR mask(2) + CRC(2)
case ModbusFunctionCode::READ_WRITE_MULTIPLE_REGISTERS:
// address(1) + function(1) + read start address(2) + read quantity(2) + write start address(2) +
// write quantity(2) + byte count(1) + data + CRC(2)
return 13 + (size > 10 ? std::min(frame[10], uint8_t(MAX_NUM_OF_REGISTERS_TO_WRITE * 2)) : 0);
case ModbusFunctionCode::READ_FIFO_QUEUE:
// address(1) + function(1) + fifo address(2) CRC(2)
return 6;
default:
return MIN_FRAME_SIZE; // unknown length
}
}
static size_t required_payload_size(SensorValueType sensor_value_type) { static size_t required_payload_size(SensorValueType sensor_value_type) {
switch (sensor_value_type) { switch (sensor_value_type) {
case SensorValueType::U_WORD: case SensorValueType::U_WORD:
@@ -67,7 +140,7 @@ void number_to_payload(std::vector<uint16_t> &data, int64_t value, SensorValueTy
} }
int64_t payload_to_number(const std::vector<uint8_t> &data, SensorValueType sensor_value_type, uint8_t offset, int64_t payload_to_number(const std::vector<uint8_t> &data, SensorValueType sensor_value_type, uint8_t offset,
uint32_t bitmask) { uint32_t bitmask, bool *error_return) {
int64_t value = 0; // int64_t because it can hold signed and unsigned 32 bits int64_t value = 0; // int64_t because it can hold signed and unsigned 32 bits
// Validate offset against the buffer for all types, including RAW/unsupported, so // Validate offset against the buffer for all types, including RAW/unsupported, so
@@ -75,6 +148,8 @@ int64_t payload_to_number(const std::vector<uint8_t> &data, SensorValueType sens
if (static_cast<size_t>(offset) > data.size()) { if (static_cast<size_t>(offset) > data.size()) {
ESP_LOGE(TAG, "not enough data for value type=%u offset=%u size=%zu", static_cast<unsigned int>(sensor_value_type), ESP_LOGE(TAG, "not enough data for value type=%u offset=%u size=%zu", static_cast<unsigned int>(sensor_value_type),
static_cast<unsigned int>(offset), data.size()); static_cast<unsigned int>(offset), data.size());
if (error_return)
*error_return = true;
return value; return value;
} }
@@ -87,6 +162,8 @@ int64_t payload_to_number(const std::vector<uint8_t> &data, SensorValueType sens
ESP_LOGE(TAG, "not enough data for value type=%u offset=%u size=%zu required=%zu", ESP_LOGE(TAG, "not enough data for value type=%u offset=%u size=%zu required=%zu",
static_cast<unsigned int>(sensor_value_type), static_cast<unsigned int>(offset), data.size(), static_cast<unsigned int>(sensor_value_type), static_cast<unsigned int>(offset), data.size(),
required_size); required_size);
if (error_return)
*error_return = true;
return value; return value;
} }
@@ -136,4 +213,102 @@ int64_t payload_to_number(const std::vector<uint8_t> &data, SensorValueType sens
} }
return value; return value;
} }
StaticVector<uint8_t, MAX_PDU_SIZE> create_client_pdu(ModbusFunctionCode function_code, uint16_t start_address,
uint16_t number_of_entities, const uint8_t *values,
size_t values_len) {
if (is_function_code_read(static_cast<uint8_t>(function_code))) {
if (values != nullptr || values_len > 0) {
ESP_LOGW(TAG, "Values provided for read function code %02X, but will be ignored",
static_cast<uint8_t>(function_code));
}
} else if (is_function_code_write(static_cast<uint8_t>(function_code))) {
if (values == nullptr || values_len == 0) {
ESP_LOGE(TAG, "No values provided for write function code %02X", static_cast<uint8_t>(function_code));
return {};
}
} else {
ESP_LOGE(TAG, "Unsupported function code %02X for client PDU creation", static_cast<uint8_t>(function_code));
return {};
}
if (number_of_entities == 0) {
ESP_LOGE(TAG, "Number of entities is zero for function code %02X", static_cast<uint8_t>(function_code));
return {};
}
switch (function_code) {
case ModbusFunctionCode::READ_COILS:
if (number_of_entities > MAX_NUM_OF_COILS_TO_READ) {
ESP_LOGE(TAG, "number_of_entities %u exceeds maximum coils to read %u for function code %02X",
number_of_entities, MAX_NUM_OF_COILS_TO_READ, static_cast<uint8_t>(function_code));
return {};
}
break;
case ModbusFunctionCode::READ_DISCRETE_INPUTS:
if (number_of_entities > MAX_NUM_OF_DISCRETE_INPUTS_TO_READ) {
ESP_LOGE(TAG, "number_of_entities %u exceeds maximum discrete inputs to read %u for function code %02X",
number_of_entities, MAX_NUM_OF_DISCRETE_INPUTS_TO_READ, static_cast<uint8_t>(function_code));
return {};
}
break;
case ModbusFunctionCode::READ_HOLDING_REGISTERS:
case ModbusFunctionCode::READ_INPUT_REGISTERS:
if (number_of_entities > MAX_NUM_OF_REGISTERS_TO_READ) {
ESP_LOGE(TAG, "number_of_entities %u exceeds maximum registers to read %u for function code %02X",
number_of_entities, MAX_NUM_OF_REGISTERS_TO_READ, static_cast<uint8_t>(function_code));
return {};
}
break;
case ModbusFunctionCode::WRITE_SINGLE_COIL:
case ModbusFunctionCode::WRITE_SINGLE_REGISTER:
break; // number_of_entities is ignored for single write, so no need to validate
case ModbusFunctionCode::WRITE_MULTIPLE_COILS:
case ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS:
if (number_of_entities > MAX_NUM_OF_REGISTERS_TO_WRITE) {
ESP_LOGE(TAG, "number_of_entities %u exceeds maximum registers to write %u for function code %02X",
number_of_entities, MAX_NUM_OF_REGISTERS_TO_WRITE, static_cast<uint8_t>(function_code));
return {};
}
break;
default:
ESP_LOGE(TAG, "Unsupported function code %u for client PDU creation", static_cast<unsigned int>(function_code));
return {};
}
StaticVector<uint8_t, MAX_PDU_SIZE> pdu;
pdu.push_back(static_cast<uint8_t>(function_code));
pdu.push_back(start_address >> 8);
pdu.push_back(start_address >> 0);
if (function_code != ModbusFunctionCode::WRITE_SINGLE_COIL &&
function_code != ModbusFunctionCode::WRITE_SINGLE_REGISTER) {
pdu.push_back(number_of_entities >> 8);
pdu.push_back(number_of_entities >> 0);
}
if (is_function_code_write(static_cast<uint8_t>(function_code))) {
if (function_code == ModbusFunctionCode::WRITE_MULTIPLE_COILS ||
function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) {
// 6 bytes of overhead (fc + start_addr×2 + qty×2 + byte_count) leave MAX_PDU_SIZE-6 bytes for values
static constexpr size_t MAX_WRITE_MULTIPLE_VALUES_LEN = MAX_PDU_SIZE - 6;
if (values_len > MAX_WRITE_MULTIPLE_VALUES_LEN) {
ESP_LOGE(TAG, "values_len %zu exceeds PDU capacity %zu, dropping request", values_len,
MAX_WRITE_MULTIPLE_VALUES_LEN);
return {};
}
pdu.push_back(values_len); // Byte count is required for write multiple
for (size_t i = 0; i < values_len; i++)
pdu.push_back(values[i]);
} else {
// Write single register or coil (2 bytes)
if (values_len < 2) {
ESP_LOGE(TAG, "values_len %zu too small for write-single command (need 2), dropping request", values_len);
return {};
}
pdu.push_back(values[0]);
pdu.push_back(values[1]);
}
}
return pdu;
}
} // namespace esphome::modbus::helpers } // namespace esphome::modbus::helpers

View File

@@ -9,6 +9,58 @@
namespace esphome::modbus::helpers { namespace esphome::modbus::helpers {
inline bool is_function_code_read(uint8_t function_code) {
ModbusFunctionCode masked_function_code = static_cast<ModbusFunctionCode>(function_code & FUNCTION_CODE_MASK);
return masked_function_code == ModbusFunctionCode::READ_COILS ||
masked_function_code == ModbusFunctionCode::READ_DISCRETE_INPUTS ||
masked_function_code == ModbusFunctionCode::READ_HOLDING_REGISTERS ||
masked_function_code == ModbusFunctionCode::READ_INPUT_REGISTERS;
}
inline bool is_function_code_write(uint8_t function_code) {
ModbusFunctionCode masked_function_code = static_cast<ModbusFunctionCode>(function_code & FUNCTION_CODE_MASK);
return masked_function_code == ModbusFunctionCode::WRITE_SINGLE_COIL ||
masked_function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER ||
masked_function_code == ModbusFunctionCode::WRITE_MULTIPLE_COILS ||
masked_function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS;
}
inline bool is_function_code_exception(uint8_t function_code) {
return (static_cast<uint8_t>(function_code) & FUNCTION_CODE_EXCEPTION_MASK) != 0;
}
inline bool is_function_code_custom(uint8_t function_code) {
uint8_t masked_function_code = function_code & FUNCTION_CODE_MASK;
return (masked_function_code >= FUNCTION_CODE_USER_DEFINED_SPACE_1_INIT &&
masked_function_code <= FUNCTION_CODE_USER_DEFINED_SPACE_1_END) ||
(masked_function_code >= FUNCTION_CODE_USER_DEFINED_SPACE_2_INIT &&
masked_function_code <= FUNCTION_CODE_USER_DEFINED_SPACE_2_END);
}
// Returns the expected length of a server response frame based on the function code
// If the frame is too short to determine the length, returns the minimum length
uint16_t server_frame_length(const uint8_t *frame, size_t size);
// Returns the expected length of a client request frame based on the function code
// If the frame is too short to determine the length, returns the minimum length
uint16_t client_frame_length(const uint8_t *frame, size_t size);
inline uint8_t server_frame_data_offset(const uint8_t *frame, size_t size) {
if (size < 2)
return 0;
switch (static_cast<ModbusFunctionCode>(frame[1])) {
case ModbusFunctionCode::READ_COILS:
case ModbusFunctionCode::READ_DISCRETE_INPUTS:
case ModbusFunctionCode::READ_HOLDING_REGISTERS:
case ModbusFunctionCode::READ_INPUT_REGISTERS:
return 3; // address(1) + function(1) + byte count(1) + data + CRC(2)
default:
return 2;
}
}
inline uint8_t client_frame_data_offset(const uint8_t *, size_t) { return 2; }
enum class SensorValueType : uint8_t { enum class SensorValueType : uint8_t {
RAW = 0x00, // variable length RAW = 0x00, // variable length
U_WORD = 0x1, // 1 Register unsigned U_WORD = 0x1, // 1 Register unsigned
@@ -41,21 +93,21 @@ inline ModbusFunctionCode modbus_register_read_function(ModbusRegisterType reg_t
case ModbusRegisterType::READ: case ModbusRegisterType::READ:
return ModbusFunctionCode::READ_INPUT_REGISTERS; return ModbusFunctionCode::READ_INPUT_REGISTERS;
default: default:
return ModbusFunctionCode::CUSTOM; return ModbusFunctionCode::INVALID;
} }
} }
inline ModbusFunctionCode modbus_register_write_function(ModbusRegisterType reg_type) { inline ModbusFunctionCode modbus_register_write_function(ModbusRegisterType reg_type, bool multiple = false) {
switch (reg_type) { switch (reg_type) {
case ModbusRegisterType::COIL: case ModbusRegisterType::COIL:
return ModbusFunctionCode::WRITE_SINGLE_COIL; return multiple ? ModbusFunctionCode::WRITE_MULTIPLE_COILS : ModbusFunctionCode::WRITE_SINGLE_COIL;
case ModbusRegisterType::DISCRETE_INPUT:
return ModbusFunctionCode::CUSTOM;
case ModbusRegisterType::HOLDING: case ModbusRegisterType::HOLDING:
return ModbusFunctionCode::READ_WRITE_MULTIPLE_REGISTERS; return multiple ? ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS : ModbusFunctionCode::WRITE_SINGLE_REGISTER;
// These register types can't be written (per spec)
case ModbusRegisterType::READ: case ModbusRegisterType::READ:
case ModbusRegisterType::DISCRETE_INPUT:
default: default:
return ModbusFunctionCode::CUSTOM; return ModbusFunctionCode::INVALID;
} }
} }
@@ -112,31 +164,31 @@ inline uint64_t qword_from_hex_str(const std::string &value, uint8_t pos) {
* @param buffer_offset offset in bytes. * @param buffer_offset offset in bytes.
* @return value of type T extracted from buffer * @return value of type T extracted from buffer
*/ */
template<typename T> T get_data(const std::vector<uint8_t> &data, size_t buffer_offset) { template<typename T> T get_data(const uint8_t *data, size_t buffer_offset) {
if (sizeof(T) == sizeof(uint8_t)) { if (sizeof(T) == sizeof(uint8_t)) {
return T(data[buffer_offset]); return T(data[buffer_offset]);
} }
if (sizeof(T) == sizeof(uint16_t)) { if (sizeof(T) == sizeof(uint16_t)) {
return T((uint16_t(data[buffer_offset + 0]) << 8) | (uint16_t(data[buffer_offset + 1]) << 0)); return T((uint16_t(data[buffer_offset + 0]) << 8) | (uint16_t(data[buffer_offset + 1]) << 0));
} }
if (sizeof(T) == sizeof(uint32_t)) { if (sizeof(T) == sizeof(uint32_t)) {
return static_cast<uint32_t>(get_data<uint16_t>(data, buffer_offset)) << 16 | 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)); static_cast<uint32_t>(get_data<uint16_t>(data, buffer_offset + 2));
} }
if (sizeof(T) == sizeof(uint64_t)) { if (sizeof(T) == sizeof(uint64_t)) {
return static_cast<uint64_t>(get_data<uint32_t>(data, buffer_offset)) << 32 | 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_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) || static_assert(sizeof(T) == sizeof(uint8_t) || sizeof(T) == sizeof(uint16_t) || sizeof(T) == sizeof(uint32_t) ||
sizeof(T) == sizeof(uint64_t), sizeof(T) == sizeof(uint64_t),
"Unsupported type size in get_data; only 1, 2, 4, or 8-byte integer types are supported."); "Unsupported type size in get_data; only 1, 2, 4, or 8-byte integer types are supported.");
return T{}; 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);
}
/** Extract coil data from modbus response buffer /** Extract coil data from modbus response buffer
* Responses for coil are packed into bytes . * Responses for coil are packed into bytes .
* coil 3 is bit 3 of the first response byte * coil 3 is bit 3 of the first response byte
@@ -188,7 +240,27 @@ void number_to_payload(std::vector<uint16_t> &data, int64_t value, SensorValueTy
* @return 64-bit number of the payload * @return 64-bit number of the payload
*/ */
int64_t payload_to_number(const std::vector<uint8_t> &data, SensorValueType sensor_value_type, uint8_t offset, int64_t payload_to_number(const std::vector<uint8_t> &data, SensorValueType sensor_value_type, uint8_t offset,
uint32_t bitmask); uint32_t bitmask, bool *error_return = nullptr);
/** Create a modbus clinet pdu for reading/writing single/multiple coils/register/inputs.
* @param function_code the modbus function code to use. One of:
* READ_COILS
* READ_DISCRETE_INPUTS
* READ_HOLDING_REGISTERS
* READ_INPUT_REGISTERS
* WRITE_SINGLE_COIL
* WRITE_SINGLE_REGISTER
* WRITE_MULTIPLE_COILS
* WRITE_MULTIPLE_REGISTERS
* @param start_address coil/register/input starting address
* @param number_of_entities number of coils/registers/inputs to read/write
* @param values optional payload bytes to write (nullptr for read commands)
* @param values_len length of values array
* @return PDU (function code + data, no address, no CRC)
*/
StaticVector<uint8_t, MAX_PDU_SIZE> create_client_pdu(ModbusFunctionCode function_code, uint16_t start_address,
uint16_t number_of_entities, const uint8_t *values = nullptr,
size_t values_len = 0);
inline std::vector<uint16_t> float_to_payload(float value, SensorValueType value_type) { inline std::vector<uint16_t> float_to_payload(float value, SensorValueType value_type) {
int64_t val; int64_t val;

View File

@@ -201,7 +201,7 @@ void ModbusController::update() {
// walk through the sensors and determine the register ranges to read // walk through the sensors and determine the register ranges to read
size_t ModbusController::create_register_ranges_() { size_t ModbusController::create_register_ranges_() {
this->register_ranges_.clear(); this->register_ranges_.clear();
if (this->parent_->role == modbus::ModbusRole::CLIENT && this->sensorset_.empty()) { if (this->sensorset_.empty()) {
ESP_LOGW(TAG, "No sensors registered"); ESP_LOGW(TAG, "No sensors registered");
return 0; return 0;
} }

View File

@@ -279,7 +279,7 @@ class ModbusCommandItem {
* Responses for the commands are dispatched to the modbus sensor items. * Responses for the commands are dispatched to the modbus sensor items.
*/ */
class ModbusController : public PollingComponent, public modbus::ModbusDevice { class ModbusController : public PollingComponent, public modbus::ModbusClientDevice {
public: public:
void dump_config() override; void dump_config() override;
void loop() override; void loop() override;

View File

@@ -27,7 +27,7 @@ MULTI_CONF = True
modbus_server_ns = cg.esphome_ns.namespace("modbus_server") modbus_server_ns = cg.esphome_ns.namespace("modbus_server")
ModbusServer = modbus_server_ns.class_( ModbusServer = modbus_server_ns.class_(
"ModbusServer", cg.Component, modbus.ModbusDevice "ModbusServer", cg.Component, modbus.ModbusServerDevice
) )
ServerCourtesyResponse = modbus_server_ns.struct("ServerCourtesyResponse") ServerCourtesyResponse = modbus_server_ns.struct("ServerCourtesyResponse")
@@ -44,7 +44,7 @@ SERVER_COURTESY_RESPONSE_SCHEMA = cv.Schema(
ModbusServerRegisterSchema = cv.Schema( ModbusServerRegisterSchema = cv.Schema(
{ {
cv.GenerateID(): cv.declare_id(ServerRegister), cv.GenerateID(): cv.declare_id(ServerRegister),
cv.Required(CONF_ADDRESS): cv.positive_int, cv.Required(CONF_ADDRESS): cv.hex_uint16_t,
cv.Optional(CONF_VALUE_TYPE, default="U_WORD"): cv.enum(SENSOR_VALUE_TYPE), cv.Optional(CONF_VALUE_TYPE, default="U_WORD"): cv.enum(SENSOR_VALUE_TYPE),
cv.Required(CONF_READ_LAMBDA): cv.returning_lambda, cv.Required(CONF_READ_LAMBDA): cv.returning_lambda,
cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda,
@@ -61,7 +61,7 @@ CONFIG_SCHEMA = cv.All(
CONF_REGISTERS, CONF_REGISTERS,
): cv.ensure_list(ModbusServerRegisterSchema), ): cv.ensure_list(ModbusServerRegisterSchema),
} }
).extend(modbus.modbus_device_schema(0x01)), ).extend(modbus.modbus_device_schema(0x01, role="server")),
) )
@@ -119,6 +119,5 @@ async def to_code(config):
) )
) )
cg.add(var.add_server_register(server_register_var)) cg.add(var.add_server_register(server_register_var))
cg.add(var.set_address(config[CONF_ADDRESS]))
await cg.register_component(var, config) await cg.register_component(var, config)
return await modbus.register_modbus_device(var, config) return await modbus.register_modbus_server_device(var, config)

View File

@@ -5,6 +5,7 @@
namespace esphome::modbus_server { namespace esphome::modbus_server {
using modbus::ModbusFunctionCode; using modbus::ModbusFunctionCode;
using modbus::ModbusExceptionCode; using modbus::ModbusExceptionCode;
using modbus::helpers::payload_to_number;
static const char *const TAG = "modbus_server"; static const char *const TAG = "modbus_server";
@@ -16,7 +17,7 @@ void ModbusServer::on_modbus_read_registers(uint8_t function_code, uint16_t star
this->address_, function_code, start_address, number_of_registers); this->address_, function_code, start_address, number_of_registers);
if (number_of_registers == 0 || number_of_registers > modbus::MAX_NUM_OF_REGISTERS_TO_READ) { if (number_of_registers == 0 || number_of_registers > modbus::MAX_NUM_OF_REGISTERS_TO_READ) {
ESP_LOGW(TAG, "Invalid number of registers %d. Sending exception response.", number_of_registers); ESP_LOGW(TAG, "Invalid number of registers %" PRIu16 ". Sending exception response.", number_of_registers);
this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_ADDRESS);
return; return;
} }
@@ -30,9 +31,10 @@ void ModbusServer::on_modbus_read_registers(uint8_t function_code, uint16_t star
break; break;
} }
int64_t value = server_register->read_lambda(); int64_t value = server_register->read_lambda();
char value_buf[ServerRegister::FORMAT_VALUE_BUF_SIZE];
ESP_LOGV(TAG, "Matched register. Address: 0x%02X. Value type: %zu. Register count: %u. Value: %s.", ESP_LOGV(TAG, "Matched register. Address: 0x%02X. Value type: %zu. Register count: %u. Value: %s.",
server_register->address, static_cast<size_t>(server_register->value_type), server_register->address, static_cast<size_t>(server_register->value_type),
server_register->register_count, server_register->format_value(value).c_str()); server_register->register_count, server_register->format_value(value, value_buf, sizeof(value_buf)));
std::vector<uint16_t> payload; std::vector<uint16_t> payload;
payload.reserve(server_register->register_count * 2); payload.reserve(server_register->register_count * 2);
@@ -49,7 +51,7 @@ void ModbusServer::on_modbus_read_registers(uint8_t function_code, uint16_t star
(current_address <= this->server_courtesy_response_.register_last_address)) { (current_address <= this->server_courtesy_response_.register_last_address)) {
ESP_LOGV(TAG, ESP_LOGV(TAG,
"Could not match any register to address 0x%02X, but default allowed. " "Could not match any register to address 0x%02X, but default allowed. "
"Returning default value: %d.", "Returning default value: %" PRIu16 ".",
current_address, this->server_courtesy_response_.register_value); current_address, this->server_courtesy_response_.register_value);
sixteen_bit_response.push_back(this->server_courtesy_response_.register_value); sixteen_bit_response.push_back(this->server_courtesy_response_.register_value);
current_address += 1; // Just increment by 1, as the default response is a single register current_address += 1; // Just increment by 1, as the default response is a single register
@@ -64,20 +66,22 @@ void ModbusServer::on_modbus_read_registers(uint8_t function_code, uint16_t star
} }
std::vector<uint8_t> response; std::vector<uint8_t> response;
if (number_of_registers != sixteen_bit_response.size())
ESP_LOGW(TAG, "Response size not matched to request register count.");
response.push_back(sixteen_bit_response.size() * 2); // actual byte count
for (auto v : sixteen_bit_response) { for (auto v : sixteen_bit_response) {
auto decoded_value = decode_value(v); auto decoded_value = decode_value(v);
response.push_back(decoded_value[0]); response.push_back(decoded_value[0]);
response.push_back(decoded_value[1]); response.push_back(decoded_value[1]);
} }
this->send(function_code, response);
this->send(function_code, start_address, number_of_registers, response.size(), response.data());
} }
void ModbusServer::on_modbus_write_registers(uint8_t function_code, const std::vector<uint8_t> &data) { void ModbusServer::on_modbus_write_registers(uint8_t function_code, const std::vector<uint8_t> &data) {
uint16_t number_of_registers; uint16_t number_of_registers;
uint16_t payload_offset; uint16_t payload_offset;
if (function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { if (static_cast<ModbusFunctionCode>(function_code) == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) {
if (data.size() < 5) { if (data.size() < 5) {
ESP_LOGW(TAG, "Write multiple registers data too short (%zu bytes)", data.size()); ESP_LOGW(TAG, "Write multiple registers data too short (%zu bytes)", data.size());
this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE);
@@ -85,13 +89,15 @@ void ModbusServer::on_modbus_write_registers(uint8_t function_code, const std::v
} }
number_of_registers = uint16_t(data[3]) | (uint16_t(data[2]) << 8); number_of_registers = uint16_t(data[3]) | (uint16_t(data[2]) << 8);
if (number_of_registers == 0 || number_of_registers > modbus::MAX_NUM_OF_REGISTERS_TO_WRITE) { if (number_of_registers == 0 || number_of_registers > modbus::MAX_NUM_OF_REGISTERS_TO_WRITE) {
ESP_LOGW(TAG, "Invalid number of registers %d. Sending exception response.", number_of_registers); ESP_LOGW(TAG, "Invalid number of registers %" PRIu16 ". Sending exception response.", number_of_registers);
this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE);
return; return;
} }
uint16_t payload_size = data[4]; uint16_t payload_size = data[4];
if (payload_size != number_of_registers * 2) { if (payload_size != number_of_registers * 2) {
ESP_LOGW(TAG, "Payload size of %d bytes is not 2 times the number of registers (%d). Sending exception response.", ESP_LOGW(TAG,
"Payload size of %" PRIu16 " bytes is not 2 times the number of registers (%" PRIu16
"). Sending exception response.",
payload_size, number_of_registers); payload_size, number_of_registers);
this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE);
return; return;
@@ -103,7 +109,7 @@ void ModbusServer::on_modbus_write_registers(uint8_t function_code, const std::v
return; return;
} }
payload_offset = 5; payload_offset = 5;
} else if (function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER) { } else if (static_cast<ModbusFunctionCode>(function_code) == ModbusFunctionCode::WRITE_SINGLE_REGISTER) {
if (data.size() < 4) { if (data.size() < 4) {
ESP_LOGW(TAG, "Write single register data too short (%zu bytes)", data.size()); ESP_LOGW(TAG, "Write single register data too short (%zu bytes)", data.size());
this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE);
@@ -148,15 +154,22 @@ void ModbusServer::on_modbus_write_registers(uint8_t function_code, const std::v
if (!for_each_register([](ServerRegister *server_register, uint16_t offset) -> bool { if (!for_each_register([](ServerRegister *server_register, uint16_t offset) -> bool {
return server_register->write_lambda != nullptr; return server_register->write_lambda != nullptr;
})) { })) {
this->send_error(function_code, ModbusExceptionCode::ILLEGAL_FUNCTION); ESP_LOGW(TAG, "Invalid register address. Sending exception response.");
this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_ADDRESS);
return; return;
} }
// Actually write to the registers: // Actually write to the registers:
if (!for_each_register([&data](ServerRegister *server_register, uint16_t offset) { if (!for_each_register([&data](ServerRegister *server_register, uint16_t offset) {
int64_t number = modbus::helpers::payload_to_number(data, server_register->value_type, offset, 0xFFFFFFFF); bool error = false;
int64_t number = payload_to_number(data, server_register->value_type, offset, 0xFFFFFFFF, &error);
if (error) {
return false;
} else {
return server_register->write_lambda(number); return server_register->write_lambda(number);
}
})) { })) {
ESP_LOGW(TAG, "Could not write all registers. Sending exception response.");
this->send_error(function_code, ModbusExceptionCode::SERVICE_DEVICE_FAILURE); this->send_error(function_code, ModbusExceptionCode::SERVICE_DEVICE_FAILURE);
return; return;
} }

View File

@@ -52,32 +52,34 @@ class ServerRegister {
}; };
} }
// Formats a raw value into a string representation based on the value type for debugging
std::string format_value(int64_t value) const {
// max 44: float with %.1f can be up to 42 chars (3.4e38 → 39 integer digits + sign + decimal + 1 digit) // max 44: float with %.1f can be up to 42 chars (3.4e38 → 39 integer digits + sign + decimal + 1 digit)
// plus null terminator = 43, rounded to 44 for 4-byte alignment // plus null terminator = 43, rounded to 44 for 4-byte alignment
char buf[44]; static constexpr size_t FORMAT_VALUE_BUF_SIZE = 44;
// Formats a raw value into a caller-provided buffer based on the value type for debugging.
// Returns buf for convenience.
const char *format_value(int64_t value, char *buf, size_t buf_size) const {
switch (this->value_type) { switch (this->value_type) {
case SensorValueType::U_WORD: case SensorValueType::U_WORD:
case SensorValueType::U_DWORD: case SensorValueType::U_DWORD:
case SensorValueType::U_DWORD_R: case SensorValueType::U_DWORD_R:
case SensorValueType::U_QWORD: case SensorValueType::U_QWORD:
case SensorValueType::U_QWORD_R: case SensorValueType::U_QWORD_R:
buf_append_printf(buf, sizeof(buf), 0, "%" PRIu64, static_cast<uint64_t>(value)); buf_append_printf(buf, buf_size, 0, "%" PRIu64, static_cast<uint64_t>(value));
return buf; return buf;
case SensorValueType::S_WORD: case SensorValueType::S_WORD:
case SensorValueType::S_DWORD: case SensorValueType::S_DWORD:
case SensorValueType::S_DWORD_R: case SensorValueType::S_DWORD_R:
case SensorValueType::S_QWORD: case SensorValueType::S_QWORD:
case SensorValueType::S_QWORD_R: case SensorValueType::S_QWORD_R:
buf_append_printf(buf, sizeof(buf), 0, "%" PRId64, value); buf_append_printf(buf, buf_size, 0, "%" PRId64, value);
return buf; return buf;
case SensorValueType::FP32_R: case SensorValueType::FP32_R:
case SensorValueType::FP32: case SensorValueType::FP32:
buf_append_printf(buf, sizeof(buf), 0, "%.1f", bit_cast<float>(static_cast<uint32_t>(value))); buf_append_printf(buf, buf_size, 0, "%.1f", bit_cast<float>(static_cast<uint32_t>(value)));
return buf; return buf;
default: default:
buf_append_printf(buf, sizeof(buf), 0, "%" PRId64, value); buf_append_printf(buf, buf_size, 0, "%" PRId64, value);
return buf; return buf;
} }
} }
@@ -89,12 +91,10 @@ class ServerRegister {
WriteLambda write_lambda; WriteLambda write_lambda;
}; };
class ModbusServer : public Component, public modbus::ModbusDevice { class ModbusServer : public Component, public modbus::ModbusServerDevice {
public: public:
void dump_config() override; void dump_config() override;
/// Not used for ModbusServer.
void on_modbus_data(const std::vector<uint8_t> &data) override{};
/// Registers a server register with the controller. Called by esphomes code generator /// Registers a server register with the controller. Called by esphomes code generator
void add_server_register(ServerRegister *server_register) { server_registers_.push_back(server_register); } void add_server_register(ServerRegister *server_register) { server_registers_.push_back(server_register); }
/// called when a modbus request (function code 0x03 or 0x04) was parsed without errors /// called when a modbus request (function code 0x03 or 0x04) was parsed without errors

View File

@@ -1,7 +1,6 @@
from collections import UserDict from collections import UserDict
from collections.abc import Callable from collections.abc import Callable
from functools import reduce from functools import reduce
import logging
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -36,8 +35,6 @@ from esphome.const import (
) )
from esphome.core import EsphomeError from esphome.core import EsphomeError
_LOGGER = logging.getLogger(__name__)
DOMAIN = CONF_PACKAGES DOMAIN = CONF_PACKAGES
# Guard against infinite include chains (e.g. A includes B includes A). # Guard against infinite include chains (e.g. A includes B includes A).
MAX_INCLUDE_DEPTH = 20 MAX_INCLUDE_DEPTH = 20
@@ -53,18 +50,6 @@ def is_remote_package(package_config: dict) -> bool:
return CONF_URL in package_config return CONF_URL in package_config
def is_package_definition(value: object) -> bool:
"""Returns True if the value looks like a package definition rather than a config fragment.
Package definitions are IncludeFile objects, git URL shorthand strings, or
remote package dicts (containing a ``url:`` key). Config fragments are
plain dicts that represent component configuration.
"""
return isinstance(value, (yaml_util.IncludeFile, str)) or (
isinstance(value, dict) and is_remote_package(value)
)
def valid_package_contents(package_config: dict) -> dict: def valid_package_contents(package_config: dict) -> dict:
"""Validate that a package looks like a plausible ESPHome config fragment. """Validate that a package looks like a plausible ESPHome config fragment.
@@ -134,22 +119,6 @@ def validate_source_shorthand(value):
return REMOTE_PACKAGE_SCHEMA(conf) return REMOTE_PACKAGE_SCHEMA(conf)
def deprecate_single_package(config: dict) -> dict:
_LOGGER.warning(
"""
Including a single package under `packages:`, i.e., `packages: !include mypackage.yaml` is deprecated.
This method for including packages will go away in 2026.7.0
Please use a list instead:
packages:
- !include mypackage.yaml
See https://github.com/esphome/esphome/pull/12116
"""
)
return config
REMOTE_PACKAGE_SCHEMA = cv.All( REMOTE_PACKAGE_SCHEMA = cv.All(
cv.Schema( cv.Schema(
{ {
@@ -198,10 +167,7 @@ CONFIG_SCHEMA = cv.Any( # under `packages:` we can have either:
str: PACKAGE_SCHEMA, # a named dict of package definitions, or str: PACKAGE_SCHEMA, # a named dict of package definitions, or
} }
), ),
[PACKAGE_SCHEMA], # a list of package definitions, or [PACKAGE_SCHEMA], # a list of package definitions
cv.All( # a single package definition (deprecated)
cv.ensure_list(PACKAGE_SCHEMA), deprecate_single_package
),
) )
@@ -348,7 +314,6 @@ def _walk_packages(
config: dict, config: dict,
callback: PackageCallback, callback: PackageCallback,
context: ContextVars | None = None, context: ContextVars | None = None,
validate_deprecated: bool = True,
path: yaml_util.DocumentPath | None = None, path: yaml_util.DocumentPath | None = None,
) -> dict: ) -> dict:
"""Walks the packages structure in priority order, invoking ``callback`` on each package definition found. """Walks the packages structure in priority order, invoking ``callback`` on each package definition found.
@@ -378,17 +343,7 @@ def _walk_packages(
elif ( elif (
result := _walk_package_dict(packages, callback, context, packages_path) result := _walk_package_dict(packages, callback, context, packages_path)
) is not None: ) is not None:
if not validate_deprecated or any(
is_package_definition(v) for v in packages.values()
):
raise result raise result
# Fallback: treat the dict as a single deprecated package.
# This block can be removed once the single-package
# deprecation period (2026.7.0) is over.
config[CONF_PACKAGES] = [packages]
return _walk_packages(
deprecate_single_package(config), callback, context, path=path
)
config[CONF_PACKAGES] = packages config[CONF_PACKAGES] = packages
return config return config
@@ -588,9 +543,6 @@ class _PackageProcessor:
path: yaml_util.DocumentPath, path: yaml_util.DocumentPath,
) -> dict: ) -> dict:
"""Resolve a single package and recurse into any nested packages.""" """Resolve a single package and recurse into any nested packages."""
from_remote = isinstance(package_config, dict) and is_remote_package(
package_config
)
package_config = self.resolve_package(package_config, context_vars, path) package_config = self.resolve_package(package_config, context_vars, path)
context_vars = self.collect_substitutions(package_config, context_vars) context_vars = self.collect_substitutions(package_config, context_vars)
@@ -600,17 +552,10 @@ class _PackageProcessor:
# Push context from !include vars on the packages key (the package root # Push context from !include vars on the packages key (the package root
# was already pushed in collect_substitutions above). # was already pushed in collect_substitutions above).
context_vars = push_context(package_config[CONF_PACKAGES], context_vars) context_vars = push_context(package_config[CONF_PACKAGES], context_vars)
# Disable the deprecated single-package fallback for remote
# packages. _process_remote_package returns dicts with
# already-resolved values that is_package_definition cannot
# distinguish from config fragments, so the fallback would
# always fire and mask real errors with wrong paths
# (packages->0 instead of packages-><name>).
return _walk_packages( return _walk_packages(
package_config, package_config,
self.process_package, self.process_package,
context_vars, context_vars,
validate_deprecated=not from_remote,
path=path, path=path,
) )
@@ -673,7 +618,7 @@ def merge_packages(config: dict) -> dict:
merge_list.append(package_config) merge_list.append(package_config)
return _walk_packages(package_config, process_package_callback, path=path) return _walk_packages(package_config, process_package_callback, path=path)
_walk_packages(config, process_package_callback, validate_deprecated=False) _walk_packages(config, process_package_callback)
# Merge all packages into the main config: # Merge all packages into the main config:
config = reduce(lambda new, old: merge_config(old, new), merge_list, config) config = reduce(lambda new, old: merge_config(old, new), merge_list, config)
del config[CONF_PACKAGES] del config[CONF_PACKAGES]

View File

@@ -28,9 +28,6 @@ class MideaData {
bool is_valid() const { return this->data_[OFFSET_CS] == this->calc_cs_(); } bool is_valid() const { return this->data_[OFFSET_CS] == this->calc_cs_(); }
void finalize() { this->data_[OFFSET_CS] = this->calc_cs_(); } void finalize() { this->data_[OFFSET_CS] = this->calc_cs_(); }
bool is_compliment(const MideaData &rhs) const; bool is_compliment(const MideaData &rhs) const;
/// @deprecated Allocates heap memory. Use to_str() instead. Removed in 2026.7.0.
ESPDEPRECATED("Allocates heap memory. Use to_str() instead. Removed in 2026.7.0.", "2026.1.0")
std::string to_string() const { return format_hex_pretty(this->data_.data(), this->data_.size()); } // NOLINT
/// Buffer size for to_str(): 6 bytes = "AA.BB.CC.DD.EE.FF\0" /// Buffer size for to_str(): 6 bytes = "AA.BB.CC.DD.EE.FF\0"
static constexpr size_t TO_STR_BUFFER_SIZE = format_hex_pretty_size(6); static constexpr size_t TO_STR_BUFFER_SIZE = format_hex_pretty_size(6);
/// Format to buffer, returns pointer to buffer /// Format to buffer, returns pointer to buffer

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import gzip import gzip
import logging
import esphome.codegen as cg import esphome.codegen as cg
from esphome.components import web_server_base from esphome.components import web_server_base
@@ -38,6 +39,8 @@ from esphome.core import CORE, CoroPriority, coroutine_with_priority
import esphome.final_validate as fv import esphome.final_validate as fv
from esphome.types import ConfigType from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
AUTO_LOAD = ["json", "web_server_base"] AUTO_LOAD = ["json", "web_server_base"]
CONF_SORTING_GROUP_ID = "sorting_group_id" CONF_SORTING_GROUP_ID = "sorting_group_id"
@@ -71,6 +74,15 @@ def default_url(config: ConfigType) -> ConfigType:
return config return config
def validate_version_deprecated(config: ConfigType) -> ConfigType:
if config[CONF_VERSION] == 1:
_LOGGER.warning(
"Version 1 of 'web_server' is deprecated and will be removed in "
"2027.1.0. Please migrate to version 2 (the default) or version 3."
)
return config
def validate_local(config: ConfigType) -> ConfigType: def validate_local(config: ConfigType) -> ConfigType:
if CONF_LOCAL in config and config[CONF_VERSION] == 1: if CONF_LOCAL in config and config[CONF_VERSION] == 1:
raise cv.Invalid("'local' is not supported in version 1") raise cv.Invalid("'local' is not supported in version 1")
@@ -220,6 +232,7 @@ CONFIG_SCHEMA = cv.All(
] ]
), ),
default_url, default_url,
validate_version_deprecated,
validate_local, validate_local,
validate_sorting_groups, validate_sorting_groups,
validate_ota, validate_ota,

View File

@@ -164,36 +164,9 @@ EntityMatchResult UrlMatch::match_entity(EntityBase *entity) const {
} }
#endif #endif
// Try matching by entity name (new format) // Match by entity name
if (this->id == entity->get_name()) { if (this->id == entity->get_name()) {
result.matched = true; result.matched = true;
return result;
}
// Fall back to object_id (deprecated format)
char object_id_buf[OBJECT_ID_MAX_LEN];
StringRef object_id = entity->get_object_id_to(object_id_buf);
if (this->id == object_id) {
result.matched = true;
// Log deprecation warning
#ifdef USE_DEVICES
Device *device = entity->get_device();
if (device != nullptr) {
ESP_LOGW(TAG,
"Deprecated URL format: /%.*s/%.*s/%.*s - use entity name '/%.*s/%s/%s' instead. "
"Object ID URLs will be removed in 2026.7.0.",
(int) this->domain.size(), this->domain.c_str(), (int) this->device_name.size(),
this->device_name.c_str(), (int) this->id.size(), this->id.c_str(), (int) this->domain.size(),
this->domain.c_str(), device->get_name(), entity->get_name().c_str());
} else
#endif
{
ESP_LOGW(TAG,
"Deprecated URL format: /%.*s/%.*s - use entity name '/%.*s/%s' instead. "
"Object ID URLs will be removed in 2026.7.0.",
(int) this->domain.size(), this->domain.c_str(), (int) this->id.size(), this->id.c_str(),
(int) this->domain.size(), this->domain.c_str(), entity->get_name().c_str());
}
} }
return result; return result;

View File

@@ -76,7 +76,7 @@ struct UrlMatch {
bool method_equals(const __FlashStringHelper *str) const { return this->method == str; } bool method_equals(const __FlashStringHelper *str) const { return this->method == str; }
#endif #endif
/// Match entity by name first, then fall back to object_id with deprecation warning /// Match entity by name
/// Returns EntityMatchResult with match status and whether action segment is empty /// Returns EntityMatchResult with match status and whether action segment is empty
EntityMatchResult match_entity(EntityBase *entity) const; EntityMatchResult match_entity(EntityBase *entity) const;
}; };

View File

@@ -86,14 +86,6 @@ std::string str_sprintf(const char *fmt, ...) {
return str; return str;
} }
// --- Value formatting helpers ---
std::string value_accuracy_to_string(float value, int8_t accuracy_decimals) {
char buf[VALUE_ACCURACY_MAX_LEN];
value_accuracy_to_buf(buf, value, accuracy_decimals);
return std::string(buf);
}
// --- Base64 helpers --- // --- Base64 helpers ---
static constexpr const char *BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" static constexpr const char *BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"

View File

@@ -94,14 +94,6 @@ std::string format_hex_pretty(const std::string &data, char separator = '.', boo
/// @warning Allocates heap memory. Use format_bin_to() with a stack buffer instead. /// @warning Allocates heap memory. Use format_bin_to() with a stack buffer instead.
std::string format_bin(const uint8_t *data, size_t length); std::string format_bin(const uint8_t *data, size_t length);
// --- Value formatting helpers (allocating) ---
/// Format a float value with accuracy decimals to a string.
/// @deprecated Allocates heap memory. Use value_accuracy_to_buf() instead. Removed in 2026.7.0.
__attribute__((deprecated("Allocates heap memory. Use value_accuracy_to_buf() instead. Removed in 2026.7.0.")))
std::string
value_accuracy_to_string(float value, int8_t accuracy_decimals);
// --- Base64 helpers (allocating) --- // --- Base64 helpers (allocating) ---
/// Encode a byte buffer to base64 string. /// Encode a byte buffer to base64 string.

View File

@@ -194,15 +194,6 @@ class Application {
/// Buffer must be BUILD_TIME_STR_SIZE bytes (compile-time enforced) /// Buffer must be BUILD_TIME_STR_SIZE bytes (compile-time enforced)
void get_build_time_string(std::span<char, BUILD_TIME_STR_SIZE> buffer); void get_build_time_string(std::span<char, BUILD_TIME_STR_SIZE> buffer);
/// Get the build time as a string (deprecated, use get_build_time_string() instead)
// Remove before 2026.7.0
ESPDEPRECATED("Use get_build_time_string() instead. Removed in 2026.7.0", "2026.1.0")
std::string get_compilation_time() {
char buf[BUILD_TIME_STR_SIZE];
this->get_build_time_string(buf);
return std::string(buf);
}
/// Get the cached time in milliseconds from when the current component started its loop execution /// Get the cached time in milliseconds from when the current component started its loop execution
inline uint32_t IRAM_ATTR HOT get_loop_component_start_time() const { return this->loop_component_start_time_; } inline uint32_t IRAM_ATTR HOT get_loop_component_start_time() const { return this->loop_component_start_time_; }

View File

@@ -147,13 +147,6 @@ std::string EntityBase::get_icon() const {
} }
#endif // !USE_ESP8266 #endif // !USE_ESP8266
// Entity Object ID - computed on-demand from name
std::string EntityBase::get_object_id() const {
char buf[OBJECT_ID_MAX_LEN];
size_t len = this->write_object_id_to(buf, sizeof(buf));
return std::string(buf, len);
}
// Calculate Object ID Hash directly from name using snake_case + sanitize // Calculate Object ID Hash directly from name using snake_case + sanitize
void EntityBase::calc_object_id_() { void EntityBase::calc_object_id_() {
this->object_id_hash_ = fnv1_hash_object_id(this->name_.c_str(), this->name_.size()); this->object_id_hash_ = fnv1_hash_object_id(this->name_.c_str(), this->name_.size());

View File

@@ -73,18 +73,6 @@ class EntityBase {
// Get whether this Entity has its own name or it should use the device friendly_name. // Get whether this Entity has its own name or it should use the device friendly_name.
bool has_own_name() const { return this->flags_.has_own_name; } bool has_own_name() const { return this->flags_.has_own_name; }
// Get the sanitized name of this Entity as an ID.
// Deprecated: object_id mangles names and all object_id methods are planned for removal.
// See https://github.com/esphome/backlog/issues/76
// Now is the time to stop using object_id entirely. If you still need it temporarily,
// use get_object_id_to() which will remain available longer but will also eventually be removed.
ESPDEPRECATED("object_id mangles names and all object_id methods are planned for removal "
"(see https://github.com/esphome/backlog/issues/76). "
"Now is the time to stop using object_id. If still needed, use get_object_id_to() "
"which will remain available longer. get_object_id() will be removed in 2026.7.0",
"2025.12.0")
std::string get_object_id() const;
// Get the unique Object ID of this Entity // Get the unique Object ID of this Entity
uint32_t get_object_id_hash() const { return this->object_id_hash_; } uint32_t get_object_id_hash() const { return this->object_id_hash_; }

View File

@@ -337,7 +337,7 @@ def get_base_entity_object_id(
This function calculates what object_id_c_str_ should be set to in C++. This function calculates what object_id_c_str_ should be set to in C++.
The C++ EntityBase::get_object_id() (entity_base.cpp lines 38-49) works as: The C++ EntityBase::write_object_id_to() (entity_base.cpp) works as:
- If !has_own_name && is_name_add_mac_suffix_enabled(): - If !has_own_name && is_name_add_mac_suffix_enabled():
return str_sanitize(str_snake_case(App.get_friendly_name())) // Dynamic return str_sanitize(str_snake_case(App.get_friendly_name())) // Dynamic
- Else: - Else:

View File

@@ -1,8 +1,6 @@
#pragma once #pragma once
#include <algorithm> #include <algorithm>
#include <cstdint> #include <cstdint>
#include <cstring>
#include <string>
#include "esphome/core/helpers.h" #include "esphome/core/helpers.h"
#include "esphome/core/log.h" #include "esphome/core/log.h"
@@ -80,11 +78,6 @@ class GPIOPin {
/// which may exceed len-1 if truncation occurred (snprintf semantics) /// which may exceed len-1 if truncation occurred (snprintf semantics)
virtual size_t dump_summary(char *buffer, size_t len) const; virtual size_t dump_summary(char *buffer, size_t len) const;
/// Get a summary of this pin as a string.
/// @deprecated Use dump_summary(char*, size_t) instead. Will be removed in 2026.7.0.
ESPDEPRECATED("Override dump_summary(char*, size_t) instead. Will be removed in 2026.7.0.", "2026.1.0")
virtual std::string dump_summary() const;
virtual bool is_internal() { return false; } virtual bool is_internal() { return false; }
}; };
@@ -122,28 +115,14 @@ class InternalGPIOPin : public GPIOPin {
virtual void attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const = 0; virtual void attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const = 0;
}; };
// Inline default implementations for GPIOPin virtual methods. // Inline default implementation for GPIOPin::dump_summary.
// These provide bridge functionality for backwards compatibility with external components. // Writes an empty summary; subclasses override to provide pin details.
// Default implementation bridges to old std::string method for backwards compatibility.
inline size_t GPIOPin::dump_summary(char *buffer, size_t len) const { inline size_t GPIOPin::dump_summary(char *buffer, size_t len) const {
if (len == 0) if (len > 0)
buffer[0] = '\0';
return 0; return 0;
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
std::string s = this->dump_summary();
#pragma GCC diagnostic pop
size_t copy_len = std::min(s.size(), len - 1);
memcpy(buffer, s.c_str(), copy_len);
buffer[copy_len] = '\0';
return s.size(); // Return would-be length (snprintf semantics)
} }
// Default implementation returns empty string.
// External components should override this if they haven't migrated to buffer-based version.
// Remove before 2026.7.0
inline std::string GPIOPin::dump_summary() const { return {}; }
// Inline helper for log_pin - allows compiler to inline into log_pin in gpio.cpp // Inline helper for log_pin - allows compiler to inline into log_pin in gpio.cpp
inline void log_pin_with_prefix(const char *tag, const char *prefix, GPIOPin *pin) { inline void log_pin_with_prefix(const char *tag, const char *prefix, GPIOPin *pin) {
char buffer[GPIO_SUMMARY_MAX_LEN]; char buffer[GPIO_SUMMARY_MAX_LEN];

View File

@@ -12,7 +12,6 @@ from esphome.components.packages import (
_substitute_package_definition, _substitute_package_definition,
_walk_packages, _walk_packages,
do_packages_pass, do_packages_pass,
is_package_definition,
merge_packages, merge_packages,
resolve_packages, resolve_packages,
) )
@@ -89,44 +88,6 @@ def packages_pass(config):
return config return config
_INCLUDE_FILE = "INCLUDE_FILE"
@pytest.mark.parametrize(
("value", "expected"),
[
# IncludeFile objects are package definitions
(_INCLUDE_FILE, True),
# Git URL shorthand strings are package definitions
("github://esphome/firmware/base.yaml@main", True),
# Remote package dicts (with url key) are package definitions
({"url": "https://github.com/esphome/firmware", "file": "base.yaml"}, True),
# Plain config dicts are NOT package definitions (they are config fragments)
({"wifi": {"ssid": "test"}}, False),
# None is not a package definition
(None, False),
# Lists are not package definitions
([{"wifi": {"ssid": "test"}}], False),
# Empty dicts are not package definitions
({}, False),
],
ids=[
"include_file",
"git_shorthand",
"remote_package",
"config_fragment",
"none",
"list",
"empty_dict",
],
)
def test_is_package_definition(value: object, expected: bool) -> None:
"""Test that is_package_definition correctly identifies package definitions."""
if value is _INCLUDE_FILE:
value = MagicMock(spec=IncludeFile)
assert is_package_definition(value) is expected
def test_package_unused(basic_esphome, basic_wifi) -> None: def test_package_unused(basic_esphome, basic_wifi) -> None:
""" """
Ensures do_package_pass does not change a config if packages aren't used. Ensures do_package_pass does not change a config if packages aren't used.
@@ -210,30 +171,6 @@ def test_package_include(basic_wifi, basic_esphome) -> None:
assert actual == expected assert actual == expected
def test_single_package(
basic_esphome,
basic_wifi,
caplog: pytest.LogCaptureFixture,
) -> None:
"""
Tests the simple case where a single package is added to the top-level config as is.
In this test, the CONF_WIFI config is expected to be simply added to the top-level config.
This tests the case where the user just put packages: !include package.yaml, not
part of a list or mapping of packages.
This behavior is deprecated, the test also checks if a warning is issued.
"""
config = {CONF_ESPHOME: basic_esphome, CONF_PACKAGES: {CONF_WIFI: basic_wifi}}
expected = {CONF_ESPHOME: basic_esphome, CONF_WIFI: basic_wifi}
with caplog.at_level("WARNING"):
actual = packages_pass(config)
assert actual == expected
assert "This method for including packages will go away in 2026.7.0" in caplog.text
def test_package_append(basic_wifi, basic_esphome) -> None: def test_package_append(basic_wifi, basic_esphome) -> None:
""" """
Tests the case where a key is present in both a package and top-level config. Tests the case where a key is present in both a package and top-level config.
@@ -1154,6 +1091,10 @@ def test_packages_include_file_resolves_to_invalid_type_raises(
6, 6,
"some string", "some string",
True, True,
None,
["some string"],
{"some_component": 8},
{3: 2},
], ],
) )
def test_invalid_package_contents_rejected(invalid_package: object) -> None: def test_invalid_package_contents_rejected(invalid_package: object) -> None:
@@ -1167,28 +1108,15 @@ def test_invalid_package_contents_rejected(invalid_package: object) -> None:
do_packages_pass(config) do_packages_pass(config)
@pytest.mark.xfail( def test_single_package_fragment_form_rejected() -> None:
reason="Deprecated single-package fallback swallows these errors. " """The deprecated single-package form is removed and now raises.
"Remove xfail when single-package deprecation is removed (2026.7.0).",
strict=True, Previously ``packages: !include some_package.yaml`` resolving to a bare config
) fragment dict was silently wrapped and merged via the single-package fallback.
@pytest.mark.parametrize( That form must now raise instead of being accepted.
"invalid_package", """
[
None,
["some string"],
{"some_component": 8},
{3: 2},
],
)
def test_invalid_package_contents_masked_by_deprecation(
invalid_package: object,
) -> None:
"""These invalid packages are swallowed by the deprecated single-package fallback."""
config = { config = {
CONF_PACKAGES: { CONF_PACKAGES: {CONF_WIFI: {CONF_SSID: "test", CONF_PASSWORD: "secret"}},
"some_package": invalid_package,
},
} }
with pytest.raises(cv.Invalid): with pytest.raises(cv.Invalid):
do_packages_pass(config) do_packages_pass(config)
@@ -1231,14 +1159,10 @@ def test_named_dict_with_include_files_no_false_deprecation_warning(
assert "deprecated" not in caplog.text.lower() assert "deprecated" not in caplog.text.lower()
def test_validate_deprecated_false_raises_directly( def test_named_package_errors_raise_directly(
caplog: pytest.LogCaptureFixture, caplog: pytest.LogCaptureFixture,
) -> None: ) -> None:
"""With validate_deprecated=False, errors raise directly without fallback. """Errors processing a named-dict package raise directly, with no deprecation warning."""
This is the codepath used for remote packages where _process_remote_package
returns already-resolved dicts that is_package_definition cannot detect.
"""
config = { config = {
CONF_PACKAGES: { CONF_PACKAGES: {
"pkg_a": {CONF_WIFI: {CONF_SSID: "test"}}, "pkg_a": {CONF_WIFI: {CONF_SSID: "test"}},
@@ -1261,7 +1185,7 @@ def test_validate_deprecated_false_raises_directly(
caplog.at_level(logging.WARNING), caplog.at_level(logging.WARNING),
pytest.raises(cv.Invalid, match="nested error"), pytest.raises(cv.Invalid, match="nested error"),
): ):
_walk_packages(config, failing_callback, validate_deprecated=False) _walk_packages(config, failing_callback)
assert "deprecated" not in caplog.text.lower() assert "deprecated" not in caplog.text.lower()
@@ -1296,40 +1220,6 @@ def test_error_on_first_declared_package_still_detected() -> None:
_walk_packages(config, fail_on_last) _walk_packages(config, fail_on_last)
def test_deprecated_single_package_fallback_still_works(
caplog: pytest.LogCaptureFixture,
) -> None:
"""The deprecated single-package form still falls back at the top level.
When a dict's values are plain config fragments (not package definitions)
and the callback fails, the deprecated fallback wraps the dict in a list
and retries with a deprecation warning.
"""
config = {
CONF_PACKAGES: {
CONF_WIFI: {CONF_SSID: "test", CONF_PASSWORD: "secret"},
},
}
attempt = 0
def fail_then_succeed(
package_config: dict, context: object, path: DocumentPath | None = None
) -> dict:
nonlocal attempt
attempt += 1
if attempt == 1:
# First attempt: treating as named dict fails
raise cv.Invalid("not a valid package")
# Second attempt: after fallback wraps as list, succeeds
return package_config
with caplog.at_level(logging.WARNING):
_walk_packages(config, fail_then_succeed)
assert "deprecated" in caplog.text.lower()
def test_merge_packages_invalid_nested_type_raises() -> None: def test_merge_packages_invalid_nested_type_raises() -> None:
"""Invalid nested packages type during merge raises cv.Invalid.""" """Invalid nested packages type during merge raises cv.Invalid."""
config = { config = {

View File

@@ -4,6 +4,181 @@
namespace esphome::modbus::helpers { namespace esphome::modbus::helpers {
using FC = ModbusFunctionCode;
// --- server_frame_length ---------------------------------------------------
// Frame layout: address(1) + function(1) + ... + CRC(2). Fixtures borrowed from
// tests/integration/fixtures/uart_mock_modbus.yaml.
TEST(ModbusServerFrameLength, TooShortReturnsMinimum) {
const uint8_t frame[] = {0x01};
EXPECT_EQ(server_frame_length(frame, 1), MIN_FRAME_SIZE);
}
TEST(ModbusServerFrameLength, ReadHoldingUsesByteCount) {
// inject_rx for basic_register: 2 data bytes -> 5 + 2 = 7
const uint8_t frame[] = {0x01, 0x03, 0x02, 0x01, 0x03, 0xF9, 0xD5};
EXPECT_EQ(server_frame_length(frame, sizeof(frame)), 7);
}
TEST(ModbusServerFrameLength, ReadByteCountCappedAtMax) {
const uint8_t frame[] = {0x01, 0x03, 0xFF}; // claim 255 bytes
EXPECT_EQ(server_frame_length(frame, sizeof(frame)), 5 + MAX_NUM_OF_REGISTERS_TO_READ * 2);
}
TEST(ModbusServerFrameLength, ReadMissingByteCountReturnsHeaderOnly) {
const uint8_t frame[] = {0x01, 0x03};
EXPECT_EQ(server_frame_length(frame, sizeof(frame)), 5);
}
TEST(ModbusServerFrameLength, ExceptionResponse) {
// exception_response fixture: function code 0x83 has the exception bit set
const uint8_t frame[] = {0x01, 0x83, 0x02, 0xC0, 0xF1};
EXPECT_EQ(server_frame_length(frame, sizeof(frame)), 5);
}
TEST(ModbusServerFrameLength, WriteResponsesAreFixed) {
for (FC fc :
{FC::WRITE_SINGLE_COIL, FC::WRITE_SINGLE_REGISTER, FC::WRITE_MULTIPLE_COILS, FC::WRITE_MULTIPLE_REGISTERS}) {
const uint8_t frame[] = {0x01, static_cast<uint8_t>(fc)};
EXPECT_EQ(server_frame_length(frame, sizeof(frame)), 8) << "fc=" << static_cast<int>(fc);
}
}
TEST(ModbusServerFrameLength, MiscFixedAndUnknown) {
const uint8_t mask[] = {0x01, static_cast<uint8_t>(FC::MASK_WRITE_REGISTER)};
const uint8_t fifo[] = {0x01, static_cast<uint8_t>(FC::READ_FIFO_QUEUE)};
const uint8_t unknown[] = {0x01, 0x42};
EXPECT_EQ(server_frame_length(mask, sizeof(mask)), 10);
EXPECT_EQ(server_frame_length(fifo, sizeof(fifo)), 6);
EXPECT_EQ(server_frame_length(unknown, sizeof(unknown)), MIN_FRAME_SIZE);
}
// --- client_frame_length ---------------------------------------------------
TEST(ModbusClientFrameLength, TooShortReturnsMinimum) {
const uint8_t frame[] = {0x01};
EXPECT_EQ(client_frame_length(frame, 1), MIN_FRAME_SIZE);
}
TEST(ModbusClientFrameLength, ReadAndWriteSingleAreFixed) {
// basic_register request fixture is a read-holding request -> 8 bytes
const uint8_t read[] = {0x01, 0x03, 0x00, 0x03, 0x00, 0x01, 0x74, 0x0A};
EXPECT_EQ(client_frame_length(read, sizeof(read)), 8);
for (FC fc : {FC::READ_COILS, FC::READ_DISCRETE_INPUTS, FC::READ_INPUT_REGISTERS, FC::WRITE_SINGLE_COIL,
FC::WRITE_SINGLE_REGISTER}) {
const uint8_t frame[] = {0x01, static_cast<uint8_t>(fc)};
EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 8) << "fc=" << static_cast<int>(fc);
}
}
TEST(ModbusClientFrameLength, WriteMultipleUsesByteCount) {
// write 2 registers (4 data bytes): addr(2)+qty(2)+count(1) then data; count is frame[6]
const uint8_t frame[] = {0x01, 0x10, 0x00, 0x00, 0x00, 0x02, 0x04, 0x00, 0x0B, 0x00, 0x16};
EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 9 + 4);
}
TEST(ModbusClientFrameLength, WriteMultipleByteCountCapped) {
const uint8_t frame[] = {0x01, 0x0F, 0x00, 0x00, 0x00, 0x02, 0xFF};
EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 9 + MAX_NUM_OF_REGISTERS_TO_WRITE * 2);
}
TEST(ModbusClientFrameLength, WriteMultipleMissingByteCount) {
const uint8_t frame[] = {0x01, 0x10, 0x00, 0x00, 0x00, 0x02};
EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 9);
}
TEST(ModbusClientFrameLength, MiscFixedAndUnknown) {
const uint8_t mask[] = {0x01, static_cast<uint8_t>(FC::MASK_WRITE_REGISTER)};
const uint8_t fifo[] = {0x01, static_cast<uint8_t>(FC::READ_FIFO_QUEUE)};
const uint8_t unknown[] = {0x01, 0x42};
EXPECT_EQ(client_frame_length(mask, sizeof(mask)), 10);
EXPECT_EQ(client_frame_length(fifo, sizeof(fifo)), 6);
EXPECT_EQ(client_frame_length(unknown, sizeof(unknown)), MIN_FRAME_SIZE);
}
// --- create_client_pdu -----------------------------------------------------
// PDU = function code + data (no address, no CRC).
TEST(ModbusCreateClientPdu, ReadHolding) {
auto pdu = create_client_pdu(FC::READ_HOLDING_REGISTERS, 0x0003, 1);
const std::vector<uint8_t> expected{0x03, 0x00, 0x03, 0x00, 0x01};
EXPECT_EQ(std::vector<uint8_t>(pdu.begin(), pdu.end()), expected);
}
TEST(ModbusCreateClientPdu, WriteSingleOmitsQuantity) {
const uint8_t values[] = {0x00, 0x0B};
auto pdu = create_client_pdu(FC::WRITE_SINGLE_REGISTER, 0x0003, 1, values, sizeof(values));
const std::vector<uint8_t> expected{0x06, 0x00, 0x03, 0x00, 0x0B};
EXPECT_EQ(std::vector<uint8_t>(pdu.begin(), pdu.end()), expected);
}
TEST(ModbusCreateClientPdu, WriteSingleTooFewValuesReturnsEmpty) {
const uint8_t values[] = {0x00};
auto pdu = create_client_pdu(FC::WRITE_SINGLE_COIL, 0x0003, 1, values, sizeof(values));
EXPECT_TRUE(pdu.empty());
}
TEST(ModbusCreateClientPdu, WriteMultipleIncludesByteCount) {
const uint8_t values[] = {0x00, 0x0B, 0x00, 0x16};
auto pdu = create_client_pdu(FC::WRITE_MULTIPLE_REGISTERS, 0x0000, 2, values, sizeof(values));
const std::vector<uint8_t> expected{0x10, 0x00, 0x00, 0x00, 0x02, 0x04, 0x00, 0x0B, 0x00, 0x16};
EXPECT_EQ(std::vector<uint8_t>(pdu.begin(), pdu.end()), expected);
}
TEST(ModbusCreateClientPdu, WriteMultipleOverCapacityReturnsEmpty) {
std::vector<uint8_t> values(MAX_PDU_SIZE - 6 + 1, 0xAA);
auto pdu = create_client_pdu(FC::WRITE_MULTIPLE_REGISTERS, 0x0000, 1, values.data(), values.size());
EXPECT_TRUE(pdu.empty());
}
TEST(ModbusCreateClientPdu, UnsupportedFunctionCodeReturnsEmpty) {
auto pdu = create_client_pdu(FC::READ_FIFO_QUEUE, 0x0000, 1);
EXPECT_TRUE(pdu.empty());
}
TEST(ModbusCreateClientPdu, ZeroEntitiesReturnsEmpty) {
auto pdu = create_client_pdu(FC::READ_HOLDING_REGISTERS, 0x0000, 0);
EXPECT_TRUE(pdu.empty());
}
TEST(ModbusCreateClientPdu, WriteWithoutValuesReturnsEmpty) {
auto pdu = create_client_pdu(FC::WRITE_MULTIPLE_REGISTERS, 0x0000, 1, nullptr, 0);
EXPECT_TRUE(pdu.empty());
}
TEST(ModbusCreateClientPdu, ReadHoldingOverMaxReturnsEmpty) {
auto pdu = create_client_pdu(FC::READ_HOLDING_REGISTERS, 0x0000, MAX_NUM_OF_REGISTERS_TO_READ + 1);
EXPECT_TRUE(pdu.empty());
}
// Regression: coils allow up to 2000 entities, well above the 125 register limit.
// A switch fall-through previously subjected coil/discrete reads to the register limit.
TEST(ModbusCreateClientPdu, ReadCoilsAboveRegisterLimitIsValid) {
const uint16_t quantity = MAX_NUM_OF_REGISTERS_TO_READ + 1; // 126: valid for coils, too many for registers
auto pdu = create_client_pdu(FC::READ_COILS, 0x0000, quantity);
const std::vector<uint8_t> expected{0x01, 0x00, 0x00, static_cast<uint8_t>(quantity >> 8),
static_cast<uint8_t>(quantity & 0xFF)};
EXPECT_EQ(std::vector<uint8_t>(pdu.begin(), pdu.end()), expected);
}
TEST(ModbusCreateClientPdu, ReadCoilsOverMaxReturnsEmpty) {
auto pdu = create_client_pdu(FC::READ_COILS, 0x0000, MAX_NUM_OF_COILS_TO_READ + 1);
EXPECT_TRUE(pdu.empty());
}
TEST(ModbusCreateClientPdu, ReadDiscreteInputsOverMaxReturnsEmpty) {
auto pdu = create_client_pdu(FC::READ_DISCRETE_INPUTS, 0x0000, MAX_NUM_OF_DISCRETE_INPUTS_TO_READ + 1);
EXPECT_TRUE(pdu.empty());
}
TEST(ModbusCreateClientPdu, WriteMultipleOverEntityLimitReturnsEmpty) {
const uint8_t values[] = {0x00, 0x0B};
auto pdu = create_client_pdu(FC::WRITE_MULTIPLE_REGISTERS, 0x0000, MAX_NUM_OF_REGISTERS_TO_WRITE + 1, values,
sizeof(values));
EXPECT_TRUE(pdu.empty());
}
TEST(ModbusHelpersTest, PayloadToNumberRejectsOffsetAtEndOfBuffer) { TEST(ModbusHelpersTest, PayloadToNumberRejectsOffsetAtEndOfBuffer) {
const std::vector<uint8_t> data{0x12, 0x34}; const std::vector<uint8_t> data{0x12, 0x34};
EXPECT_EQ(payload_to_number(data, SensorValueType::U_WORD, 2, 0xFFFFFFFF), 0); EXPECT_EQ(payload_to_number(data, SensorValueType::U_WORD, 2, 0xFFFFFFFF), 0);

View File

@@ -1,59 +0,0 @@
#include <gtest/gtest.h>
#include "esphome/components/modbus/modbus.h"
#include "esphome/core/helpers.h"
namespace esphome::modbus {
// Exposes protected methods for testing.
class TestModbus : public Modbus {
public:
bool test_parse_modbus_byte(uint8_t byte) { return this->parse_modbus_byte_(byte); }
void test_clear_rx_buffer() { this->rx_buffer_.clear(); }
void set_waiting(uint8_t addr) { this->waiting_for_response_ = addr; }
};
class MockDevice : public ModbusDevice {
public:
void on_modbus_data(const std::vector<uint8_t> &data) override { this->data_received = true; }
bool data_received{false};
};
TEST(ModbusTest, TwoByteRegressionTest) {
TestModbus modbus;
modbus.set_role(ModbusRole::CLIENT);
// First byte (at=0)
EXPECT_TRUE(modbus.test_parse_modbus_byte(0x01));
// Second byte (at=1)
// This used to reach raw[2] because it skipped the if(at==2) check, causing a
// buffer overflow.
EXPECT_TRUE(modbus.test_parse_modbus_byte(0x03));
}
TEST(ModbusTest, TestValidFrame) {
TestModbus modbus;
modbus.set_role(ModbusRole::CLIENT);
MockDevice device;
device.set_parent(&modbus);
device.set_address(0x01);
modbus.register_device(&device);
modbus.set_waiting(0x01);
// Address 1, Function 3, Length 2, Data 0x1234
uint8_t frame_data[] = {0x01, 0x03, 0x02, 0x12, 0x34};
uint16_t crc = esphome::crc16(frame_data, sizeof(frame_data));
std::vector<uint8_t> frame;
for (uint8_t b : frame_data)
frame.push_back(b);
frame.push_back(crc & 0xFF);
frame.push_back((crc >> 8) & 0xFF);
for (size_t i = 0; i < frame.size(); i++) {
bool result = modbus.test_parse_modbus_byte(frame[i]);
EXPECT_TRUE(result) << "Failed at byte " << i << " (0x" << std::hex << (int) frame[i] << ")";
}
EXPECT_TRUE(device.data_received);
}
} // namespace esphome::modbus

View File

@@ -49,15 +49,16 @@ modbus_controller:
- address: 1 - address: 1
id: modbus_controller_ok id: modbus_controller_ok
max_cmd_retries: 2 max_cmd_retries: 2
update_interval: 1s # Update interval is set to never to prevent automatic polling: the test will trigger requests by pressing the "Start Scenario" button
update_interval: never
- address: 2 - address: 2
id: modbus_controller_slow id: modbus_controller_slow
max_cmd_retries: 0 max_cmd_retries: 0
update_interval: 1s update_interval: never
- address: 3 - address: 3
id: modbus_controller_offline id: modbus_controller_offline
max_cmd_retries: 0 max_cmd_retries: 0
update_interval: 1s update_interval: never
sensor: sensor:
- platform: modbus_controller - platform: modbus_controller
@@ -91,4 +92,11 @@ button:
name: "Start Scenario" name: "Start Scenario"
id: start_scenario_btn id: start_scenario_btn
on_press: on_press:
- lambda: "id(virtual_uart_dev).start_scenario();" - lambda: |-
id(virtual_uart_dev).start_scenario();
id(modbus_controller_ok).set_update_interval(1000);
id(modbus_controller_ok).start_poller();
id(modbus_controller_slow).set_update_interval(1000);
id(modbus_controller_slow).start_poller();
id(modbus_controller_offline).set_update_interval(1000);
id(modbus_controller_offline).start_poller();

View File

@@ -54,7 +54,11 @@ modbus:
sensor: sensor:
- platform: sdm_meter - platform: sdm_meter
address: 2 address: 2
update_interval: 1s id: sdm_meter_1
# update_interval is set to never to avoid automatic polling before the test starts the scenario.
# The test will manually start the poller after subscribing to states, to ensure no state changes are missed.
# This also allows us to assert there are no modbus errors/warnings during the initial request/response.
update_interval: never
phase_a: phase_a:
voltage: voltage:
name: sdm_voltage name: sdm_voltage
@@ -64,4 +68,7 @@ button:
name: "Start Scenario" name: "Start Scenario"
id: start_scenario_btn id: start_scenario_btn
on_press: on_press:
- lambda: "id(virtual_uart_dev).start_scenario();" - lambda: |-
id(virtual_uart_dev).start_scenario();
id(sdm_meter_1).set_update_interval(1000);
id(sdm_meter_1).start_poller();

View File

@@ -53,8 +53,8 @@ modbus:
modbus_controller: modbus_controller:
- address: 1 - address: 1
modbus_id: virtual_modbus_controller modbus_id: virtual_modbus_controller
update_interval: 1s
id: modbus_controller_1 id: modbus_controller_1
update_interval: 1s
modbus_server: modbus_server:
- address: 1 - address: 1
@@ -176,6 +176,4 @@ button:
- platform: template - platform: template
name: "Start Scenario" name: "Start Scenario"
id: start_scenario_btn id: start_scenario_btn
on_press: # This test does not have anything to start (mock is autostart)
- lambda: "id(virtual_uart_server).start_scenario();"
- lambda: "id(virtual_uart_controller).start_scenario();"

View File

@@ -113,7 +113,4 @@ button:
- platform: template - platform: template
name: "Start Scenario" name: "Start Scenario"
id: start_scenario_btn id: start_scenario_btn
on_press: # This test does not have anything to start (mock is autostart)
- lambda: "id(virtual_uart_server).start_scenario();"
- lambda: "id(virtual_uart_server_2).start_scenario();"
- lambda: "id(virtual_uart_controller).start_scenario();"

View File

@@ -326,6 +326,4 @@ button:
- platform: template - platform: template
name: "Start Scenario" name: "Start Scenario"
id: start_scenario_btn id: start_scenario_btn
on_press: # This test does not have anything to start (mock is autostart)
- lambda: "id(virtual_uart_server).start_scenario();"
- lambda: "id(virtual_uart_controller).start_scenario();"

View File

@@ -53,7 +53,11 @@ modbus:
sensor: sensor:
- platform: sdm_meter - platform: sdm_meter
address: 2 address: 2
update_interval: 1s id: sdm_meter_1
# update_interval is set to never to avoid automatic polling before the test starts the scenario.
# The test will manually start the poller after subscribing to states, to ensure no state changes are missed.
# This also allows us to assert there are no modbus errors/warnings during the initial request/response.
update_interval: never
phase_a: phase_a:
voltage: voltage:
name: sdm_voltage name: sdm_voltage
@@ -63,4 +67,7 @@ button:
name: "Start Scenario" name: "Start Scenario"
id: start_scenario_btn id: start_scenario_btn
on_press: on_press:
- lambda: "id(virtual_uart_dev).start_scenario();" - lambda: |-
id(virtual_uart_dev).start_scenario();
id(sdm_meter_1).set_update_interval(1000);
id(sdm_meter_1).start_poller();

View File

@@ -127,15 +127,18 @@ async def test_uart_mock_modbus_timing(
) -> None: ) -> None:
"""Test modbus timing with multi-register SDM meter response.""" """Test modbus timing with multi-register SDM meter response."""
line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback()
tracker = SensorTracker(["sdm_voltage"]) tracker = SensorTracker(["sdm_voltage"])
voltage_changed = tracker.expect_any("sdm_voltage") voltage_changed = tracker.expect_any("sdm_voltage")
async with ( async with (
run_compiled(yaml_config), run_compiled(yaml_config, line_callback=line_callback),
api_client_connected() as client, api_client_connected() as client,
): ):
await tracker.setup_and_start_scenario(client) await tracker.setup_and_start_scenario(client)
await tracker.await_change(voltage_changed, "sdm_voltage") await tracker.await_change(voltage_changed, "sdm_voltage")
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -148,26 +151,25 @@ async def test_uart_mock_modbus_no_threshold(
Without the 50ms fallback timeout, the chunked response with a 40ms gap Without the 50ms fallback timeout, the chunked response with a 40ms gap
between USB packets would cause a false timeout and CRC failure cascade. between USB packets would cause a false timeout and CRC failure cascade.
Bus-level warnings (CRC failures, buffer clears) are expected during Bus-level warnings (CRC/parse failures, buffer clears) are NOT expected during
chunked reassembly — the test only verifies the final value arrives. chunked reassembly, if timeouts are set properly — these warnings indicate undersized timeouts.
""" """
line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback()
tracker = SensorTracker(["sdm_voltage"]) tracker = SensorTracker(["sdm_voltage"])
voltage_changed = tracker.expect_any("sdm_voltage") voltage_changed = tracker.expect_any("sdm_voltage")
async with ( async with (
run_compiled(yaml_config), run_compiled(yaml_config, line_callback=line_callback),
api_client_connected() as client, api_client_connected() as client,
): ):
await tracker.setup_and_start_scenario(client) await tracker.setup_and_start_scenario(client)
await tracker.await_change(voltage_changed, "sdm_voltage") await tracker.await_change(voltage_changed, "sdm_voltage")
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.xfail(
reason="Modbus parser cannot handle server responses from other devices on the bus. Fix tracked in PR #11969.",
strict=True,
)
async def test_uart_mock_modbus_server( async def test_uart_mock_modbus_server(
yaml_config: str, yaml_config: str,
run_compiled: RunCompiledFunction, run_compiled: RunCompiledFunction,
@@ -308,10 +310,6 @@ async def test_uart_mock_modbus_server_controller_write(
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.xfail(
reason="Modbus parser cannot handle server responses from other devices on the bus. Fix tracked in PR #11969.",
strict=True,
)
async def test_uart_mock_modbus_server_controller_multiple( async def test_uart_mock_modbus_server_controller_multiple(
yaml_config: str, yaml_config: str,
run_compiled: RunCompiledFunction, run_compiled: RunCompiledFunction,

View File

@@ -174,10 +174,11 @@ def test_empty_name_fallback() -> None:
def test_name_add_mac_suffix_behavior() -> None: def test_name_add_mac_suffix_behavior() -> None:
"""Test behavior related to name_add_mac_suffix. """Test behavior related to name_add_mac_suffix.
In C++, when name_add_mac_suffix is enabled and entity has no name, In C++, an entity's object_id is computed from its name_ via
get_object_id() returns str_sanitize(str_snake_case(App.get_friendly_name())) write_object_id_to() (sanitized snake_case). When an entity has no name,
dynamically. Our function always returns the same result since we're configure_entity_() sets name_ from the friendly name, with the MAC suffix
calculating the base for duplicate tracking. appended when name_add_mac_suffix is enabled. Our function always returns
the same result since we're calculating the base for duplicate tracking.
""" """
# The function should always return the same result regardless of # The function should always return the same result regardless of
# name_add_mac_suffix setting, as we're calculating the base object_id # name_add_mac_suffix setting, as we're calculating the base object_id