From f5ee72753dcf273821ee84812720780966f742f9 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Fri, 7 Aug 2026 14:18:20 -0500 Subject: [PATCH] [modbus_client] Add typed read/write actions (#18078) Co-authored-by: J. Nick Koston Co-authored-by: Claude --- esphome/components/modbus/__init__.py | 8 + esphome/components/modbus/modbus_helpers.cpp | 24 +- esphome/components/modbus/modbus_helpers.h | 16 +- esphome/components/modbus_client/__init__.py | 277 +++++++++++++++++- .../components/modbus_client/modbus_client.h | 251 +++++++++++++++- tests/components/modbus_client/common.yaml | 62 ++++ .../uart_mock_modbus_client_typed.yaml | 176 +++++++++++ tests/integration/test_uart_mock_modbus.py | 52 ++++ 8 files changed, 847 insertions(+), 19 deletions(-) create mode 100644 tests/integration/fixtures/uart_mock_modbus_client_typed.yaml diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index bc52263aef..c91032801b 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -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) diff --git a/esphome/components/modbus/modbus_helpers.cpp b/esphome/components/modbus/modbus_helpers.cpp index 2c87928e9f..a0c8440c79 100644 --- a/esphome/components/modbus/modbus_helpers.cpp +++ b/esphome/components/modbus/modbus_helpers.cpp @@ -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 values) { +// Shared by the two bool-container overloads: both index the same way, so the packing is written once. +template +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 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(packed.data(), packed.size()), values.size())); + build_write_coils_pdu(pdu, start_address, PackedBits(std::span(packed.data(), packed.size()), count)); return pdu; } + +PduBuffer create_write_coils_pdu(uint16_t start_address, std::span values) { + return create_write_coils_pdu_from_bools(start_address, values); +} + +PduBuffer create_write_coils_pdu(uint16_t start_address, const std::vector &values) { + return create_write_coils_pdu_from_bools(start_address, values); +} } // namespace esphome::modbus::helpers diff --git a/esphome/components/modbus/modbus_helpers.h b/esphome/components/modbus/modbus_helpers.h index 36e3b6c7be..2c312b8a61 100644 --- a/esphome/components/modbus/modbus_helpers.h +++ b/esphome/components/modbus/modbus_helpers.h @@ -366,6 +366,8 @@ std::optional registers_to_number(const uint16_t *registers, size_t cou using PduBuffer = StaticVector; using ReadPdu = StaticVector; using WriteSinglePdu = StaticVector; +/// Scratch space for packing coils into wire layout: one bit per coil, sized for the spec maximum. +using CoilPackBuffer = StaticVector; /** 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 is bit-packed and - * does not convert to a span; pass a std::array 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 values); +/** Create modbus write multiple coils command (function 0x0F) from a std::vector. + * Prefer the span overload above whenever the coils are already in contiguous storage - a std::array + * or any other contiguous bool container converts to it. This overload exists only because std::vector + * 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 &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 diff --git a/esphome/components/modbus_client/__init__.py b/esphome/components/modbus_client/__init__.py index e8a75a1b6c..538936c93b 100644 --- a/esphome/components/modbus_client/__init__.py +++ b/esphome/components/modbus_client/__init__.py @@ -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, []) diff --git a/esphome/components/modbus_client/modbus_client.h b/esphome/components/modbus_client/modbus_client.h index d4b3792a5f..599be85cb8 100644 --- a/esphome/components/modbus_client/modbus_client.h +++ b/esphome/components/modbus_client/modbus_client.h @@ -5,6 +5,7 @@ #include "esphome/core/automation.h" #include +#include namespace esphome::modbus_client { @@ -57,6 +58,16 @@ template class ClientActionBase : public Action, 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 pdu) { + if (!this->send_pdu(pdu)) + this->on_not_sent(pdu); + } + Trigger> sent_trigger_; Trigger, modbus::ExceptionCode> error_trigger_; Trigger> no_response_trigger_; @@ -79,14 +90,7 @@ template class ModbusClientSendAction : public ClientActionBase< return &this->response_trigger_; } - void play(const Ts &...x) override { - auto pdu = this->pdu_.value(x...); - const std::span 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 request_pdu, std::span response_pdu) override { this->response_trigger_.trigger(request_pdu, response_pdu); @@ -96,4 +100,235 @@ template class ModbusClientSendAction : public ClientActionBase< Trigger, std::span> 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 class TypedClientActionBase : public ClientActionBase { + public: + Trigger, std::span> *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 request_pdu, std::span 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> 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 class ReadRegistersAction : public TypedClientActionBase { + public: + explicit ReadRegistersAction(bool holding) : holding_(holding) {} + TEMPLATABLE_VALUE(uint16_t, start_address) + TEMPLATABLE_VALUE(uint16_t, count) + + Trigger> *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 registers, + modbus::ResponseStatus status) override { + if (this->is_success_(status)) + this->response_trigger_.trigger(registers); + } + + protected: + Trigger> 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 class ReadBitsAction : public TypedClientActionBase { + public: + explicit ReadBitsAction(bool coils) : coils_(coils) {} + TEMPLATABLE_VALUE(uint16_t, start_address) + TEMPLATABLE_VALUE(uint16_t, count) + + Trigger *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 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 class WriteSingleRegisterAction : public TypedClientActionBase { + 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 class WriteSingleCoilAction : public TypedClientActionBase { + 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 class WriteMultipleRegistersAction : public TypedClientActionBase { + 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(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 (*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(this->values_.data, static_cast(this->len_)))); + return; + } + const std::vector values = this->values_.func(x...); + this->send_or_resolve_(modbus::helpers::create_write_registers_pdu(start, std::span(values))); + } + void on_write_multiple_registers(uint16_t start_address, std::span 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 (*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 - already a bit per coil rather than +/// a byte - and is packed into a stack buffer on the way to the builder. +template class WriteMultipleCoilsAction : public TypedClientActionBase { + 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(count); + } + /// Lambda config: the coils are only known at play() time. + void set_values_template(std::vector (*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(this->count_); + this->send_or_resolve_(modbus::helpers::create_write_coils_pdu( + start, + modbus::PackedBits(std::span(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 (*func)(Ts...); + const uint8_t *packed; + } values_; +}; + } // namespace esphome::modbus_client diff --git a/tests/components/modbus_client/common.yaml b/tests/components/modbus_client/common.yaml index 19e81f4e48..19acbe9b0e 100644 --- a/tests/components/modbus_client/common.yaml +++ b/tests/components/modbus_client/common.yaml @@ -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);' diff --git a/tests/integration/fixtures/uart_mock_modbus_client_typed.yaml b/tests/integration/fixtures/uart_mock_modbus_client_typed.yaml new file mode 100644 index 0000000000..167ad2c5bb --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_client_typed.yaml @@ -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);" diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index 75adcc0e3d..ca0041cc5b 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -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,