[modbus_controller] Refactor to simplify message handling (#11781)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bonne Eggleston
2026-08-05 11:02:56 -05:00
committed by GitHub
co-authored by Claude Fable 5
parent e31a43af17
commit b93b21eab1
17 changed files with 824 additions and 481 deletions
@@ -128,6 +128,11 @@ inline bool value_type_is_float(SensorValueType v) {
return v == SensorValueType::FP32 || v == SensorValueType::FP32_R;
}
/// Coils and discrete inputs are the bit-addressed entity tables; the other types are 16-bit registers.
inline bool is_entity_type_binary(EntityType type) {
return type == EntityType::COIL || type == EntityType::DISCRETE_INPUT;
}
inline FunctionCode modbus_register_read_function(EntityType reg_type) {
switch (reg_type) {
case EntityType::COIL:
@@ -42,22 +42,38 @@ AUTO_LOAD = ["modbus"]
MULTI_CONF = True
modbus_controller_ns = cg.esphome_ns.namespace("modbus_controller")
ModbusController = modbus_controller_ns.class_(
"ModbusController", cg.PollingComponent, modbus.ModbusClientDevice
)
ModbusController = modbus_controller_ns.class_("ModbusController", cg.PollingComponent)
SensorItem = modbus_controller_ns.struct("SensorItem")
_LOGGER = logging.getLogger(__name__)
# Remove before 2027.2.0
_REMOVED_OPTIONS = {
CONF_COMMAND_THROTTLE: "Command spacing is handled by the 'modbus' component - use 'turnaround_time' there instead.",
CONF_ALLOW_DUPLICATE_COMMANDS: "Polling commands are deduplicated by the modbus hub; one-shot commands (writes) are always transmitted.",
}
def _warn_removed_options(config: ConfigType) -> ConfigType:
"""Warn about options that no longer do anything, but let the config compile."""
for option, replacement in _REMOVED_OPTIONS.items():
if option in config:
_LOGGER.warning(
"[modbus_controller] '%s' no longer has any effect and will be removed in 2027.2.0. %s",
option,
replacement,
)
return config
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(ModbusController),
cv.Optional(CONF_ALLOW_DUPLICATE_COMMANDS, default=False): cv.boolean,
cv.Optional(
CONF_COMMAND_THROTTLE, default="0ms"
): cv.positive_time_period_milliseconds,
# Removed options: accepted (and ignored) until 2027.2.0 so existing configs keep building.
cv.Optional(CONF_ALLOW_DUPLICATE_COMMANDS): cv.boolean,
cv.Optional(CONF_COMMAND_THROTTLE): cv.positive_time_period_milliseconds,
cv.Optional(CONF_SERVER_COURTESY_RESPONSE): cv.invalid(
"This option has been removed. Use modbus_server component instead: https://esphome.io/components/modbus_server/"
),
@@ -74,7 +90,8 @@ CONFIG_SCHEMA = cv.All(
}
)
.extend(cv.polling_component_schema("60s"))
.extend(modbus.modbus_device_schema(0x01))
.extend(modbus.modbus_device_schema(0x01)),
_warn_removed_options,
)
ModbusItemBaseSchema = cv.Schema(
@@ -198,8 +215,6 @@ _CALLBACK_AUTOMATIONS = (
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
cg.add(var.set_allow_duplicate_commands(config[CONF_ALLOW_DUPLICATE_COMMANDS]))
cg.add(var.set_command_throttle(config[CONF_COMMAND_THROTTLE]))
cg.add(var.set_max_cmd_retries(config[CONF_MAX_CMD_RETRIES]))
cg.add(var.set_offline_skip_updates(config[CONF_OFFLINE_SKIP_UPDATES]))
await register_modbus_device(var, config)
@@ -4,7 +4,7 @@
#include "esphome/components/modbus_controller/modbus_controller.h"
#include "esphome/core/component.h"
#include <vector>
#include <span>
namespace esphome::modbus_controller {
@@ -20,7 +20,7 @@ class ModbusBinarySensor final : public Component, public binary_sensor::BinaryS
this->skip_updates = skip_updates;
this->force_new_range = force_new_range;
if (register_type == modbus::EntityType::COIL || register_type == modbus::EntityType::DISCRETE_INPUT) {
if (modbus::helpers::is_entity_type_binary(register_type)) {
this->register_count = offset + 1;
} else {
this->register_count = 1;
@@ -6,209 +6,221 @@ namespace esphome::modbus_controller {
static const char *const TAG = "modbus_controller";
void ModbusController::setup() { this->create_register_ranges_(); }
void ModbusController::setup() { this->create_polling_commands_(); }
/*
To work with the existing modbus class and avoid polling for responses a command queue is used.
send_next_command will submit the command at the top of the queue and set the corresponding callback
to handle the response from the device.
Once the response has been processed it is removed from the queue and the next command is sent
*/
bool ModbusController::send_next_command_() {
uint32_t last_send = millis() - this->last_command_timestamp_;
ModbusCommandItem::ModbusCommandItem(ModbusController &controller, modbus::ModbusClientHub *parent, uint8_t address,
RegisterRange &&range)
: modbus::ModbusClientDevice(parent, address),
sensors(std::move(range.sensors)),
skip_updates(range.skip_updates),
register_type_(range.register_type),
start_address_(range.start_address),
register_count_(range.register_count),
function_code_(modbus::helpers::modbus_register_read_function(range.register_type)),
controller_(&controller) {}
if ((last_send > this->command_throttle_) && this->ready_for_immediate_send() && !this->command_queue_.empty()) {
auto &command = this->command_queue_.front();
// remove from queue if command was sent too often
if (!command->should_retry(this->max_cmd_retries_)) {
if (!this->module_offline_) {
ESP_LOGW(TAG, "Modbus device=%d set offline", this->address_);
if (this->offline_skip_updates_ > 0) {
// Update skip_updates_counter to stop flooding channel with timeouts
for (auto &r : this->register_ranges_) {
r.skip_updates_counter = this->offline_skip_updates_;
}
}
this->module_offline_ = true;
this->offline_callback_.call((int) command->function_code, command->register_address);
}
ESP_LOGD(TAG, "Modbus command to device=%d register=0x%02X no response received - removed from send queue",
this->address_, command->register_address);
this->command_queue_.pop_front();
} else {
ESP_LOGV(TAG, "Sending next modbus command to device %d register 0x%02X count %d", this->address_,
command->register_address, command->register_count);
command->send();
this->last_command_timestamp_ = millis();
this->command_sent_callback_.call((int) command->function_code, command->register_address);
// remove from queue if no handler is defined
if (!command->on_data_func) {
this->command_queue_.pop_front();
}
}
}
return (!this->command_queue_.empty());
ModbusCommandItem::ModbusCommandItem(ModbusController &controller, modbus::ModbusClientHub *parent, uint8_t address,
SensorItem *sensor)
: modbus::ModbusClientDevice(parent, address),
skip_updates(sensor->skip_updates),
start_address_(sensor->start_address),
register_count_(sensor->register_count),
function_code_(FunctionCode::CUSTOM),
custom_data_(&sensor->custom_data),
controller_(&controller) {
this->sensors.insert(sensor);
}
// Queue incoming response
void ModbusController::on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) {
if (this->command_queue_.empty()) {
ESP_LOGW(TAG, "Received modbus data but command queue is empty");
return;
// The base deletes copy/move; command items re-provide construction. The moved-from device must not
// unregister the hub slot we just took over, so its parent_ is cleared. The copy constructor exists
// only for callers that pass an lvalue to queue_command() (in-tree callers move); remove it when
// queue_command() is removed.
ModbusCommandItem::ModbusCommandItem(const ModbusCommandItem &other)
: modbus::ModbusClientDevice(other.parent_, other.address_),
sensors(other.sensors),
skip_updates(other.skip_updates),
on_data_func(other.on_data_func),
register_type_(other.register_type_),
start_address_(other.start_address_),
register_count_(other.register_count_),
function_code_(other.function_code_),
custom_data_(other.custom_data_),
controller_(other.controller_) {
// SmallInlineBuffer is move-only, so deep-copy the bytes explicitly.
this->payload.set(other.payload.data(), other.payload.size());
}
ModbusCommandItem::ModbusCommandItem(ModbusCommandItem &&other) noexcept
: modbus::ModbusClientDevice(other.parent_, other.address_),
sensors(std::move(other.sensors)),
skip_updates(other.skip_updates),
on_data_func(std::move(other.on_data_func)),
payload(std::move(other.payload)),
register_type_(other.register_type_),
start_address_(other.start_address_),
register_count_(other.register_count_),
function_code_(other.function_code_),
custom_data_(other.custom_data_),
controller_(other.controller_) {
other.parent_ = nullptr;
}
// A valid response: the device is online. Dispatch the payload to the handler or the range's sensors.
void ModbusCommandItem::on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) {
if (this->controller_ != nullptr)
this->controller_->set_online(true, static_cast<int>(this->function_code_), this->start_address_);
auto data = modbus::helpers::server_pdu_payload(response_pdu);
if (this->on_data_func) {
this->on_data_func(this->register_type_, this->start_address_, data);
} else if (modbus::helpers::is_function_code_write(static_cast<uint8_t>(this->function_code_))) {
// write acknowledgement - nothing to publish
} else {
for (auto *sensor : this->sensors)
sensor->parse_and_publish(data);
}
auto &current_command = this->command_queue_.front();
if (current_command != nullptr) {
if (this->controller_ != nullptr)
this->controller_->unqueue_command(this);
}
// An exception response is still a legitimate reply, so the device is considered online.
void ModbusCommandItem::on_error(std::span<const uint8_t> request_pdu, modbus::ExceptionCode exception_code) {
const uint8_t function_code = request_pdu.empty() ? 0 : request_pdu[0];
ESP_LOGW(TAG, "Modbus error function code: 0x%X register 0x%X exception: %d", function_code, this->start_address_,
static_cast<uint8_t>(exception_code));
if (this->controller_ != nullptr) {
this->controller_->set_online(true, function_code, this->start_address_);
this->controller_->unqueue_command(this);
}
}
// Not being sent says nothing about online/offline status; just drop it from the pending list.
void ModbusCommandItem::on_not_sent(std::span<const uint8_t> request_pdu) {
// A dropped write is lost while the entity has already published optimistically, so surface it.
if (modbus::helpers::is_function_code_write(static_cast<uint8_t>(this->function_code_))) {
ESP_LOGW(TAG, "Write not sent: function 0x%X register 0x%X", static_cast<uint8_t>(this->function_code_),
this->start_address_);
}
if (this->controller_ != nullptr)
this->controller_->unqueue_command(this);
}
// Fired once per wire transmission (including hub re-queues from a retry), so the on_command_sent
// trigger reflects when the frame actually went out, not when it was queued.
void ModbusCommandItem::on_sent(std::span<const uint8_t> request_pdu) {
if (this->controller_ != nullptr)
this->controller_->command_sent(static_cast<int>(this->function_code_), this->start_address_);
}
bool ModbusCommandItem::on_no_response(std::span<const uint8_t> request_pdu) {
if (this->controller_ == nullptr)
return false;
this->controller_->increment_non_response_count();
if (this->controller_->can_send()) {
// Have the hub re-queue the frame it is holding; on_sent fires again when it goes back out.
return true;
}
this->controller_->set_online(false, static_cast<int>(this->function_code_), this->start_address_);
this->controller_->unqueue_command(this);
return false;
}
void ModbusController::set_online(bool online, int function_code, int register_address) {
if (online) {
this->cmd_non_responses_ = 0;
if (this->module_offline_) {
ESP_LOGW(TAG, "Modbus device=%d back online", this->address_);
if (this->offline_skip_updates_ > 0) {
// Restore skip_updates_counter to restore commands updates
for (auto &r : this->register_ranges_) {
r.skip_updates_counter = 0;
}
}
// Restore module online state
this->module_offline_ = false;
this->online_callback_.call((int) current_command->function_code, current_command->register_address);
this->online_callback_.call(function_code, register_address);
}
} else {
// Offline is a property of the physical device, so drop every sender's queued frames for its
// address; retired frames get on_not_sent(), which reclaims one-shots through the normal path.
this->hub_->clear_tx_queue_for_address(this->address_);
if (!this->module_offline_) {
ESP_LOGW(TAG, "Modbus device=%d set offline", this->address_);
this->module_offline_ = true;
this->module_offline_at_ = this->update_counter_;
this->offline_callback_.call(function_code, register_address);
}
// Move the commandItem to the response queue. The span points into the hub's receive buffer, so
// copy the payload into the command for deferred processing in loop().
auto data = modbus::helpers::server_pdu_payload(response_pdu);
current_command->payload.assign(data.begin(), data.end());
this->incoming_queue_.push(std::move(current_command));
ESP_LOGV(TAG, "Modbus response queued");
this->command_queue_.pop_front();
}
}
// Dispatch the response to the registered handler
void ModbusController::process_modbus_data_(const ModbusCommandItem *response) {
ESP_LOGV(TAG, "Process modbus response for address 0x%X size: %zu", response->register_address,
response->payload.size());
response->on_data_func(response->register_type, response->register_address, response->payload);
void ModbusController::queue_command(ModbusCommandItem command) {
this->sweep_completed_one_shots_(); // reclaim finished one-shots before adding a new one
// Duplicates are the caller's to manage; the controller only holds the item until its terminal callback.
this->one_shot_command_items_.push_back(make_unique<ModbusCommandItem>(std::move(command)));
// A refused frame gets no terminal callback (see the hub contract), so reclaim the item here.
auto &item = this->one_shot_command_items_.back();
if (!item->send()) {
// The caller (e.g. a write entity) has usually already published optimistically - surface the loss.
ESP_LOGW(TAG, "Command refused by hub: type=0x%X address=0x%X", static_cast<uint8_t>(item->register_type()),
item->register_address());
item->pending_removal = true;
}
}
void ModbusController::on_error(std::span<const uint8_t> request_pdu, modbus::ExceptionCode exception_code) {
// The request function code (request_pdu[0]) already carries what the log needs; the exception bit only
// ever appears on the response, so no masking is needed here.
const uint8_t function_code = request_pdu.empty() ? 0 : request_pdu[0];
ESP_LOGE(TAG, "Modbus error function code: 0x%X exception: %d ", function_code, static_cast<uint8_t>(exception_code));
if (this->command_queue_.empty()) {
void ModbusController::unqueue_command(const ModbusCommandItem *command) {
// Called as the last action of the command's own callback, and from send() after send_pdu (which may
// synchronously call on_not_sent). Destroying `command` here would leave send() and the hub touching a
// freed object, so we only FLAG it; sweep_completed_one_shots_() erases it later at a safe point. No-op
// for polling commands (they persist and are not in the one-shot list).
for (auto &item : this->one_shot_command_items_) {
if (item.get() == command) {
item->pending_removal = true;
return;
}
}
}
void ModbusController::sweep_completed_one_shots_() {
this->one_shot_command_items_.remove_if(
[](const std::unique_ptr<ModbusCommandItem> &item) { return item->pending_removal; });
}
void ModbusController::update_range_(ModbusCommandItem &cmd) {
if (this->update_counter_ % (cmd.skip_updates + 1) != 0) {
ESP_LOGVV(TAG, "Skipping update for range 0x%X", cmd.register_address());
return;
}
// Remove pending command waiting for a response
auto &current_command = this->command_queue_.front();
if (current_command != nullptr) {
ESP_LOGE(TAG,
"Modbus error - last command: function code=0x%X register address = 0x%X "
"registers count=%d "
"payload size=%zu",
function_code, current_command->register_address, current_command->register_count,
current_command->payload.size());
this->command_queue_.pop_front();
}
// A refusal is already logged by the hub; note the affected range for controller-level diagnostics.
if (!cmd.send())
ESP_LOGD(TAG, "Poll refused by hub for range 0x%X", cmd.register_address());
}
SensorSet ModbusController::find_sensors_(modbus::EntityType register_type, uint16_t start_address) const {
auto reg_it = std::find_if(
std::begin(this->register_ranges_), std::end(this->register_ranges_),
[=](RegisterRange const &r) { return (r.start_address == start_address && r.register_type == register_type); });
if (reg_it == this->register_ranges_.end()) {
ESP_LOGE(TAG, "No matching range for sensor found - start_address : 0x%X", start_address);
} else {
return reg_it->sensors;
}
// not found
return {};
}
void ModbusController::on_register_data(modbus::EntityType register_type, uint16_t start_address,
const std::vector<uint8_t> &data) {
ESP_LOGV(TAG, "data for register address : 0x%X : ", start_address);
// loop through all sensors in this range; each reads its own bytes from the position resolved for it.
auto sensors = find_sensors_(register_type, start_address);
for (auto *sensor : sensors) {
sensor->parse_and_publish(data);
}
}
void ModbusController::queue_command(const ModbusCommandItem &command) {
if (!this->allow_duplicate_commands_) {
// check if this command is already qeued.
// not very effective but the queue is never really large
for (auto &item : this->command_queue_) {
if (item->is_equal(command)) {
ESP_LOGW(TAG, "Duplicate modbus command found: type=0x%x address=%u count=%u",
static_cast<uint8_t>(command.register_type), command.register_address, command.register_count);
// update the payload of the queued command
// replaces a previous command
item->payload = command.payload;
return;
}
}
}
this->command_queue_.push_back(make_unique<ModbusCommandItem>(command));
}
void ModbusController::update_range_(RegisterRange &r) {
ESP_LOGV(TAG, "Range : %X Size: %x (%d) skip: %d", r.start_address, r.register_count, (int) r.register_type,
r.skip_updates_counter);
if (r.skip_updates_counter == 0) {
// if a custom command is used the user supplied custom_data is only available in the SensorItem.
if (r.register_type == modbus::EntityType::CUSTOM) {
auto sensors = this->find_sensors_(r.register_type, r.start_address);
if (!sensors.empty()) {
auto sensor = sensors.cbegin();
auto command_item = ModbusCommandItem::create_custom_command(
this, (*sensor)->custom_data,
[this](modbus::EntityType register_type, uint16_t start_address, const std::vector<uint8_t> &data) {
this->on_register_data(modbus::EntityType::CUSTOM, start_address, data);
});
command_item.register_address = (*sensor)->start_address;
command_item.register_count = (*sensor)->register_count;
command_item.function_code = FunctionCode::CUSTOM;
queue_command(command_item);
void ModbusController::update() {
this->sweep_completed_one_shots_(); // reclaim one-shots deferred out of their own callbacks
if (this->module_offline_) {
// Offline probing follows the offline cadence alone; per-range skip_updates resumes once the
// device is back online. Requiring both cadences to coincide would leave phase combinations
// where a probe never goes out.
if (offline_retry_due(this->update_counter_, this->module_offline_at_, this->offline_skip_updates_)) {
ESP_LOGV(TAG, "Module offline - retrying");
this->cmd_non_responses_ = 0; // allow the probe through can_send()
for (auto &cmd : this->polling_command_items_) {
if (!cmd.send())
ESP_LOGD(TAG, "Probe refused by hub for range 0x%X", cmd.register_address());
}
} else {
queue_command(ModbusCommandItem::create_read_command(this, r.register_type, r.start_address, r.register_count));
ESP_LOGV(TAG, "Module offline - skipping update");
}
r.skip_updates_counter = r.skip_updates; // reset counter to config value
} else {
r.skip_updates_counter--;
}
}
//
// Queue the modbus requests to be send.
// Once we get a response to the command it is removed from the queue and the next command is send
//
void ModbusController::update() {
if (!this->command_queue_.empty()) {
ESP_LOGV(TAG, "%zu modbus commands already in queue", this->command_queue_.size());
} else {
ESP_LOGV(TAG, "Updating modbus component");
this->update_counter_++;
return;
}
for (auto &r : this->register_ranges_) {
ESP_LOGVV(TAG, "Updating range 0x%X", r.start_address);
update_range_(r);
if (this->can_send()) {
for (auto &cmd : this->polling_command_items_) {
ESP_LOGVV(TAG, "Updating range 0x%X", cmd.register_address());
this->update_range_(cmd);
}
}
this->update_counter_++;
}
// walk through the sensors and determine the register ranges to read
size_t ModbusController::create_register_ranges_() {
this->register_ranges_.clear();
void ModbusController::create_polling_commands_() {
if (this->sensorset_.empty()) {
ESP_LOGW(TAG, "No sensors registered");
return 0;
return;
}
// Sensors are walked in the sensor set's order (see SensorItemsComparator): register type, then
@@ -299,7 +311,7 @@ size_t ModbusController::create_register_ranges_() {
if (!join) {
if (have_range) {
ESP_LOGV(TAG, "Add range 0x%X %d skip:%d", r.start_address, r.register_count, r.skip_updates);
this->register_ranges_.push_back(std::move(r));
this->create_polling_command_(std::move(r));
}
r = {};
range_bytes = curr->get_register_size();
@@ -311,7 +323,6 @@ size_t ModbusController::create_register_ranges_() {
r.register_count = curr->register_count;
r.register_type = curr->register_type;
r.skip_updates = curr->skip_updates;
r.skip_updates_counter = 0;
have_range = true;
} else if (curr->skip_updates != 0) {
// use the lowest non-zero skip_updates for the whole range (0 is the default and is excluded)
@@ -326,10 +337,11 @@ size_t ModbusController::create_register_ranges_() {
}
if (have_range) {
ESP_LOGV(TAG, "Add last range 0x%X %d skip:%d", r.start_address, r.register_count, r.skip_updates);
this->register_ranges_.push_back(std::move(r));
this->create_polling_command_(std::move(r));
}
return this->register_ranges_.size();
// Reclaim growth slack; safe here because nothing has registered with the hub yet (see the
// lifetime note on polling_command_items_).
this->polling_command_items_.shrink_to_fit();
}
void ModbusController::dump_config() {
@@ -348,222 +360,163 @@ void ModbusController::dump_config() {
it->get_register_size());
}
ESP_LOGCONFIG(TAG, "ranges");
for (auto &it : this->register_ranges_) {
ESP_LOGCONFIG(TAG, " Range type=%u start=0x%X count=%d skip_updates=%d", static_cast<uint8_t>(it.register_type),
it.start_address, it.register_count, it.skip_updates);
for (auto &it : this->polling_command_items_) {
ESP_LOGCONFIG(TAG, " Range type=%u start=0x%X count=%d skip_updates=%d", static_cast<uint8_t>(it.register_type()),
it.register_address(), it.register_count(), it.skip_updates);
}
#endif
}
void ModbusController::loop() {
// Incoming data to process?
if (!this->incoming_queue_.empty()) {
auto &message = this->incoming_queue_.front();
if (message != nullptr)
this->process_modbus_data_(message.get());
this->incoming_queue_.pop();
void ModbusController::on_write_register_response(EntityType register_type, uint16_t start_address,
std::span<const uint8_t> data) {
// A well-formed write ACK echoes address and value, but a truncated PDU yields a short/empty span.
if (data.size() >= 3) {
ESP_LOGV(TAG, "Command ACK 0x%X %d ", modbus::helpers::get_data<uint16_t>(data.data(), 0),
modbus::helpers::get_data<int16_t>(data.data(), 1));
} else {
// all messages processed send pending commands
this->send_next_command_();
}
}
void ModbusController::on_write_register_response(modbus::EntityType register_type, uint16_t start_address,
const std::vector<uint8_t> &data) {
ESP_LOGV(TAG, "Command ACK 0x%X %d ", modbus::helpers::get_data<uint16_t>(data, 0),
modbus::helpers::get_data<int16_t>(data, 1));
}
void ModbusController::dump_sensors_() {
ESP_LOGV(TAG, "sensors");
for (auto &it : this->sensorset_) {
ESP_LOGV(TAG, " Sensor start=0x%X count=%d size=%zu offset=%d", it->start_address, it->register_count,
it->get_register_size(), it->offset);
ESP_LOGV(TAG, "Command ACK (short payload, %zu bytes)", data.size());
}
}
ModbusCommandItem ModbusCommandItem::create_read_command(
ModbusController *modbusdevice, modbus::EntityType register_type, uint16_t start_address, uint16_t register_count,
std::function<void(modbus::EntityType register_type, uint16_t start_address, const std::vector<uint8_t> &data)>
&&handler) {
ModbusCommandItem cmd;
cmd.modbusdevice = modbusdevice;
cmd.register_type = register_type;
cmd.function_code = modbus::helpers::modbus_register_read_function(register_type);
cmd.register_address = start_address;
cmd.register_count = register_count;
ModbusController *modbusdevice, EntityType register_type, uint16_t start_address, uint16_t register_count,
std::function<void(EntityType register_type, uint16_t start_address, std::span<const uint8_t> data)> &&handler) {
ModbusCommandItem cmd(*modbusdevice, modbusdevice->hub(), modbusdevice->device_address());
cmd.set_command_(modbus::helpers::modbus_register_read_function(register_type), register_type, start_address,
register_count);
cmd.on_data_func = std::move(handler);
return cmd;
}
ModbusCommandItem ModbusCommandItem::create_read_command(ModbusController *modbusdevice,
modbus::EntityType register_type, uint16_t start_address,
uint16_t register_count) {
ModbusCommandItem cmd;
cmd.modbusdevice = modbusdevice;
cmd.register_type = register_type;
cmd.function_code = modbus::helpers::modbus_register_read_function(register_type);
cmd.register_address = start_address;
cmd.register_count = register_count;
cmd.on_data_func = [modbusdevice](modbus::EntityType register_type, uint16_t start_address,
const std::vector<uint8_t> &data) {
modbusdevice->on_register_data(register_type, start_address, data);
};
return cmd;
}
ModbusCommandItem ModbusCommandItem::create_write_multiple_command(ModbusController *modbusdevice,
uint16_t start_address, uint16_t register_count,
const std::vector<uint16_t> &values) {
ModbusCommandItem cmd;
cmd.modbusdevice = modbusdevice;
cmd.register_type = modbus::EntityType::HOLDING;
cmd.function_code = FunctionCode::WRITE_MULTIPLE_REGISTERS;
cmd.register_address = start_address;
cmd.register_count = register_count;
cmd.on_data_func = [modbusdevice, cmd](modbus::EntityType register_type, uint16_t start_address,
const std::vector<uint8_t> &data) {
modbusdevice->on_write_register_response(cmd.register_type, start_address, data);
ModbusCommandItem cmd(*modbusdevice, modbusdevice->hub(), modbusdevice->device_address());
cmd.set_command_(FunctionCode::WRITE_MULTIPLE_REGISTERS, EntityType::HOLDING, start_address, register_count);
cmd.on_data_func = [modbusdevice](EntityType register_type, uint16_t start_address, std::span<const uint8_t> data) {
modbusdevice->on_write_register_response(register_type, start_address, data);
};
uint8_t *p = cmd.payload.init(values.size() * 2);
for (auto v : values) {
auto decoded_value = decode_value(v);
cmd.payload.push_back(decoded_value[0]);
cmd.payload.push_back(decoded_value[1]);
*p++ = decoded_value[0];
*p++ = decoded_value[1];
}
return cmd;
}
ModbusCommandItem ModbusCommandItem::create_write_single_coil(ModbusController *modbusdevice, uint16_t address,
bool value) {
ModbusCommandItem cmd;
cmd.modbusdevice = modbusdevice;
cmd.register_type = modbus::EntityType::COIL;
cmd.function_code = FunctionCode::WRITE_SINGLE_COIL;
cmd.register_address = address;
cmd.register_count = 1;
cmd.on_data_func = [modbusdevice, cmd](modbus::EntityType register_type, uint16_t start_address,
const std::vector<uint8_t> &data) {
modbusdevice->on_write_register_response(cmd.register_type, start_address, data);
ModbusCommandItem cmd(*modbusdevice, modbusdevice->hub(), modbusdevice->device_address());
cmd.set_command_(FunctionCode::WRITE_SINGLE_COIL, EntityType::COIL, address, 1);
cmd.on_data_func = [modbusdevice](EntityType register_type, uint16_t start_address, std::span<const uint8_t> data) {
modbusdevice->on_write_register_response(register_type, start_address, data);
};
cmd.payload.push_back(value ? 0xFF : 0);
cmd.payload.push_back(0);
uint8_t *p = cmd.payload.init(2);
p[0] = value ? 0xFF : 0;
p[1] = 0;
return cmd;
}
ModbusCommandItem ModbusCommandItem::create_write_multiple_coils(ModbusController *modbusdevice, uint16_t start_address,
const std::vector<bool> &values) {
ModbusCommandItem cmd;
cmd.modbusdevice = modbusdevice;
cmd.register_type = modbus::EntityType::COIL;
cmd.function_code = FunctionCode::WRITE_MULTIPLE_COILS;
cmd.register_address = start_address;
cmd.register_count = values.size();
cmd.on_data_func = [modbusdevice, cmd](modbus::EntityType register_type, uint16_t start_address,
const std::vector<uint8_t> &data) {
modbusdevice->on_write_register_response(cmd.register_type, start_address, data);
ModbusCommandItem cmd(*modbusdevice, modbusdevice->hub(), modbusdevice->device_address());
cmd.set_command_(FunctionCode::WRITE_MULTIPLE_COILS, EntityType::COIL, start_address, values.size());
cmd.on_data_func = [modbusdevice](EntityType register_type, uint16_t start_address, std::span<const uint8_t> data) {
modbusdevice->on_write_register_response(register_type, start_address, data);
};
uint8_t bitmask = 0;
int bitcounter = 0;
uint8_t *p = cmd.payload.init((values.size() + 7) / 8);
memset(p, 0, (values.size() + 7) / 8);
size_t bit = 0;
for (auto coil : values) {
if (coil) {
bitmask |= (1 << bitcounter);
p[bit / 8] |= (1 << (bit % 8));
}
bitcounter++;
if (bitcounter % 8 == 0) {
cmd.payload.push_back(bitmask);
bitmask = 0;
}
}
// add remaining bits
if (bitcounter % 8) {
cmd.payload.push_back(bitmask);
bit++;
}
return cmd;
}
ModbusCommandItem ModbusCommandItem::create_write_single_command(ModbusController *modbusdevice, uint16_t start_address,
uint16_t value) {
ModbusCommandItem cmd;
cmd.modbusdevice = modbusdevice;
cmd.register_type = modbus::EntityType::HOLDING;
cmd.function_code = FunctionCode::WRITE_SINGLE_REGISTER;
cmd.register_address = start_address;
cmd.register_count = 1; // not used here anyways
cmd.on_data_func = [modbusdevice, cmd](modbus::EntityType register_type, uint16_t start_address,
const std::vector<uint8_t> &data) {
modbusdevice->on_write_register_response(cmd.register_type, start_address, data);
ModbusCommandItem cmd(*modbusdevice, modbusdevice->hub(), modbusdevice->device_address());
cmd.set_command_(FunctionCode::WRITE_SINGLE_REGISTER, EntityType::HOLDING, start_address, 1);
cmd.on_data_func = [modbusdevice](EntityType register_type, uint16_t start_address, std::span<const uint8_t> data) {
modbusdevice->on_write_register_response(register_type, start_address, data);
};
auto decoded_value = decode_value(value);
cmd.payload.push_back(decoded_value[0]);
cmd.payload.push_back(decoded_value[1]);
uint8_t *p = cmd.payload.init(2);
p[0] = decoded_value[0];
p[1] = decoded_value[1];
return cmd;
}
ModbusCommandItem ModbusCommandItem::create_custom_command(
ModbusController *modbusdevice, const std::vector<uint8_t> &values,
std::function<void(modbus::EntityType register_type, uint16_t start_address, const std::vector<uint8_t> &data)>
&&handler) {
ModbusCommandItem cmd;
cmd.modbusdevice = modbusdevice;
cmd.function_code = FunctionCode::CUSTOM;
std::function<void(EntityType register_type, uint16_t start_address, std::span<const uint8_t> data)> &&handler) {
ModbusCommandItem cmd(*modbusdevice, modbusdevice->hub(), modbusdevice->device_address());
cmd.function_code_ = FunctionCode::CUSTOM;
if (handler == nullptr) {
cmd.on_data_func = [](modbus::EntityType register_type, uint16_t start_address, const std::vector<uint8_t> &data) {
cmd.on_data_func = [](EntityType register_type, uint16_t start_address, std::span<const uint8_t> data) {
ESP_LOGI(TAG, "Custom Command sent");
};
} else {
cmd.on_data_func = handler;
}
cmd.payload = values;
cmd.payload.set(values.data(), values.size());
return cmd;
}
ModbusCommandItem ModbusCommandItem::create_custom_command(
ModbusController *modbusdevice, const std::vector<uint16_t> &values,
std::function<void(modbus::EntityType register_type, uint16_t start_address, const std::vector<uint8_t> &data)>
&&handler) {
ModbusCommandItem cmd = {};
cmd.modbusdevice = modbusdevice;
cmd.function_code = FunctionCode::CUSTOM;
std::function<void(EntityType register_type, uint16_t start_address, std::span<const uint8_t> data)> &&handler) {
ModbusCommandItem cmd(*modbusdevice, modbusdevice->hub(), modbusdevice->device_address());
cmd.function_code_ = FunctionCode::CUSTOM;
if (handler == nullptr) {
cmd.on_data_func = [](modbus::EntityType register_type, uint16_t start_address, const std::vector<uint8_t> &data) {
cmd.on_data_func = [](EntityType register_type, uint16_t start_address, std::span<const uint8_t> data) {
ESP_LOGI(TAG, "Custom Command sent");
};
} else {
cmd.on_data_func = handler;
}
uint8_t *p = cmd.payload.init(values.size() * 2);
for (auto v : values) {
cmd.payload.push_back((v >> 8) & 0xFF);
cmd.payload.push_back(v & 0xFF);
*p++ = (v >> 8) & 0xFF;
*p++ = v & 0xFF;
}
return cmd;
}
bool ModbusCommandItem::send() {
if (this->function_code != FunctionCode::CUSTOM) {
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()));
bool accepted;
if (this->function_code_ != FunctionCode::CUSTOM) {
accepted = this->send_pdu(modbus::helpers::create_client_pdu(
this->function_code_, this->start_address_, this->register_count_,
this->payload.empty() ? nullptr : this->payload.data(), this->payload.size()));
} else {
modbusdevice->send_raw(this->payload);
// Custom command: the bytes are a complete raw frame (address + PDU). Send the PDU to the frame's own
// address (which may differ from this controller's); the hub appends the CRC and routes the response
// back to this item by pointer. (send_raw() is deprecated, so send_pdu() is called with the extracted
// address. Raw-frame semantics are kept here; the custom_pdu migration is a later step.)
std::span<const uint8_t> frame =
this->custom_data_ != nullptr ? std::span<const uint8_t>(*this->custom_data_) : this->payload;
if (frame.empty()) {
ESP_LOGW(TAG, "Empty custom command frame, not sent");
accepted = false;
} else {
accepted = this->parent_->send_pdu(frame[0], frame.subspan(1), this);
}
}
this->send_count_++;
ESP_LOGV(TAG, "Command sent %d 0x%X %d send_count: %d", uint8_t(this->function_code), this->register_address,
this->register_count, this->send_count_);
return true;
}
bool ModbusCommandItem::is_equal(const ModbusCommandItem &other) {
// for custom commands we have to check for identical payloads, since
// address/count/type fields will be set to zero
return this->function_code == FunctionCode::CUSTOM
? this->payload == other.payload
: other.register_address == this->register_address && other.register_count == this->register_count &&
other.register_type == this->register_type && other.function_code == this->function_code;
// The on_command_sent trigger fires from on_sent() when the frame actually reaches the wire.
if (accepted) {
ESP_LOGV(TAG, "Command queued %d 0x%X %d", uint8_t(this->function_code_), this->start_address_,
this->register_count_);
}
return accepted;
}
} // namespace esphome::modbus_controller
@@ -7,7 +7,6 @@
#include "esphome/core/automation.h"
#include <list>
#include <queue>
#include <set>
#include <span>
#include <utility>
@@ -17,6 +16,7 @@ namespace esphome::modbus_controller {
class ModbusController;
using modbus::EntityType;
using modbus::ExceptionCode;
using modbus::FunctionCode;
using modbus::helpers::SensorValueType;
@@ -133,9 +133,7 @@ class SensorItem {
virtual void parse_and_publish(std::span<const uint8_t> data) = 0;
/// Coils and discrete inputs address individual bits; every other type addresses 16-bit registers.
bool addresses_bits() const {
return this->register_type == modbus::EntityType::COIL || this->register_type == modbus::EntityType::DISCRETE_INPUT;
}
bool addresses_bits() const { return modbus::helpers::is_entity_type_binary(this->register_type); }
/// Address a write entity (switch/number/select) targets, derived from its resolved position within
/// the range so that a write lands on the register the sensor reads from.
@@ -193,7 +191,7 @@ class SensorItem {
bool force_new_range{false};
};
// ModbusController::create_register_ranges_ tries to optimize register range
// ModbusController::create_polling_commands_ tries to optimize register range
// for this the sensors must be ordered by register_type, start_address and bitmask
class SensorItemsComparator {
public:
@@ -232,25 +230,64 @@ struct RegisterRange {
uint16_t start_address;
modbus::EntityType register_type;
uint8_t register_count;
uint16_t skip_updates; // the config value
SensorSet sensors; // all sensors of this range
uint16_t skip_updates_counter; // the running value
uint16_t skip_updates; // the config value
SensorSet sensors; // all sensors of this range
};
class ModbusCommandItem {
/// A single modbus command. Each command is its own ModbusClientDevice: it sends its frame to the hub
/// and the hub routes the response back to this object's on_modbus_* callbacks, so the controller no
/// longer has to match responses to a FIFO queue.
class ModbusCommandItem : public modbus::ModbusClientDevice {
public:
static const size_t MAX_PAYLOAD_BYTES = 240;
ModbusController *modbusdevice{nullptr};
uint16_t register_address{0};
uint16_t register_count{0};
FunctionCode function_code{FunctionCode::CUSTOM};
modbus::EntityType register_type{modbus::EntityType::CUSTOM};
std::function<void(modbus::EntityType register_type, uint16_t start_address, const std::vector<uint8_t> &data)>
on_data_func;
std::vector<uint8_t> payload = {};
/// Empty command with no controller connection (kept for source compatibility with value-type usage).
ModbusCommandItem(ModbusController &controller, modbus::ModbusClientHub *parent, uint8_t address)
: modbus::ModbusClientDevice(parent, address), controller_(&controller) {}
/// Read command built from a range; the read PDU is rebuilt from these fields at send time.
ModbusCommandItem(ModbusController &controller, modbus::ModbusClientHub *parent, uint8_t address,
RegisterRange &&range);
/// Custom polling command: the PDU bytes are referenced from the sensor (not copied); responses are
/// dispatched to that sensor.
ModbusCommandItem(ModbusController &controller, modbus::ModbusClientHub *parent, uint8_t address, SensorItem *sensor);
// The base deletes copy/move (its destructor unregisters the device from the hub queue), but command
// items are stored in value containers, so copy/move CONSTRUCTION is re-provided (copy only for the
// queue_command() path). Assignment stays deleted: the item's address-in-memory is its hub identity.
ModbusCommandItem(const ModbusCommandItem &other);
ModbusCommandItem(ModbusCommandItem &&other) noexcept;
ModbusCommandItem &operator=(ModbusCommandItem &&) = delete;
SensorSet sensors; // sensors served by this command (empty for factory/write commands)
uint16_t skip_updates{0};
std::function<void(EntityType register_type, uint16_t start_address, std::span<const uint8_t> data)> on_data_func;
/// Write data bytes for the command (register/coil values), or the raw frame of a one-shot custom
/// command; reads leave it empty. Small-buffer optimized: fixed-size commands (single-register/coil
/// writes) fit in the 8-byte inline buffer with no heap; only large multi-register or custom frames
/// spill to a single one-time heap allocation. This keeps runtime one-shot writes off the heap without
/// reserving a max-size buffer per command item.
SmallInlineBuffer<8> payload;
// Set by unqueue_command() when this one-shot has completed. The controller erases flagged items at a
// safe point (update()/queue_command()), never from inside the command's own callback.
bool pending_removal{false};
/// called when a modbus response was parsed without errors
void on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override;
/// called when a modbus error (exception) response was received
void on_error(std::span<const uint8_t> request_pdu, modbus::ExceptionCode exception_code) override;
/// called when the command could not be sent
void on_not_sent(std::span<const uint8_t> request_pdu) override;
/// called when the command's frame is actually written to the wire; fires the on_command_sent trigger
void on_sent(std::span<const uint8_t> request_pdu) override;
/// called on timeout; returns true to have the hub re-queue the frame for a retry
bool on_no_response(std::span<const uint8_t> request_pdu) override;
uint16_t register_address() const { return this->start_address_; }
uint16_t register_count() const { return this->register_count_; }
EntityType register_type() const { return this->register_type_; }
/// Queue this command's frame on the hub. Returns false when refused, in which case no callback ever comes.
/// The item is the hub device, so it must stay alive until its terminal callback; a destroyed item's
/// pending frame is silently retired.
bool send();
/// Check if the command should be retried based on the max_retries parameter
bool should_retry(uint8_t max_retries) { return this->send_count_ <= max_retries; };
/// factory methods
/** Create modbus read command
@@ -263,19 +300,8 @@ class ModbusCommandItem {
* @return ModbusCommandItem with the prepared command
*/
static ModbusCommandItem create_read_command(
ModbusController *modbusdevice, modbus::EntityType register_type, uint16_t start_address, uint16_t register_count,
std::function<void(modbus::EntityType register_type, uint16_t start_address, const std::vector<uint8_t> &data)>
&&handler);
/** Create modbus read command
* Function code 02-04
* @param modbusdevice pointer to the device to execute the command
* @param function_code modbus function code for the read command
* @param start_address modbus address of the first register to read
* @param register_count number of registers to read
* @return ModbusCommandItem with the prepared command
*/
static ModbusCommandItem create_read_command(ModbusController *modbusdevice, modbus::EntityType register_type,
uint16_t start_address, uint16_t register_count);
ModbusController *modbusdevice, EntityType register_type, uint16_t start_address, uint16_t register_count,
std::function<void(EntityType register_type, uint16_t start_address, std::span<const uint8_t> data)> &&handler);
/** Create modbus read command
* Function code 02-04
* @param modbusdevice pointer to the device to execute the command
@@ -324,8 +350,8 @@ class ModbusCommandItem {
*/
static ModbusCommandItem create_custom_command(
ModbusController *modbusdevice, const std::vector<uint8_t> &values,
std::function<void(modbus::EntityType register_type, uint16_t start_address, const std::vector<uint8_t> &data)>
&&handler = nullptr);
std::function<void(EntityType register_type, uint16_t start_address, std::span<const uint8_t> data)> &&handler =
nullptr);
/** Create custom modbus command
* @param modbusdevice pointer to the device to execute the command
@@ -336,17 +362,33 @@ class ModbusCommandItem {
*/
static ModbusCommandItem create_custom_command(
ModbusController *modbusdevice, const std::vector<uint16_t> &values,
std::function<void(modbus::EntityType register_type, uint16_t start_address, const std::vector<uint8_t> &data)>
&&handler = nullptr);
bool is_equal(const ModbusCommandItem &other);
std::function<void(EntityType register_type, uint16_t start_address, std::span<const uint8_t> data)> &&handler =
nullptr);
protected:
// wrong commands (esp. custom commands) can block the send queue, limit the number of repeats.
/// How many times this command has been sent
uint8_t send_count_{0};
void set_command_(FunctionCode function_code, EntityType register_type, uint16_t start_address,
uint16_t register_count) {
this->function_code_ = function_code;
this->register_type_ = register_type;
this->start_address_ = start_address;
this->register_count_ = register_count;
}
EntityType register_type_{EntityType::CUSTOM};
uint16_t start_address_{0};
uint16_t register_count_{0};
FunctionCode function_code_{FunctionCode::CUSTOM};
/// Custom polling commands reference the PDU bytes owned by their SensorItem instead of copying them.
const std::vector<uint8_t> *custom_data_{nullptr};
ModbusController *controller_{nullptr};
};
/// Whether an offline probe is due this update cycle: every offline_skip_updates + 1 cycles,
/// anchored at the cycle the device went offline. Pure so the cadence (including update_counter
/// wraparound) can be unit tested; used by ModbusController::update().
inline bool offline_retry_due(uint16_t update_counter, uint16_t module_offline_at, uint16_t offline_skip_updates) {
return static_cast<uint16_t>(update_counter + 1 - module_offline_at) % (offline_skip_updates + 1) == 0;
}
/** Modbus controller class.
* Each instance handles the modbus commuinication for all sensors with the same modbus address
*
@@ -355,48 +397,46 @@ class ModbusCommandItem {
* Responses for the commands are dispatched to the modbus sensor items.
*/
class ModbusController final : public PollingComponent, public modbus::ModbusClientDevice {
class ModbusController final : public PollingComponent {
public:
void dump_config() override;
void loop() override;
// No loop() override: the hub owns transmit/receive timing and each command routes its own
// response, so the controller never joins the looping components at all.
void setup() override;
void update() override;
/// queues a modbus command in the send queue
void queue_command(const ModbusCommandItem &command);
/// Sends a raw payload (address byte + PDU, no CRC) with responses routed back to this controller.
/// The payload carries its own address byte, which may differ from this controller's address.
/// Deliberately shadows the deprecated ModbusClientDevice::send_raw() with identical semantics:
/// controller-level raw sends stay supported until the command machinery is replaced.
void send_raw(const std::vector<uint8_t> &payload) {
if (payload.empty())
return; // refused at the door, like every invalid send; no callback follows
this->parent_->send_pdu(payload[0], std::span<const uint8_t>(payload).subspan(1), this);
}
// The controller is not itself a modbus device - its commands and writer entities send as their own
// devices. It only owns the hub + address so those senders can be built against them.
void set_parent(modbus::ModbusClientHub *hub) { this->hub_ = hub; }
void set_address(uint8_t address) { this->address_ = address; }
/// The hub and modbus address this controller talks to. Used to build commands/entities that send as
/// their own device.
modbus::ModbusClientHub *hub() const { return this->hub_; }
uint8_t device_address() const { return this->address_; }
/// Queues a one-shot modbus command (writes, custom commands); taken by value, so std::move to avoid a copy.
void queue_command(ModbusCommandItem command);
/// Flags a finished one-shot command for removal. Called by the command as the last action of its own
/// callback, so the item is not destroyed here (send() and the hub still touch it) but swept later.
void unqueue_command(const ModbusCommandItem *command);
/// Registers a sensor with the controller. Called by esphomes code generator
void add_sensor_item(SensorItem *item) { sensorset_.insert(item); }
/// called when a modbus response was parsed without errors
void on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override;
/// called when a modbus error response was received
void on_error(std::span<const uint8_t> request_pdu, modbus::ExceptionCode exception_code) override;
/// default delegate called by process_modbus_data when a response has retrieved from the incoming queue
void on_register_data(modbus::EntityType register_type, uint16_t start_address, const std::vector<uint8_t> &data);
/// default delegate called by process_modbus_data when a response for a write response has retrieved from the
/// incoming queue
void on_write_register_response(modbus::EntityType register_type, uint16_t start_address,
const std::vector<uint8_t> &data);
/// Allow a duplicate command to be sent
void set_allow_duplicate_commands(bool allow_duplicate_commands) {
this->allow_duplicate_commands_ = allow_duplicate_commands;
/// Handles a write command acknowledgement (used by write command on_data_func handlers).
void on_write_register_response(EntityType register_type, uint16_t start_address, std::span<const uint8_t> data);
/// Update the online/offline state after a response or a run of timeouts, firing the callbacks.
void set_online(bool online, int function_code, int register_address);
/// Fire the on_command_sent trigger (called when a command's frame reaches the wire).
void command_sent(int function_code, int register_address) {
this->command_sent_callback_.call(function_code, register_address);
}
/// get if a duplicate command can be sent
bool get_allow_duplicate_commands() { return this->allow_duplicate_commands_; }
/// called by esphome generated code to set the command_throttle period
void set_command_throttle(uint16_t command_throttle) { this->command_throttle_ = command_throttle; }
/// A command timed out; bump the consecutive-timeout counter used by can_send()/offline detection.
void increment_non_response_count() { this->cmd_non_responses_++; }
/// Whether more retries are allowed before the device is considered offline. Deliberately pooled
/// per device, not per command: online/offline is a property of the physical device.
bool can_send() { return this->cmd_non_responses_ <= this->max_cmd_retries_; }
/// called by esphome generated code to set the offline_skip_updates
void set_offline_skip_updates(uint16_t offline_skip_updates) { this->offline_skip_updates_ = offline_skip_updates; }
/// get the number of queued modbus commands (should be mostly empty)
size_t get_command_queue_length() { return command_queue_.size(); }
/// get if the module is offline, didn't respond the last command
bool get_module_offline() { return module_offline_; }
/// Set callback for commands
@@ -418,33 +458,48 @@ class ModbusController final : public PollingComponent, public modbus::ModbusCli
protected:
/// parse sensormap_ and create range of sequential addresses
size_t create_register_ranges_();
// find register in sensormap. Returns iterator with all registers having the same start address
SensorSet find_sensors_(modbus::EntityType register_type, uint16_t start_address) const;
/// submit the read command for the address range to the send queue
void update_range_(RegisterRange &r);
/// parse incoming modbus data
void process_modbus_data_(const ModbusCommandItem *response);
/// send the next modbus command from the send queue
bool send_next_command_();
/// dump the parsed sensormap for diagnostics
void dump_sensors_();
/// Group the registered sensors into contiguous ranges and create one polling command per range.
void create_polling_commands_();
/// build one persistent polling command from a range and add it to polling_command_items_
void create_polling_command_(RegisterRange &&range) {
// A custom range polls the first sensor's custom_data (a ready-made raw frame); it needs the
// sensor constructor so the command references those bytes and decodes the real function code.
// The response still dispatches to every sensor in the range.
if (range.register_type == EntityType::CUSTOM && !range.sensors.empty()) {
auto &cmd = this->polling_command_items_.emplace_back(*this, this->hub_, this->address_, *range.sensors.begin());
cmd.sensors = std::move(range.sensors);
cmd.skip_updates = range.skip_updates; // the range's merged rate, not the first sensor's
} else {
this->polling_command_items_.emplace_back(*this, this->hub_, this->address_, std::move(range));
}
}
/// send a range's polling command if it is due this update
void update_range_(ModbusCommandItem &cmd);
/// The hub this controller's commands/entities send through, and the modbus address they target.
modbus::ModbusClientHub *hub_{nullptr};
uint8_t address_{0};
/// Collection of all sensors for this component
SensorSet sensorset_;
/// Continuous range of modbus registers
std::vector<RegisterRange> register_ranges_{};
/// Hold the pending requests to be sent
std::list<std::unique_ptr<ModbusCommandItem>> command_queue_;
/// modbus response data waiting to get processed
std::queue<std::unique_ptr<ModbusCommandItem>> incoming_queue_;
/// if duplicate commands can be sent
bool allow_duplicate_commands_{false};
/// when was the last send operation
uint32_t last_command_timestamp_{0};
/// min time in ms between sending modbus commands
uint16_t command_throttle_{0};
/// One persistent command per register range, each its own ModbusClientDevice. Built once in setup()
/// (create_polling_commands_ feeds each range straight in; the vector may reallocate as it grows, which
/// is safe because no command has registered with the hub yet) and never appended to afterward, so the
/// hub's device pointers stay valid once commands start sending.
std::vector<ModbusCommandItem> polling_command_items_{};
/// Dynamically queued one-shot commands (writes, custom commands). std::list keeps stable addresses.
std::list<std::unique_ptr<ModbusCommandItem>> one_shot_command_items_;
/// Erases one-shot commands flagged by unqueue_command(). Safe even when reached from inside a hub
/// callback (via an on_online/on_offline/on_command_sent automation that queues a command): the
/// destructor detaches via clear_tx_queue_for_device(), which the hub allows from callbacks, and the
/// item running its callback is not flagged until that callback returns.
void sweep_completed_one_shots_();
/// if module didn't respond the last command
bool module_offline_{false};
/// update_counter_ value at which the module went offline (for offline_skip_updates timing)
uint16_t module_offline_at_{0};
/// counts update() cycles; drives skip_updates and offline timing
uint16_t update_counter_{0};
/// consecutive non-responses; drives can_send() and offline detection
uint8_t cmd_non_responses_{0};
/// how many updates to skip if module is offline
uint16_t offline_skip_updates_{0};
/// How many times we will retry a command if we get no response
@@ -462,7 +517,7 @@ class ModbusController final : public PollingComponent, public modbus::ModbusCli
* @param item SensorItem object
* @return float value of data
*/
inline float payload_to_float(std::span<const uint8_t> data, const SensorItem &item, size_t offset) {
inline float payload_to_float(std::span<const uint8_t> data, const SensorItem &item, uint8_t offset) {
int64_t number = modbus::helpers::payload_to_number(data, item.sensor_value_type, offset, item.bitmask).value_or(0);
float float_value;
@@ -29,7 +29,7 @@ void ModbusNumber::parse_and_publish(std::span<const uint8_t> data) {
}
void ModbusNumber::control(float value) {
ModbusCommandItem write_cmd;
optional<ModbusCommandItem> write_cmd;
std::vector<uint16_t> data;
float write_value = value;
// Is there are lambda configured?
@@ -55,11 +55,11 @@ void ModbusNumber::control(float value) {
#endif
ESP_LOGV(TAG, "Modbus Number write raw: %s",
format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size()));
write_cmd = ModbusCommandItem::create_custom_command(
write_cmd.emplace(ModbusCommandItem::create_custom_command(
this->parent_, data,
[this, write_cmd](modbus::EntityType register_type, uint16_t start_address, const std::vector<uint8_t> &data) {
this->parent_->on_write_register_response(write_cmd.register_type, this->start_address, data);
});
[this](modbus::EntityType register_type, uint16_t start_address, std::span<const uint8_t> data) {
this->parent_->on_write_register_response(register_type, this->start_address, data);
}));
} else {
std::vector<uint16_t> payload;
modbus::helpers::float_to_payload(payload, write_value, this->sensor_value_type);
@@ -70,20 +70,21 @@ void ModbusNumber::control(float value) {
// Create and send the write command
if (this->register_count == 1 && !this->use_write_multiple_) {
write_cmd = ModbusCommandItem::create_write_single_command(this->parent_, this->write_address(), payload[0]);
write_cmd.emplace(
ModbusCommandItem::create_write_single_command(this->parent_, this->write_address(), payload[0]));
} else {
write_cmd = ModbusCommandItem::create_write_multiple_command(this->parent_, this->write_address(),
this->register_count, payload);
write_cmd.emplace(ModbusCommandItem::create_write_multiple_command(this->parent_, this->write_address(),
this->register_count, payload));
}
// publish new value
write_cmd.on_data_func = [this, write_cmd, value](modbus::EntityType register_type, uint16_t start_address,
const std::vector<uint8_t> &data) {
write_cmd->on_data_func = [this, value](modbus::EntityType register_type, uint16_t start_address,
std::span<const uint8_t> data) {
// gets called when the write command is ack'd from the device
this->parent_->on_write_register_response(write_cmd.register_type, start_address, data);
this->parent_->on_write_register_response(register_type, start_address, data);
this->publish_state(value);
};
}
this->parent_->queue_command(write_cmd);
this->parent_->queue_command(std::move(*write_cmd));
this->publish_state(value);
}
void ModbusNumber::dump_config() { LOG_NUMBER(TAG, "Modbus Number", this); }
@@ -58,15 +58,15 @@ void ModbusFloatOutput::write_state(float value) {
}
// Create and send the write command
ModbusCommandItem write_cmd;
optional<ModbusCommandItem> write_cmd;
if (this->register_count == 1 && !this->use_write_multiple_) {
write_cmd =
ModbusCommandItem::create_write_single_command(this->parent_, this->start_address + this->offset, data[0]);
write_cmd.emplace(
ModbusCommandItem::create_write_single_command(this->parent_, this->start_address + this->offset, data[0]));
} else {
write_cmd = ModbusCommandItem::create_write_multiple_command(this->parent_, this->start_address + this->offset,
data.size(), data);
write_cmd.emplace(ModbusCommandItem::create_write_multiple_command(
this->parent_, this->start_address + this->offset, data.size(), data));
}
this->parent_->queue_command(write_cmd);
this->parent_->queue_command(std::move(*write_cmd));
}
void ModbusFloatOutput::dump_config() {
@@ -82,7 +82,7 @@ void ModbusFloatOutput::dump_config() {
// ModbusBinaryOutput
void ModbusBinaryOutput::write_state(bool state) {
// This will be called every time the user requests a state change.
ModbusCommandItem cmd;
optional<ModbusCommandItem> cmd;
std::vector<uint8_t> data;
// Is there are lambda configured?
@@ -105,11 +105,11 @@ void ModbusBinaryOutput::write_state(bool state) {
#endif
ESP_LOGV(TAG, "Modbus binary output write raw: %s",
format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size()));
cmd = ModbusCommandItem::create_custom_command(
cmd.emplace(ModbusCommandItem::create_custom_command(
this->parent_, data,
[this, cmd](modbus::EntityType register_type, uint16_t start_address, const std::vector<uint8_t> &data) {
this->parent_->on_write_register_response(cmd.register_type, this->start_address, data);
});
[this](modbus::EntityType register_type, uint16_t start_address, std::span<const uint8_t> data) {
this->parent_->on_write_register_response(register_type, this->start_address, data);
}));
} else {
ESP_LOGV(TAG, "Write new state: value is %s, type is %d address = %X, offset = %x", ONOFF(state),
(int) this->register_type, this->start_address, this->offset);
@@ -117,12 +117,14 @@ void ModbusBinaryOutput::write_state(bool state) {
// offset for coil and discrete inputs is the coil/register number not bytes
if (this->use_write_multiple_) {
std::vector<bool> states{state};
cmd = ModbusCommandItem::create_write_multiple_coils(this->parent_, this->start_address + this->offset, states);
cmd.emplace(
ModbusCommandItem::create_write_multiple_coils(this->parent_, this->start_address + this->offset, states));
} else {
cmd = ModbusCommandItem::create_write_single_coil(this->parent_, this->start_address + this->offset, state);
cmd.emplace(
ModbusCommandItem::create_write_single_coil(this->parent_, this->start_address + this->offset, state));
}
}
this->parent_->queue_command(cmd);
this->parent_->queue_command(std::move(*cmd));
}
void ModbusBinaryOutput::dump_config() {
@@ -86,14 +86,15 @@ void ModbusSelect::control(size_t index) {
}
const uint16_t write_address = this->write_address();
ModbusCommandItem write_cmd;
optional<ModbusCommandItem> write_cmd;
if ((this->register_count == 1) && (!this->use_write_multiple_)) {
write_cmd = ModbusCommandItem::create_write_single_command(this->parent_, write_address, data[0]);
write_cmd.emplace(ModbusCommandItem::create_write_single_command(this->parent_, write_address, data[0]));
} else {
write_cmd = ModbusCommandItem::create_write_multiple_command(this->parent_, write_address, data.size(), data);
write_cmd.emplace(
ModbusCommandItem::create_write_multiple_command(this->parent_, write_address, data.size(), data));
}
this->parent_->queue_command(write_cmd);
this->parent_->queue_command(std::move(*write_cmd));
if (this->optimistic_)
this->publish_state(index);
@@ -4,7 +4,7 @@
#include "esphome/components/sensor/sensor.h"
#include "esphome/core/component.h"
#include <vector>
#include <span>
namespace esphome::modbus_controller {
@@ -59,7 +59,7 @@ void ModbusSwitch::parse_and_publish(std::span<const uint8_t> data) {
void ModbusSwitch::write_state(bool state) {
// This will be called every time the user requests a state change.
ModbusCommandItem cmd;
optional<ModbusCommandItem> cmd;
std::vector<uint8_t> data;
// Is there are lambda configured?
if (this->write_transform_func_.has_value()) {
@@ -81,11 +81,11 @@ void ModbusSwitch::write_state(bool state) {
#endif
ESP_LOGV(TAG, "Modbus Switch write raw: %s",
format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size()));
cmd = ModbusCommandItem::create_custom_command(
cmd.emplace(ModbusCommandItem::create_custom_command(
this->parent_, data,
[this, cmd](modbus::EntityType register_type, uint16_t start_address, const std::vector<uint8_t> &data) {
this->parent_->on_write_register_response(cmd.register_type, this->start_address, data);
});
[this](modbus::EntityType register_type, uint16_t start_address, std::span<const uint8_t> data) {
this->parent_->on_write_register_response(register_type, this->start_address, data);
}));
} else {
ESP_LOGV(TAG, "write_state '%s': new value = %s type = %d address = %X offset = %x", this->get_name().c_str(),
ONOFF(state), (int) this->register_type, this->start_address, this->offset);
@@ -93,21 +93,22 @@ void ModbusSwitch::write_state(bool state) {
// offset for coil and discrete inputs is the coil/register number not bytes
if (this->use_write_multiple_) {
std::vector<bool> states{state};
cmd = ModbusCommandItem::create_write_multiple_coils(this->parent_, this->write_address(), states);
cmd.emplace(ModbusCommandItem::create_write_multiple_coils(this->parent_, this->write_address(), states));
} else {
cmd = ModbusCommandItem::create_write_single_coil(this->parent_, this->write_address(), state);
cmd.emplace(ModbusCommandItem::create_write_single_coil(this->parent_, this->write_address(), state));
}
} else {
if (this->use_write_multiple_) {
std::vector<uint16_t> bool_states(1, state ? (0xFFFF & this->bitmask) : 0);
cmd = ModbusCommandItem::create_write_multiple_command(this->parent_, this->write_address(), 1, bool_states);
cmd.emplace(
ModbusCommandItem::create_write_multiple_command(this->parent_, this->write_address(), 1, bool_states));
} else {
cmd = ModbusCommandItem::create_write_single_command(this->parent_, this->write_address(),
state ? 0xFFFF & this->bitmask : 0u);
cmd.emplace(ModbusCommandItem::create_write_single_command(this->parent_, this->write_address(),
state ? 0xFFFF & this->bitmask : 0u));
}
}
}
this->parent_->queue_command(cmd);
this->parent_->queue_command(std::move(*cmd));
this->publish_state(state);
}
// ModbusSwitch end
@@ -4,7 +4,7 @@
#include "esphome/components/text_sensor/text_sensor.h"
#include "esphome/core/component.h"
#include <vector>
#include <span>
namespace esphome::modbus_controller {
+5
View File
@@ -184,6 +184,11 @@ template<size_t InlineSize = 8> class SmallInlineBuffer {
SmallInlineBuffer(const SmallInlineBuffer &) = delete;
SmallInlineBuffer &operator=(const SmallInlineBuffer &) = delete;
bool empty() const { return this->len_ == 0; }
// Conversion to std::span for compatibility with span-based APIs
operator std::span<const uint8_t>() const { return std::span<const uint8_t>(this->data(), this->len_); }
/// Resize to `size` bytes of (uninitialized) storage and return a writable pointer to fill.
/// Allocates heap only when `size` exceeds the inline capacity. Use this when the contents are
/// built in place (e.g. assembling a frame and appending a checksum) to avoid a staging copy.
@@ -158,8 +158,9 @@ sensor:
address: 0x9020
value_type: U_WORD
offset: 2
# Raw-decode lambda in the documented style: `item->offset` locates this sensor's data in the range
# response, and the compatibility helpers accept the span the lambda is handed.
# Raw-decode lambda kept on the deprecated get_data() helper on purpose: `data` is a span now, so this
# pins that the compatibility overload still accepts one. The deprecation warning it raises is the
# point - it is what a user on the old helper sees. `item->offset` locates this sensor's data.
- platform: modbus_controller
modbus_controller_id: modbus_controller1
id: modbus_sensor_raw_lambda
@@ -0,0 +1,56 @@
#include <gtest/gtest.h>
#include <cstdint>
#include "esphome/components/modbus_controller/modbus_controller.h"
namespace esphome::modbus_controller::testing {
// A probe must come due exactly once per offline_skip_updates + 1 cycles from the trip point,
// for every phase between the trip cycle and the update counter. Pins the regression where a
// probe additionally required a range's skip_updates cadence to coincide, which some phase
// combinations never satisfy - the device then never polled again.
TEST(OfflineRetryCadence, DueOncePerWindowForEveryPhase) {
for (uint16_t skip = 0; skip <= 5; skip++) {
const uint16_t period = skip + 1;
for (uint16_t offline_at = 0; offline_at <= 7; offline_at++) {
uint16_t due_count = 0;
for (uint32_t counter = offline_at; counter < offline_at + 4u * period; counter++) {
if (offline_retry_due(static_cast<uint16_t>(counter), offline_at, skip))
due_count++;
}
EXPECT_EQ(due_count, 4) << "skip=" << skip << " offline_at=" << offline_at;
}
}
}
// The first probe goes out within one window of going offline: after at most skip skipped cycles.
TEST(OfflineRetryCadence, FirstProbeWithinOneWindow) {
for (uint16_t skip = 0; skip <= 5; skip++) {
for (uint16_t offline_at = 0; offline_at <= 7; offline_at++) {
uint16_t counter = offline_at;
uint16_t skipped = 0;
while (!offline_retry_due(counter, offline_at, skip)) {
counter++;
skipped++;
ASSERT_LE(skipped, skip) << "skip=" << skip << " offline_at=" << offline_at;
}
}
}
}
// The cadence neither stretches nor collapses when update_counter_ wraps past 65535.
TEST(OfflineRetryCadence, SurvivesCounterWraparound) {
const uint16_t skip = 2; // period 3
const uint16_t offline_at = 65530;
uint16_t counter = offline_at;
uint16_t due_count = 0;
for (int i = 0; i < 30; i++) { // crosses the wrap mid-run
if (offline_retry_due(counter, offline_at, skip))
due_count++;
counter++;
}
EXPECT_EQ(due_count, 10);
}
} // namespace esphome::modbus_controller::testing
@@ -0,0 +1,87 @@
esphome:
name: uart-mock-modbus-custom-command
host:
api:
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
- id: virtual_uart_server
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- id: virtual_uart_controller
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
modbus:
- uart_id: virtual_uart_server
id: virtual_modbus_server
role: server
- uart_id: virtual_uart_controller
id: virtual_modbus_controller
role: client
turnaround_time: 10ms
modbus_controller:
- address: 1
modbus_id: virtual_modbus_controller
id: modbus_controller_1
update_interval: 1s
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
id: modbus_server_1
registers:
- address: 0x01
value_type: U_WORD
read_lambda: return 259;
sensor:
# Plain read to confirm the controller <-> server link is up.
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "plain_read"
address: 0x01
register_type: holding
value_type: U_WORD
# Custom command: a raw frame {device address, function code, address hi, address lo,
# count hi, count lo}; the CRC is appended by the hub. Reads holding register 0x0001,
# count 1; the lambda parses the response payload (the register value, big-endian).
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "custom_read"
custom_command: [0x01, 0x03, 0x00, 0x01, 0x00, 0x01]
lambda: |-
if (data.size() < 2) return {};
return (float) ((data[0] << 8) | data[1]);
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
# This test does not have anything to start (mock is autostart)
@@ -0,0 +1,95 @@
esphome:
name: uart-mock-modbus-offline
host:
api:
logger:
level: DEBUG
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
# Whether the mock device answers requests. Starts false so the controller
# runs through its retries and goes offline; the test flips it via the
# "Serve" button to exercise the offline retry/recovery path.
globals:
- id: serve
type: bool
initial_value: "false"
uart_mock:
- id: virtual_uart
baud_rate: 9600
auto_start: true
debug:
on_tx:
# While serve is false every request times out; once true, answer the
# (only) request - read holding register 3 on device 1 - with value 259.
- uart_mock.inject_rx:
id: virtual_uart
data: !lambda |-
if (!id(serve))
return {};
return {0x01, 0x03, 0x02, 0x01, 0x03, 0xF9, 0xD5};
modbus:
- uart_id: virtual_uart
id: virtual_modbus_client
send_wait_time: 100ms
turnaround_time: 10ms
modbus_controller:
- address: 1
modbus_id: virtual_modbus_client
id: ctl
max_cmd_retries: 1
# offline_skip_updates and the sensor's skip_updates deliberately share a period: offline
# probing must follow the offline cadence alone, or phase combinations like this one can
# leave the device never probing again.
offline_skip_updates: 1
update_interval: never
on_offline:
then:
- lambda: id(link_state).publish_state(0);
on_online:
then:
- lambda: id(link_state).publish_state(1);
sensor:
- platform: modbus_controller
modbus_controller_id: ctl
name: reg
id: reg
address: 0x03
register_type: holding
value_type: U_WORD
skip_updates: 1
# Mirrors the controller's online state so the test can await the transitions.
- platform: template
name: link_state
id: link_state
update_interval: never
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
on_press:
- lambda: |-
id(ctl).set_update_interval(200);
id(ctl).start_poller();
- platform: template
name: "Serve"
id: serve_btn
on_press:
- globals.set:
id: serve
value: "true"
+74 -8
View File
@@ -452,14 +452,80 @@ async def test_uart_mock_modbus_shared_address(
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
@pytest.mark.xfail(
strict=True,
reason="Fair bus scheduling across controllers sharing one client hub "
"requires the modbus_controller refactor in esphome#11781. On dev the "
"controllers each queue independently and contend for the bus, so the "
"request counts diverge. Expected to XPASS (and this marker removed) once "
"that refactor lands.",
)
@pytest.mark.asyncio
async def test_uart_mock_modbus_custom_command(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test a custom_command sensor polling a register served by the mock server.
The custom_command is a raw frame (device address + PDU); the hub appends the CRC and
routes the response back to the polling command, whose sensor lambda parses the payload.
Guards the custom polling wiring: the command must reference the sensor's custom_data and
decode the real function code, or nothing is ever transmitted. A plain read on the same
register anchors the bus.
"""
line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback()
expected_values = {"plain_read": 259, "custom_read": 259}
tracker = SensorTracker(list(expected_values.keys()))
futures = tracker.expect_all(expected_values)
async with (
run_compiled(yaml_config, line_callback=line_callback),
api_client_connected() as client,
):
await tracker.setup_and_start_scenario(client)
await tracker.await_all(futures)
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
@pytest.mark.asyncio
async def test_uart_mock_modbus_offline(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""A silent device drives the controller offline; answering again recovers it.
The mock answers nothing at first, so the controller burns through max_cmd_retries
(1 retry after the first timeout) and fires on_offline. While offline it keeps
retrying every offline_skip_updates+1 cycles. The test then flips the mock to
answering; the next retry gets a response, on_online fires, and the register value
publishes. This pins the pooled non-response counter, can_send() gating, the
offline retry cadence, and recovery - none of which the responding-path tests touch.
The fixture gives offline_skip_updates and the sensor's skip_updates the same period
on purpose: offline probing must follow the offline cadence alone, since requiring
both cadences to coincide leaves phase combinations where no probe ever goes out.
"""
tracker = SensorTracker(["link_state", "reg"])
offline_future = tracker.expect("link_state", 0)
async with (
run_compiled(yaml_config),
api_client_connected() as client,
):
entities = await tracker.setup_and_start_scenario(client)
# The unanswered poll and its retry each time out (~100ms), then on_offline fires.
await tracker.await_change(offline_future, "link_state", timeout=5.0)
# Register the recovery expectations before waking the device so no update is missed.
online_future = tracker.expect("link_state", 1)
value_future = tracker.expect("reg", 259)
serve_btn = find_entity(entities, "serve", ButtonInfo)
assert serve_btn is not None, "Serve button not found"
client.button_command(serve_btn.key)
# The next offline-cadence retry gets an answer: back online, value published.
await tracker.await_change(online_future, "link_state", timeout=5.0)
await tracker.await_change(value_future, "reg", timeout=5.0)
@pytest.mark.asyncio
async def test_uart_mock_modbus_fairness(
yaml_config: str,