mirror of
https://github.com/esphome/esphome.git
synced 2026-10-05 18:41:32 +00:00
[modbus_controller] Refactor to simplify message handling (#11781)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
e31a43af17
commit
b93b21eab1
@@ -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 ¤t_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 ¤t_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 {
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user