[modbus_controller] Writer entities as their own hub device; heap-free, byte-accurate write path (#18082)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Bonne Eggleston
2026-08-26 18:24:09 -05:00
committed by GitHub
co-authored by J. Nick Koston
parent 9508165797
commit 5df1c7f1d3
19 changed files with 598 additions and 235 deletions
+3
View File
@@ -3,6 +3,9 @@ import esphome.codegen as cg
modbus_ns = cg.esphome_ns.namespace("modbus")
modbus_helpers_ns = modbus_ns.namespace("helpers")
RegisterValues = modbus_ns.class_("RegisterValues")
PduBuffer = modbus_helpers_ns.class_("PduBuffer")
FunctionCode_ns = modbus_ns.namespace("FunctionCode")
FunctionCode = FunctionCode_ns.enum("FunctionCode")
@@ -191,7 +191,7 @@ ModbusItemBaseSchema = cv.Schema(
)
def validate_modbus_register(config):
def validate_modbus_register(config: ConfigType) -> ConfigType:
# custom_command is the deprecated alias for custom_pdu (migrated later in final validate); treat
# either as "a custom frame is configured" so the address/register_type rules match.
has_custom = CONF_CUSTOM_PDU in config or CONF_CUSTOM_COMMAND in config
@@ -278,7 +278,7 @@ def _final_validate(config: ConfigType) -> None:
FINAL_VALIDATE_SCHEMA = _final_validate
def modbus_calc_properties(config):
def modbus_calc_properties(config: ConfigType) -> tuple[int, int]:
byte_offset = 0
reg_count = 0
if CONF_OFFSET in config:
@@ -307,8 +307,12 @@ def modbus_calc_properties(config):
async def add_modbus_base_properties(
var, config, sensor_type, lambda_param_type=cg.float_, lambda_return_type=float
):
var: cg.MockObj,
config: ConfigType,
sensor_type: cg.MockObjClass,
lambda_param_type: cg.MockObj = cg.float_,
lambda_return_type: Any = float,
) -> None:
if CONF_CUSTOM_PDU in config:
cg.add(var.set_custom_pdu(config[CONF_CUSTOM_PDU]))
@@ -347,8 +351,11 @@ _CALLBACK_AUTOMATIONS = (
)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
async def to_code(config: ConfigType) -> None:
# Await the hub first, so no entity can bind to a controller that doesn't have one yet.
hub = await cg.get_variable(config[modbus.CONF_MODBUS_ID])
var = cg.new_Pvariable(config[CONF_ID], hub, config[CONF_ADDRESS])
await cg.register_component(var, config)
cg.add(var.set_max_cmd_retries(config[CONF_MAX_CMD_RETRIES]))
cg.add(var.set_offline_skip_updates(config[CONF_OFFLINE_SKIP_UPDATES]))
cg.add(
@@ -356,17 +363,22 @@ async def to_code(config):
modbus.command_options_expression(config, direction="read")
)
)
await register_modbus_device(var, config)
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
async def register_modbus_device(var, config):
async def register_modbus_device(var: cg.MockObj, config: ConfigType) -> cg.MockObj:
# Remove before 2027.3.0
_LOGGER.warning(
"'modbus_controller.register_modbus_device' is deprecated, use "
"'modbus.register_modbus_client_device' and set the address on your own "
"class instead. Will be removed in 2027.3.0"
)
cg.add(var.set_address(config[CONF_ADDRESS]))
await cg.register_component(var, config)
return await modbus.register_modbus_client_device(var, config)
def function_code_to_register(function_code):
def function_code_to_register(function_code: str) -> cg.MockObj:
FUNCTION_CODE_TYPE_MAP = {
"read_coils": EntityType.COIL,
"read_discrete_inputs": EntityType.DISCRETE_INPUT,
@@ -10,6 +10,73 @@ 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;
this->dispatched_ = true;
return this->parent_->queue_pdu(frame[0], frame.subspan(1), this);
}
void WriterDevice::set_controller(ModbusController *controller) {
this->controller_ = controller;
this->set_parent(controller->hub());
this->set_address(controller->device_address());
}
void WriterDevice::notify_online_(std::span<const uint8_t> request_pdu) {
if (this->controller_ != nullptr)
this->controller_->set_online(true, fc_of(request_pdu), addr_of(request_pdu));
}
void WriterDevice::on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) {
this->notify_online_(request_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) {
ESP_LOGW(TAG, "Modbus error function code: 0x%X register 0x%X exception: %d", fc_of(request_pdu),
addr_of(request_pdu), static_cast<uint8_t>(exception_code));
this->notify_online_(request_pdu); // an exception is still a legitimate reply -> device is online
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 WriterDevice::on_sent(std::span<const uint8_t> request_pdu) {
if (this->controller_ != nullptr)
this->controller_->command_sent(fc_of(request_pdu), addr_of(request_pdu));
}
void WriterDevice::on_not_sent(std::span<const uint8_t> 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_of(request_pdu))) {
ESP_LOGW(TAG, "Write not sent: function 0x%X register 0x%X", fc_of(request_pdu), addr_of(request_pdu));
} else {
ESP_LOGD(TAG, "Request not sent: function 0x%X register 0x%X", fc_of(request_pdu), addr_of(request_pdu));
}
}
bool WriterDevice::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, fc_of(request_pdu), addr_of(request_pdu));
return false;
}
ModbusCommandItem::ModbusCommandItem(ModbusController &controller, modbus::ModbusClientHub *parent, uint8_t address,
RegisterRange &&range)
: modbus::ModbusClientDevice(parent, address),
@@ -232,6 +232,115 @@ struct RegisterRange {
SensorSet sensors; // all sensors of this range
};
/// A hub device owned by a writer entity (switch/number/select/output) through WriterEntity.
/// Centralises the feedback to the controller - online/offline tracking, retry counting and the
/// on_command_sent trigger - and records every dispatch, so a write lambda can tell "I sent it myself"
/// from "use the default write". The hub base is inherited protected, so the public members below are
/// the entity's whole request API and nothing can bypass the recording or re-target the device.
class WriterDevice final : protected modbus::ModbusClientDevice {
protected:
void on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override;
void on_error(std::span<const uint8_t> request_pdu, modbus::ExceptionCode exception_code) override;
void on_sent(std::span<const uint8_t> request_pdu) override;
void on_not_sent(std::span<const uint8_t> request_pdu) override;
bool on_no_response(std::span<const uint8_t> request_pdu) override;
void notify_online_(std::span<const uint8_t> request_pdu);
/// Function code / register address decoded from a request PDU ([fc, addr_hi, addr_lo, ...]).
static int fc_of(std::span<const uint8_t> pdu) { return pdu.empty() ? 0 : (pdu[0] & modbus::FUNCTION_CODE_MASK); }
static int addr_of(std::span<const uint8_t> pdu) {
return pdu.size() >= 3 ? modbus::helpers::get_data<uint16_t>(pdu.data(), 1) : 0;
}
/// Declared before controller_ so they land in the padding after ModbusClientDevice::custom_response_warned_
/// instead of adding a word to every entity that owns a device.
/// dispatched_: a frame was queued since the last clear_dispatched_().
/// write_buffer_deprecated_warned_: warn-once for the legacy write_lambda buffer parameter.
bool dispatched_{false};
bool write_buffer_deprecated_warned_{false};
ModbusController *controller_{nullptr};
public:
/// Whether a frame was queued to the hub since the last clear_dispatched_().
bool dispatched() const { return this->dispatched_; }
bool write_single_register(uint16_t address, uint16_t value) {
this->dispatched_ = true;
return modbus::ModbusClientDevice::write_single_register(address, value);
}
bool write_single_coil(uint16_t address, bool value) {
this->dispatched_ = true;
return modbus::ModbusClientDevice::write_single_coil(address, value);
}
bool write_multiple_registers(uint16_t address, std::span<const uint16_t> values) {
this->dispatched_ = true;
return modbus::ModbusClientDevice::write_multiple_registers(address, values);
}
bool write_multiple_coils(uint16_t address, std::span<const bool> values) {
this->dispatched_ = true;
return modbus::ModbusClientDevice::write_multiple_coils(address, values);
}
bool write_multiple_coils(uint16_t address, modbus::PackedBits bits) {
this->dispatched_ = true;
return modbus::ModbusClientDevice::write_multiple_coils(address, bits);
}
bool queue_pdu(std::span<const uint8_t> pdu, modbus::CommandOptions options = {}) {
this->dispatched_ = true;
return modbus::ModbusClientDevice::queue_pdu(pdu, options);
}
/// Send a legacy raw frame (address + function code + data) to the frame's own address.
/// Serves only the deprecated write_lambda buffer path. Remove before 2027.3.0.
bool send_raw_frame_deprecated(std::span<const uint8_t> frame);
void clear_tx_queue_for_device() { modbus::ModbusClientDevice::clear_tx_queue_for_device(); }
// Entity plumbing, public because the owning WriterEntity holds the only reachable instance (device_ is
// protected there and the hub sees just the masked base) - reachability is the access gate, not a friend.
void set_controller(ModbusController *controller);
void clear_dispatched() { this->dispatched_ = false; }
/// Warn once per entity that filling the write_lambda buffer parameter is deprecated (the entity is now the
/// command - call a write helper / queue_pdu() on `item` instead). The buffer parameter is removed in 2027.3.0.
void warn_write_buffer_deprecated(const LogString *platform, uint16_t address);
};
/// Gives a writer entity the write API of the WriterDevice it owns. The device is a member, not a base:
/// the mixin declares no virtual function, so an entity mixing it in gains no second vtable and all the
/// writer platforms share the single WriterDevice vtable instead of each emitting its own copy.
/// The forwarders keep `item->write_*()` working unchanged inside a write_lambda.
class WriterEntity {
public:
bool dispatched() const { return this->device_.dispatched(); }
bool write_single_register(uint16_t address, uint16_t value) {
return this->device_.write_single_register(address, value);
}
bool write_single_coil(uint16_t address, bool value) { return this->device_.write_single_coil(address, value); }
bool write_multiple_registers(uint16_t address, std::span<const uint16_t> values) {
return this->device_.write_multiple_registers(address, values);
}
bool write_multiple_coils(uint16_t address, std::span<const bool> values) {
return this->device_.write_multiple_coils(address, values);
}
bool write_multiple_coils(uint16_t address, modbus::PackedBits bits) {
return this->device_.write_multiple_coils(address, bits);
}
bool queue_pdu(std::span<const uint8_t> pdu, modbus::CommandOptions options = {}) {
return this->device_.queue_pdu(pdu, options);
}
void clear_tx_queue_for_device() { this->device_.clear_tx_queue_for_device(); }
protected:
bool send_raw_frame_deprecated_(std::span<const uint8_t> frame) {
return this->device_.send_raw_frame_deprecated(frame);
}
void set_controller_(ModbusController *controller) { this->device_.set_controller(controller); }
void clear_dispatched_() { this->device_.clear_dispatched(); }
void warn_write_buffer_deprecated_(const LogString *platform, uint16_t address) {
this->device_.warn_write_buffer_deprecated(platform, address);
}
WriterDevice device_;
};
/// 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.
@@ -398,17 +507,16 @@ inline bool offline_retry_due(uint16_t update_counter, uint16_t module_offline_a
class ModbusController final : public PollingComponent {
public:
// The controller is not itself a modbus device - its commands and writer entities send as their own
// devices, built against this hub + address.
ModbusController(modbus::ModbusClientHub *hub, uint8_t address) : hub_(hub), address_(address) {}
void dump_config() 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;
// 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_; }
@@ -3,6 +3,7 @@ from esphome.components import number
from esphome.components.modbus.helpers import (
MODBUS_WRITE_REGISTER_TYPE,
SENSOR_VALUE_TYPE,
RegisterValues,
)
import esphome.config_validation as cv
from esphome.const import (
@@ -13,6 +14,7 @@ from esphome.const import (
CONF_MULTIPLY,
CONF_STEP,
)
from esphome.types import ConfigType
from .. import (
ModbusItemBaseSchema,
@@ -43,7 +45,7 @@ ModbusNumber = modbus_controller_ns.class_(
)
def validate_min_max(config):
def validate_min_max(config: ConfigType) -> ConfigType:
if config[CONF_MAX_VALUE] <= config[CONF_MIN_VALUE]:
raise cv.Invalid("max_value must be greater than min_value")
if config[CONF_MIN_VALUE] < -16777215:
@@ -53,7 +55,7 @@ def validate_min_max(config):
return config
def validate_modbus_number(config):
def validate_modbus_number(config: ConfigType) -> ConfigType:
# custom_command is the deprecated alias for custom_pdu (migrated later in final validate).
has_custom = CONF_CUSTOM_PDU in config or CONF_CUSTOM_COMMAND in config
if not has_custom and CONF_ADDRESS not in config:
@@ -89,7 +91,7 @@ CONFIG_SCHEMA = cv.All(
FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item
async def to_code(config):
async def to_code(config: ConfigType) -> None:
byte_offset, reg_count = modbus_calc_properties(config)
var = cg.new_Pvariable(
config[CONF_ID],
@@ -124,7 +126,7 @@ async def to_code(config):
[
(ModbusNumber.operator("ptr"), "item"),
(cg.float_, "x"),
(cg.std_vector.template(cg.uint16).operator("ref"), "payload"),
(RegisterValues.operator("ref"), "payload"),
],
return_type=cg.optional.template(float),
)
@@ -1,4 +1,3 @@
#include <vector>
#include "modbus_number.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
@@ -29,62 +28,73 @@ void ModbusNumber::parse_and_publish(std::span<const uint8_t> data) {
}
void ModbusNumber::control(float value) {
optional<ModbusCommandItem> write_cmd;
std::vector<uint16_t> data;
this->clear_dispatched_();
// A new write supersedes this entity's own not-yet-sent writes: drop them (and detach any in-flight one)
// so a rapidly-changing value writes the latest, not every intermediate.
this->clear_tx_queue_for_device();
modbus::RegisterValues data;
float write_value = value;
// Is there are lambda configured?
if (this->write_transform_func_.has_value()) {
// data is passed by reference
// the lambda can fill the empty vector directly
// in that case the return value is ignored
// The lambda may drive the write itself via item->write_*(), override the value (return a value), or
// (deprecated) fill `data` with the register words to write.
auto val = (*this->write_transform_func_)(this, value, data);
if (val.has_value()) {
ESP_LOGV(TAG, "Value overwritten by lambda");
write_value = val.value();
} else {
if (this->dispatched()) {
this->publish_state(value);
return;
}
if (!data.empty()) {
// Deprecated buffer path (frozen): the lambda filled a legacy raw frame as words; pack it big-endian.
this->warn_write_buffer_deprecated_(LOG_STR("number"), this->start_address);
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
char hex_buf[format_hex_pretty_uint16_size(MODBUS_NUMBER_MAX_LOG_REGISTERS)];
#endif
ESP_LOGV(TAG, "Modbus Number write raw: %s",
format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size()));
// Sized to hold RegisterValues at capacity, so a full buffer can never truncate into a valid frame.
StaticVector<uint8_t, modbus::MAX_NUM_OF_REGISTERS_TO_READ * 2> bytes;
for (uint16_t word : data) {
const auto word_bytes = decode_value(word);
bytes.push_back(word_bytes[0]);
bytes.push_back(word_bytes[1]);
}
if (!this->send_raw_frame_deprecated_(std::span<const uint8_t>(bytes.data(), bytes.size()))) {
ESP_LOGW(TAG, "Modbus write for '%s' was refused by the hub; state not published", this->get_name().c_str());
return;
}
this->publish_state(value);
return;
}
if (!val.has_value()) {
ESP_LOGV(TAG, "Communication handled by lambda - exiting control");
return;
}
ESP_LOGV(TAG, "Value overwritten by lambda");
write_value = val.value();
} else {
write_value = this->multiply_by_ * write_value;
}
if (!data.empty()) {
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
char hex_buf[format_hex_pretty_uint16_size(MODBUS_NUMBER_MAX_LOG_REGISTERS)];
#endif
ESP_LOGV(TAG, "Modbus Number write raw: %s",
format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size()));
write_cmd.emplace(ModbusCommandItem::create_custom_command(
this->parent_, 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);
modbus::helpers::float_to_payload(data, write_value, this->sensor_value_type);
// float_to_payload() appends nothing for RAW, so an empty payload must be caught before data[0] below.
if (data.empty()) {
ESP_LOGW(TAG, "No payload was created for updating number");
return;
}
ESP_LOGD(TAG,
"Updating register: connected Sensor=%s start address=0x%X register count=%d new value=%.02f (val=%.02f)",
this->get_name().c_str(), this->start_address, this->register_count, value, write_value);
ESP_LOGD(TAG,
"Updating register: connected Sensor=%s start address=0x%X register count=%d new value=%.02f (val=%.02f)",
this->get_name().c_str(), this->start_address, this->register_count, value, write_value);
// Create and send the write command
if (this->register_count == 1 && !this->use_write_multiple_) {
write_cmd.emplace(
ModbusCommandItem::create_write_single_command(this->parent_, this->write_address(), payload[0]));
} else {
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, 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(register_type, start_address, data);
this->publish_state(value);
};
bool queued;
if (this->register_count == 1 && !this->use_write_multiple_) {
queued = this->write_single_register(this->write_address(), data[0]);
} else {
queued = this->write_multiple_registers(this->write_address(), data);
}
if (!queued) {
ESP_LOGW(TAG, "Modbus write for '%s' was refused by the hub; state not published", this->get_name().c_str());
return;
}
this->parent_->queue_command(std::move(*write_cmd));
this->publish_state(value);
}
void ModbusNumber::dump_config() { LOG_NUMBER(TAG, "Modbus Number", this); }
@@ -10,7 +10,7 @@ namespace esphome::modbus_controller {
using value_to_data_t = std::function<float>(float);
class ModbusNumber final : public number::Number, public Component, public SensorItem {
class ModbusNumber final : public number::Number, public Component, public SensorItem, public WriterEntity {
public:
ModbusNumber(modbus::EntityType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask,
SensorValueType value_type, int register_count, bool force_new_range) {
@@ -26,11 +26,11 @@ class ModbusNumber final : public number::Number, public Component, public Senso
void dump_config() override;
void parse_and_publish(std::span<const uint8_t> data) override;
float get_setup_priority() const override { return setup_priority::HARDWARE; }
void set_parent(ModbusController *parent) { this->parent_ = parent; }
void set_parent(ModbusController *parent) { this->set_controller_(parent); }
void set_write_multiply(float factor) { this->multiply_by_ = factor; }
using transform_func_t = optional<float> (*)(ModbusNumber *, float, std::span<const uint8_t>);
using write_transform_func_t = optional<float> (*)(ModbusNumber *, float, std::vector<uint16_t> &);
using write_transform_func_t = optional<float> (*)(ModbusNumber *, float, modbus::RegisterValues &);
void set_template(transform_func_t f) { this->transform_func_ = f; }
void set_write_template(write_transform_func_t f) { this->write_transform_func_ = f; }
void set_use_write_mutiple(bool use_write_multiple) { this->use_write_multiple_ = use_write_multiple; }
@@ -39,7 +39,6 @@ class ModbusNumber final : public number::Number, public Component, public Senso
void control(float value) override;
optional<transform_func_t> transform_func_{nullopt};
optional<write_transform_func_t> write_transform_func_{nullopt};
ModbusController *parent_{nullptr};
float multiply_by_{1.0};
bool use_write_multiple_{false};
};
@@ -1,8 +1,13 @@
import esphome.codegen as cg
from esphome.components import output
from esphome.components.modbus.helpers import SENSOR_VALUE_TYPE
from esphome.components.modbus.helpers import (
SENSOR_VALUE_TYPE,
PduBuffer,
RegisterValues,
)
import esphome.config_validation as cv
from esphome.const import CONF_ADDRESS, CONF_ID, CONF_MULTIPLY
from esphome.types import ConfigType
from .. import (
ModbusItemBaseSchema,
@@ -73,7 +78,7 @@ CONFIG_SCHEMA = cv.typed_schema(
)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
byte_offset, reg_count = modbus_calc_properties(config)
# Binary Output
write_template = None
@@ -89,7 +94,7 @@ async def to_code(config):
[
(ModbusBinaryOutput.operator("ptr"), "item"),
(cg.bool_, "x"),
(cg.std_vector.template(cg.uint8).operator("ref"), "payload"),
(PduBuffer.operator("ref"), "payload"),
],
return_type=cg.optional.template(bool),
)
@@ -109,7 +114,7 @@ async def to_code(config):
[
(ModbusFloatOutput.operator("ptr"), "item"),
(cg.float_, "x"),
(cg.std_vector.template(cg.uint16).operator("ref"), "payload"),
(RegisterValues.operator("ref"), "payload"),
],
return_type=cg.optional.template(float),
)
@@ -2,6 +2,8 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <array>
namespace esphome::modbus_controller {
static const char *const TAG = "modbus_controller.output";
@@ -13,25 +15,33 @@ static constexpr size_t MODBUS_OUTPUT_MAX_LOG_BYTES = 64;
*
*/
void ModbusFloatOutput::write_state(float value) {
std::vector<uint16_t> data;
this->clear_dispatched_();
// A new write supersedes this entity's own not-yet-sent writes: drop them (and detach any in-flight one)
// so a rapidly-changing value writes the latest, not every intermediate.
this->clear_tx_queue_for_device();
modbus::RegisterValues data;
auto original_value = value;
// Is there are lambda configured?
if (this->write_transform_func_.has_value()) {
// data is passed by reference
// the lambda can fill the empty vector directly
// in that case the return value is ignored
// The lambda may drive the write itself via item->write_*(), override the value (return a value), or
// (deprecated) fill `data` with the register words to write.
auto val = (*this->write_transform_func_)(this, value, data);
if (val.has_value()) {
ESP_LOGV(TAG, "Value overwritten by lambda");
value = val.value();
} else {
if (this->dispatched()) {
return;
}
if (!data.empty()) {
// Deprecated buffer path (frozen): the lambda supplied the register words for the shared write below.
this->warn_write_buffer_deprecated_(LOG_STR("float output"), this->start_address);
} else if (!val.has_value()) {
ESP_LOGV(TAG, "Communication handled by lambda - exiting control");
return;
} else {
ESP_LOGV(TAG, "Value overwritten by lambda");
value = val.value();
}
} else {
value = this->multiply_by_ * value;
}
// lambda didn't set payload
if (data.empty()) {
modbus::helpers::float_to_payload(data, value, this->sensor_value_type);
}
@@ -57,16 +67,15 @@ void ModbusFloatOutput::write_state(float value) {
return;
}
// Create and send the write command
optional<ModbusCommandItem> write_cmd;
bool queued;
if (this->register_count == 1 && !this->use_write_multiple_) {
write_cmd.emplace(
ModbusCommandItem::create_write_single_command(this->parent_, this->start_address + this->offset, data[0]));
queued = this->write_single_register(this->write_address(), data[0]);
} else {
write_cmd.emplace(ModbusCommandItem::create_write_multiple_command(
this->parent_, this->start_address + this->offset, data.size(), data));
queued = this->write_multiple_registers(this->write_address(), data);
}
if (!queued) {
ESP_LOGW(TAG, "Modbus output write (address 0x%X) was refused by the hub", this->write_address());
}
this->parent_->queue_command(std::move(*write_cmd));
}
void ModbusFloatOutput::dump_config() {
@@ -81,50 +90,52 @@ void ModbusFloatOutput::dump_config() {
// ModbusBinaryOutput
void ModbusBinaryOutput::write_state(bool state) {
// This will be called every time the user requests a state change.
optional<ModbusCommandItem> cmd;
std::vector<uint8_t> data;
this->clear_dispatched_();
// A new write supersedes this entity's own not-yet-sent writes: drop them (and detach any in-flight one)
// so a rapidly-changing value writes the latest, not every intermediate.
this->clear_tx_queue_for_device();
modbus::helpers::PduBuffer data;
// Is there are lambda configured?
if (this->write_transform_func_.has_value()) {
// data is passed by reference
// the lambda can fill the empty vector directly
// in that case the return value is ignored
// The lambda may drive the write itself via item->write_*/queue_pdu(), override the value (return a value),
// or (deprecated) fill `data` with a custom PDU.
auto val = (*this->write_transform_func_)(this, state, data);
if (val.has_value()) {
ESP_LOGV(TAG, "Value overwritten by lambda");
state = val.value();
} else {
if (this->dispatched()) {
return;
}
if (!data.empty()) {
this->warn_write_buffer_deprecated_(LOG_STR("binary output"), this->start_address);
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
char hex_buf[format_hex_pretty_size(MODBUS_OUTPUT_MAX_LOG_BYTES)];
#endif
ESP_LOGV(TAG, "Modbus binary output write raw: %s",
format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size()));
// The lambda filled a legacy raw frame (device address + function code + data).
if (!this->send_raw_frame_deprecated_(data)) {
ESP_LOGW(TAG, "Modbus output write (address 0x%X) was refused by the hub", this->write_address());
}
return;
}
if (!val.has_value()) {
ESP_LOGV(TAG, "Communication handled by lambda - exiting control");
return;
}
ESP_LOGV(TAG, "Value overwritten by lambda");
state = val.value();
}
if (!data.empty()) {
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
char hex_buf[format_hex_pretty_size(MODBUS_OUTPUT_MAX_LOG_BYTES)];
#endif
ESP_LOGV(TAG, "Modbus binary output write raw: %s",
format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size()));
cmd.emplace(ModbusCommandItem::create_custom_command(
this->parent_, 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);
}));
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);
// offset for coil and discrete inputs is the coil/register number not bytes
bool queued;
if (this->use_write_multiple_) {
std::array<bool, 1> states{state};
queued = this->write_multiple_coils(this->write_address(), states);
} 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);
// offset for coil and discrete inputs is the coil/register number not bytes
if (this->use_write_multiple_) {
std::vector<bool> states{state};
cmd.emplace(
ModbusCommandItem::create_write_multiple_coils(this->parent_, this->start_address + this->offset, states));
} else {
cmd.emplace(
ModbusCommandItem::create_write_single_coil(this->parent_, this->start_address + this->offset, state));
}
queued = this->write_single_coil(this->write_address(), state);
}
if (!queued) {
ESP_LOGW(TAG, "Modbus output write (address 0x%X) was refused by the hub", this->write_address());
}
this->parent_->queue_command(std::move(*cmd));
}
void ModbusBinaryOutput::dump_config() {
@@ -8,26 +8,24 @@
namespace esphome::modbus_controller {
class ModbusFloatOutput final : public output::FloatOutput, public Component, public SensorItem {
class ModbusFloatOutput final : public output::FloatOutput, public Component, public SensorItem, public WriterEntity {
public:
ModbusFloatOutput(uint16_t start_address, uint8_t offset, SensorValueType value_type, int register_count) {
this->register_type = modbus::EntityType::HOLDING;
this->set_address(start_address);
this->set_offset_from_start_address(offset);
this->set_address(start_address + offset);
this->set_offset_from_start_address(0);
this->bitmask = 0xFFFFFFFF;
this->register_count = register_count;
this->sensor_value_type = value_type;
this->set_address(this->start_address + offset);
this->set_offset_from_start_address(0);
}
void dump_config() override;
void set_parent(ModbusController *parent) { this->parent_ = parent; }
void set_parent(ModbusController *parent) { this->set_controller_(parent); }
void set_write_multiply(float factor) { this->multiply_by_ = factor; }
// Do nothing
void parse_and_publish(std::span<const uint8_t> data) override{};
using write_transform_func_t = optional<float> (*)(ModbusFloatOutput *, float, std::vector<uint16_t> &);
using write_transform_func_t = optional<float> (*)(ModbusFloatOutput *, float, modbus::RegisterValues &);
void set_write_template(write_transform_func_t f) { this->write_transform_func_ = f; }
void set_use_write_mutiple(bool use_write_multiple) { this->use_write_multiple_ = use_write_multiple; }
@@ -35,29 +33,28 @@ class ModbusFloatOutput final : public output::FloatOutput, public Component, pu
void write_state(float value) override;
optional<write_transform_func_t> write_transform_func_{nullopt};
ModbusController *parent_{nullptr};
float multiply_by_{1.0};
bool use_write_multiple_{false};
};
class ModbusBinaryOutput final : public output::BinaryOutput, public Component, public SensorItem {
class ModbusBinaryOutput final : public output::BinaryOutput, public Component, public SensorItem, public WriterEntity {
public:
ModbusBinaryOutput(uint16_t start_address, uint8_t offset) {
this->register_type = modbus::EntityType::COIL;
this->set_address(start_address);
// A coil offset is a coil count; fold it into the address.
this->set_address(start_address + offset);
this->bitmask = 0xFFFFFFFF;
this->sensor_value_type = SensorValueType::BIT;
this->register_count = 1;
this->set_address(this->start_address + offset);
this->set_offset_from_start_address(0);
}
void dump_config() override;
void set_parent(ModbusController *parent) { this->parent_ = parent; }
void set_parent(ModbusController *parent) { this->set_controller_(parent); }
// Do nothing
void parse_and_publish(std::span<const uint8_t> data) override{};
using write_transform_func_t = optional<bool> (*)(ModbusBinaryOutput *, bool, std::vector<uint8_t> &);
using write_transform_func_t = optional<bool> (*)(ModbusBinaryOutput *, bool, modbus::helpers::PduBuffer &);
void set_write_template(write_transform_func_t f) { this->write_transform_func_ = f; }
void set_use_write_mutiple(bool use_write_multiple) { this->use_write_multiple_ = use_write_multiple; }
@@ -65,7 +62,6 @@ class ModbusBinaryOutput final : public output::BinaryOutput, public Component,
void write_state(bool state) override;
optional<write_transform_func_t> write_transform_func_{nullopt};
ModbusController *parent_{nullptr};
bool use_write_multiple_{false};
};
@@ -1,8 +1,16 @@
from collections.abc import Callable
from typing import Any
import esphome.codegen as cg
from esphome.components import select
from esphome.components.modbus.helpers import SENSOR_VALUE_TYPE, TYPE_REGISTER_MAP
from esphome.components.modbus.helpers import (
SENSOR_VALUE_TYPE,
TYPE_REGISTER_MAP,
RegisterValues,
)
import esphome.config_validation as cv
from esphome.const import CONF_ADDRESS, CONF_ID, CONF_LAMBDA, CONF_OPTIMISTIC
from esphome.types import ConfigType
from .. import (
ModbusController,
@@ -29,8 +37,8 @@ ModbusSelect = modbus_controller_ns.class_(
)
def ensure_option_map():
def validator(value):
def ensure_option_map() -> Callable[[Any], dict[str, int]]:
def validator(value: Any) -> dict[str, int]:
cv.check_not_templatable(value)
option = cv.All(cv.string_strict)
mapping = cv.All(cv.int_range(-(2**63), 2**63 - 1))
@@ -47,7 +55,7 @@ def ensure_option_map():
return validator
def register_count_value_type_min(value):
def register_count_value_type_min(value: ConfigType) -> ConfigType:
reg_count = value.get(CONF_REGISTER_COUNT)
if reg_count is not None:
value_type = value[CONF_VALUE_TYPE]
@@ -87,7 +95,7 @@ CONFIG_SCHEMA = cv.All(
)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
value_type = config[CONF_VALUE_TYPE]
reg_count = config.get(CONF_REGISTER_COUNT)
if reg_count is None:
@@ -132,7 +140,7 @@ async def to_code(config):
(ModbusSelect.operator("const_ptr"), "item"),
(cg.std_string.operator("const").operator("ref"), "x"),
(cg.int64, "value"),
(cg.std_vector.template(cg.uint16).operator("ref"), "payload"),
(RegisterValues.operator("ref"), "payload"),
],
return_type=cg.optional.template(cg.int64),
)
@@ -46,35 +46,43 @@ void ModbusSelect::control(size_t index) {
const char *option = this->option_at(index);
ESP_LOGD(TAG, "Found value %lld for option '%s'", *mapval, option);
std::vector<uint16_t> data;
this->clear_dispatched_();
// A new write supersedes this entity's own not-yet-sent writes: drop them (and detach any in-flight one)
// so a rapidly-changing value writes the latest, not every intermediate.
this->clear_tx_queue_for_device();
modbus::RegisterValues data;
if (this->write_transform_func_.has_value()) {
// Transform func requires string parameter for backward compatibility
// The lambda may drive the write itself via item->write_*(), override the mapping value (return a value),
// or (deprecated) fill `data` with the register words to write. Transform func requires string parameter
// for backward compatibility.
auto val = (*this->write_transform_func_)(this, std::string(option), *mapval, data);
if (val.has_value()) {
mapval = val;
ESP_LOGV(TAG, "write_lambda returned mapping value %lld", *mapval);
} else {
if (this->dispatched()) {
if (this->optimistic_)
this->publish_state(index);
return;
}
if (!data.empty()) {
// Deprecated buffer path (frozen): the lambda supplied the register words for the shared write below.
this->warn_write_buffer_deprecated_(LOG_STR("select"), this->start_address);
} else if (!val.has_value()) {
ESP_LOGD(TAG, "Communication handled by write_lambda - exiting control");
return;
} else {
mapval = val;
ESP_LOGV(TAG, "write_lambda returned mapping value %lld", *mapval);
}
}
if (data.empty()) {
modbus::helpers::number_to_payload(data, *mapval, this->sensor_value_type);
} else {
ESP_LOGV(TAG, "Using payload from write lambda");
// number_to_payload() appends nothing for RAW.
if (data.empty()) {
ESP_LOGW(TAG, "No payload was created for updating select");
return;
}
}
if (data.empty()) {
ESP_LOGW(TAG, "No payload was created for updating select");
return;
}
// The command declares register_count registers, so the payload must be exactly that many words:
// a value type narrower than the declared width is zero-padded (the config deliberately allows
// register_count larger than the value type). Anything else would put a byte count on the wire
// that disagrees with the quantity field, which conformant devices reject.
// register_count declares the READ range width - it may pull neighboring registers into one poll -
// so a write covers exactly the registers the value occupies: the quantity comes from the payload,
// never from register_count (padding to it would zero registers the user only declared for reading).
@@ -86,16 +94,17 @@ void ModbusSelect::control(size_t index) {
}
const uint16_t write_address = this->write_address();
optional<ModbusCommandItem> write_cmd;
bool queued;
if ((this->register_count == 1) && (!this->use_write_multiple_)) {
write_cmd.emplace(ModbusCommandItem::create_write_single_command(this->parent_, write_address, data[0]));
queued = this->write_single_register(write_address, data[0]);
} else {
write_cmd.emplace(
ModbusCommandItem::create_write_multiple_command(this->parent_, write_address, data.size(), data));
queued = this->write_multiple_registers(write_address, data);
}
this->parent_->queue_command(std::move(*write_cmd));
if (!queued) {
ESP_LOGW(TAG, "Modbus write for '%s' was refused by the hub; state not published", this->get_name().c_str());
return;
}
if (this->optimistic_)
this->publish_state(index);
}
@@ -9,7 +9,7 @@
namespace esphome::modbus_controller {
class ModbusSelect final : public Component, public select::Select, public SensorItem {
class ModbusSelect final : public Component, public select::Select, public SensorItem, public WriterEntity {
public:
ModbusSelect(SensorValueType sensor_value_type, uint16_t start_address, uint8_t register_count, bool force_new_range,
std::vector<int64_t> mapping) {
@@ -26,9 +26,9 @@ class ModbusSelect final : public Component, public select::Select, public Senso
using transform_func_t = optional<std::string> (*)(ModbusSelect *const, int64_t, std::span<const uint8_t>);
using write_transform_func_t = optional<int64_t> (*)(ModbusSelect *const, const std::string &, int64_t,
std::vector<uint16_t> &);
modbus::RegisterValues &);
void set_parent(ModbusController *const parent) { this->parent_ = parent; }
void set_parent(ModbusController *const parent) { this->set_controller_(parent); }
void set_use_write_mutiple(bool use_write_multiple) { this->use_write_multiple_ = use_write_multiple; }
void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; }
void set_template(transform_func_t f) { this->transform_func_ = f; }
@@ -40,7 +40,6 @@ class ModbusSelect final : public Component, public select::Select, public Senso
protected:
std::vector<int64_t> mapping_{};
ModbusController *parent_{nullptr};
bool use_write_multiple_{false};
bool optimistic_{false};
optional<transform_func_t> transform_func_{nullopt};
@@ -1,8 +1,9 @@
import esphome.codegen as cg
from esphome.components import switch
from esphome.components.modbus.helpers import MODBUS_REGISTER_TYPE
from esphome.components.modbus.helpers import MODBUS_REGISTER_TYPE, PduBuffer
import esphome.config_validation as cv
from esphome.const import CONF_ADDRESS, CONF_ASSUMED_STATE, CONF_ID
from esphome.types import ConfigType
from .. import (
ModbusItemBaseSchema,
@@ -48,7 +49,7 @@ CONFIG_SCHEMA = cv.All(
FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item
async def to_code(config):
async def to_code(config: ConfigType) -> None:
byte_offset, _ = modbus_calc_properties(config)
var = cg.new_Pvariable(
config[CONF_ID],
@@ -74,7 +75,7 @@ async def to_code(config):
[
(ModbusSwitch.operator("ptr"), "item"),
(cg.bool_, "x"),
(cg.std_vector.template(cg.uint8).operator("ref"), "payload"),
(PduBuffer.operator("ref"), "payload"),
],
return_type=cg.optional.template(bool),
)
@@ -3,6 +3,8 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <array>
namespace esphome::modbus_controller {
static const char *const TAG = "modbus_controller.switch";
@@ -58,57 +60,64 @@ 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.
optional<ModbusCommandItem> cmd;
std::vector<uint8_t> data;
// Is there are lambda configured?
this->clear_dispatched_();
// A new write supersedes this entity's own not-yet-sent writes: drop them (and detach any in-flight one)
// so a rapidly-changing value writes the latest, not every intermediate.
this->clear_tx_queue_for_device();
modbus::helpers::PduBuffer data;
if (this->write_transform_func_.has_value()) {
// data is passed by reference
// the lambda can fill the empty vector directly
// in that case the return value is ignored
// The lambda may drive the write itself via item->write_*/queue_pdu(), override the written value (return a
// value), or (deprecated) fill `data` with a custom PDU.
auto val = (*this->write_transform_func_)(this, state, data);
if (val.has_value()) {
ESP_LOGV(TAG, "Value overwritten by lambda");
state = val.value();
} else {
if (this->dispatched()) {
this->publish_state(state);
return;
}
if (!data.empty()) {
this->warn_write_buffer_deprecated_(LOG_STR("switch"), this->start_address);
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
char hex_buf[format_hex_pretty_size(MODBUS_SWITCH_MAX_LOG_BYTES)];
#endif
ESP_LOGV(TAG, "Modbus Switch write raw: %s",
format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size()));
// The lambda filled a legacy raw frame (device address + function code + data).
if (!this->send_raw_frame_deprecated_(data)) {
ESP_LOGW(TAG, "Modbus write for '%s' was refused by the hub; state not published", this->get_name().c_str());
return;
}
this->publish_state(state);
return;
}
if (!val.has_value()) {
ESP_LOGV(TAG, "Communication handled by lambda - exiting control");
return;
}
ESP_LOGV(TAG, "Value overwritten by lambda");
state = val.value();
}
if (!data.empty()) {
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
char hex_buf[format_hex_pretty_size(MODBUS_SWITCH_MAX_LOG_BYTES)];
#endif
ESP_LOGV(TAG, "Modbus Switch write raw: %s",
format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size()));
cmd.emplace(ModbusCommandItem::create_custom_command(
this->parent_, 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);
if (this->register_type == modbus::EntityType::COIL) {
// offset for coil and discrete inputs is the coil/register number not bytes
if (this->use_write_multiple_) {
std::vector<bool> states{state};
cmd.emplace(ModbusCommandItem::create_write_multiple_coils(this->parent_, this->write_address(), states));
} else {
cmd.emplace(ModbusCommandItem::create_write_single_coil(this->parent_, this->write_address(), state));
}
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);
bool queued;
if (this->register_type == EntityType::COIL) {
// offset for coil and discrete inputs is the coil/register number not bytes
if (this->use_write_multiple_) {
std::array<bool, 1> states{state};
queued = this->write_multiple_coils(this->write_address(), states);
} else {
if (this->use_write_multiple_) {
std::vector<uint16_t> bool_states(1, state ? (0xFFFF & this->bitmask) : 0);
cmd.emplace(
ModbusCommandItem::create_write_multiple_command(this->parent_, this->write_address(), 1, bool_states));
} else {
cmd.emplace(ModbusCommandItem::create_write_single_command(this->parent_, this->write_address(),
state ? 0xFFFF & this->bitmask : 0u));
}
queued = this->write_single_coil(this->write_address(), state);
}
} else {
if (this->use_write_multiple_) {
std::array<uint16_t, 1> states{static_cast<uint16_t>(state ? (0xFFFF & this->bitmask) : 0)};
queued = this->write_multiple_registers(this->write_address(), states);
} else {
queued = this->write_single_register(this->write_address(), state ? 0xFFFF & this->bitmask : 0u);
}
}
this->parent_->queue_command(std::move(*cmd));
if (!queued) {
ESP_LOGW(TAG, "Modbus write for '%s' was refused by the hub; state not published", this->get_name().c_str());
return;
}
this->publish_state(state);
}
// ModbusSwitch end
@@ -8,7 +8,7 @@
namespace esphome::modbus_controller {
class ModbusSwitch final : public Component, public switch_::Switch, public SensorItem {
class ModbusSwitch final : public Component, public switch_::Switch, public SensorItem, public WriterEntity {
public:
ModbusSwitch(modbus::EntityType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask,
bool force_new_range) {
@@ -30,17 +30,16 @@ class ModbusSwitch final : public Component, public switch_::Switch, public Sens
void set_assumed_state(bool assumed_state);
void set_state(bool state) { this->state = state; }
void parse_and_publish(std::span<const uint8_t> data) override;
void set_parent(ModbusController *parent) { this->parent_ = parent; }
void set_parent(ModbusController *parent) { this->set_controller_(parent); }
using transform_func_t = optional<bool> (*)(ModbusSwitch *, bool, std::span<const uint8_t>);
using write_transform_func_t = optional<bool> (*)(ModbusSwitch *, bool, std::vector<uint8_t> &);
using write_transform_func_t = optional<bool> (*)(ModbusSwitch *, bool, modbus::helpers::PduBuffer &);
void set_template(transform_func_t f) { this->publish_transform_func_ = f; }
void set_write_template(write_transform_func_t f) { this->write_transform_func_ = f; }
void set_use_write_mutiple(bool use_write_multiple) { this->use_write_multiple_ = use_write_multiple; }
protected:
bool assumed_state() override;
ModbusController *parent_{nullptr};
bool use_write_multiple_{false};
optional<transform_func_t> publish_transform_func_{nullopt};
optional<write_transform_func_t> write_transform_func_{nullopt};
@@ -13,7 +13,7 @@ namespace esphome::modbus_controller::testing {
// malformed. Built at its true byte count, the oversize frame is refused by the hub's size check with
// a log instead.
TEST(ModbusCommandPayload, CoilWritePayloadIsExactSizedNotTruncated) {
ModbusController controller;
ModbusController controller(nullptr, 1);
std::vector<bool> coils(modbus::MAX_NUM_OF_COILS_TO_WRITE + 1, true);
auto cmd = ModbusCommandItem::create_write_multiple_coils(&controller, 0x10, coils);
EXPECT_EQ(cmd.payload.size(), modbus::packed_bit_bytes(coils.size()));
@@ -21,7 +21,7 @@ TEST(ModbusCommandPayload, CoilWritePayloadIsExactSizedNotTruncated) {
// LSB-first packing with zeroed pad bits, matching the wire layout the PDU builders produce.
TEST(ModbusCommandPayload, CoilWritePacksLsbFirstWithZeroPad) {
ModbusController controller;
ModbusController controller(nullptr, 1);
const std::vector<bool> coils{true, false, true, true};
auto cmd = ModbusCommandItem::create_write_multiple_coils(&controller, 0x10, coils);
ASSERT_EQ(cmd.payload.size(), 1u);
@@ -0,0 +1,97 @@
esphome:
name: uart-mock-modbus-lambda-write
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;
globals:
- id: reg30
type: uint16_t
initial_value: "0"
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: 0x30
value_type: U_WORD
read_lambda: return id(reg30);
write_lambda: id(reg30) = x; return true;
# A COIL-type switch (assumed_state, write-only) whose write_lambda ignores its own coil type and instead
# drives a HOLDING-REGISTER write on the mock server through the entity itself: `item` IS the command, so
# item->write_single_register() sends a register write from a coil entity (cross-type). Returning nothing
# (an empty optional) tells the write path the lambda already dispatched the frame - no default coil write.
switch:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "cross_switch"
register_type: coil
address: 0x00
assumed_state: true
write_lambda: |-
item->write_single_register(0x30, x ? 1234 : 0);
return {};
sensor:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_30"
address: 0x30
register_type: holding
value_type: U_WORD
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
# This test does not have anything to start (mock is autostart)
+43 -15
View File
@@ -969,10 +969,10 @@ async def test_uart_mock_modbus_client_read_write(
@pytest.mark.xfail(
strict=True,
reason="Byte-accurate register-offset writes require the modbus_controller "
"entity-device change; on dev the byte offset is folded into the address "
"(writes 0x12 instead of 0x11). The write and read assertions both flip via "
"the same switch-constructor fold. Remove this marker when that change merges.",
reason="Byte-accurate register-offset writes land in the follow-up offset fix; "
"until then the byte offset is folded into the address (writes 0x12 instead of "
"0x11). The write and read assertions both flip via the same switch-constructor "
"fold. Remove this marker when that change merges.",
)
@pytest.mark.asyncio
async def test_uart_mock_modbus_register_offset(
@@ -1029,14 +1029,42 @@ async def test_uart_mock_modbus_register_offset(
)
@pytest.mark.xfail(
strict=True,
reason="The deprecated write buffer requires the modbus_controller "
"entity-device change; on dev a nullopt-returning write_lambda early-returns "
"before the buffer is used, so the write never happens. The warn-once "
"assertion matches the log substring 'write_lambda buffer'. Remove this "
"marker when that change merges.",
)
@pytest.mark.asyncio
async def test_uart_mock_modbus_lambda_write(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test a write_lambda that drives the write through the entity itself (item is the command).
`cross_switch` is a coil-type switch whose write_lambda ignores its own type and calls
item->write_single_register(0x30, ...) - a register write issued from a coil entity. The lambda
returns an empty optional, so the write path detects the lambda already dispatched a frame and does
not fall back to the default coil write. Success is reg_30 reading back the value the lambda wrote,
which proves both the new item->write_* path and cross-type flexibility.
"""
tracker = SensorTracker(["reg_30"])
initial = tracker.expect("reg_30", 0)
wrote_30 = tracker.expect("reg_30", 1234)
async with (
run_compiled(yaml_config),
api_client_connected() as client,
):
entities = await tracker.setup_and_start_scenario(client)
await tracker.await_change(initial, "reg_30", timeout=4.0)
switch = find_entity(entities, "cross_switch", SwitchInfo)
assert switch is not None, "cross_switch not found"
client.switch_command(switch.key, True)
# The coil switch's lambda wrote register 0x30 via item->write_single_register(); reg_30 must
# read back 1234. If the entity-as-command dispatch were broken, no register write would go out
# and this would time out.
await tracker.await_change(wrote_30, "reg_30", timeout=4.0)
@pytest.mark.asyncio
async def test_uart_mock_modbus_deprecated_write_buffer(
yaml_config: str,
@@ -1046,9 +1074,9 @@ async def test_uart_mock_modbus_deprecated_write_buffer(
"""Test the deprecated write_lambda buffer path still works, and warns once per entity.
buf_number's write_lambda fills the old `payload` buffer with a legacy raw frame as words (device
address + function code + data) instead of calling item->write_*. Two writes must both land with the
legacy raw-frame semantics, and the one-time deprecation warning must fire exactly once per entity
regardless of how many writes happen.
address + function code + data) and returns {} instead of calling item->write_*. Both writes must
land - a filled buffer is sent, as the docs have always described - and the one-time deprecation
warning must fire exactly once per entity regardless of how many writes happen.
"""
warn_count = 0