mirror of
https://github.com/esphome/esphome.git
synced 2026-08-22 22:26:21 +00:00
[modbus] Route broadcast writes (address 0) to all server devices (#17387)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
co-authored by
Copilot Autofix powered by AI
J. Nick Koston
parent
252bb3333e
commit
2730c10c2c
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Literal
|
||||
from typing import Any, Literal
|
||||
|
||||
from esphome import pins
|
||||
import esphome.codegen as cg
|
||||
@@ -99,15 +99,28 @@ async def to_code(config):
|
||||
cg.add(var.set_turnaround_time(config[CONF_TURNAROUND_TIME]))
|
||||
|
||||
|
||||
def _validate_server_address(value: Any) -> int:
|
||||
address = cv.hex_uint8_t(value)
|
||||
# The broadcast address (0) is delivered to every device and is never answered (Modbus 4.1),
|
||||
# so it cannot identify an individual server device.
|
||||
if address == 0:
|
||||
raise cv.Invalid(
|
||||
"Address 0 is the Modbus broadcast address and cannot be used as a "
|
||||
"server device address. Assign a unique unit address instead."
|
||||
)
|
||||
return address
|
||||
|
||||
|
||||
def modbus_device_schema(default_address, role: Literal["client", "server"] = "client"):
|
||||
hub_type = ModbusClient if role == "client" else ModbusServer
|
||||
address_validator = _validate_server_address if role == "server" else cv.hex_uint8_t
|
||||
schema = {
|
||||
cv.GenerateID(CONF_MODBUS_ID): cv.use_id(hub_type),
|
||||
}
|
||||
if default_address is None:
|
||||
schema[cv.Required(CONF_ADDRESS)] = cv.hex_uint8_t
|
||||
schema[cv.Required(CONF_ADDRESS)] = address_validator
|
||||
else:
|
||||
schema[cv.Optional(CONF_ADDRESS, default=default_address)] = cv.hex_uint8_t
|
||||
schema[cv.Optional(CONF_ADDRESS, default=default_address)] = address_validator
|
||||
return cv.Schema(schema)
|
||||
|
||||
|
||||
|
||||
@@ -183,6 +183,10 @@ void ModbusServerHub::parse_modbus_frames() {
|
||||
size_t size = this->rx_buffer_.size();
|
||||
ESP_LOGVV(TAG, "Parsing frames buffer size = %" PRIu32, size);
|
||||
bool retry_as_client = false;
|
||||
// A broadcast is a client request, never a peer response; clear any stale expectation (RTU is half-duplex).
|
||||
const bool is_broadcast = this->rx_buffer_[0] == BROADCAST_ADDRESS;
|
||||
if (is_broadcast)
|
||||
this->expecting_peer_response_ = 0;
|
||||
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",
|
||||
@@ -277,11 +281,17 @@ bool ModbusServerHub::parse_modbus_client_frame_() {
|
||||
// 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);
|
||||
uint8_t data_buffer[MAX_FRAME_SIZE] = {};
|
||||
std::memcpy(data_buffer, this->rx_buffer_.data() + data_offset, data_len);
|
||||
std::span<const uint8_t> data(data_buffer, data_len);
|
||||
this->clear_rx_buffer_(LOG_STR("parse succeeded"), false, frame_length);
|
||||
|
||||
this->process_modbus_client_frame_(address, function_code, data);
|
||||
if (address == BROADCAST_ADDRESS) {
|
||||
// Keep the unicast response buffers out of the broadcast call chain.
|
||||
this->process_broadcast_frame_(function_code, data);
|
||||
} else {
|
||||
this->process_modbus_client_frame_(address, function_code, data);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -365,15 +375,85 @@ ModbusServerDevice *ModbusServerHub::find_device_(uint8_t address) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool ModbusServerHub::check_register_range_(uint8_t address, uint8_t function_code, uint16_t start_address,
|
||||
uint16_t number_of_registers) {
|
||||
ResponseStatus ModbusServerHub::check_register_range_(uint16_t start_address, uint16_t number_of_registers) {
|
||||
if ((uint32_t) start_address + number_of_registers > 0x10000u) {
|
||||
ESP_LOGW(TAG, "Register address out of range - start: %" PRIu16 " num: %" PRIu16, start_address,
|
||||
number_of_registers);
|
||||
this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_DATA_ADDRESS);
|
||||
return false;
|
||||
return ExceptionCode::ILLEGAL_DATA_ADDRESS;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// Write PDU layout after the function code: start address(2) [+ quantity(2) + byte count(1)] + register values.
|
||||
// The value subspans taken at these offsets stay in range because client_pdu_length() clamps the byte count to the
|
||||
// same maximum the callers' number_of_registers * 2 == number_of_bytes guard enforces.
|
||||
static constexpr size_t WRITE_SINGLE_VALUES_OFFSET = 2;
|
||||
static constexpr size_t WRITE_MULTIPLE_VALUES_OFFSET = 5;
|
||||
// FC 0x17 writes follow read start(2) + read quantity(2) + write start(2) + write quantity(2) + byte count(1).
|
||||
static constexpr size_t READ_WRITE_VALUES_OFFSET = 9;
|
||||
|
||||
ResponseStatus ModbusServerHub::parse_write_single_(std::span<const uint8_t> data, uint16_t &start_address,
|
||||
RegisterValues ®isters) {
|
||||
start_address = helpers::get_data<uint16_t>(data.data(), 0);
|
||||
// No range check needed: one register can never push start_address + 1 past the address space.
|
||||
this->assemble_registers_(data.subspan(WRITE_SINGLE_VALUES_OFFSET, sizeof(uint16_t)), registers);
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
ResponseStatus ModbusServerHub::parse_write_multiple_(std::span<const uint8_t> data, uint16_t &start_address,
|
||||
RegisterValues ®isters) {
|
||||
start_address = helpers::get_data<uint16_t>(data.data(), 0);
|
||||
uint16_t number_of_registers = helpers::get_data<uint16_t>(data.data(), 2);
|
||||
uint8_t number_of_bytes = helpers::get_data<uint8_t>(data.data(), 4);
|
||||
if (number_of_registers == 0 || number_of_registers > MAX_NUM_OF_REGISTERS_TO_WRITE ||
|
||||
number_of_registers * 2 != number_of_bytes) {
|
||||
ESP_LOGW(TAG, "Invalid number of registers %" PRIu16 " or bytes %" PRIu8, number_of_registers, number_of_bytes);
|
||||
return ExceptionCode::ILLEGAL_DATA_VALUE;
|
||||
}
|
||||
if (ResponseStatus status = this->check_register_range_(start_address, number_of_registers); status.has_value()) {
|
||||
return status;
|
||||
}
|
||||
this->assemble_registers_(data.subspan(WRITE_MULTIPLE_VALUES_OFFSET, number_of_bytes), registers);
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
void ModbusServerHub::assemble_registers_(std::span<const uint8_t> values, RegisterValues ®isters) {
|
||||
for (size_t offset = 0; offset + 1 < values.size(); offset += 2) {
|
||||
registers.push_back(helpers::get_data<uint16_t>(values.data(), offset));
|
||||
}
|
||||
}
|
||||
|
||||
void ModbusServerHub::process_broadcast_frame_(uint8_t function_code, std::span<const uint8_t> data) {
|
||||
// Broadcasts are only meaningful for register writes and are never answered (Modbus 4.1 / 6.12), so an
|
||||
// unsupported function code or a validation failure is silently dropped instead of replying with an exception.
|
||||
// Coil writes (FC 0x05/0x0F) are also broadcastable by spec, but server coil handlers are not implemented yet.
|
||||
uint16_t start_address;
|
||||
RegisterValues registers;
|
||||
ResponseStatus status;
|
||||
switch (static_cast<FunctionCode>(function_code)) {
|
||||
case FunctionCode::WRITE_SINGLE_REGISTER:
|
||||
status = this->parse_write_single_(data, start_address, registers);
|
||||
break;
|
||||
case FunctionCode::WRITE_MULTIPLE_REGISTERS:
|
||||
status = this->parse_write_multiple_(data, start_address, registers);
|
||||
break;
|
||||
default:
|
||||
// Reads and read/write require a reply, so they are not valid as broadcasts.
|
||||
ESP_LOGV(TAG, "Ignoring broadcast with unsupported function code %" PRIu8, function_code);
|
||||
return;
|
||||
}
|
||||
if (status.has_value()) {
|
||||
return;
|
||||
}
|
||||
for (auto *device : this->devices_) {
|
||||
// A broadcast is never answered, so a rejecting device has no other feedback channel; log it so a
|
||||
// misconfigured register map is diagnosable instead of looking identical to a successful write.
|
||||
if (ResponseStatus device_status = device->on_broadcast_write_registers(start_address, registers);
|
||||
device_status.has_value()) {
|
||||
ESP_LOGV(TAG, "Device %" PRIu8 " rejected broadcast write with exception %" PRIu8, device->get_address(),
|
||||
static_cast<uint8_t>(device_status.value()));
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ModbusServerHub::build_or_reject_read_response_(uint8_t address, uint8_t function_code, ResponseStatus status,
|
||||
@@ -420,7 +500,8 @@ bool ModbusServerHub::build_or_reject_read_response_(uint8_t address, uint8_t fu
|
||||
return true;
|
||||
}
|
||||
|
||||
void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t function_code, const uint8_t *data) {
|
||||
void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t function_code,
|
||||
std::span<const uint8_t> data) {
|
||||
ModbusServerDevice *device = this->find_device_(address);
|
||||
if (device == nullptr) {
|
||||
this->expecting_peer_response_ = address;
|
||||
@@ -437,14 +518,16 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func
|
||||
case FunctionCode::READ_HOLDING_REGISTERS:
|
||||
case FunctionCode::READ_INPUT_REGISTERS: {
|
||||
// PDU data: start address(2) + quantity(2).
|
||||
uint16_t start_address = helpers::get_data<uint16_t>(data, 0);
|
||||
uint16_t number_of_registers = helpers::get_data<uint16_t>(data, 2);
|
||||
uint16_t start_address = helpers::get_data<uint16_t>(data.data(), 0);
|
||||
uint16_t number_of_registers = helpers::get_data<uint16_t>(data.data(), 2);
|
||||
if (number_of_registers == 0 || number_of_registers > MAX_NUM_OF_REGISTERS_TO_READ) {
|
||||
ESP_LOGW(TAG, "Invalid number of registers %" PRIu16, number_of_registers);
|
||||
this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_DATA_VALUE);
|
||||
return;
|
||||
}
|
||||
if (!this->check_register_range_(address, function_code, start_address, number_of_registers)) {
|
||||
status = this->check_register_range_(start_address, number_of_registers);
|
||||
if (status.has_value()) {
|
||||
this->send_exception_(address, function_code, status.value());
|
||||
return;
|
||||
}
|
||||
RegisterValues registers;
|
||||
@@ -462,46 +545,31 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func
|
||||
}
|
||||
case FunctionCode::WRITE_SINGLE_REGISTER:
|
||||
case FunctionCode::WRITE_MULTIPLE_REGISTERS: {
|
||||
// PDU data: start address(2) [+ quantity(2) + byte count(1)] + register values.
|
||||
// A single-register write always targets one register; for a multiple-register write the
|
||||
// quantity is in the frame and its byte count must equal quantity * 2. The register values are
|
||||
// assembled into registers below so the handler doesn't have to know the request framing.
|
||||
uint16_t start_address = helpers::get_data<uint16_t>(data, 0);
|
||||
uint16_t number_of_registers = 1;
|
||||
uint16_t values_offset = 2; // single write: values follow the 2-byte start address
|
||||
if (static_cast<FunctionCode>(function_code) == FunctionCode::WRITE_MULTIPLE_REGISTERS) {
|
||||
number_of_registers = helpers::get_data<uint16_t>(data, 2);
|
||||
uint8_t number_of_bytes = helpers::get_data<uint8_t>(data, 4);
|
||||
values_offset = 5; // multiple write: values follow start address(2) + quantity(2) + byte count(1)
|
||||
if (number_of_registers == 0 || number_of_registers > MAX_NUM_OF_REGISTERS_TO_WRITE ||
|
||||
number_of_registers * 2 != number_of_bytes) {
|
||||
ESP_LOGW(TAG, "Invalid number of registers %" PRIu16 " or bytes %" PRIu8, number_of_registers,
|
||||
number_of_bytes);
|
||||
this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_DATA_VALUE);
|
||||
return;
|
||||
}
|
||||
if (!this->check_register_range_(address, function_code, start_address, number_of_registers)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Assemble the register values (host byte order) so the handler never sees wire framing.
|
||||
// Parse and validate the write PDU into host-order register values; reply with an exception on failure.
|
||||
uint16_t start_address;
|
||||
RegisterValues registers;
|
||||
for (uint16_t i = 0; i < number_of_registers; i++) {
|
||||
registers.push_back(helpers::get_data<uint16_t>(data, values_offset + i * 2));
|
||||
if (static_cast<FunctionCode>(function_code) == FunctionCode::WRITE_SINGLE_REGISTER) {
|
||||
status = this->parse_write_single_(data, start_address, registers);
|
||||
} else {
|
||||
status = this->parse_write_multiple_(data, start_address, registers);
|
||||
}
|
||||
if (status.has_value()) {
|
||||
this->send_exception_(address, function_code, status.value());
|
||||
return;
|
||||
}
|
||||
status = device->on_write_registers(start_address, registers);
|
||||
response_data = data; // echo the request header per Modbus 6.6, 6.12
|
||||
response_data = data.data(); // echo the request header per Modbus 6.6, 6.12
|
||||
response_len = 4;
|
||||
break;
|
||||
}
|
||||
case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS: {
|
||||
// PDU data: read start address(2) + read quantity(2) + write start address(2) + write quantity(2) +
|
||||
// write byte count(1) + write register values. Per Modbus 6.17 the write is performed before the read.
|
||||
uint16_t read_start_address = helpers::get_data<uint16_t>(data, 0);
|
||||
uint16_t number_of_registers = helpers::get_data<uint16_t>(data, 2);
|
||||
uint16_t write_start_address = helpers::get_data<uint16_t>(data, 4);
|
||||
uint16_t number_of_write_registers = helpers::get_data<uint16_t>(data, 6);
|
||||
uint8_t number_of_bytes = helpers::get_data<uint8_t>(data, 8);
|
||||
uint16_t read_start_address = helpers::get_data<uint16_t>(data.data(), 0);
|
||||
uint16_t number_of_registers = helpers::get_data<uint16_t>(data.data(), 2);
|
||||
uint16_t write_start_address = helpers::get_data<uint16_t>(data.data(), 4);
|
||||
uint16_t number_of_write_registers = helpers::get_data<uint16_t>(data.data(), 6);
|
||||
uint8_t number_of_bytes = helpers::get_data<uint8_t>(data.data(), 8);
|
||||
if (number_of_registers == 0 || number_of_registers > MAX_NUM_OF_REGISTERS_TO_READ ||
|
||||
number_of_write_registers == 0 || number_of_write_registers > MAX_NUM_OF_REGISTERS_TO_WRITE_RW ||
|
||||
number_of_write_registers * 2 != number_of_bytes) {
|
||||
@@ -510,18 +578,19 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func
|
||||
this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_DATA_VALUE);
|
||||
return;
|
||||
}
|
||||
if (!this->check_register_range_(address, function_code, read_start_address, number_of_registers) ||
|
||||
!this->check_register_range_(address, function_code, write_start_address, number_of_write_registers)) {
|
||||
status = this->check_register_range_(read_start_address, number_of_registers);
|
||||
if (!status.has_value()) {
|
||||
status = this->check_register_range_(write_start_address, number_of_write_registers);
|
||||
}
|
||||
if (status.has_value()) {
|
||||
this->send_exception_(address, function_code, status.value());
|
||||
return;
|
||||
}
|
||||
// Perform the write first (Modbus 6.17). Scoped so the write values are off the stack before the read
|
||||
// values are allocated, keeping only one RegisterValues buffer live at a time.
|
||||
{
|
||||
// Assemble the written register values (host byte order); they follow the 9-byte request header.
|
||||
RegisterValues write_registers;
|
||||
for (uint16_t i = 0; i < number_of_write_registers; i++) {
|
||||
write_registers.push_back(helpers::get_data<uint16_t>(data, 9 + i * 2));
|
||||
}
|
||||
this->assemble_registers_(data.subspan(READ_WRITE_VALUES_OFFSET, number_of_bytes), write_registers);
|
||||
// Dispatch to the standalone write and read handlers so any device implementing those supports 0x17
|
||||
// without a dedicated handler; a device that maps registers by address reconstructs the read response
|
||||
// from the values it just stored.
|
||||
|
||||
@@ -330,12 +330,22 @@ class ModbusServerHub : public Modbus {
|
||||
void parse_modbus_frames() override;
|
||||
bool parse_modbus_client_frame_();
|
||||
void process_modbus_server_frame(uint8_t address, std::span<const uint8_t> pdu) override;
|
||||
void process_modbus_client_frame_(uint8_t address, uint8_t function_code, const uint8_t *data);
|
||||
void process_modbus_client_frame_(uint8_t address, uint8_t function_code, std::span<const uint8_t> data);
|
||||
// Dispatches a broadcast (address 0) write to every registered device; broadcasts are never answered.
|
||||
void process_broadcast_frame_(uint8_t function_code, std::span<const uint8_t> data);
|
||||
// Parses a WRITE_SINGLE_REGISTER / WRITE_MULTIPLE_REGISTERS PDU into start_address and the host-order register
|
||||
// values, validating the register count and address range. Returns std::nullopt on success, otherwise the Modbus
|
||||
// exception code describing the failure. Shared by unicast writes (which reply with the exception) and broadcast
|
||||
// writes (which silently drop invalid frames).
|
||||
ResponseStatus parse_write_single_(std::span<const uint8_t> data, uint16_t &start_address, RegisterValues ®isters);
|
||||
ResponseStatus parse_write_multiple_(std::span<const uint8_t> data, uint16_t &start_address,
|
||||
RegisterValues ®isters);
|
||||
// Appends the big-endian register values in values to registers, in host byte order.
|
||||
void assemble_registers_(std::span<const uint8_t> values, RegisterValues ®isters);
|
||||
ModbusServerDevice *find_device_(uint8_t address);
|
||||
// Returns true if [start_address, start_address + number_of_registers) fits in the 16-bit address space.
|
||||
// On failure, logs and sends an ILLEGAL_DATA_ADDRESS exception to the client.
|
||||
bool check_register_range_(uint8_t address, uint8_t function_code, uint16_t start_address,
|
||||
uint16_t number_of_registers);
|
||||
// Returns std::nullopt if [start_address, start_address + number_of_registers) fits in the 16-bit address space,
|
||||
// otherwise ILLEGAL_DATA_ADDRESS. The caller sends the exception reply if one is required.
|
||||
ResponseStatus check_register_range_(uint16_t start_address, uint16_t number_of_registers);
|
||||
|
||||
// Builds the body of a register read response (byte count followed by the big-endian register values) into
|
||||
// response_buffer. Shared by every function code that answers with register values, so the read reply stays
|
||||
@@ -603,9 +613,18 @@ class ModbusServerDevice {
|
||||
virtual ResponseStatus on_write_registers(uint16_t start_address, const RegisterValues ®isters) {
|
||||
return ExceptionCode::ILLEGAL_FUNCTION;
|
||||
};
|
||||
// Hub entry point for broadcast (address 0) writes, which are never answered.
|
||||
ResponseStatus on_broadcast_write_registers(uint16_t start_address, const RegisterValues ®isters) {
|
||||
this->broadcast_write_ = true;
|
||||
ResponseStatus status = this->on_write_registers(start_address, registers);
|
||||
this->broadcast_write_ = false;
|
||||
return status;
|
||||
}
|
||||
|
||||
protected:
|
||||
uint8_t address_{0};
|
||||
// Set while handling a broadcast write: the caller sends no reply, so a rejection has no wire consequence.
|
||||
bool broadcast_write_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::modbus
|
||||
|
||||
@@ -116,6 +116,10 @@ static constexpr uint16_t READ_PDU_SIZE = 5;
|
||||
// A single-write PDU is always function code(1) + address(2) + value(2)
|
||||
static constexpr uint16_t WRITE_SINGLE_PDU_SIZE = 5;
|
||||
static constexpr uint16_t MAX_FRAME_SIZE = 256;
|
||||
|
||||
// 4.1 Address 0 is the broadcast address: the request is processed by every device and never answered.
|
||||
static constexpr uint8_t BROADCAST_ADDRESS = 0;
|
||||
|
||||
// Both send paths bound their payload so the framed result lands exactly on the RTU limit: a client
|
||||
// PDU gains an address byte and a CRC, a raw server frame gains a CRC. send_frame_() therefore never
|
||||
// has to check the framed size - it cannot be exceeded.
|
||||
|
||||
@@ -145,7 +145,12 @@ modbus::ResponseStatus ModbusServer::on_write_registers(uint16_t start_address,
|
||||
}
|
||||
return true;
|
||||
})) {
|
||||
ESP_LOGW(TAG, "Write request rejected before applying any register. Sending exception response.");
|
||||
// On a broadcast every device that does not map these registers rejects them, which is the normal case.
|
||||
if (this->broadcast_write_) {
|
||||
ESP_LOGV(TAG, "Write request rejected before applying any register.");
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Write request rejected before applying any register.");
|
||||
}
|
||||
return precheck;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Tests for modbus configuration validation."""
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components import modbus
|
||||
from esphome.components.modbus import CONF_MODBUS_ID, _validate_server_address
|
||||
from esphome.const import CONF_ADDRESS
|
||||
|
||||
|
||||
def test_server_address_accepts_valid_unit_address() -> None:
|
||||
# A normal unit address (1-247) is accepted and returned as an int.
|
||||
assert _validate_server_address(1) == 1
|
||||
assert _validate_server_address(247) == 247
|
||||
|
||||
|
||||
def test_server_address_accepts_hex_string() -> None:
|
||||
# hex_uint8_t parses hex strings, and the validator returns the parsed int.
|
||||
assert _validate_server_address("0x10") == 0x10
|
||||
|
||||
|
||||
def test_server_address_zero_rejected() -> None:
|
||||
# Address 0 is the Modbus broadcast address and cannot identify a server device.
|
||||
with pytest.raises(cv.Invalid, match="broadcast address"):
|
||||
_validate_server_address(0)
|
||||
|
||||
|
||||
def test_server_schema_rejects_address_zero() -> None:
|
||||
# The server-role schema wires in _validate_server_address, so address 0 is rejected there too.
|
||||
schema = modbus.modbus_device_schema(0x01, role="server")
|
||||
with pytest.raises(cv.Invalid, match="broadcast address"):
|
||||
schema({CONF_MODBUS_ID: "hub", CONF_ADDRESS: 0})
|
||||
|
||||
|
||||
def test_client_schema_still_accepts_address_zero() -> None:
|
||||
# Not rejected for clients today, but not supported either: a client broadcast gets no reply and
|
||||
# stalls the hub for the full send-wait.
|
||||
schema = modbus.modbus_device_schema(0x01)
|
||||
assert schema({CONF_MODBUS_ID: "hub", CONF_ADDRESS: 0})[CONF_ADDRESS] == 0
|
||||
@@ -0,0 +1,276 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <vector>
|
||||
|
||||
#include "common.h"
|
||||
#include "esphome/components/modbus/modbus.h"
|
||||
|
||||
namespace esphome::modbus {
|
||||
|
||||
namespace {
|
||||
|
||||
// A server device that records the writes the hub routes to it.
|
||||
class RecordingDevice : public ModbusServerDevice {
|
||||
public:
|
||||
explicit RecordingDevice(uint8_t address) { this->set_address(address); }
|
||||
|
||||
ResponseStatus on_write_registers(uint16_t start_address, const RegisterValues ®isters) override {
|
||||
this->write_count++;
|
||||
this->last_start_address = start_address;
|
||||
this->last_values.assign(registers.begin(), registers.end());
|
||||
return std::nullopt; // return value is ignored for broadcasts, which are never answered
|
||||
}
|
||||
|
||||
int write_count{0};
|
||||
uint16_t last_start_address{0};
|
||||
std::vector<uint16_t> last_values;
|
||||
};
|
||||
|
||||
// A server device that rejects every write, to exercise the broadcast dispatch loop's rejection branch.
|
||||
class RejectingDevice : public ModbusServerDevice {
|
||||
public:
|
||||
explicit RejectingDevice(uint8_t address) { this->set_address(address); }
|
||||
|
||||
ResponseStatus on_write_registers(uint16_t start_address, const RegisterValues ®isters) override {
|
||||
this->write_count++;
|
||||
return ExceptionCode::ILLEGAL_DATA_ADDRESS;
|
||||
}
|
||||
|
||||
int write_count{0};
|
||||
};
|
||||
|
||||
// A UART that records every byte written so the test can assert the hub sends no reply.
|
||||
class RecordingUART : public testing::NullUART {
|
||||
public:
|
||||
void write_array(const uint8_t *data, size_t len) override {
|
||||
this->written.insert(this->written.end(), data, data + len);
|
||||
}
|
||||
std::vector<uint8_t> written;
|
||||
};
|
||||
|
||||
// Drives full frames through the server hub's receive path in tests.
|
||||
class TestServerHub : public ModbusServerHub {
|
||||
public:
|
||||
bool tx_blocked() override { return false; }
|
||||
|
||||
// Builds a complete client frame (address + FC + pdu + CRC) and runs the full receive-side parser
|
||||
// (parse_modbus_frames), so the expecting-peer-response routing is exercised, not just the frame parser
|
||||
// below it. Returns true once the buffer has fully drained.
|
||||
bool run_receive_parser_for_test(uint8_t address, uint8_t function_code, const uint8_t *pdu_data,
|
||||
size_t pdu_data_len) {
|
||||
this->rx_buffer_.clear();
|
||||
this->rx_buffer_.reserve(pdu_data_len + 4);
|
||||
this->rx_buffer_.push_back(address);
|
||||
this->rx_buffer_.push_back(function_code);
|
||||
this->rx_buffer_.insert(this->rx_buffer_.end(), pdu_data, pdu_data + pdu_data_len);
|
||||
uint16_t crc = crc16(this->rx_buffer_.data(), this->rx_buffer_.size());
|
||||
this->rx_buffer_.push_back(crc & 0xFF);
|
||||
this->rx_buffer_.push_back(crc >> 8);
|
||||
this->parse_modbus_frames();
|
||||
return this->rx_buffer_.empty();
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
// A broadcast (address 0) single-register write reaches every registered device and is not answered.
|
||||
// Driven through the full receive parser (parse_modbus_frames) so the address-0 routing -- frame length,
|
||||
// CRC, and client-vs-broadcast dispatch -- is exercised, not just the handler below it.
|
||||
TEST(ModbusBroadcast, SingleRegisterWriteReachesAllDevicesWithoutReply) {
|
||||
TestServerHub hub;
|
||||
RecordingUART uart;
|
||||
hub.set_uart_parent(&uart);
|
||||
|
||||
RecordingDevice device_a(0x02);
|
||||
RecordingDevice device_b(0x03);
|
||||
hub.register_device(&device_a);
|
||||
hub.register_device(&device_b);
|
||||
|
||||
// FC 0x06 payload: start address 0x9D31, value 0x00A5 (big-endian, no address/CRC).
|
||||
const uint8_t pdu_data[] = {0x9D, 0x31, 0x00, 0xA5};
|
||||
ASSERT_TRUE(hub.run_receive_parser_for_test(
|
||||
BROADCAST_ADDRESS, static_cast<uint8_t>(FunctionCode::WRITE_SINGLE_REGISTER), pdu_data, sizeof(pdu_data)));
|
||||
|
||||
for (RecordingDevice *device : {&device_a, &device_b}) {
|
||||
EXPECT_EQ(device->write_count, 1);
|
||||
EXPECT_EQ(device->last_start_address, 0x9D31);
|
||||
ASSERT_EQ(device->last_values.size(), 1u);
|
||||
EXPECT_EQ(device->last_values[0], 0x00A5);
|
||||
}
|
||||
EXPECT_TRUE(uart.written.empty()); // broadcasts are never answered
|
||||
}
|
||||
|
||||
// A single-register broadcast (FC 0x06) must still reach every device when the hub is mid-way through
|
||||
// waiting for a peer's response. Its frame length matches a response frame, so without the address-0 guard
|
||||
// in parse_modbus_frames() it would be swallowed by the response parser instead of being dispatched.
|
||||
TEST(ModbusBroadcast, SingleRegisterBroadcastDispatchedWhileExpectingPeerResponse) {
|
||||
TestServerHub hub;
|
||||
RecordingUART uart;
|
||||
hub.set_uart_parent(&uart);
|
||||
|
||||
RecordingDevice device_a(0x02);
|
||||
RecordingDevice device_b(0x03);
|
||||
hub.register_device(&device_a);
|
||||
hub.register_device(&device_b);
|
||||
|
||||
// A unicast write addressed to an unregistered peer (0x09) leaves the hub expecting that peer's response.
|
||||
const uint8_t peer_pdu[] = {0x00, 0x10, 0x00, 0x2A};
|
||||
ASSERT_TRUE(hub.run_receive_parser_for_test(0x09, static_cast<uint8_t>(FunctionCode::WRITE_SINGLE_REGISTER), peer_pdu,
|
||||
sizeof(peer_pdu)));
|
||||
ASSERT_EQ(device_a.write_count, 0); // the peer request is not for our devices
|
||||
ASSERT_EQ(device_b.write_count, 0);
|
||||
|
||||
// The broadcast that follows must still be delivered to every device, and still without a reply.
|
||||
const uint8_t pdu_data[] = {0x9D, 0x31, 0x00, 0xA5};
|
||||
ASSERT_TRUE(hub.run_receive_parser_for_test(
|
||||
BROADCAST_ADDRESS, static_cast<uint8_t>(FunctionCode::WRITE_SINGLE_REGISTER), pdu_data, sizeof(pdu_data)));
|
||||
|
||||
for (RecordingDevice *device : {&device_a, &device_b}) {
|
||||
EXPECT_EQ(device->write_count, 1);
|
||||
EXPECT_EQ(device->last_start_address, 0x9D31);
|
||||
ASSERT_EQ(device->last_values.size(), 1u);
|
||||
EXPECT_EQ(device->last_values[0], 0x00A5);
|
||||
}
|
||||
EXPECT_TRUE(uart.written.empty()); // broadcasts are never answered
|
||||
}
|
||||
|
||||
// After dispatching a broadcast, the hub must not still expect a peer response: a following unicast FC 0x06
|
||||
// to one of our own devices must be handled, not misparsed as that peer's response and dropped.
|
||||
TEST(ModbusBroadcast, BroadcastClearsStalePeerExpectation) {
|
||||
TestServerHub hub;
|
||||
RecordingUART uart;
|
||||
hub.set_uart_parent(&uart);
|
||||
|
||||
RecordingDevice device(0x02);
|
||||
hub.register_device(&device);
|
||||
|
||||
// A unicast write to an unregistered peer (0x09) leaves the hub expecting that peer's response.
|
||||
const uint8_t pdu_data[] = {0x00, 0x10, 0x00, 0x2A};
|
||||
ASSERT_TRUE(hub.run_receive_parser_for_test(0x09, static_cast<uint8_t>(FunctionCode::WRITE_SINGLE_REGISTER), pdu_data,
|
||||
sizeof(pdu_data)));
|
||||
|
||||
// The broadcast that follows clears that expectation as it is dispatched.
|
||||
ASSERT_TRUE(hub.run_receive_parser_for_test(
|
||||
BROADCAST_ADDRESS, static_cast<uint8_t>(FunctionCode::WRITE_SINGLE_REGISTER), pdu_data, sizeof(pdu_data)));
|
||||
ASSERT_EQ(device.write_count, 1);
|
||||
|
||||
// The next unicast FC 0x06 to our own device is handled, not swallowed by the stale expectation.
|
||||
ASSERT_TRUE(hub.run_receive_parser_for_test(0x02, static_cast<uint8_t>(FunctionCode::WRITE_SINGLE_REGISTER), pdu_data,
|
||||
sizeof(pdu_data)));
|
||||
EXPECT_EQ(device.write_count, 2);
|
||||
}
|
||||
|
||||
// A broadcast multi-register write is decoded and delivered to every device, still without a reply.
|
||||
TEST(ModbusBroadcast, MultipleRegisterWriteReachesAllDevicesWithoutReply) {
|
||||
TestServerHub hub;
|
||||
RecordingUART uart;
|
||||
hub.set_uart_parent(&uart);
|
||||
|
||||
RecordingDevice device_a(0x02);
|
||||
RecordingDevice device_b(0x03);
|
||||
hub.register_device(&device_a);
|
||||
hub.register_device(&device_b);
|
||||
|
||||
// FC 0x10 payload: start 0x9D31, quantity 2, byte count 4, values 0x0102 and 0x0304.
|
||||
const uint8_t pdu_data[] = {0x9D, 0x31, 0x00, 0x02, 0x04, 0x01, 0x02, 0x03, 0x04};
|
||||
ASSERT_TRUE(hub.run_receive_parser_for_test(
|
||||
BROADCAST_ADDRESS, static_cast<uint8_t>(FunctionCode::WRITE_MULTIPLE_REGISTERS), pdu_data, sizeof(pdu_data)));
|
||||
|
||||
for (RecordingDevice *device : {&device_a, &device_b}) {
|
||||
EXPECT_EQ(device->write_count, 1);
|
||||
EXPECT_EQ(device->last_start_address, 0x9D31);
|
||||
ASSERT_EQ(device->last_values.size(), 2u);
|
||||
EXPECT_EQ(device->last_values[0], 0x0102);
|
||||
EXPECT_EQ(device->last_values[1], 0x0304);
|
||||
}
|
||||
EXPECT_TRUE(uart.written.empty());
|
||||
}
|
||||
|
||||
// A read broadcast is meaningless (it would need a reply), so nothing is dispatched and nothing is sent.
|
||||
TEST(ModbusBroadcast, ReadFunctionCodeIsIgnoredAndProducesNoReply) {
|
||||
TestServerHub hub;
|
||||
RecordingUART uart;
|
||||
hub.set_uart_parent(&uart);
|
||||
|
||||
RecordingDevice device(0x02);
|
||||
hub.register_device(&device);
|
||||
|
||||
// FC 0x03 payload: start 0x0000, quantity 2. Reads cannot be broadcast.
|
||||
const uint8_t pdu_data[] = {0x00, 0x00, 0x00, 0x02};
|
||||
ASSERT_TRUE(hub.run_receive_parser_for_test(
|
||||
BROADCAST_ADDRESS, static_cast<uint8_t>(FunctionCode::READ_HOLDING_REGISTERS), pdu_data, sizeof(pdu_data)));
|
||||
|
||||
EXPECT_EQ(device.write_count, 0); // no device was written
|
||||
EXPECT_TRUE(uart.written.empty()); // and the broadcast address is never answered
|
||||
}
|
||||
|
||||
// An invalid broadcast write is silently dropped: no writes dispatched and no exception reply sent.
|
||||
TEST(ModbusBroadcast, InvalidMultipleWriteBroadcastProducesNoWriteAndNoReply) {
|
||||
TestServerHub hub;
|
||||
RecordingUART uart;
|
||||
hub.set_uart_parent(&uart);
|
||||
|
||||
RecordingDevice device_a(0x02);
|
||||
RecordingDevice device_b(0x03);
|
||||
hub.register_device(&device_a);
|
||||
hub.register_device(&device_b);
|
||||
|
||||
// FC 0x10 payload: quantity 2 but byte count 2 (should be 4), so parsing fails.
|
||||
const uint8_t pdu_data[] = {0x9D, 0x31, 0x00, 0x02, 0x02, 0x01, 0x02};
|
||||
ASSERT_TRUE(hub.run_receive_parser_for_test(
|
||||
BROADCAST_ADDRESS, static_cast<uint8_t>(FunctionCode::WRITE_MULTIPLE_REGISTERS), pdu_data, sizeof(pdu_data)));
|
||||
|
||||
EXPECT_EQ(device_a.write_count, 0);
|
||||
EXPECT_EQ(device_b.write_count, 0);
|
||||
EXPECT_TRUE(uart.written.empty());
|
||||
}
|
||||
|
||||
// A device that rejects a broadcast write must not stop dispatch to devices registered after it, and the
|
||||
// broadcast is still never answered.
|
||||
TEST(ModbusBroadcast, RejectingDeviceDoesNotStopBroadcastDispatch) {
|
||||
TestServerHub hub;
|
||||
RecordingUART uart;
|
||||
hub.set_uart_parent(&uart);
|
||||
|
||||
RejectingDevice rejecter(0x02);
|
||||
RecordingDevice device(0x03);
|
||||
hub.register_device(&rejecter); // registered first, so a rejection happens before the normal device
|
||||
hub.register_device(&device);
|
||||
|
||||
// FC 0x06 payload: start address 0x9D31, value 0x00A5 (big-endian, no address/CRC).
|
||||
const uint8_t pdu_data[] = {0x9D, 0x31, 0x00, 0xA5};
|
||||
ASSERT_TRUE(hub.run_receive_parser_for_test(
|
||||
BROADCAST_ADDRESS, static_cast<uint8_t>(FunctionCode::WRITE_SINGLE_REGISTER), pdu_data, sizeof(pdu_data)));
|
||||
|
||||
EXPECT_EQ(rejecter.write_count, 1); // the rejecting device was still invoked
|
||||
EXPECT_EQ(device.write_count, 1); // and dispatch continued to the device registered after it
|
||||
EXPECT_EQ(device.last_start_address, 0x9D31);
|
||||
ASSERT_EQ(device.last_values.size(), 1u);
|
||||
EXPECT_EQ(device.last_values[0], 0x00A5);
|
||||
EXPECT_TRUE(uart.written.empty()); // a broadcast is never answered, even when a device rejects
|
||||
}
|
||||
|
||||
// A unicast out-of-range write sends exactly one exception frame on the wire.
|
||||
TEST(ModbusBroadcast, UnicastOutOfRangeWriteSendsSingleExceptionFrame) {
|
||||
TestServerHub hub;
|
||||
RecordingUART uart;
|
||||
hub.set_uart_parent(&uart);
|
||||
|
||||
RecordingDevice device(0x02);
|
||||
hub.register_device(&device);
|
||||
|
||||
// FC 0x10 payload: start 0xFFFF, quantity 2, byte count 4, values valid but address range overflows.
|
||||
const uint8_t pdu_data[] = {0xFF, 0xFF, 0x00, 0x02, 0x04, 0x01, 0x02, 0x03, 0x04};
|
||||
ASSERT_TRUE(hub.run_receive_parser_for_test(0x02, static_cast<uint8_t>(FunctionCode::WRITE_MULTIPLE_REGISTERS),
|
||||
pdu_data, sizeof(pdu_data)));
|
||||
|
||||
EXPECT_EQ(device.write_count, 0);
|
||||
ASSERT_EQ(uart.written.size(), 5u);
|
||||
EXPECT_EQ(uart.written[0], 0x02); // server address
|
||||
EXPECT_EQ(uart.written[1], static_cast<uint8_t>(FunctionCode::WRITE_MULTIPLE_REGISTERS) | 0x80);
|
||||
EXPECT_EQ(uart.written[2], static_cast<uint8_t>(ExceptionCode::ILLEGAL_DATA_ADDRESS));
|
||||
}
|
||||
|
||||
} // namespace esphome::modbus
|
||||
Reference in New Issue
Block a user