[modbus_client] Add typed read/write actions (#18078)

Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Bonne Eggleston
2026-08-07 14:18:20 -05:00
committed by GitHub
co-authored by J. Nick Koston Claude
parent ee13996ee6
commit f5ee72753d
8 changed files with 847 additions and 19 deletions
+8
View File
@@ -21,6 +21,14 @@ AUTO_LOAD = ["modbus_client"]
# Mirrors modbus::MAX_PDU_SIZE in modbus_definitions.h: 256-byte RTU frame minus address and CRC.
MAX_PDU_SIZE = 253
# Mirror the per-function entity count limits from modbus_definitions.h. Keep these in step with the
# C++ constants of the same name; the spec sets a different ceiling for each function code.
MAX_NUM_OF_COILS_TO_READ = 2000
MAX_NUM_OF_DISCRETE_INPUTS_TO_READ = 2000
MAX_NUM_OF_COILS_TO_WRITE = 1968
MAX_NUM_OF_REGISTERS_TO_READ = 125
MAX_NUM_OF_REGISTERS_TO_WRITE = 123
modbus_ns = cg.esphome_ns.namespace("modbus")
Modbus = modbus_ns.class_("Modbus", cg.Component, uart.UARTDevice)
ModbusServer = modbus_ns.class_("ModbusServerHub", Modbus)
+17 -7
View File
@@ -534,23 +534,33 @@ PduBuffer create_write_coils_pdu(uint16_t start_address, PackedBits bits) {
return pdu;
}
PduBuffer create_write_coils_pdu(uint16_t start_address, std::span<const bool> values) {
// Shared by the two bool-container overloads: both index the same way, so the packing is written once.
template<typename BoolContainer>
static PduBuffer create_write_coils_pdu_from_bools(uint16_t start_address, const BoolContainer &values) {
PduBuffer pdu; // declared before every return so NRVO fires (all paths return the same object)
const size_t count = values.size();
// Bound before packing so the transient buffer below cannot overflow; the shared core validates the rest.
if (values.size() > MAX_NUM_OF_COILS_TO_WRITE) {
ESP_LOGE(TAG, "values.size() %zu exceeds maximum coils to write %u, dropping request", values.size(),
if (count > MAX_NUM_OF_COILS_TO_WRITE) {
ESP_LOGE(TAG, "values.size() %zu exceeds maximum coils to write %u, dropping request", count,
MAX_NUM_OF_COILS_TO_WRITE);
return pdu;
}
StaticVector<uint8_t, packed_bit_bytes(MAX_NUM_OF_COILS_TO_WRITE)> packed;
for (size_t i = 0; i != values.size(); i++) {
CoilPackBuffer packed;
for (size_t i = 0; i != count; i++) {
if (i % 8 == 0)
packed.push_back(0);
if (values[i])
packed[i / 8] |= (1 << (i % 8));
}
build_write_coils_pdu(pdu, start_address,
PackedBits(std::span<const uint8_t>(packed.data(), packed.size()), values.size()));
build_write_coils_pdu(pdu, start_address, PackedBits(std::span<const uint8_t>(packed.data(), packed.size()), count));
return pdu;
}
PduBuffer create_write_coils_pdu(uint16_t start_address, std::span<const bool> values) {
return create_write_coils_pdu_from_bools(start_address, values);
}
PduBuffer create_write_coils_pdu(uint16_t start_address, const std::vector<bool> &values) {
return create_write_coils_pdu_from_bools(start_address, values);
}
} // namespace esphome::modbus::helpers
+14 -2
View File
@@ -366,6 +366,8 @@ std::optional<int64_t> registers_to_number(const uint16_t *registers, size_t cou
using PduBuffer = StaticVector<uint8_t, MAX_PDU_SIZE>;
using ReadPdu = StaticVector<uint8_t, READ_PDU_SIZE>;
using WriteSinglePdu = StaticVector<uint8_t, WRITE_SINGLE_PDU_SIZE>;
/// Scratch space for packing coils into wire layout: one bit per coil, sized for the spec maximum.
using CoilPackBuffer = StaticVector<uint8_t, packed_bit_bytes(MAX_NUM_OF_COILS_TO_WRITE)>;
/** Create a modbus read request PDU.
* @param function_code one of READ_COILS, READ_DISCRETE_INPUTS, READ_HOLDING_REGISTERS, READ_INPUT_REGISTERS
@@ -427,12 +429,22 @@ WriteSinglePdu create_write_single_coil_pdu(uint16_t address, bool value);
* Function 0x0F Write Multiple Coils
* @param start_address modbus address of the first coil to write
* @param values coil values to write; the coil count is values.size() (at most MAX_NUM_OF_COILS_TO_WRITE, an
* over-long set is rejected and an empty PDU is returned). Note std::vector<bool> is bit-packed and
* does not convert to a span; pass a std::array<bool, N> or other contiguous bool container.
* over-long set is rejected and an empty PDU is returned)
* @return PDU (function code + data, no address, no CRC)
*/
PduBuffer create_write_coils_pdu(uint16_t start_address, std::span<const bool> values);
/** Create modbus write multiple coils command (function 0x0F) from a std::vector<bool>.
* Prefer the span overload above whenever the coils are already in contiguous storage - a std::array<bool, N>
* or any other contiguous bool container converts to it. This overload exists only because std::vector<bool>
* is bit-packed and so cannot convert to a span; without it every caller holding one re-implements the packing.
* @param start_address modbus address of the first coil to write
* @param values coil values to write; the coil count is values.size() (at most MAX_NUM_OF_COILS_TO_WRITE, an
* over-long set is rejected and an empty PDU is returned)
* @return PDU (function code + data, no address, no CRC)
*/
PduBuffer create_write_coils_pdu(uint16_t start_address, const std::vector<bool> &values);
/** Create modbus write multiple coils command (function 0x0F) from bits packed as on the wire.
* @param start_address modbus address of the first coil to write
* @param bits PackedBits view of the coils to write (at most MAX_NUM_OF_COILS_TO_WRITE); invalid
+275 -2
View File
@@ -1,24 +1,58 @@
from collections.abc import Callable
from typing import Any
from esphome import automation
import esphome.codegen as cg
from esphome.components import modbus
import esphome.config_validation as cv
from esphome.const import CONF_ADDRESS, CONF_ON_ERROR, CONF_ON_RESPONSE
from esphome.core import Lambda
from esphome.const import (
CONF_ADDRESS,
CONF_COUNT,
CONF_ON_ERROR,
CONF_ON_RESPONSE,
CONF_VALUE,
)
from esphome.core import ID, Lambda
from esphome.types import ConfigType, TemplateArgsType
CODEOWNERS = ["@exciton"]
DEPENDENCIES = ["modbus"]
CONF_ON_CUSTOM_RESPONSE = "on_custom_response"
CONF_ON_NO_RESPONSE = "on_no_response"
CONF_ON_NOT_SENT = "on_not_sent"
CONF_ON_SENT = "on_sent"
CONF_PDU = "pdu"
CONF_RETRY = "retry"
CONF_START_ADDRESS = "start_address"
CONF_VALUES = "values"
modbus_client_ns = cg.esphome_ns.namespace("modbus_client")
ModbusClientSendAction = modbus_client_ns.class_(
"ModbusClientSendAction", automation.Action, modbus.ModbusClientDevice
)
ReadRegistersAction = modbus_client_ns.class_(
"ReadRegistersAction", automation.Action, modbus.ModbusClientDevice
)
WriteSingleRegisterAction = modbus_client_ns.class_(
"WriteSingleRegisterAction", automation.Action, modbus.ModbusClientDevice
)
WriteSingleCoilAction = modbus_client_ns.class_(
"WriteSingleCoilAction", automation.Action, modbus.ModbusClientDevice
)
ReadBitsAction = modbus_client_ns.class_(
"ReadBitsAction", automation.Action, modbus.ModbusClientDevice
)
WriteMultipleRegistersAction = modbus_client_ns.class_(
"WriteMultipleRegistersAction", automation.Action, modbus.ModbusClientDevice
)
WriteMultipleCoilsAction = modbus_client_ns.class_(
"WriteMultipleCoilsAction", automation.Action, modbus.ModbusClientDevice
)
# Packed bit view delivered to read_coils / read_discrete_inputs on_response handlers.
PackedBits = modbus.modbus_ns.class_("PackedBits")
# The exception code passed to on_error handlers.
ExceptionCode = modbus.modbus_ns.enum("ExceptionCode")
@@ -34,6 +68,11 @@ _PDU_SPAN = cg.std_span.template(cg.uint8.operator("const"))
_PDU_BUFFER = modbus.modbus_ns.namespace("helpers").class_("PduBuffer")
def _packed_bit_bytes(bits: int) -> int:
"""Mirrors modbus::packed_bit_bytes(): bytes needed to hold this many coils on the wire."""
return (bits + 7) // 8
def _synchronous_handler(value: ConfigType) -> ConfigType:
"""Reject deferring actions in a handler: its PDU spans point into hub buffers that are reused
once the handler returns, and DelayAction and friends capture the trigger args for later replay."""
@@ -108,6 +147,11 @@ async def register_client_action(
await cg.templatable(config[CONF_ADDRESS], args, cg.uint8)
)
)
# Present for every typed action and absent from modbus_client.send, which has a pdu instead.
if (start_address := config.get(CONF_START_ADDRESS)) is not None:
cg.add(
var.set_start_address(await cg.templatable(start_address, args, cg.uint16))
)
if sent_conf := config.get(CONF_ON_SENT):
await automation.build_automation(
var.get_sent_trigger(), [(_PDU_SPAN, "request")], sent_conf
@@ -116,6 +160,15 @@ async def register_client_action(
await automation.build_automation(
var.get_response_trigger(), response_args, response_conf
)
if custom_conf := config.get(CONF_ON_CUSTOM_RESPONSE):
# Tell the action a handler exists; without this it falls back to the base's warn-once log so an
# unhandled diverted reply is still reported instead of firing an empty trigger.
cg.add(var.set_custom_response_handled())
await automation.build_automation(
var.get_custom_response_trigger(),
[(_PDU_SPAN, "request"), (_PDU_SPAN, "response")],
custom_conf,
)
if error_conf := config.get(CONF_ON_ERROR):
await automation.build_automation(
var.get_error_trigger(),
@@ -162,3 +215,223 @@ async def modbus_client_send_to_code(config, action_id, template_arg, args):
args,
[(_PDU_SPAN, "request"), (_PDU_SPAN, "response")],
)
# --- Typed actions: request PDUs come from the device base's typed senders, replies from its dispatch,
# --- so on_response delivers decoded arguments (host-order words) instead of raw PDU spans.
_REGISTER_SPAN = cg.std_span.template(cg.uint16.operator("const"))
# Every typed action addresses a register or coil range and reports through the same two reply handlers.
_TYPED_ACTION_SCHEMA = _ACTION_BASE_SCHEMA.extend(
{
cv.Required(CONF_START_ADDRESS): cv.templatable(cv.hex_uint16_t),
# Both use _handler_schema(): the decoded arguments (values span, bits view) point at buffers the
# hub reuses once the handler returns, so a deferring action would resume on freed memory.
cv.Optional(CONF_ON_RESPONSE): _handler_schema(),
# A reply the dispatch gate diverts (not a standard-conformant transaction) arrives here with the
# raw request/response PDUs; real device exceptions still arrive via on_error.
cv.Optional(CONF_ON_CUSTOM_RESPONSE): _handler_schema(),
}
)
def _no_address_overflow(count_key: str) -> Callable[[ConfigType], ConfigType]:
"""Reject a range that runs past the 16-bit address space, which the device could never answer.
Only literal configurations can be checked: either operand may be a lambda, and its value is not known
until play(). The PDU builders repeat this check at runtime, so the lambda case is still rejected and
logged - just later.
"""
def validate(config: ConfigType) -> ConfigType:
start = config[CONF_START_ADDRESS]
count = config[count_key]
if isinstance(start, Lambda) or isinstance(count, Lambda):
return config
# CONF_COUNT is a number; CONF_VALUES is the list whose length is the count.
length = count if isinstance(count, int) else len(count)
if start + length > 0x10000:
raise cv.Invalid(
f"{CONF_START_ADDRESS} 0x{start:04X} plus {length} entities runs past the end of the "
f"16-bit address space (last addressable entity is 0xFFFF)",
path=[CONF_START_ADDRESS],
)
return config
return validate
def _read_schema(max_count: int) -> cv.All:
"""Read action schema. The spec sets the read ceiling per function code, so each one passes its own."""
return cv.All(
_TYPED_ACTION_SCHEMA.extend(
{
cv.Optional(CONF_COUNT, default=1): cv.templatable(
cv.int_range(min=1, max=max_count)
),
}
),
_no_address_overflow(CONF_COUNT),
)
def _write_multiple_schema(item: Callable[[Any], Any], max_values: int) -> cv.All:
"""Multi-write action schema, differing only in the element type and the spec's per-function limit."""
return cv.All(
_TYPED_ACTION_SCHEMA.extend(
{
cv.Required(CONF_VALUES): cv.templatable(
cv.All(cv.ensure_list(item), cv.Length(min=1, max=max_values))
),
}
),
_no_address_overflow(CONF_VALUES),
)
_READ_REGISTERS_SCHEMA = _read_schema(modbus.MAX_NUM_OF_REGISTERS_TO_READ)
_WRITE_SINGLE_REGISTER_SCHEMA = _TYPED_ACTION_SCHEMA.extend(
{cv.Required(CONF_VALUE): cv.templatable(cv.hex_uint16_t)}
)
# A coil is one bit, so the value is a boolean - the wire only carries 0x0000 or 0xFF00.
_WRITE_SINGLE_COIL_SCHEMA = _TYPED_ACTION_SCHEMA.extend(
{cv.Required(CONF_VALUE): cv.templatable(cv.boolean)}
)
async def _read_registers_to_code(config, action_id, template_arg, args, holding):
var = cg.new_Pvariable(action_id, template_arg, holding)
cg.add(var.set_count(await cg.templatable(config[CONF_COUNT], args, cg.uint16)))
return await register_client_action(var, config, args, [(_REGISTER_SPAN, "values")])
@automation.register_action(
"modbus_client.read_holding_registers",
ReadRegistersAction,
_READ_REGISTERS_SCHEMA,
synchronous=True,
)
async def read_holding_registers_to_code(config, action_id, template_arg, args):
return await _read_registers_to_code(config, action_id, template_arg, args, True)
@automation.register_action(
"modbus_client.read_input_registers",
ReadRegistersAction,
_READ_REGISTERS_SCHEMA,
synchronous=True,
)
async def read_input_registers_to_code(config, action_id, template_arg, args):
return await _read_registers_to_code(config, action_id, template_arg, args, False)
async def _write_single_to_code(config, action_id, template_arg, args, value_type):
var = cg.new_Pvariable(action_id, template_arg)
cg.add(var.set_value(await cg.templatable(config[CONF_VALUE], args, value_type)))
return await register_client_action(var, config, args, [])
@automation.register_action(
"modbus_client.write_single_register",
WriteSingleRegisterAction,
_WRITE_SINGLE_REGISTER_SCHEMA,
synchronous=True,
)
async def write_single_register_to_code(config, action_id, template_arg, args):
return await _write_single_to_code(config, action_id, template_arg, args, cg.uint16)
@automation.register_action(
"modbus_client.write_single_coil",
WriteSingleCoilAction,
_WRITE_SINGLE_COIL_SCHEMA,
synchronous=True,
)
async def write_single_coil_to_code(config, action_id, template_arg, args):
return await _write_single_to_code(config, action_id, template_arg, args, cg.bool_)
_READ_COILS_SCHEMA = _read_schema(modbus.MAX_NUM_OF_COILS_TO_READ)
_READ_DISCRETE_INPUTS_SCHEMA = _read_schema(modbus.MAX_NUM_OF_DISCRETE_INPUTS_TO_READ)
async def _read_bits_to_code(config, action_id, template_arg, args, coils):
var = cg.new_Pvariable(action_id, template_arg, coils)
cg.add(var.set_count(await cg.templatable(config[CONF_COUNT], args, cg.uint16)))
return await register_client_action(var, config, args, [(PackedBits, "bits")])
@automation.register_action(
"modbus_client.read_coils",
ReadBitsAction,
_READ_COILS_SCHEMA,
synchronous=True,
)
async def read_coils_to_code(config, action_id, template_arg, args):
return await _read_bits_to_code(config, action_id, template_arg, args, True)
@automation.register_action(
"modbus_client.read_discrete_inputs",
ReadBitsAction,
_READ_DISCRETE_INPUTS_SCHEMA,
synchronous=True,
)
async def read_discrete_inputs_to_code(config, action_id, template_arg, args):
return await _read_bits_to_code(config, action_id, template_arg, args, False)
_WRITE_MULTIPLE_REGISTERS_SCHEMA = _write_multiple_schema(
cv.hex_uint16_t, modbus.MAX_NUM_OF_REGISTERS_TO_WRITE
)
_WRITE_MULTIPLE_COILS_SCHEMA = _write_multiple_schema(
cv.boolean, modbus.MAX_NUM_OF_COILS_TO_WRITE
)
@automation.register_action(
"modbus_client.write_multiple_registers",
WriteMultipleRegistersAction,
_WRITE_MULTIPLE_REGISTERS_SCHEMA,
synchronous=True,
)
async def write_multiple_registers_to_code(config, action_id, template_arg, args):
var = cg.new_Pvariable(action_id, template_arg)
values = config[CONF_VALUES]
if cg.is_template(values):
templ = await cg.templatable(values, args, cg.std_vector.template(cg.uint16))
cg.add(var.set_values_template(templ))
else:
# A static list goes to flash, so play() sends straight from there without allocating.
arr_id = ID(f"{action_id}_values", is_declaration=True, type=cg.uint16)
arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*values))
cg.add(var.set_values_static(arr, len(values)))
return await register_client_action(var, config, args, [])
@automation.register_action(
"modbus_client.write_multiple_coils",
WriteMultipleCoilsAction,
_WRITE_MULTIPLE_COILS_SCHEMA,
synchronous=True,
)
async def write_multiple_coils_to_code(config, action_id, template_arg, args):
var = cg.new_Pvariable(action_id, template_arg)
values = config[CONF_VALUES]
if cg.is_template(values):
templ = await cg.templatable(values, args, cg.std_vector.template(cg.bool_))
cg.add(var.set_values_template(templ))
else:
# Pack to wire layout (LSB first) here, so the runtime neither allocates nor packs.
packed = bytearray(_packed_bit_bytes(len(values)))
for i, coil in enumerate(values):
if coil:
packed[i // 8] |= 1 << (i % 8)
arr_id = ID(f"{action_id}_values", is_declaration=True, type=cg.uint8)
arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*packed))
cg.add(var.set_values_static(arr, len(values)))
return await register_client_action(var, config, args, [])
@@ -5,6 +5,7 @@
#include "esphome/core/automation.h"
#include <span>
#include <vector>
namespace esphome::modbus_client {
@@ -57,6 +58,16 @@ template<typename... Ts> class ClientActionBase : public Action<Ts...>, public m
}
protected:
/// The hub refuses some sends at the door with no callback (a duplicate write already pending, a full
/// queue, or an empty PDU - which is how the create_*_pdu() builders reject out-of-spec input). Every
/// send still gets exactly one outcome, so resolve refusals here via on_not_sent.
/// Takes a span, not a PduBuffer: the builders return right-sized buffers (a read PDU is 5 bytes), and
/// a PduBuffer parameter would widen each one to the 253-byte maximum just to cross the call.
void send_or_resolve_(std::span<const uint8_t> pdu) {
if (!this->send_pdu(pdu))
this->on_not_sent(pdu);
}
Trigger<std::span<const uint8_t>> sent_trigger_;
Trigger<std::span<const uint8_t>, modbus::ExceptionCode> error_trigger_;
Trigger<std::span<const uint8_t>> no_response_trigger_;
@@ -79,14 +90,7 @@ template<typename... Ts> class ModbusClientSendAction : public ClientActionBase<
return &this->response_trigger_;
}
void play(const Ts &...x) override {
auto pdu = this->pdu_.value(x...);
const std::span<const uint8_t> span(pdu.data(), pdu.size());
// The hub refuses some sends at the door with no callback (an empty PDU, a duplicate write already
// pending, a full queue). Every send still gets exactly one outcome, so resolve those via on_not_sent.
if (!this->send_pdu(span))
this->on_not_sent(span);
}
void play(const Ts &...x) override { this->send_or_resolve_(this->pdu_.value(x...)); }
void on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override {
this->response_trigger_.trigger(request_pdu, response_pdu);
@@ -96,4 +100,235 @@ template<typename... Ts> class ModbusClientSendAction : public ClientActionBase<
Trigger<std::span<const uint8_t>, std::span<const uint8_t>> response_trigger_;
};
/// Typed actions: these do NOT override the raw on_response, so the base ModbusClientDevice default runs
/// the shared dispatch (validation gate + decode) and the typed callbacks below fire directly on the
/// action. A reply the gate diverts (not a standard-conformant transaction) fires the on_custom_response
/// trigger with the raw request/response PDUs, so non-standard replies stay handleable; the spans are only
/// valid for the duration of the trigger. (For a typed-built request the gate can only divert on the
/// response, never with an exception status - real device exceptions arrive via on_error, which
/// ClientActionBase already routes straight to its trigger, so the typed callbacks below only ever see a
/// success status.)
template<typename... Ts> class TypedClientActionBase : public ClientActionBase<Ts...> {
public:
Trigger<std::span<const uint8_t>, std::span<const uint8_t>> *get_custom_response_trigger() {
return &this->custom_response_trigger_;
}
/// Set by codegen when the config declares on_custom_response. Without it an unhandled diverted reply
/// would fire an empty trigger and vanish, so the base's warn-once diagnostic has to stay reachable.
void set_custom_response_handled() { this->custom_response_handled_ = true; }
void on_custom_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu,
modbus::ResponseStatus status) override {
if (!this->custom_response_handled_) {
modbus::ModbusClientDevice::on_custom_response(request_pdu, response_pdu, status);
return;
}
this->custom_response_trigger_.trigger(request_pdu, response_pdu);
}
protected:
/// Defensive assertion, not a live branch: ClientActionBase::on_error intercepts every exception reply
/// before the dispatch runs, so a typed callback below is only ever reached with a success status. Kept
/// so a future change to that interception cannot silently deliver an exception as a successful reply.
bool is_success_(modbus::ResponseStatus status) { return !status.has_value(); }
Trigger<std::span<const uint8_t>, std::span<const uint8_t>> custom_response_trigger_;
bool custom_response_handled_{false};
};
/// modbus_client.read_holding_registers / read_input_registers: on_response delivers the registers in
/// host byte order as `values` (only valid for the duration of the trigger).
template<typename... Ts> class ReadRegistersAction : public TypedClientActionBase<Ts...> {
public:
explicit ReadRegistersAction(bool holding) : holding_(holding) {}
TEMPLATABLE_VALUE(uint16_t, start_address)
TEMPLATABLE_VALUE(uint16_t, count)
Trigger<std::span<const uint16_t>> *get_response_trigger() { return &this->response_trigger_; }
void play(const Ts &...x) override {
const auto function_code =
this->holding_ ? modbus::FunctionCode::READ_HOLDING_REGISTERS : modbus::FunctionCode::READ_INPUT_REGISTERS;
this->send_or_resolve_(
modbus::helpers::create_read_pdu(function_code, this->start_address_.value(x...), this->count_.value(x...)));
}
void on_read_registers(modbus::EntityType entity_type, uint16_t start_address, std::span<const uint16_t> registers,
modbus::ResponseStatus status) override {
if (this->is_success_(status))
this->response_trigger_.trigger(registers);
}
protected:
Trigger<std::span<const uint16_t>> response_trigger_;
bool holding_;
};
/// modbus_client.read_coils / read_discrete_inputs: on_response delivers the bits as a PackedBits view
/// (bit 0 = the bit at start_address; only valid for the duration of the trigger).
template<typename... Ts> class ReadBitsAction : public TypedClientActionBase<Ts...> {
public:
explicit ReadBitsAction(bool coils) : coils_(coils) {}
TEMPLATABLE_VALUE(uint16_t, start_address)
TEMPLATABLE_VALUE(uint16_t, count)
Trigger<modbus::PackedBits> *get_response_trigger() { return &this->response_trigger_; }
void play(const Ts &...x) override {
const auto function_code =
this->coils_ ? modbus::FunctionCode::READ_COILS : modbus::FunctionCode::READ_DISCRETE_INPUTS;
this->send_or_resolve_(
modbus::helpers::create_read_pdu(function_code, this->start_address_.value(x...), this->count_.value(x...)));
}
void on_read_bits(modbus::EntityType entity_type, uint16_t start_address, modbus::PackedBits bits,
modbus::ResponseStatus status) override {
if (this->is_success_(status))
this->response_trigger_.trigger(bits);
}
protected:
Trigger<modbus::PackedBits> response_trigger_;
bool coils_;
};
/// modbus_client.write_single_register: on_response is the acknowledgement (the ack only echoes the
/// request, so it carries no arguments).
template<typename... Ts> class WriteSingleRegisterAction : public TypedClientActionBase<Ts...> {
public:
TEMPLATABLE_VALUE(uint16_t, start_address)
TEMPLATABLE_VALUE(uint16_t, value)
Trigger<> *get_response_trigger() { return &this->response_trigger_; }
void play(const Ts &...x) override {
this->send_or_resolve_(
modbus::helpers::create_write_single_register_pdu(this->start_address_.value(x...), this->value_.value(x...)));
}
void on_write_single_register(uint16_t address, uint16_t value, modbus::ResponseStatus status) override {
if (this->is_success_(status))
this->response_trigger_.trigger();
}
protected:
Trigger<> response_trigger_;
};
/// modbus_client.write_single_coil: on_response is the acknowledgement (no arguments). A coil holds one
/// bit, so the value is a bool - the wire only ever carries 0x0000 or 0xFF00.
template<typename... Ts> class WriteSingleCoilAction : public TypedClientActionBase<Ts...> {
public:
TEMPLATABLE_VALUE(uint16_t, start_address)
TEMPLATABLE_VALUE(bool, value)
Trigger<> *get_response_trigger() { return &this->response_trigger_; }
void play(const Ts &...x) override {
this->send_or_resolve_(
modbus::helpers::create_write_single_coil_pdu(this->start_address_.value(x...), this->value_.value(x...)));
}
void on_write_single_coil(uint16_t address, bool value, modbus::ResponseStatus status) override {
if (this->is_success_(status))
this->response_trigger_.trigger();
}
protected:
Trigger<> response_trigger_;
};
/// modbus_client.write_multiple_registers: on_response is the acknowledgement (no arguments).
/// A `values:` list is emitted as a flash array and sent straight from there; only a lambda builds a
/// vector, and only when it runs. Same split as canbus's send action, and for the same reason: a static
/// list must not allocate on every play().
template<typename... Ts> class WriteMultipleRegistersAction : public TypedClientActionBase<Ts...> {
public:
TEMPLATABLE_VALUE(uint16_t, start_address)
/// Static config: the registers live in flash, so play() neither allocates nor copies.
void set_values_static(const uint16_t *values, size_t len) {
this->values_.data = values;
this->len_ = static_cast<ssize_t>(len);
}
/// Lambda config: the registers are only known at play() time. Stateless lambdas (all ESPHome
/// generates) convert to a plain function pointer, so this stays pointer-sized.
void set_values_template(std::vector<uint16_t> (*func)(Ts...)) {
this->values_.func = func;
this->len_ = -1; // sentinel: template mode
}
Trigger<> *get_response_trigger() { return &this->response_trigger_; }
void play(const Ts &...x) override {
const uint16_t start = this->start_address_.value(x...);
// An empty or over-long set rejects into an empty PDU inside the builder, which logs the reason;
// the empty PDU then resolves via on_not_sent like any refused send.
if (this->len_ >= 0) {
this->send_or_resolve_(modbus::helpers::create_write_registers_pdu(
start, std::span<const uint16_t>(this->values_.data, static_cast<size_t>(this->len_))));
return;
}
const std::vector<uint16_t> values = this->values_.func(x...);
this->send_or_resolve_(modbus::helpers::create_write_registers_pdu(start, std::span<const uint16_t>(values)));
}
void on_write_multiple_registers(uint16_t start_address, std::span<const uint16_t> registers,
modbus::ResponseStatus status) override {
if (this->is_success_(status))
this->response_trigger_.trigger();
}
protected:
Trigger<> response_trigger_;
ssize_t len_{-1}; // -1 = template mode, >= 0 = static mode with this many registers
union Values {
std::vector<uint16_t> (*func)(Ts...);
const uint16_t *data;
} values_;
};
/// modbus_client.write_multiple_coils: on_response is the acknowledgement (no arguments).
/// A `values:` list is packed into wire layout at code-generation time and stored in flash, so play()
/// neither allocates nor packs. A lambda returns std::vector<bool> - already a bit per coil rather than
/// a byte - and is packed into a stack buffer on the way to the builder.
template<typename... Ts> class WriteMultipleCoilsAction : public TypedClientActionBase<Ts...> {
public:
TEMPLATABLE_VALUE(uint16_t, start_address)
/// Static config: `packed` is the wire layout (LSB first) held in flash, `count` the number of coils.
void set_values_static(const uint8_t *packed, size_t count) {
this->values_.packed = packed;
this->count_ = static_cast<ssize_t>(count);
}
/// Lambda config: the coils are only known at play() time.
void set_values_template(std::vector<bool> (*func)(Ts...)) {
this->values_.func = func;
this->count_ = -1; // sentinel: template mode
}
Trigger<> *get_response_trigger() { return &this->response_trigger_; }
void play(const Ts &...x) override {
const uint16_t start = this->start_address_.value(x...);
if (this->count_ >= 0) {
const auto count = static_cast<uint16_t>(this->count_);
this->send_or_resolve_(modbus::helpers::create_write_coils_pdu(
start,
modbus::PackedBits(std::span<const uint8_t>(this->values_.packed, modbus::packed_bit_bytes(count)), count)));
return;
}
// The builder packs and bound-checks; an over-long set is rejected and logged there.
this->send_or_resolve_(modbus::helpers::create_write_coils_pdu(start, this->values_.func(x...)));
}
void on_write_multiple_coils(uint16_t start_address, modbus::PackedBits bits,
modbus::ResponseStatus status) override {
if (this->is_success_(status))
this->response_trigger_.trigger();
}
protected:
Trigger<> response_trigger_;
ssize_t count_{-1}; // -1 = template mode, >= 0 = static mode with this many coils
union Values {
std::vector<bool> (*func)(Ts...);
const uint8_t *packed;
} values_;
};
} // namespace esphome::modbus_client
@@ -51,3 +51,65 @@ button:
on_not_sent:
then:
- lambda: 'ESP_LOGW("modbus_client.test", "not sent fc 0x%X", request.empty() ? 0 : request[0]);'
- platform: template
name: "Typed Actions"
on_press:
- modbus_client.write_single_register:
address: 0x01
start_address: 0x0102
value: !lambda "return 42;"
on_response:
then:
- logger.log: "write acked"
on_error:
then:
- lambda: 'ESP_LOGW("modbus_client.test", "write exception %d", (int) exception_code);'
- modbus_client.read_holding_registers:
address: !lambda "return 1;"
start_address: 0x10
count: 2
on_response:
then:
- lambda: 'ESP_LOGI("modbus_client.test", "first=%u n=%u", values[0], (unsigned) values.size());'
on_no_response:
then:
- logger.log: "typed read timeout"
- modbus_client.read_input_registers:
address: 0x01
start_address: 0x20
on_custom_response:
then:
- lambda: |-
ESP_LOGW("modbus_client.test", "non-standard reply: fc 0x%02X, %u byte request",
response.empty() ? 0 : response[0], (unsigned) request.size());
- modbus_client.write_single_coil:
address: 0x01
start_address: 0x01
value: true
- modbus_client.read_coils:
address: 0x01
start_address: 0x03
count: 16
on_response:
then:
- lambda: 'ESP_LOGI("modbus_client.test", "coil0=%d n=%u", bits[0], (unsigned) bits.size());'
- modbus_client.read_discrete_inputs:
address: 0x01
start_address: 0x00
on_error:
then:
- lambda: 'ESP_LOGW("modbus_client.test", "fc 0x%X exception %d", request.empty() ? 0 : request[0], (int) exception_code);'
- modbus_client.write_multiple_registers:
address: 0x01
start_address: 0x0200
values: !lambda "return {1, 2, 3};"
on_response:
then:
- lambda: 'ESP_LOGI("modbus_client.test", "multi write acked");'
- modbus_client.write_multiple_coils:
address: 0x01
start_address: 0x0010
values: [true, false, true]
on_error:
then:
- lambda: 'ESP_LOGW("modbus_client.test", "fc 0x%X exception %d", request.empty() ? 0 : request[0], (int) exception_code);'
@@ -0,0 +1,176 @@
esphome:
name: uart-mock-modbus-client-typed
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_client
data: !lambda return data;
- id: virtual_uart_client
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_client
id: virtual_modbus_client
role: client
turnaround_time: 10ms
globals:
- id: reg10
type: uint16_t
initial_value: "0"
- id: reg11
type: uint16_t
initial_value: "0"
- id: reg12
type: uint16_t
initial_value: "0"
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
id: modbus_server_1
registers:
- address: 0x10
value_type: U_WORD
read_lambda: return id(reg10);
write_lambda: |-
id(reg10) = x;
return true;
- address: 0x11
value_type: U_WORD
read_lambda: return id(reg11);
write_lambda: |-
id(reg11) = x;
return true;
- address: 0x12
value_type: U_WORD
read_lambda: return id(reg12);
write_lambda: |-
id(reg12) = x;
return true;
sensor:
- platform: template
name: "typed_value"
id: typed_value
- platform: template
name: "ack_flag"
id: ack_flag
- platform: template
name: "error_code"
id: error_code
- platform: template
name: "coil_error_code"
id: coil_error_code
- platform: template
name: "multi_value"
id: multi_value
- platform: template
name: "multi_coil_error"
id: multi_coil_error
- platform: template
name: "not_sent_flag"
id: not_sent_flag
# Typed actions end to end: a typed write lands on the server (ack -> ack_flag), the typed read-back
# decodes the written value from the reply words (values[0] -> typed_value), and a read of an unserved
# register resolves via on_error with the device's exception code (-> error_code).
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
on_press:
- modbus_client.write_single_register:
address: 1
start_address: 0x10
value: 777
on_response:
then:
- lambda: "id(ack_flag).publish_state(1);"
- modbus_client.read_holding_registers:
address: 1
start_address: 0x10
on_response:
then:
- lambda: |-
if (!values.empty())
id(typed_value).publish_state(values[0]);
- modbus_client.read_holding_registers:
address: 1
start_address: 0x99
on_error:
then:
- lambda: "id(error_code).publish_state((int) exception_code);"
# The mock server is register-only, so a coil read draws ILLEGAL_FUNCTION - proving the bit-read
# action's request PDU and its typed error delivery.
- modbus_client.read_coils:
address: 1
start_address: 0x00
count: 8
on_error:
then:
- lambda: "id(coil_error_code).publish_state((int) exception_code);"
# Multi-register write (fc 0x10, served) then read-back of the second written register.
- modbus_client.write_multiple_registers:
address: 1
start_address: 0x11
values: [111, 222]
on_response:
then:
- modbus_client.read_holding_registers:
address: 1
start_address: 0x12
on_response:
then:
- lambda: |-
if (!values.empty())
id(multi_value).publish_state(values[0]);
# A count lambda can go out of spec at runtime: the builder rejects it into an empty PDU, the hub
# refuses that at the door, and the send resolves via on_not_sent (no reply will ever come).
- modbus_client.read_holding_registers:
address: 1
start_address: 0x10
count: !lambda "return 0;"
on_not_sent:
then:
- lambda: "id(not_sent_flag).publish_state(1);"
# Multi-coil write (fc 0x0F): the register-only server answers ILLEGAL_FUNCTION.
- modbus_client.write_multiple_coils:
address: 1
start_address: 0x00
values: [true, false, true]
on_error:
then:
- lambda: "id(multi_coil_error).publish_state((int) exception_code);"
@@ -434,6 +434,58 @@ async def test_uart_mock_modbus_server_controller_multiple(
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
@pytest.mark.asyncio
async def test_uart_mock_modbus_client_typed(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test the typed modbus_client actions end to end (each action its own hub device).
Start Scenario fires three typed actions: write_single_register puts 777 in server register 0x10 (the
ack fires on_response -> ack_flag); read_holding_registers reads it back,
with the reply decoded by the shared device dispatch into host-order words (values[0] -> typed_value);
a read of unserved register 0x99 resolves via on_error with the device's exception code
(ILLEGAL_DATA_ADDRESS = 2 -> error_code); a coil read of the register-only server resolves via
on_error with ILLEGAL_FUNCTION (= 1 -> coil_error_code), proving the bit-read request and typed error
delivery. A multi-register write (fc 0x10) lands on registers 0x11/0x12 with the read-back of 0x12
chained inside its ack handler (-> multi_value = 222); a multi-coil write draws ILLEGAL_FUNCTION from
the register-only server (-> multi_coil_error = 1). A read whose count lambda returns 0 at runtime
builds an empty (rejected) PDU, is refused at the hub door, and resolves via on_not_sent
(-> not_sent_flag).
"""
tracker = SensorTracker(
[
"typed_value",
"ack_flag",
"error_code",
"coil_error_code",
"multi_value",
"multi_coil_error",
"not_sent_flag",
]
)
futures = tracker.expect_all(
{
"typed_value": 777,
"ack_flag": 1,
"error_code": 2,
"coil_error_code": 1,
"multi_value": 222,
"multi_coil_error": 1,
"not_sent_flag": 1,
}
)
async with (
run_compiled(yaml_config),
api_client_connected() as client,
):
await tracker.setup_and_start_scenario(client)
await tracker.await_all(futures)
@pytest.mark.asyncio
async def test_uart_mock_modbus_client_inline(
yaml_config: str,