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