diff --git a/CODEOWNERS b/CODEOWNERS index d64e862c39..d2e26edca3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -352,6 +352,7 @@ esphome/components/mlx90393/* @functionpointer esphome/components/mlx90614/* @jesserockz esphome/components/mmc5603/* @benhoff esphome/components/mmc5983/* @agoode +esphome/components/modbus_client/* @exciton esphome/components/modbus_controller/* @martgras esphome/components/modbus_controller/binary_sensor/* @martgras esphome/components/modbus_controller/number/* @martgras diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index 9e64540382..bc52263aef 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -14,6 +14,12 @@ import esphome.final_validate as fv _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ["uart"] +# Loading the hub makes the modbus_client.* actions available (they are registry entries only; no code is +# generated unless a config uses one). +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 modbus_ns = cg.esphome_ns.namespace("modbus") Modbus = modbus_ns.class_("Modbus", cg.Component, uart.UARTDevice) diff --git a/esphome/components/modbus_client/__init__.py b/esphome/components/modbus_client/__init__.py new file mode 100644 index 0000000000..e8a75a1b6c --- /dev/null +++ b/esphome/components/modbus_client/__init__.py @@ -0,0 +1,164 @@ +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.types import ConfigType, TemplateArgsType + +CODEOWNERS = ["@exciton"] +DEPENDENCIES = ["modbus"] + +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" + +modbus_client_ns = cg.esphome_ns.namespace("modbus_client") +ModbusClientSendAction = modbus_client_ns.class_( + "ModbusClientSendAction", automation.Action, modbus.ModbusClientDevice +) + +# The exception code passed to on_error handlers. +ExceptionCode = modbus.modbus_ns.enum("ExceptionCode") + +# Lambda argument types for the reply handlers: the device address the send targeted, and the +# request/response PDUs (function code + data). The spans are only valid for the duration of the handler. +_PDU_SPAN = cg.std_span.template(cg.uint8.operator("const")) + +# The pdu lambda's return type: a stack-allocated StaticVector capped at the Modbus PDU limit +# (modbus.MAX_PDU_SIZE). Lambdas can return a byte list or a modbus::helpers::create_*_pdu() result. +# The list form below is bounded by cv.Length; a lambda cannot be. PduBuffer drops bytes past +# modbus.MAX_PDU_SIZE without reporting it, so an over-long lambda PDU is silently truncated. +_PDU_BUFFER = modbus.modbus_ns.namespace("helpers").class_("PduBuffer") + + +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.""" + if automation.has_non_synchronous_actions(value): + raise cv.Invalid( + "Deferring actions (delay, wait_until, script.wait, ...) are not allowed in modbus_client " + "handlers: the request/response data is only valid while the handler runs. Copy what you " + "need into globals first, then defer in a separate script or automation." + ) + return value + + +def _handler_schema() -> cv.All: + return cv.All(automation.validate_automation(single=True), _synchronous_handler) + + +# Each action is its own hub device: the modbus hub routes the reply straight back to the action that +# sent it, so the address can even be templatable - the reply is matched by the action's identity, not +# its address. +_ACTION_BASE_SCHEMA = cv.Schema( + { + cv.GenerateID(modbus.CONF_MODBUS_ID): cv.use_id(modbus.ModbusClient), + cv.Required(CONF_ADDRESS): cv.templatable(cv.hex_uint8_t), + # Optional handlers. on_sent fires when the frame reaches the wire; the reply handlers arrive + # later (fire-and-continue), so all run with the request/reply available - not the outer + # automation's variables. + cv.Optional(CONF_ON_SENT): _handler_schema(), + cv.Optional(CONF_ON_ERROR): _handler_schema(), + # on_no_response takes either a returning lambda (`!lambda "return ;"`, gets `request`, + # returns true to have the hub retry the frame) OR a `then:` automation of actions; the automation + # form may also carry an optional `retry:` returning lambda to run actions AND decide the retry. + cv.Optional(CONF_ON_NO_RESPONSE): cv.All( + cv.Any( + cv.returning_lambda, + automation.validate_automation( + {cv.Optional(CONF_RETRY): cv.returning_lambda}, single=True + ), + ), + _synchronous_handler, + ), + cv.Optional(CONF_ON_NOT_SENT): _handler_schema(), + } +) + +MODBUS_CLIENT_SEND_SCHEMA = _ACTION_BASE_SCHEMA.extend( + { + cv.Required(CONF_PDU): cv.templatable( + cv.All( + cv.ensure_list(cv.hex_uint8_t), + cv.Length(min=1, max=modbus.MAX_PDU_SIZE), + ) + ), + cv.Optional(CONF_ON_RESPONSE): _handler_schema(), + } +) + + +async def register_client_action( + var: cg.MockObj, + config: ConfigType, + args: TemplateArgsType, + response_args: TemplateArgsType, +) -> cg.MockObj: + """Wire the shared action plumbing: hub parent, templated device address, outcome triggers. + + response_args are the on_response handler's arguments, which differ per action. + """ + parent = await cg.get_variable(config[modbus.CONF_MODBUS_ID]) + cg.add(var.set_parent(parent)) + cg.add( + var.set_target_address( + await cg.templatable(config[CONF_ADDRESS], args, cg.uint8) + ) + ) + if sent_conf := config.get(CONF_ON_SENT): + await automation.build_automation( + var.get_sent_trigger(), [(_PDU_SPAN, "request")], sent_conf + ) + if response_conf := config.get(CONF_ON_RESPONSE): + await automation.build_automation( + var.get_response_trigger(), response_args, response_conf + ) + if error_conf := config.get(CONF_ON_ERROR): + await automation.build_automation( + var.get_error_trigger(), + [(_PDU_SPAN, "request"), (ExceptionCode, "exception_code")], + error_conf, + ) + if (no_response_conf := config.get(CONF_ON_NO_RESPONSE)) is not None: + # The lambda form IS the retry decision; the automation form runs actions and may carry a nested + # `retry:` lambda. Either way the retry lambda's bool becomes on_no_response()'s return value. + if isinstance(no_response_conf, Lambda): + retry_conf = no_response_conf + else: + await automation.build_automation( + var.get_no_response_trigger(), + [(_PDU_SPAN, "request")], + no_response_conf, + ) + retry_conf = no_response_conf.get(CONF_RETRY) + if retry_conf is not None: + retry_lambda = await cg.process_lambda( + retry_conf, [(_PDU_SPAN, "request")], return_type=cg.bool_ + ) + cg.add(var.set_retry(retry_lambda)) + if not_sent_conf := config.get(CONF_ON_NOT_SENT): + await automation.build_automation( + var.get_not_sent_trigger(), [(_PDU_SPAN, "request")], not_sent_conf + ) + return var + + +@automation.register_action( + "modbus_client.send", + ModbusClientSendAction, + MODBUS_CLIENT_SEND_SCHEMA, + synchronous=True, +) +async def modbus_client_send_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + template_ = await cg.templatable(config[CONF_PDU], args, _PDU_BUFFER) + cg.add(var.set_pdu(template_)) + return await register_client_action( + var, + config, + args, + [(_PDU_SPAN, "request"), (_PDU_SPAN, "response")], + ) diff --git a/esphome/components/modbus_client/modbus_client.h b/esphome/components/modbus_client/modbus_client.h new file mode 100644 index 0000000000..d4b3792a5f --- /dev/null +++ b/esphome/components/modbus_client/modbus_client.h @@ -0,0 +1,99 @@ +#pragma once + +#include "esphome/components/modbus/modbus.h" +#include "esphome/components/modbus/modbus_helpers.h" +#include "esphome/core/automation.h" + +#include + +namespace esphome::modbus_client { + +/// Shared base for the modbus_client actions. Each ACTION INSTANCE is its own modbus::ModbusClientDevice: +/// the hub routes every reply (or its lack) straight back to the action that sent it, so there is no +/// central client object and no request matching. The device address is templatable; it is stamped on the +/// device at play() time; the hub routes each reply by device pointer, so a changed address never +/// mis-routes an earlier reply. (The address is not passed to the reply triggers - under overlapping +/// sends it could misreport, and the handler can recompute the expression it configured.) +template class ClientActionBase : public Action, public modbus::ModbusClientDevice { + public: + TEMPLATABLE_VALUE(uint8_t, target_address) // the modbus device address + + Trigger> *get_sent_trigger() { return &this->sent_trigger_; } + Trigger, modbus::ExceptionCode> *get_error_trigger() { return &this->error_trigger_; } + Trigger> *get_no_response_trigger() { return &this->no_response_trigger_; } + Trigger> *get_not_sent_trigger() { return &this->not_sent_trigger_; } + + /// The retry decision for on_no_response: given the request PDU, return true to have the hub re-queue + /// the frame. Set from the lambda form or a then: automation's nested retry lambda; may coexist with + /// the no_response trigger (actions run, then this decides the retry). + using retry_func_t = bool (*)(std::span); + void set_retry(retry_func_t f) { this->retry_func_ = f; } + + /// The frame was written to the wire: fires once per transmission, before any reply, and never for a + /// send that ended in on_not_sent. request_pdu is the PDU sent (function code + data). + void on_sent(std::span request_pdu) override { this->sent_trigger_.trigger(request_pdu); } + /// Never reached the wire (tx queue full, cleared, or a duplicate write dropped by the hub's dedup). + void on_not_sent(std::span request_pdu) override { this->not_sent_trigger_.trigger(request_pdu); } + /// A Modbus exception reply. Lives here beside its trigger so every action subclass gets the pairing: + /// register_client_action() wires on_error for all of them, so a derived class must not have to + /// remember the override. + void on_error(std::span request_pdu, modbus::ExceptionCode exception_code) override { + this->error_trigger_.trigger(request_pdu, exception_code); + } + /// No reply within send_wait_time. Run the on_no_response actions (empty in the pure-lambda form), + /// then let the retry lambda, if set, decide whether the hub re-queues the frame (true = retry). The + /// two coexist: a then: automation can also carry a retry lambda. No lambda = no retry. + bool on_no_response(std::span request_pdu) override { + this->no_response_trigger_.trigger(request_pdu); + if (this->retry_func_ != nullptr) + return this->retry_func_(request_pdu); + return false; + } + /// Stamp the templated device address before every play(): subclasses cannot forget it, and the hub + /// routes each reply by device pointer, so a changed address never mis-routes earlier replies. + void play_complex(const Ts &...x) override { + this->set_address(this->target_address_.value(x...)); + Action::play_complex(x...); + } + + protected: + Trigger> sent_trigger_; + Trigger, modbus::ExceptionCode> error_trigger_; + Trigger> no_response_trigger_; + Trigger> not_sent_trigger_; + retry_func_t retry_func_{nullptr}; +}; + +/// modbus_client.send: fire a raw PDU (function code + data; the hub adds address and CRC). The reply is +/// delivered raw - on_response(request, response) - deliberately bypassing the typed dispatch, so +/// non-standard/custom transactions pass through untouched. +/// The PDU is a stack-allocated modbus::helpers::PduBuffer, so a pdu lambda can build one with the +/// modbus::helpers::create_*_pdu() builders and return it directly (smaller builder results convert). +/// A PduBuffer drops bytes past modbus::MAX_PDU_SIZE without reporting it (the hub's oversize check +/// cannot fire - that limit is the capacity), so an over-long lambda-built PDU is silently truncated. +template class ModbusClientSendAction : public ClientActionBase { + public: + TEMPLATABLE_VALUE(modbus::helpers::PduBuffer, pdu) + + Trigger, std::span> *get_response_trigger() { + 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 on_response(std::span request_pdu, std::span response_pdu) override { + this->response_trigger_.trigger(request_pdu, response_pdu); + } + + protected: + Trigger, std::span> response_trigger_; +}; + +} // namespace esphome::modbus_client diff --git a/tests/component_tests/modbus_client/test_modbus_client.py b/tests/component_tests/modbus_client/test_modbus_client.py new file mode 100644 index 0000000000..10f9bc588e --- /dev/null +++ b/tests/component_tests/modbus_client/test_modbus_client.py @@ -0,0 +1,116 @@ +"""Tests for modbus_client configuration validation. + +Handler PDU spans point into hub buffers reused once the handler returns, so the deferring-actions +guard is a safety property: these tests pin it to every handler slot. +""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.modbus_client import ( + CONF_ON_NO_RESPONSE, + CONF_ON_NOT_SENT, + CONF_ON_SENT, + CONF_PDU, + MODBUS_CLIENT_SEND_SCHEMA, +) +from esphome.const import CONF_ADDRESS, CONF_ON_ERROR, CONF_ON_RESPONSE +from esphome.core import Lambda +from esphome.types import ConfigType + +# Every handler slot on modbus_client.send. All five must reject deferring actions. +HANDLER_KEYS = [ + CONF_ON_SENT, + CONF_ON_RESPONSE, + CONF_ON_ERROR, + CONF_ON_NO_RESPONSE, + CONF_ON_NOT_SENT, +] + +# A deferring action (registered synchronous=False) and a synchronous one, for contrast. +DEFERRING_ACTION = {"delay": "1s"} +SYNCHRONOUS_ACTION = {"lambda": Lambda('ESP_LOGD("test", "ran");')} +TRUE_CONDITION = {"lambda": Lambda("return true;")} + +# The same deferring action buried inside nested control flow, which the guard must still find. +NESTED_ACTIONS = [ + pytest.param( + [{"if": {"condition": TRUE_CONDITION, "then": [DEFERRING_ACTION]}}], + id="if", + ), + pytest.param([{"repeat": {"count": 2, "then": [DEFERRING_ACTION]}}], id="repeat"), + pytest.param( + [ + { + "repeat": { + "count": 2, + "then": [ + { + "if": { + "condition": TRUE_CONDITION, + "then": [DEFERRING_ACTION], + } + } + ], + } + } + ], + id="repeat_if", + ), +] + +DEFER_MESSAGE = "Deferring actions" + + +def _config(handler_key: str, actions: list) -> ConfigType: + """A minimal valid modbus_client.send config with one handler populated.""" + return { + CONF_ADDRESS: 0x01, + CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x01], + handler_key: {"then": actions}, + } + + +@pytest.mark.parametrize("handler_key", HANDLER_KEYS) +def test_synchronous_handler_accepted(handler_key: str) -> None: + # The guard must not get in the way of an ordinary inline handler. + MODBUS_CLIENT_SEND_SCHEMA(_config(handler_key, [SYNCHRONOUS_ACTION])) + + +@pytest.mark.parametrize("handler_key", HANDLER_KEYS) +def test_deferring_action_rejected(handler_key: str) -> None: + with pytest.raises(cv.Invalid, match=DEFER_MESSAGE): + MODBUS_CLIENT_SEND_SCHEMA(_config(handler_key, [DEFERRING_ACTION])) + + +@pytest.mark.parametrize("handler_key", HANDLER_KEYS) +@pytest.mark.parametrize("actions", NESTED_ACTIONS) +def test_nested_deferring_action_rejected(handler_key: str, actions: list) -> None: + # has_non_synchronous_actions recurses, so a delay buried in if:/repeat: is still caught. + with pytest.raises(cv.Invalid, match=DEFER_MESSAGE): + MODBUS_CLIENT_SEND_SCHEMA(_config(handler_key, actions)) + + +def test_on_no_response_lambda_form_accepted() -> None: + # The returning-lambda form has no action list; the guard is a no-op on it. + MODBUS_CLIENT_SEND_SCHEMA( + { + CONF_ADDRESS: 0x01, + CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x01], + CONF_ON_NO_RESPONSE: Lambda("return false;"), + } + ) + + +def test_on_no_response_retry_lambda_accepted() -> None: + # The automation form may also carry a nested retry: lambda. + MODBUS_CLIENT_SEND_SCHEMA( + { + CONF_ADDRESS: 0x01, + CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x01], + CONF_ON_NO_RESPONSE: { + "then": [SYNCHRONOUS_ACTION], + "retry": Lambda("return true;"), + }, + } + ) diff --git a/tests/components/modbus_client/common.yaml b/tests/components/modbus_client/common.yaml new file mode 100644 index 0000000000..19e81f4e48 --- /dev/null +++ b/tests/components/modbus_client/common.yaml @@ -0,0 +1,53 @@ +# The modbus_client actions are self-contained hub devices: each takes the hub (auto-resolved when there +# is a single modbus client hub) and a templatable device address; no component block is needed. The +# address is not passed back to reply handlers - recompute the configured expression if needed. +# The hub does not bound retries, so a retry lambda must (here: a counter capped at 3), or a dead +# device is retried forever. Reset the counter before the send or on a terminal outcome (on_response) +# so the cap is per transaction, not per device lifetime. Never reset in on_sent: it fires again on +# every retry, so the cap would never be reached. +globals: + - id: read_retries + type: int + initial_value: "0" + - id: combined_retries + type: int + initial_value: "0" + +button: + - platform: template + name: "Send Read" + on_press: + - lambda: "id(read_retries) = 0;" + - modbus_client.send: + address: 0x01 + pdu: [0x03, 0x00, 0x10, 0x00, 0x01] + # on_no_response lambda form: return true to retry. `request` is the timed-out PDU. + on_no_response: !lambda "return !request.empty() && request[0] == 0x03 && id(read_retries)++ < 3;" + # Per-send inline reply handlers (fire-and-continue): they run when this send's outcome is known; + # the targeted address is not passed back - recompute the configured expression if needed. + # A pdu lambda can hand-assemble bytes or return a modbus::helpers::create_*_pdu() builder result. + - modbus_client.send: + address: 0x01 + pdu: !lambda "return modbus::helpers::create_read_pdu(modbus::FunctionCode::READ_HOLDING_REGISTERS, 0x0010, 1);" + - modbus_client.send: + address: !lambda "return 1;" + pdu: !lambda "return {0x03, 0x00, 0x10, 0x00, 0x01};" + on_sent: + then: + - lambda: 'ESP_LOGI("modbus_client.test", "sent fc 0x%X", request.empty() ? 0 : request[0]);' + on_response: + then: + - lambda: |- + id(combined_retries) = 0; + ESP_LOGI("modbus_client.test", "got %d bytes", (int) response.size()); + on_error: + then: + - lambda: 'ESP_LOGW("modbus_client.test", "fc 0x%X exception %d", request.empty() ? 0 : request[0], (int) exception_code);' + # on_no_response combined form: run actions on timeout AND decide the retry via nested retry:. + on_no_response: + then: + - lambda: 'ESP_LOGW("modbus_client.test", "no reply for fc 0x%X", request.empty() ? 0 : request[0]);' + retry: !lambda "return !request.empty() && request[0] == 0x03 && id(combined_retries)++ < 3;" + on_not_sent: + then: + - lambda: 'ESP_LOGW("modbus_client.test", "not sent fc 0x%X", request.empty() ? 0 : request[0]);' diff --git a/tests/components/modbus_client/test.esp32-idf.yaml b/tests/components/modbus_client/test.esp32-idf.yaml new file mode 100644 index 0000000000..b5882e90d8 --- /dev/null +++ b/tests/components/modbus_client/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml + modbus_client: !include common.yaml diff --git a/tests/components/modbus_client/test.esp8266-ard.yaml b/tests/components/modbus_client/test.esp8266-ard.yaml new file mode 100644 index 0000000000..151922b0d5 --- /dev/null +++ b/tests/components/modbus_client/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + modbus: !include ../../test_build_components/common/modbus/esp8266-ard.yaml + modbus_client: !include common.yaml diff --git a/tests/components/modbus_client/test.rp2040-ard.yaml b/tests/components/modbus_client/test.rp2040-ard.yaml new file mode 100644 index 0000000000..aaf115ae45 --- /dev/null +++ b/tests/components/modbus_client/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + modbus: !include ../../test_build_components/common/modbus/rp2040-ard.yaml + modbus_client: !include common.yaml diff --git a/tests/integration/fixtures/uart_mock_modbus_client_inline.yaml b/tests/integration/fixtures/uart_mock_modbus_client_inline.yaml new file mode 100644 index 0000000000..f85206107f --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_client_inline.yaml @@ -0,0 +1,108 @@ +esphome: + name: uart-mock-modbus-client-inline + +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 + # Short wait so the no-reply cases (address 2 below) time out well within the test window. + send_wait_time: 500ms + +modbus_server: + - address: 1 + modbus_id: virtual_modbus_server + id: modbus_server_1 + registers: + - address: 0x10 + value_type: U_WORD + read_lambda: return 1234; + +sensor: + - platform: template + name: "inline_value" + id: inline_value + - platform: template + name: "timeout_flag" + id: timeout_flag + - platform: template + name: "skipped_flag" + id: skipped_flag + +# The same write action fired twice while its first frame is still awaiting a reply: the hub drops the +# duplicate write (writes are never merged) and the second firing resolves via its own on_not_sent. +# mode: parallel so the second run starts while the first send is pending. +script: + - id: dup_write + mode: parallel + then: + - modbus_client.send: + address: 2 + pdu: [0x06, 0x00, 0x10, 0x01, 0x02] + on_not_sent: + then: + - lambda: "id(skipped_flag).publish_state(1);" + +# Each action is its own hub device: address 1 is served by the mock server, address 2 answers nothing. +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + # Per-send inline on_response: decode this reply where the send was fired (fire-and-continue). + - modbus_client.send: + address: 1 + pdu: [0x03, 0x00, 0x10, 0x00, 0x01] + on_response: + then: + - lambda: |- + if (response.size() >= 4) + id(inline_value).publish_state((response[2] << 8) | response[3]); + # No server answers address 2, so this resolves via on_no_response. + - modbus_client.send: + address: 2 + pdu: [0x03, 0x00, 0x10, 0x00, 0x01] + on_no_response: + then: + - lambda: "id(timeout_flag).publish_state(1);" + - script.execute: dup_write + - script.execute: dup_write diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index 202fbe3f9c..bf163665f2 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -341,6 +341,35 @@ 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_inline( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test modbus_client.send actions: each action is its own hub device. + + Start Scenario fires: a read of served address 1 decoded in its inline on_response -> inline_value; a + read of address 2, which no server answers, resolving via on_no_response -> timeout_flag. A parallel + script fires the same write action twice while its first frame is pending; the hub drops the duplicate + write, and the second firing resolves via its own on_not_sent -> skipped_flag. This exercises + per-action reply routing, the no-reply path, and the one-outcome guarantee under the hub's write + dedup. + """ + + tracker = SensorTracker(["inline_value", "timeout_flag", "skipped_flag"]) + futures = tracker.expect_all( + {"inline_value": 1234, "timeout_flag": 1, "skipped_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, timeout=5.0) + + @pytest.mark.asyncio async def test_uart_mock_modbus_grouping( yaml_config: str,