mirror of
https://github.com/esphome/esphome.git
synced 2026-09-11 15:27:33 +00:00
Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: J. Nick Koston <nick@koston.org>
714 lines
32 KiB
C++
714 lines
32 KiB
C++
#include "modbus_controller.h"
|
|
#include "esphome/core/application.h"
|
|
#include "esphome/core/log.h"
|
|
|
|
#include <cstring>
|
|
#include <limits>
|
|
|
|
namespace esphome::modbus_controller {
|
|
|
|
static const char *const TAG = "modbus_controller";
|
|
|
|
void ModbusController::setup() { this->create_polling_commands_(); }
|
|
|
|
void WriterDevice::warn_write_buffer_deprecated(const LogString *platform, uint16_t address) {
|
|
if (this->write_buffer_deprecated_warned_)
|
|
return;
|
|
this->write_buffer_deprecated_warned_ = true;
|
|
ESP_LOGW(TAG,
|
|
"Modbus %s (address 0x%X): filling the write_lambda buffer parameter is deprecated; call a write helper / "
|
|
"queue_pdu() on the entity (item) instead. The buffer parameter is removed in 2027.3.0",
|
|
LOG_STR_ARG(platform), address);
|
|
}
|
|
|
|
bool WriterDevice::send_raw_frame_deprecated(std::span<const uint8_t> frame) {
|
|
if (frame.empty())
|
|
return false;
|
|
return this->parent_->queue_pdu(frame[0], frame.subspan(1), this);
|
|
}
|
|
|
|
void ControllerDevice::set_controller(ModbusController *controller) {
|
|
this->controller_ = controller;
|
|
this->set_parent(controller->hub());
|
|
this->set_address(controller->device_address());
|
|
}
|
|
|
|
// A request whose layout carries no start address (a custom PDU) reports -1; 0 stays a real address.
|
|
static int trigger_address(std::span<const uint8_t> request_pdu) {
|
|
const auto addr = modbus::helpers::client_pdu_start_address(request_pdu);
|
|
return addr.has_value() ? *addr : -1;
|
|
}
|
|
|
|
void ControllerDevice::notify_online_(std::span<const uint8_t> request_pdu) {
|
|
if (this->controller_ != nullptr) {
|
|
this->controller_->set_online(true, modbus::helpers::pdu_function_code(request_pdu), trigger_address(request_pdu));
|
|
}
|
|
}
|
|
|
|
void ControllerDevice::on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) {
|
|
this->notify_online_(request_pdu);
|
|
}
|
|
|
|
void ControllerDevice::on_error(std::span<const uint8_t> request_pdu, modbus::ExceptionCode exception_code) {
|
|
ESP_LOGW(TAG, "Modbus error function code: 0x%X register %d exception: %d",
|
|
modbus::helpers::pdu_function_code(request_pdu), trigger_address(request_pdu),
|
|
static_cast<uint8_t>(exception_code));
|
|
this->notify_online_(request_pdu); // an exception is still a legitimate reply -> device is online
|
|
}
|
|
|
|
void WriterDevice::on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) {
|
|
ControllerDevice::on_response(request_pdu, response_pdu);
|
|
this->dispatch_response_(request_pdu, response_pdu, std::nullopt);
|
|
}
|
|
|
|
void WriterDevice::on_error(std::span<const uint8_t> request_pdu, modbus::ExceptionCode exception_code) {
|
|
ControllerDevice::on_error(request_pdu, exception_code);
|
|
this->dispatch_response_(request_pdu, {}, exception_code);
|
|
}
|
|
|
|
// 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 ControllerDevice::on_sent(std::span<const uint8_t> request_pdu) {
|
|
if (this->controller_ != nullptr) {
|
|
this->controller_->command_sent(modbus::helpers::pdu_function_code(request_pdu), trigger_address(request_pdu));
|
|
}
|
|
}
|
|
|
|
void ControllerDevice::on_not_sent(std::span<const uint8_t> request_pdu) {
|
|
const uint8_t fc = modbus::helpers::pdu_function_code(request_pdu);
|
|
const int addr = trigger_address(request_pdu);
|
|
// Only the offline teardown reaches this (a supersede retires silently), so the frame is genuinely
|
|
// lost; a dropped write was already published optimistically, so surface it.
|
|
if (modbus::helpers::is_function_code_write(fc)) {
|
|
ESP_LOGW(TAG, "Write not sent: function 0x%X register %d", fc, addr);
|
|
} else {
|
|
ESP_LOGD(TAG, "Request not sent: function 0x%X register %d", fc, addr);
|
|
}
|
|
}
|
|
|
|
bool ControllerDevice::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())
|
|
return true; // the hub re-queues the frame it is holding; on_sent fires again on the retry
|
|
this->controller_->set_online(false, modbus::helpers::pdu_function_code(request_pdu), trigger_address(request_pdu));
|
|
return false;
|
|
}
|
|
|
|
PollingDevice::PollingDevice(ModbusController &controller, RegisterRange &&range)
|
|
: ControllerDevice(&controller), range_(std::move(range)) {}
|
|
|
|
bool PollingDevice::queue(modbus::CommandOptions options) {
|
|
bool accepted;
|
|
if (this->range_.custom_pdu != nullptr) {
|
|
accepted = this->queue_pdu(std::span<const uint8_t>(*this->range_.custom_pdu), options);
|
|
} else {
|
|
accepted = this->read_entities(this->range_.register_type, this->range_.start_address, this->range_.register_count,
|
|
options);
|
|
}
|
|
if (accepted) {
|
|
ESP_LOGV(TAG, "Poll queued type=%u 0x%X %d", static_cast<uint8_t>(this->range_.register_type),
|
|
this->range_.start_address, this->range_.register_count);
|
|
}
|
|
return accepted;
|
|
}
|
|
|
|
void PollingDevice::on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) {
|
|
this->notify_online_(request_pdu);
|
|
auto data = modbus::helpers::server_pdu_payload(response_pdu);
|
|
for (auto *sensor : this->range_.sensors)
|
|
sensor->parse_and_publish(data);
|
|
}
|
|
|
|
// ModbusCommandItem's machinery stays as-is until its removal in 2027.3.0; silence its self-references.
|
|
#pragma GCC diagnostic push
|
|
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
|
|
ModbusCommandItem::ModbusCommandItem(ModbusController &controller, modbus::ModbusClientHub *parent, uint8_t address,
|
|
RegisterRange &&range)
|
|
: modbus::ModbusClientDevice(parent, address),
|
|
sensors(std::move(range.sensors)),
|
|
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) {}
|
|
|
|
ModbusCommandItem::ModbusCommandItem(ModbusController &controller, modbus::ModbusClientHub *parent, uint8_t address,
|
|
SensorItem *sensor)
|
|
: modbus::ModbusClientDevice(parent, address),
|
|
start_address_(sensor->start_address),
|
|
register_count_(sensor->entity_count()),
|
|
custom_pdu_(&sensor->custom_pdu),
|
|
controller_(&controller) {
|
|
// The PDU's first byte is its real function code; carry it so dump_config, the on_command_sent
|
|
// trigger and the response callbacks report the actual code instead of CUSTOM.
|
|
if (!sensor->custom_pdu.empty())
|
|
this->function_code_ = static_cast<FunctionCode>(sensor->custom_pdu.data()[0]);
|
|
this->sensors.insert(sensor);
|
|
}
|
|
|
|
// 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),
|
|
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_pdu_(other.custom_pdu_),
|
|
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)),
|
|
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_pdu_(other.custom_pdu_),
|
|
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 (!this->sensors.empty()) {
|
|
// A polling command always has sensors; a factory/write command never does. Test this before the
|
|
// write-code branch so a custom_pdu whose function code is a write (e.g. 0x17, whose response
|
|
// carries read data) still reaches its sensor instead of being treated as a bare write ack.
|
|
for (auto *sensor : this->sensors)
|
|
sensor->parse_and_publish(data);
|
|
} else if (modbus::helpers::is_function_code_write(static_cast<uint8_t>(this->function_code_))) {
|
|
// write acknowledgement - nothing to publish
|
|
}
|
|
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)
|
|
return;
|
|
this->controller_->command_sent(static_cast<int>(this->function_code_), this->start_address_);
|
|
// A broadcast (address 0) is never answered (Modbus 4.1), so the hub delivers no terminal callback.
|
|
// on_sent is this command's only callback, so drop the one-shot from the queue here, or it would leak.
|
|
// Test the address the frame went to, not address_: a custom command's frame carries its own address
|
|
// (frame[0]), which may differ from this controller's. (unqueue_command() is a no-op for a poll.)
|
|
// A custom polling command sends its PDU to this controller's own address, so only a factory custom
|
|
// command (a raw frame staged in payload) can carry a different address byte.
|
|
uint8_t wire_address = this->address_;
|
|
if (this->function_code_ == FunctionCode::CUSTOM && !this->payload.empty())
|
|
wire_address = this->payload.data()[0];
|
|
if (wire_address == modbus::BROADCAST_ADDRESS)
|
|
this->controller_->unqueue_command(this);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
#pragma GCC diagnostic pop
|
|
|
|
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_);
|
|
this->module_offline_ = false;
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
#pragma GCC diagnostic push
|
|
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
|
|
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();
|
|
// We intentionally do not pass read_options_ here, because one-shot commands are usually writes, and are non-polling.
|
|
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::unqueue_command(const ModbusCommandItem *command) {
|
|
// Called as the last action of the command's own callback (on_response/on_error/on_not_sent/
|
|
// on_no_response), which the hub runs from inside its sweep while this entry is still live.
|
|
// Destroying `command` here would leave 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; });
|
|
}
|
|
|
|
#pragma GCC diagnostic pop
|
|
|
|
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; regular every-update polling resumes once
|
|
// the device is back online.
|
|
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 &poll : this->polling_devices_) {
|
|
// Probes carry the read-side options too, so a recovering device resumes streaming on the
|
|
// probe itself rather than waiting for the next update_interval.
|
|
if (!poll.queue(this->read_options_)) {
|
|
ESP_LOGD(TAG, "Probe refused by hub for range 0x%X", poll.register_address());
|
|
}
|
|
}
|
|
} else {
|
|
ESP_LOGV(TAG, "Module offline - skipping update");
|
|
}
|
|
this->update_counter_++;
|
|
return;
|
|
}
|
|
|
|
if (this->can_send()) {
|
|
for (auto &poll : this->polling_devices_) {
|
|
ESP_LOGVV(TAG, "Updating range 0x%X", poll.register_address());
|
|
// read_options_ carries the controller's continuous flag (the offline probe above sends it too).
|
|
// A refusal is already logged by the hub; note the affected range for controller-level diagnostics.
|
|
if (!poll.queue(this->read_options_)) {
|
|
ESP_LOGD(TAG, "Poll refused by hub for range 0x%X", poll.register_address());
|
|
}
|
|
}
|
|
}
|
|
this->update_counter_++;
|
|
}
|
|
|
|
// walk through the sensors and determine the register ranges to read
|
|
namespace {
|
|
|
|
class RangeBuilder {
|
|
public:
|
|
explicit RangeBuilder(FixedVector<RegisterRange> &ranges) : ranges_(ranges) {}
|
|
|
|
bool can_join(const SensorItem *curr) const {
|
|
return this->have_range_ && curr->reuse_previous_range != RangeReuse::NEVER &&
|
|
this->r_.register_type == curr->register_type && curr->register_type != modbus::EntityType::CUSTOM;
|
|
}
|
|
|
|
// A sensor that joined mid-range must never anchor this - hence both address tests.
|
|
bool try_reuse_register(SensorItem *curr) {
|
|
const uint32_t range_end = this->range_end_();
|
|
if (curr->start_address != range_end - this->prev_->entity_count() ||
|
|
this->prev_->start_address + this->prev_->entity_count() != range_end ||
|
|
curr->entity_count() != this->prev_->entity_count() ||
|
|
curr->get_register_size() != this->prev_->get_register_size()) {
|
|
return false;
|
|
}
|
|
if (!place_offset(curr, static_cast<uint32_t>(this->prev_->offset) + curr->offset_from_start_address))
|
|
return false;
|
|
ESP_LOGV(TAG, "Re-use previous register 0x%X", curr->start_address);
|
|
return true;
|
|
}
|
|
|
|
bool try_extend(SensorItem *curr) {
|
|
const uint32_t range_end = this->range_end_();
|
|
const bool reachable =
|
|
curr->reuse_previous_range == RangeReuse::ALWAYS
|
|
? curr->start_address >= range_end
|
|
: curr->start_address == range_end && (curr->addresses_bits() || !this->range_custom_size_);
|
|
if (!reachable)
|
|
return false;
|
|
const uint16_t gap = static_cast<uint16_t>(curr->start_address - range_end);
|
|
const uint32_t new_count = this->r_.register_count + gap + curr->entity_count();
|
|
const uint16_t max_quantity =
|
|
curr->addresses_bits() ? modbus::MAX_NUM_OF_COILS_TO_READ : modbus::MAX_NUM_OF_REGISTERS_TO_READ;
|
|
const uint32_t prospective_offset =
|
|
(curr->addresses_bits() ? static_cast<uint32_t>(curr->start_address - this->r_.start_address)
|
|
: static_cast<uint32_t>(this->range_bytes_) + gap * 2) +
|
|
curr->offset_from_start_address;
|
|
if (new_count > max_quantity || !place_offset(curr, prospective_offset)) {
|
|
return false;
|
|
}
|
|
if (!curr->addresses_bits())
|
|
this->range_bytes_ += static_cast<size_t>(gap) * 2;
|
|
this->range_bytes_ += curr->get_register_size();
|
|
this->range_custom_size_ = this->range_custom_size_ || has_custom_size(curr);
|
|
this->r_.register_count = static_cast<uint16_t>(new_count);
|
|
ESP_LOGV(TAG, "Extend range to include 0x%X", curr->start_address);
|
|
return true;
|
|
}
|
|
|
|
bool try_cover(SensorItem *curr) {
|
|
if (!this->range_shared_ || this->range_forced_ || curr->start_address < this->r_.start_address ||
|
|
curr->start_address + curr->entity_count() > this->range_end_() || this->range_custom_size_ ||
|
|
has_custom_size(curr)) {
|
|
return false;
|
|
}
|
|
const uint32_t addr_delta = curr->start_address - this->r_.start_address;
|
|
if (!place_offset(curr, (curr->addresses_bits() ? addr_delta : addr_delta * 2) + curr->offset_from_start_address))
|
|
return false;
|
|
ESP_LOGV(TAG, "Register 0x%X already covered by range 0x%X", curr->start_address, this->r_.start_address);
|
|
return true;
|
|
}
|
|
|
|
// A response dispatches to a single range per (start address, register type), so same-address items
|
|
// must share - even reuse_previous_range: false and custom entities.
|
|
bool try_share(SensorItem *curr) {
|
|
if (!this->have_range_ || this->r_.register_type != curr->register_type ||
|
|
this->r_.start_address != curr->start_address) {
|
|
return false;
|
|
}
|
|
curr->offset = curr->offset_from_start_address;
|
|
this->r_.register_count = std::max(this->r_.register_count, curr->entity_count());
|
|
this->range_bytes_ = std::max(this->range_bytes_, curr->get_register_size());
|
|
this->range_custom_size_ = this->range_custom_size_ || has_custom_size(curr);
|
|
this->range_shared_ = true;
|
|
this->range_forced_ = this->range_forced_ || curr->reuse_previous_range == RangeReuse::NEVER;
|
|
ESP_LOGV(TAG, "Share range start 0x%X", curr->start_address);
|
|
return true;
|
|
}
|
|
|
|
bool always_declined(const SensorItem *curr) const {
|
|
return this->have_range_ && curr->reuse_previous_range == RangeReuse::ALWAYS &&
|
|
this->r_.register_type == curr->register_type && curr->start_address != this->r_.start_address;
|
|
}
|
|
|
|
void open(SensorItem *curr) {
|
|
this->close();
|
|
this->r_ = {};
|
|
this->range_bytes_ = curr->get_register_size();
|
|
this->range_custom_size_ = has_custom_size(curr);
|
|
this->range_forced_ = curr->reuse_previous_range == RangeReuse::NEVER;
|
|
this->range_shared_ = false;
|
|
curr->offset = curr->offset_from_start_address;
|
|
this->r_.start_address = curr->start_address;
|
|
this->r_.register_count = curr->entity_count();
|
|
this->r_.register_type = curr->register_type;
|
|
if (curr->register_type == modbus::EntityType::CUSTOM)
|
|
this->r_.custom_pdu = &curr->custom_pdu;
|
|
this->have_range_ = true;
|
|
}
|
|
|
|
void record(SensorItem *curr) {
|
|
curr->range_start_address = this->r_.start_address;
|
|
this->r_.sensors.insert(curr);
|
|
this->prev_ = curr;
|
|
}
|
|
|
|
void close() {
|
|
if (!this->have_range_)
|
|
return;
|
|
ESP_LOGV(TAG, "Add range 0x%X %d", this->r_.start_address, this->r_.register_count);
|
|
this->ranges_.push_back(std::move(this->r_));
|
|
this->have_range_ = false;
|
|
}
|
|
|
|
private:
|
|
uint32_t range_end_() const { return this->r_.start_address + this->r_.register_count; }
|
|
// The resolved offset must fit its uint8_t field or the sensor would parse the wrong slice.
|
|
static bool place_offset(SensorItem *curr, uint32_t offset) {
|
|
if (offset > std::numeric_limits<uint8_t>::max())
|
|
return false;
|
|
curr->offset = static_cast<uint8_t>(offset);
|
|
return true;
|
|
}
|
|
static bool has_custom_size(const SensorItem *item) {
|
|
return item->get_register_size() != static_cast<size_t>(item->entity_count()) * 2;
|
|
}
|
|
FixedVector<RegisterRange> &ranges_;
|
|
RegisterRange r_ = {};
|
|
bool have_range_ = false;
|
|
bool range_forced_ = false; // a reuse: false member blocks the coverage join
|
|
bool range_shared_ = false; // only a share-widened range absorbs by coverage
|
|
size_t range_bytes_ = 0;
|
|
bool range_custom_size_ = false;
|
|
SensorItem *prev_ = nullptr;
|
|
};
|
|
|
|
} // namespace
|
|
|
|
void ModbusController::create_polling_commands_() {
|
|
if (this->sensorset_.empty()) {
|
|
ESP_LOGW(TAG, "No sensors registered");
|
|
return;
|
|
}
|
|
|
|
// At most one range closes per sensor plus one final close, so sensorset_.size() bounds the pushes
|
|
// (FixedVector silently drops past capacity).
|
|
FixedVector<RegisterRange> ranges;
|
|
ranges.init(this->sensorset_.size());
|
|
RangeBuilder builder(ranges);
|
|
for (SensorItem *curr : this->sensorset_) {
|
|
ESP_LOGV(TAG, "Register: 0x%X width=%u size=%zu offset=%u addr=%p", curr->start_address, curr->entity_count(),
|
|
curr->get_register_size(), curr->offset, curr);
|
|
bool join = builder.can_join(curr) &&
|
|
(builder.try_reuse_register(curr) || builder.try_extend(curr) || builder.try_cover(curr));
|
|
if (!join && builder.always_declined(curr)) {
|
|
ESP_LOGW(TAG, "reuse_previous_range on 0x%X cannot join the previous range; starting a new range",
|
|
curr->start_address);
|
|
}
|
|
join = join || builder.try_share(curr);
|
|
if (!join)
|
|
builder.open(curr);
|
|
builder.record(curr);
|
|
}
|
|
builder.close();
|
|
|
|
this->polling_devices_.init(ranges.size());
|
|
for (auto &range : ranges) {
|
|
this->polling_devices_.emplace_back(*this, std::move(range));
|
|
}
|
|
}
|
|
|
|
void ModbusController::dump_config() {
|
|
ESP_LOGCONFIG(TAG,
|
|
"ModbusController:\n"
|
|
" Address: 0x%02X\n"
|
|
" Max Command Retries: %d\n"
|
|
" Offline Skip Updates: %d\n",
|
|
this->address_, this->max_cmd_retries_, this->offline_skip_updates_);
|
|
|
|
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
|
|
ESP_LOGCONFIG(TAG, "sensormap");
|
|
for (auto &it : this->sensorset_) {
|
|
ESP_LOGCONFIG(TAG, " Sensor type=%u start=0x%X offset=0x%X width=%u size=%zu",
|
|
static_cast<uint8_t>(it->register_type), it->start_address, it->offset, it->entity_count(),
|
|
it->get_register_size());
|
|
}
|
|
ESP_LOGCONFIG(TAG, "ranges");
|
|
for (auto &it : this->polling_devices_) {
|
|
ESP_LOGCONFIG(TAG, " Range type=%u start=0x%X count=%d", static_cast<uint8_t>(it.register_type()),
|
|
it.register_address(), it.register_count());
|
|
}
|
|
#endif
|
|
}
|
|
|
|
#pragma GCC diagnostic push
|
|
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
|
|
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 {
|
|
ESP_LOGV(TAG, "Command ACK (short payload, %zu bytes)", data.size());
|
|
}
|
|
}
|
|
|
|
ModbusCommandItem ModbusCommandItem::create_read_command(
|
|
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_write_multiple_command(ModbusController *modbusdevice,
|
|
uint16_t start_address, uint16_t register_count,
|
|
const std::vector<uint16_t> &values) {
|
|
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);
|
|
*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(*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);
|
|
};
|
|
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(*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);
|
|
};
|
|
|
|
// Pack through the shared bit view (MutablePackedBits) so the coil wire layout lives in one place
|
|
// instead of an open-coded loop.
|
|
const size_t byte_count = modbus::packed_bit_bytes(values.size());
|
|
uint8_t *p = cmd.payload.init(byte_count);
|
|
memset(p, 0, byte_count);
|
|
modbus::MutablePackedBits bits(std::span<uint8_t>(p, byte_count), static_cast<uint16_t>(values.size()));
|
|
for (size_t i = 0; i != values.size(); i++) {
|
|
if (values[i])
|
|
bits.set(i, true);
|
|
}
|
|
return cmd;
|
|
}
|
|
|
|
ModbusCommandItem ModbusCommandItem::create_write_single_command(ModbusController *modbusdevice, uint16_t start_address,
|
|
uint16_t value) {
|
|
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);
|
|
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(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 = [](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.set(values.data(), values.size());
|
|
|
|
return cmd;
|
|
}
|
|
|
|
ModbusCommandItem ModbusCommandItem::create_custom_command(
|
|
ModbusController *modbusdevice, const std::vector<uint16_t> &values,
|
|
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 = [](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) {
|
|
*p++ = (v >> 8) & 0xFF;
|
|
*p++ = v & 0xFF;
|
|
}
|
|
|
|
return cmd;
|
|
}
|
|
|
|
bool ModbusCommandItem::send(modbus::CommandOptions options) {
|
|
// Options pass straight through to the hub
|
|
bool accepted;
|
|
if (this->custom_pdu_ != nullptr) {
|
|
// Custom polling command: send the sensor's ready-made PDU (function code + data, no address byte)
|
|
// to this controller's own device address; the hub prepends the address and appends the CRC.
|
|
accepted = modbus::ModbusClientDevice::queue_pdu(std::span<const uint8_t>(*this->custom_pdu_), options);
|
|
} else if (this->function_code_ != FunctionCode::CUSTOM) {
|
|
accepted = this->queue_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()),
|
|
options);
|
|
} else {
|
|
// Factory custom command: payload holds 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.
|
|
std::span<const uint8_t> frame = this->payload;
|
|
if (frame.empty()) {
|
|
ESP_LOGW(TAG, "Empty custom command frame, not sent");
|
|
accepted = false;
|
|
} else {
|
|
accepted = this->parent_->queue_pdu(frame[0], frame.subspan(1), this, options);
|
|
}
|
|
}
|
|
// 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;
|
|
}
|
|
#pragma GCC diagnostic pop
|
|
|
|
} // namespace esphome::modbus_controller
|