[modbus] Typed send helpers; migrate in-tree callers off send() (#17859)

This commit is contained in:
Bonne Eggleston
2026-07-25 21:57:35 -10:00
committed by GitHub
parent 9dd7dcb06d
commit 98f2a0b4a4
11 changed files with 144 additions and 19 deletions
@@ -7,7 +7,6 @@ namespace esphome::growatt_solar {
static const char *const TAG = "growatt_solar";
static const uint8_t MODBUS_CMD_READ_IN_REGISTERS = 0x04;
static const uint8_t MODBUS_REGISTER_COUNT[] = {33, 95}; // indexed with enum GrowattProtocolVersion
void GrowattSolar::loop() {
@@ -31,7 +30,7 @@ void GrowattSolar::update() {
}
this->waiting_to_update_ = false;
this->send(MODBUS_CMD_READ_IN_REGISTERS, 0, MODBUS_REGISTER_COUNT[this->protocol_version_]);
this->read_input_registers(0, MODBUS_REGISTER_COUNT[this->protocol_version_]);
this->last_send_ = millis();
}
@@ -7,7 +7,6 @@ namespace esphome::havells_solar {
static const char *const TAG = "havells_solar";
static const uint8_t MODBUS_CMD_READ_IN_REGISTERS = 0x03;
static const uint8_t MODBUS_REGISTER_COUNT = 48; // 48 x 16-bit registers
void HavellsSolar::on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) {
@@ -122,7 +121,7 @@ void HavellsSolar::on_response(std::span<const uint8_t> request_pdu, std::span<c
this->dci_of_t_sensor_->publish_state(dci_of_t);
}
void HavellsSolar::update() { this->send(MODBUS_CMD_READ_IN_REGISTERS, 0, MODBUS_REGISTER_COUNT); }
void HavellsSolar::update() { this->read_holding_registers(0, MODBUS_REGISTER_COUNT); }
void HavellsSolar::dump_config() {
ESP_LOGCONFIG(TAG,
"HAVELLS Solar:\n"
+1 -2
View File
@@ -7,7 +7,6 @@ namespace esphome::kuntze {
static const char *const TAG = "kuntze";
static const uint8_t CMD_READ_REG = 0x03;
static const uint16_t REGISTER[] = {4136, 4160, 4680, 6000, 4688, 4728, 5832};
// Maximum bytes to log for Modbus responses (2 registers = 4, plus count = 5)
@@ -77,7 +76,7 @@ void Kuntze::loop() {
if (this->waiting_ || (this->state_ == 0))
return;
this->last_send_ = now;
send(CMD_READ_REG, REGISTER[this->state_ - 1], 2);
this->read_holding_registers(REGISTER[this->state_ - 1], 2);
this->waiting_ = true;
}
+21
View File
@@ -718,4 +718,25 @@ void Modbus::clear_rx_buffer_(const LogString *reason, bool warn, size_t bytes_t
}
}
void ModbusClientDevice::read_entities(EntityType entity_type, uint16_t start_address, uint16_t number_of_entities) {
switch (entity_type) {
case EntityType::HOLDING:
this->read_holding_registers(start_address, number_of_entities);
return;
case EntityType::INPUT_REGISTER:
this->read_input_registers(start_address, number_of_entities);
return;
case EntityType::COIL:
this->read_coils(start_address, number_of_entities);
return;
case EntityType::DISCRETE_INPUT:
this->read_discrete_inputs(start_address, number_of_entities);
return;
default:
ESP_LOGW(TAG, "Invalid entity type for read_entities: %d", (int) entity_type);
this->on_not_sent(); // every rejected send is signalled, like send_pdu()'s own refusals
return;
}
}
} // namespace esphome::modbus
+36 -3
View File
@@ -206,9 +206,8 @@ class ModbusClientDevice {
this->on_modbus_not_sent();
#pragma GCC diagnostic pop
}
/// Called when no (valid) response arrived; return true to have the hub re-queue the frame for a retry.
/// The hub does not bound retries: the device is responsible for limiting them (e.g. track a counter and
/// return false when exhausted), or an unresponsive peer will starve other traffic on the bus.
/// Called when no matching, uninterrupted response arrived; return true to have the hub re-queue the frame for a
/// retry. The hub does not bound retries: the device is responsible for limiting them.
virtual bool on_no_response() {
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
@@ -221,6 +220,7 @@ class ModbusClientDevice {
// Remove before 2027.2.0
ESPDEPRECATED("Override on_no_response() instead. Removed in 2027.2.0", "2026.8.0")
virtual bool on_modbus_no_response() { return false; }
ESPDEPRECATED("Use the typed read_*/write_* helpers or send_pdu() instead. Removed in 2027.2.0", "2026.8.0")
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(
@@ -237,6 +237,39 @@ class ModbusClientDevice {
}
this->parent_->send_pdu(payload[0], std::span<const uint8_t>(payload).subspan(1), this);
}
// Dispatches to the matching read_* method; defined in modbus.cpp because it logs on an invalid type.
void read_entities(EntityType entity_type, uint16_t start_address, uint16_t number_of_entities);
void read_input_registers(uint16_t start_address, uint16_t number_of_registers) {
this->send_pdu(helpers::create_read_pdu(FunctionCode::READ_INPUT_REGISTERS, start_address, number_of_registers));
}
void read_holding_registers(uint16_t start_address, uint16_t number_of_registers) {
this->send_pdu(helpers::create_read_pdu(FunctionCode::READ_HOLDING_REGISTERS, start_address, number_of_registers));
}
void read_coils(uint16_t start_address, uint16_t number_of_coils) {
this->send_pdu(helpers::create_read_pdu(FunctionCode::READ_COILS, start_address, number_of_coils));
}
void read_discrete_inputs(uint16_t start_address, uint16_t number_of_inputs) {
this->send_pdu(helpers::create_read_pdu(FunctionCode::READ_DISCRETE_INPUTS, start_address, number_of_inputs));
}
void write_single_register(uint16_t start_address, uint16_t value) {
this->send_pdu(helpers::create_write_single_register_pdu(start_address, value));
}
void write_single_coil(uint16_t address, bool value) {
this->send_pdu(helpers::create_write_single_coil_pdu(address, value));
}
void write_multiple_registers(uint16_t start_address, std::span<const uint16_t> values) {
this->send_pdu(helpers::create_write_registers_pdu(start_address, values));
}
/// Note: std::vector<bool> cannot bind to std::span<const bool>; use a contiguous bool container or the packed
/// overload.
void write_multiple_coils(uint16_t start_address, std::span<const bool> values) {
this->send_pdu(helpers::create_write_coils_pdu(start_address, values));
}
/// Packed variant: a PackedBits view (the same layout on_read_coils() delivers), so
/// read-modify-write needs no unpack/repack.
void write_multiple_coils(uint16_t start_address, PackedBits bits) {
this->send_pdu(helpers::create_write_coils_pdu(start_address, bits));
}
inline void clear_tx_queue_for_address(bool clear_sent = true) {
this->parent_->clear_tx_queue_for_address(this->address_, clear_sent);
}
@@ -523,8 +523,9 @@ ModbusCommandItem ModbusCommandItem::create_custom_command(
bool ModbusCommandItem::send() {
if (this->function_code != FunctionCode::CUSTOM) {
modbusdevice->send(uint8_t(this->function_code), this->register_address, this->register_count, this->payload.size(),
this->payload.empty() ? nullptr : &this->payload[0]);
modbusdevice->send_pdu(
modbus::helpers::create_client_pdu(this->function_code, this->register_address, this->register_count,
this->payload.empty() ? nullptr : &this->payload[0], this->payload.size()));
} else {
modbusdevice->send_raw(this->payload);
}
+1 -2
View File
@@ -5,7 +5,6 @@ namespace esphome::pzemac {
static const char *const TAG = "pzemac";
static const uint8_t PZEM_CMD_READ_IN_REGISTERS = 0x04;
static const uint8_t PZEM_CMD_RESET_ENERGY = 0x42;
static const uint8_t PZEM_REGISTER_COUNT = 10; // 10x 16-bit registers
@@ -62,7 +61,7 @@ void PZEMAC::on_response(std::span<const uint8_t> request_pdu, std::span<const u
this->power_factor_sensor_->publish_state(power_factor);
}
void PZEMAC::update() { this->send(PZEM_CMD_READ_IN_REGISTERS, 0, PZEM_REGISTER_COUNT); }
void PZEMAC::update() { this->read_input_registers(0, PZEM_REGISTER_COUNT); }
void PZEMAC::dump_config() {
ESP_LOGCONFIG(TAG,
"PZEMAC:\n"
+1 -2
View File
@@ -5,7 +5,6 @@ namespace esphome::pzemdc {
static const char *const TAG = "pzemdc";
static const uint8_t PZEM_CMD_READ_IN_REGISTERS = 0x04;
static const uint8_t PZEM_CMD_RESET_ENERGY = 0x42;
static const uint8_t PZEM_REGISTER_COUNT = 10; // 10x 16-bit registers
@@ -52,7 +51,7 @@ void PZEMDC::on_response(std::span<const uint8_t> request_pdu, std::span<const u
this->energy_sensor_->publish_state(energy);
}
void PZEMDC::update() { this->send(PZEM_CMD_READ_IN_REGISTERS, 0, 8); }
void PZEMDC::update() { this->read_input_registers(0, 8); }
void PZEMDC::dump_config() {
ESP_LOGCONFIG(TAG,
"PZEMDC:\n"
+1 -2
View File
@@ -7,7 +7,6 @@ namespace esphome::sdm_meter {
static const char *const TAG = "sdm_meter";
static const uint8_t MODBUS_CMD_READ_IN_REGISTERS = 0x04;
static const uint8_t MODBUS_REGISTER_COUNT = 80; // 74 x 16-bit registers
void SDMMeter::on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) {
@@ -83,7 +82,7 @@ void SDMMeter::on_response(std::span<const uint8_t> request_pdu, std::span<const
this->export_reactive_energy_sensor_->publish_state(export_reactive_energy);
}
void SDMMeter::update() { this->send(MODBUS_CMD_READ_IN_REGISTERS, 0, MODBUS_REGISTER_COUNT); }
void SDMMeter::update() { this->read_input_registers(0, MODBUS_REGISTER_COUNT); }
void SDMMeter::dump_config() {
ESP_LOGCONFIG(TAG,
"SDM Meter:\n"
@@ -7,7 +7,6 @@ namespace esphome::selec_meter {
static const char *const TAG = "selec_meter";
static const uint8_t MODBUS_CMD_READ_IN_REGISTERS = 0x04;
static const uint8_t MODBUS_REGISTER_COUNT = 34; // 34 x 16-bit registers
void SelecMeter::on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) {
@@ -82,7 +81,7 @@ void SelecMeter::on_response(std::span<const uint8_t> request_pdu, std::span<con
this->maximum_demand_apparent_power_sensor_->publish_state(maximum_demand_apparent_power);
}
void SelecMeter::update() { this->send(MODBUS_CMD_READ_IN_REGISTERS, 0, MODBUS_REGISTER_COUNT); }
void SelecMeter::update() { this->read_input_registers(0, MODBUS_REGISTER_COUNT); }
void SelecMeter::dump_config() {
ESP_LOGCONFIG(TAG,
"SELEC Meter:\n"
@@ -272,4 +272,81 @@ TEST(ModbusDeviceShim, LegacyCallbacksReceiveTheOldShapes) {
EXPECT_EQ(device.last_error_code_, 0x02);
}
// --- typed send helpers --------------------------------------------------------------------------
// Each helper is a one-line forward onto a merged builder; these pin the function code and wire
// bytes each one queues, so a swapped code or transposed field cannot survive review silently.
TEST(ModbusTypedSendHelpers, HelpersQueueExpectedPdus) {
NoResponseProbeHub hub;
ModbusClientDevice device(&hub, 0x02);
auto check = [&](const std::vector<uint8_t> &expected) {
ASSERT_EQ(hub.queued_frames(), 1u);
auto pdu = hub.front().frame.pdu();
EXPECT_EQ(std::vector<uint8_t>(pdu.begin(), pdu.end()), expected);
hub.force_send_front();
hub.timeout_waiting(); // default on_no_response() declines the retry, dropping the frame
};
device.read_holding_registers(0x0102, 3);
check({0x03, 0x01, 0x02, 0x00, 0x03});
device.read_input_registers(0x0010, 2);
check({0x04, 0x00, 0x10, 0x00, 0x02});
device.read_coils(0x0020, 10);
check({0x01, 0x00, 0x20, 0x00, 0x0A});
device.read_discrete_inputs(0x0030, 1);
check({0x02, 0x00, 0x30, 0x00, 0x01});
device.write_single_register(0x0040, 0xABCD);
check({0x06, 0x00, 0x40, 0xAB, 0xCD});
device.write_single_coil(0x0041, true);
check({0x05, 0x00, 0x41, 0xFF, 0x00});
device.write_single_coil(0x0041, false);
check({0x05, 0x00, 0x41, 0x00, 0x00});
const uint16_t regs[] = {0x000B, 0x0016};
device.write_multiple_registers(0x0050, regs);
check({0x10, 0x00, 0x50, 0x00, 0x02, 0x04, 0x00, 0x0B, 0x00, 0x16});
const bool coils[] = {true, false, true};
device.write_multiple_coils(0x0060, coils);
check({0x0F, 0x00, 0x60, 0x00, 0x03, 0x01, 0x05});
const uint8_t packed[] = {0x05};
device.write_multiple_coils(0x0060, PackedBits(packed, 3)); // packed overload, same wire bytes
check({0x0F, 0x00, 0x60, 0x00, 0x03, 0x01, 0x05});
}
TEST(ModbusTypedSendHelpers, ReadEntitiesDispatchesByTypeAndRejectsInvalid) {
NoResponseProbeHub hub;
ModbusClientDevice device(&hub, 0x02);
device.read_entities(EntityType::HOLDING, 0x0001, 1);
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_EQ(hub.front().frame.pdu()[0], 0x03);
hub.force_send_front();
hub.timeout_waiting();
device.read_entities(EntityType::DISCRETE_INPUT, 0x0001, 1);
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_EQ(hub.front().frame.pdu()[0], 0x02);
hub.force_send_front();
hub.timeout_waiting();
device.read_entities(EntityType::CUSTOM, 0x0001, 1); // no read function: logged and not queued
EXPECT_EQ(hub.queued_frames(), 0u);
}
// A rejected read_entities() signals on_not_sent() like every other refused send.
namespace {
class NotSentCountingDevice : public ModbusClientDevice {
public:
NotSentCountingDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_not_sent() override { this->not_sent_++; }
int not_sent_{0};
};
} // namespace
TEST(ModbusTypedSendHelpers, InvalidReadEntitiesSignalsNotSent) {
NoResponseProbeHub hub;
NotSentCountingDevice device(&hub, 0x02);
device.read_entities(EntityType::CUSTOM, 0x0001, 1);
EXPECT_EQ(device.not_sent_, 1);
EXPECT_EQ(hub.queued_frames(), 0u);
}
} // namespace esphome::modbus::testing