[modbus_client] Add component for ad-hoc modbus request/response (#17676)

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Bonne Eggleston
2026-08-06 14:52:23 -05:00
committed by GitHub
co-authored by Claude J. Nick Koston
parent 53b1b3a253
commit 56682534f8
11 changed files with 585 additions and 0 deletions
+1
View File
@@ -352,6 +352,7 @@ esphome/components/mlx90393/* @functionpointer
esphome/components/mlx90614/* @jesserockz esphome/components/mlx90614/* @jesserockz
esphome/components/mmc5603/* @benhoff esphome/components/mmc5603/* @benhoff
esphome/components/mmc5983/* @agoode esphome/components/mmc5983/* @agoode
esphome/components/modbus_client/* @exciton
esphome/components/modbus_controller/* @martgras esphome/components/modbus_controller/* @martgras
esphome/components/modbus_controller/binary_sensor/* @martgras esphome/components/modbus_controller/binary_sensor/* @martgras
esphome/components/modbus_controller/number/* @martgras esphome/components/modbus_controller/number/* @martgras
+6
View File
@@ -14,6 +14,12 @@ import esphome.final_validate as fv
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
DEPENDENCIES = ["uart"] 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_ns = cg.esphome_ns.namespace("modbus")
Modbus = modbus_ns.class_("Modbus", cg.Component, uart.UARTDevice) Modbus = modbus_ns.class_("Modbus", cg.Component, uart.UARTDevice)
@@ -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 <bool>;"`, 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")],
)
@@ -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 <span>
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<typename... Ts> class ClientActionBase : public Action<Ts...>, public modbus::ModbusClientDevice {
public:
TEMPLATABLE_VALUE(uint8_t, target_address) // the modbus device address
Trigger<std::span<const uint8_t>> *get_sent_trigger() { return &this->sent_trigger_; }
Trigger<std::span<const uint8_t>, modbus::ExceptionCode> *get_error_trigger() { return &this->error_trigger_; }
Trigger<std::span<const uint8_t>> *get_no_response_trigger() { return &this->no_response_trigger_; }
Trigger<std::span<const uint8_t>> *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<const uint8_t>);
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<const uint8_t> 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<const uint8_t> 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<const uint8_t> 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<const uint8_t> 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<Ts...>::play_complex(x...);
}
protected:
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_;
Trigger<std::span<const uint8_t>> 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<typename... Ts> class ModbusClientSendAction : public ClientActionBase<Ts...> {
public:
TEMPLATABLE_VALUE(modbus::helpers::PduBuffer, pdu)
Trigger<std::span<const uint8_t>, std::span<const uint8_t>> *get_response_trigger() {
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 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);
}
protected:
Trigger<std::span<const uint8_t>, std::span<const uint8_t>> response_trigger_;
};
} // namespace esphome::modbus_client
@@ -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;"),
},
}
)
@@ -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]);'
@@ -0,0 +1,3 @@
packages:
modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml
modbus_client: !include common.yaml
@@ -0,0 +1,3 @@
packages:
modbus: !include ../../test_build_components/common/modbus/esp8266-ard.yaml
modbus_client: !include common.yaml
@@ -0,0 +1,3 @@
packages:
modbus: !include ../../test_build_components/common/modbus/rp2040-ard.yaml
modbus_client: !include common.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
@@ -341,6 +341,35 @@ async def test_uart_mock_modbus_server_controller_multiple(
_assert_no_modbus_errors(error_log_lines, warning_log_lines) _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 @pytest.mark.asyncio
async def test_uart_mock_modbus_grouping( async def test_uart_mock_modbus_grouping(
yaml_config: str, yaml_config: str,