mirror of
https://github.com/esphome/esphome.git
synced 2026-08-22 22:26:21 +00:00
[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:
co-authored by
Claude
J. Nick Koston
parent
53b1b3a253
commit
56682534f8
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@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,
|
||||
|
||||
Reference in New Issue
Block a user