[modbus_client] Add continuous option to the read and send actions (#18542)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Bonne Eggleston
2026-08-23 13:01:34 -05:00
committed by GitHub
co-authored by Claude
parent 4efd308345
commit dde6906f98
8 changed files with 261 additions and 75 deletions
+76 -3
View File
@@ -1,17 +1,23 @@
from __future__ import annotations
import logging
from typing import Any, Literal
from typing import Any, Literal, NamedTuple
from esphome import pins
import esphome.codegen as cg
from esphome.components import uart
import esphome.config_validation as cv
from esphome.const import CONF_ADDRESS, CONF_DISABLE_CRC, CONF_FLOW_CONTROL_PIN, CONF_ID
from esphome.const import (
CONF_ADDRESS,
CONF_CONTINUOUS,
CONF_DISABLE_CRC,
CONF_FLOW_CONTROL_PIN,
CONF_ID,
)
from esphome.cpp_generator import MockObj
from esphome.cpp_helpers import gpio_pin_expression
import esphome.final_validate as fv
from esphome.types import ConfigType
from esphome.types import ConfigType, TemplateArgsType
_LOGGER = logging.getLogger(__name__)
@@ -48,6 +54,73 @@ CONF_TURNAROUND_TIME = "turnaround_time"
MODBUS_ROLES = ["client", "server"]
class _CommandOption(NamedTuple):
"""One per-command option forwarded to the hub (modbus::CommandOptions)."""
conf_key: str
field: str # the C++ field, and so the set_<field>() setter name
validator: Any # the static (non-templatable) validator for the key
cpp_type: Any # the C++ type the value is generated as
default: Any
# Per-direction command options. Single-sourcing the schema and the setter generation here keeps
# them from drifting; the C++ side must add the matching field per the rules documented on
# CommandOptions (modbus.h).
_COMMAND_OPTIONS: dict[str, list[_CommandOption]] = {
"read": [_CommandOption(CONF_CONTINUOUS, "continuous", cv.boolean, bool, False)],
"write": [],
}
def _command_options(direction: str) -> list[_CommandOption]:
try:
return _COMMAND_OPTIONS[direction]
except KeyError:
raise ValueError(f"unknown command-options direction {direction!r}") from None
def command_options_schema(
*, direction: Literal["read", "write"], templatable: bool = False
) -> dict[cv.Optional, Any]:
"""Schema fragment for the per-command options a component forwards to the hub
(modbus::CommandOptions). Extend this into any schema that queues commands. Keys are
direction-specific so a schema never offers an option the hub would strip (e.g.
continuous on a write); the write side has no options yet. For actions (templatable=True the
keys also accept lambdas), register the values with register_templatable_command_options().
"""
return {
cv.Optional(option.conf_key, default=option.default): (
cv.templatable(option.validator) if templatable else option.validator
)
for option in _command_options(direction)
}
async def register_templatable_command_options(
var: MockObj, config: ConfigType, args: TemplateArgsType, direction: str
) -> None:
"""Generate the set_<option>() calls for the given direction's command options present in config.
Pass the same direction the action's command_options_schema() used, so the keys generated match
the ones the schema offered - a write action never emits a read option's setter. Options the
schema did not add are simply absent. The consumer's C++ class declares a matching
TEMPLATABLE_VALUE per option (e.g. TEMPLATABLE_VALUE(bool, continuous)).
"""
for option in _command_options(direction):
if option.conf_key not in config:
continue
value = config[option.conf_key]
# Skip codegen when the value is its C++ zero (TemplatableFn::value() returns T{} when
# unset): behaviourally identical, and saves a thunk plus a setup() call per action.
if cg.is_template(value) or value != type(value)():
cg.add(
getattr(var, f"set_{option.field}")(
await cg.templatable(value, args, option.cpp_type)
)
)
CONFIG_SCHEMA = cv.typed_schema(
{
"client": cv.Schema(
+10 -13
View File
@@ -146,7 +146,7 @@ bool ModbusClientHub::tx_buffer_empty() {
// other states are mid-transaction or owed bookkeeping, not queued sends - and a READY continuous
// poll does not count either, since it ranks below every one-shot, so a new send goes out first.
for (const auto &cmd : this->tx_buffer_) {
if (cmd.state == FrameState::READY && !cmd.continuous)
if (cmd.state == FrameState::READY && !cmd.options.continuous)
return false;
}
return true;
@@ -946,7 +946,7 @@ bool ModbusDeviceCommand::notify_retired() {
bool ModbusDeviceCommand::response(std::span<const uint8_t> response_pdu) {
this->state = this->state == FrameState::WAITING_RETIRED ? FrameState::RETIRED : FrameState::RECEIVED_RESPONSE;
// A continuous poll is never consumed by its own response; a one-shot consumes one request here.
if (!this->continuous)
if (!this->options.continuous)
this->decrement_pending();
if (this->device == nullptr)
return false;
@@ -1070,15 +1070,12 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span<const uint8_t> pdu, M
return false;
}
// Normalize the caller's options in place (the param is a by-value copy) so everything stored or
// merged below carries effective options, never the raw request.
// continuous is ignored for every mutating code (re-writing a value forever is never intended).
const bool mutates = priority == CommandPriority::WRITE;
bool continuous = false;
if (options.continuous) {
if (mutates) {
ESP_LOGV(TAG, "continuous is ignored for a mutating function (0x%X, address %" PRIu8 ")", pdu[0], address);
} else {
continuous = true;
}
if (options.continuous && priority == CommandPriority::WRITE) {
ESP_LOGW(TAG, "continuous is ignored for a mutating function (0x%X, address %" PRIu8 ")", pdu[0], address);
options.continuous = false;
}
// A duplicate of a live entry with the same owner is not queued twice; it resolves against that
@@ -1104,10 +1101,10 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span<const uint8_t> pdu, M
}
return false; // dropped: no entry, no callbacks - the refusal is the return value
}
if (continuous) {
if (options.continuous) {
item.make_continuous(true);
ESP_LOGV(TAG, "Frame already active for %" PRIu8 ", now polled continuously", address);
} else if (item.continuous) {
} else if (item.options.continuous) {
// A one-shot duplicate downgrades the poll to a one-shot: it runs one more cycle to serve this
// request, then stops (mirrors continuous incoming converting a one-shot the other way).
item.make_continuous(false);
@@ -1140,7 +1137,7 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span<const uint8_t> pdu, M
#endif
ESP_LOGV(TAG, "Adding frame to tx queue: %" PRIu8 ":%s", address,
format_hex_pretty_to(hex_buf, pdu.data(), pdu.size()));
this->tx_buffer_.emplace_back(device, address, pdu, continuous, this->next_seq_++);
this->tx_buffer_.emplace_back(device, address, pdu, options, this->next_seq_++);
return true;
}
+31 -17
View File
@@ -118,6 +118,14 @@ enum class FrameState : uint8_t {
};
// Per-command send options. Append-only; pass via designated initializers ({.continuous = true}).
// The queue entry stores this struct whole, so a new field arrives at the queue with no plumbing -
// but it arrives inert. Every new field must define three rules before it does anything:
// 1. normalization in queue_pdu() (is it valid for this function code? e.g. continuous is
// stripped for mutating codes),
// 2. a merge rule for when a duplicate send absorbs into a live entry (continuous
// upgrades/downgrades via make_continuous(); a new field needs its own answer),
// 3. teardown: retire() resets the whole struct; silent_retire() leaves it, relying on the sweep
// to erase the entry.
struct CommandOptions {
// A continuous poll lives in the queue until cancelled or failed; ignored for mutating codes.
bool continuous{false};
@@ -126,26 +134,29 @@ struct CommandOptions {
struct ModbusDeviceCommand {
ModbusClientDevice *device;
ModbusFrame frame;
FrameState state{FrameState::READY};
// A continuous poll is a subscription: pending fixed at 1, removed only by cancellation or failure.
bool continuous{false};
// Accepted requests this entry stands for, capped at max_pending(); drains one terminal each.
uint8_t pending{1};
// Place-in-line stamp (hub's free-running counter); selection takes the oldest for round-robin
// fairness within a class. Meant to wrap.
// fairness within a class. Meant to wrap. Declared ahead of the byte fields so the tail packs
// densely and a growing CommandOptions eats trailing padding before enlarging the struct.
uint16_t seq{0};
FrameState state{FrameState::READY};
// Accepted requests this entry stands for, capped at max_pending(); drains one terminal each.
// A continuous poll is a subscription: pending fixed at 1, removed only by cancellation or failure.
uint8_t pending{1};
// The entry's LIVE effective options, not a record of the caller's request: queue_pdu() normalizes
// before storing, duplicate absorption mutates continuous via make_continuous(), and retire() resets
// the struct (silent_retire() leaves it, relying on the sweep to erase the entry). See the
// CommandOptions comment for the rules a new field must define.
CommandOptions options;
// Build a command from a PDU span (caller bounds it to MAX_PDU_SIZE); fully initialized here.
// Build a command from a PDU span (caller bounds it to MAX_PDU_SIZE) and pre-normalized options;
// fully initialized here.
ModbusDeviceCommand(ModbusClientDevice *device, uint8_t address, std::span<const uint8_t> pdu,
bool continuous = false, uint16_t seq = 0)
: device(device),
frame(address, pdu.data(), static_cast<uint16_t>(pdu.size())),
continuous(continuous),
seq(seq) {}
CommandOptions options = {}, uint16_t seq = 0)
: device(device), frame(address, pdu.data(), static_cast<uint16_t>(pdu.size())), seq(seq), options(options) {}
// Transmit ordering class, derived (never stored): a continuous poll ranks below every one-shot.
CommandPriority priority() const {
return this->continuous ? CommandPriority::CONTINUOUS : classify(this->frame.pdu()[0]);
return this->options.continuous ? CommandPriority::CONTINUOUS : classify(this->frame.pdu()[0]);
}
// Wire-derived class: mutating codes rank WRITE; exception-flagged codes are excluded.
static CommandPriority classify(uint8_t function_code) {
@@ -161,7 +172,7 @@ struct ModbusDeviceCommand {
uint8_t max_pending() const {
const uint8_t fc = this->frame.pdu()[0];
const bool requeueable = !helpers::is_function_code_exception(fc) && helpers::is_function_code_read_only(fc);
return (requeueable && !this->continuous) ? 2 : 1;
return (requeueable && !this->options.continuous) ? 2 : 1;
}
// Device-scoped clear: detach with no callback (device-less, pending 0). An entry still waiting for
// a response keeps its state as a reply-ignoring shell that resolves silently; any other goes RETIRED.
@@ -196,11 +207,11 @@ struct ModbusDeviceCommand {
// retroactively inflating that no-op.
void make_continuous(bool continuous) {
if (continuous) {
this->continuous = true;
this->options.continuous = true;
this->pending = 1;
} else {
this->increment_pending();
this->continuous = false;
this->options.continuous = false;
}
}
// Address-scoped clear: keep pending and device so the sweep delivers one on_not_sent() per un-run
@@ -218,7 +229,7 @@ struct ModbusDeviceCommand {
} else if (!this->waiting_state()) { // an already-retired shell stays put; off the wire -> RETIRED
this->state = FrameState::RETIRED;
}
this->continuous = false;
this->options = {}; // reset every option so a future field is torn down without editing here
}
// True while the entry is still waiting for a response; the erase pass exempts these even at pending 0.
@@ -253,6 +264,9 @@ struct ModbusDeviceCommand {
bool notify_retired();
/// True if this command carries the same wire frame (address + PDU) as the given one.
/// Cancellation matches the exact frame, not the action instance: a continuous poll whose
/// start_address (or other field) is templated produces one poll per distinct frame, and a later
/// cancel built from different argument values will not reach the polls it does not byte-match.
bool same_frame(uint8_t address, std::span<const uint8_t> pdu) const {
const auto own_pdu = this->frame.pdu();
return own_pdu.size() == pdu.size() && this->frame.address() == address &&
+57 -13
View File
@@ -7,6 +7,7 @@ from esphome.components import modbus
import esphome.config_validation as cv
from esphome.const import (
CONF_ADDRESS,
CONF_CONTINUOUS,
CONF_COUNT,
CONF_ID,
CONF_ON_ERROR,
@@ -156,16 +157,45 @@ _ACTION_BASE_SCHEMA = cv.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(),
}
# The write codes recognised by modbus::helpers::is_function_code_write() - keep in sync. 0x17
# (read/write multiple) is included: it mutates, so the hub treats it as a write despite its read half.
_WRITE_FUNCTION_CODES = frozenset({0x05, 0x06, 0x0F, 0x10, 0x16, 0x17})
def _no_continuous_on_write(config: ConfigType) -> ConfigType:
"""Reject `continuous: true` on a static write PDU: continuous polling only applies to reads.
Only the fully-static case is decidable here; the hub strips the flag from mutating PDUs at
runtime, so a templated pdu or continuous falls through to that backstop."""
pdu = config[CONF_PDU]
if (
isinstance(pdu, list)
and config.get(CONF_CONTINUOUS) is True
# Masking the exception bit (0x90 -> 0x10) makes this check stricter than the runtime hub,
# whose classify() treats an exception-flagged code as a read and leaves continuous in place.
and pdu[0] & 0x7F in _WRITE_FUNCTION_CODES
):
raise cv.Invalid(
f"'{CONF_CONTINUOUS}: true' does not apply to a write PDU (function code "
f"0x{pdu[0]:02X}); continuous polling only applies to reads",
path=[CONF_CONTINUOUS],
)
return config
MODBUS_CLIENT_SEND_SCHEMA = cv.All(
_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),
)
),
**modbus.command_options_schema(direction="read", templatable=True),
cv.Optional(CONF_ON_RESPONSE): _handler_schema(),
}
),
_no_continuous_on_write,
)
@@ -174,6 +204,7 @@ async def register_client_action(
config: ConfigType,
args: TemplateArgsType,
response_args: TemplateArgsType,
command_direction: str = "read",
) -> cg.MockObj:
"""Wire the shared action plumbing: hub parent, templated device address, outcome triggers.
@@ -235,6 +266,12 @@ async def register_client_action(
await automation.build_automation(
var.get_not_sent_trigger(), [(_PDU_SPAN, "request")], not_sent_conf
)
# Wire any command options the action's schema opted into (e.g. continuous on reads). Pass the
# matching direction so a write action never generates a read option's setter; the write side
# has no options yet, so this is a no-op there.
await modbus.register_templatable_command_options(
var, config, args, command_direction
)
return var
@@ -318,6 +355,7 @@ def _read_schema(max_count: int) -> cv.All:
cv.Optional(CONF_COUNT, default=1): cv.templatable(
cv.int_range(min=1, max=max_count)
),
**modbus.command_options_schema(direction="read", templatable=True),
}
),
_no_address_overflow(CONF_COUNT),
@@ -379,7 +417,9 @@ async def read_input_registers_to_code(config, action_id, template_arg, args):
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, [])
return await register_client_action(
var, config, args, [], command_direction="write"
)
@automation.register_action(
@@ -458,7 +498,9 @@ async def write_multiple_registers_to_code(config, action_id, template_arg, args
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, [])
return await register_client_action(
var, config, args, [], command_direction="write"
)
@automation.register_action(
@@ -482,7 +524,9 @@ async def write_multiple_coils_to_code(config, action_id, template_arg, args):
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, [])
return await register_client_action(
var, config, args, [], command_direction="write"
)
# Read/write multiple registers (FC 0x17) writes one register block and reads another in a single
@@ -68,8 +68,8 @@ template<typename... Ts> class ClientActionBase : public Action<Ts...>, public m
/// resolves through on_sent() alone), 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->queue_pdu(pdu))
void send_or_resolve_(std::span<const uint8_t> pdu, modbus::CommandOptions options = {}) {
if (!this->queue_pdu(pdu, options))
this->on_not_sent(pdu);
}
@@ -80,6 +80,26 @@ template<typename... Ts> class ClientActionBase : public Action<Ts...>, public m
retry_func_t retry_func_{nullptr};
};
/// The read-side per-command options (modbus::CommandOptions), declared once for every action that
/// sends a read. Each option is templatable, so it cannot be built in Python the way modbus_controller
/// builds its static struct; declaring the values here instead of per action means a new read option
/// costs one TEMPLATABLE_VALUE plus one field below, and every read action picks it up.
/// The read/write split mirrors _COMMAND_OPTIONS in the modbus component's Python
/// (command_options_schema(direction="read") adds exactly these keys). When a write-side option
/// arrives it gets a WriteCommandOptions twin, so write actions never carry read-only members.
template<typename... Ts> class ReadCommandOptions {
public:
// Poll: re-queue after each success until downgraded (replay with false) or failed. The hub strips
// it for mutating function codes at the door (see modbus::CommandOptions).
TEMPLATABLE_VALUE(bool, continuous)
protected:
/// The options for this send, with every templatable value resolved against the action's arguments.
modbus::CommandOptions command_options_(const Ts &...x) const {
return {.continuous = this->continuous_.value(x...)};
}
};
/// 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.
@@ -87,7 +107,8 @@ template<typename... Ts> class ClientActionBase : public Action<Ts...>, public m
/// 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...> {
template<typename... Ts>
class ModbusClientSendAction : public ClientActionBase<Ts...>, public ReadCommandOptions<Ts...> {
public:
TEMPLATABLE_VALUE(modbus::helpers::PduBuffer, pdu)
@@ -95,7 +116,7 @@ template<typename... Ts> class ModbusClientSendAction : public ClientActionBase<
return &this->response_trigger_;
}
void play(const Ts &...x) override { this->send_or_resolve_(this->pdu_.value(x...)); }
void play(const Ts &...x) override { this->send_or_resolve_(this->pdu_.value(x...), this->command_options_(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);
@@ -140,7 +161,8 @@ template<typename... Ts> class TypedClientActionBase : public ClientActionBase<T
/// 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...> {
template<typename... Ts>
class ReadRegistersAction : public TypedClientActionBase<Ts...>, public ReadCommandOptions<Ts...> {
public:
explicit ReadRegistersAction(bool holding) : holding_(holding) {}
TEMPLATABLE_VALUE(uint16_t, start_address)
@@ -152,7 +174,8 @@ template<typename... Ts> class ReadRegistersAction : public TypedClientActionBas
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...)));
modbus::helpers::create_read_pdu(function_code, this->start_address_.value(x...), this->count_.value(x...)),
this->command_options_(x...));
}
void on_read_registers(modbus::EntityType entity_type, uint16_t start_address, std::span<const uint16_t> registers,
modbus::ResponseStatus status) override {
@@ -167,7 +190,7 @@ template<typename... Ts> class ReadRegistersAction : public TypedClientActionBas
/// 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...> {
template<typename... Ts> class ReadBitsAction : public TypedClientActionBase<Ts...>, public ReadCommandOptions<Ts...> {
public:
explicit ReadBitsAction(bool coils) : coils_(coils) {}
TEMPLATABLE_VALUE(uint16_t, start_address)
@@ -179,7 +202,8 @@ template<typename... Ts> class ReadBitsAction : public TypedClientActionBase<Ts.
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...)));
modbus::helpers::create_read_pdu(function_code, this->start_address_.value(x...), this->count_.value(x...)),
this->command_options_(x...));
}
void on_read_bits(modbus::EntityType entity_type, uint16_t start_address, modbus::PackedBits bits,
modbus::ResponseStatus status) override {
@@ -16,7 +16,13 @@ from esphome.components.modbus_client import (
CONFIG_SCHEMA,
MODBUS_CLIENT_SEND_SCHEMA,
)
from esphome.const import CONF_ADDRESS, CONF_ID, CONF_ON_ERROR, CONF_ON_RESPONSE
from esphome.const import (
CONF_ADDRESS,
CONF_CONTINUOUS,
CONF_ID,
CONF_ON_ERROR,
CONF_ON_RESPONSE,
)
from esphome.core import Lambda
from esphome.types import ConfigType
@@ -118,6 +124,29 @@ def test_on_no_response_retry_lambda_accepted() -> None:
)
def test_continuous_on_write_pdu_rejected() -> None:
"""A literal write-code PDU with continuous: true is rejected at config time (reads only)."""
with pytest.raises(cv.Invalid, match="does not apply to a write PDU"):
MODBUS_CLIENT_SEND_SCHEMA(
{
CONF_ADDRESS: 0x01,
CONF_PDU: [0x06, 0x00, 0x01, 0x00, 0x0A],
CONF_CONTINUOUS: True,
}
)
def test_continuous_on_read_pdu_accepted() -> None:
"""A literal read-code PDU with continuous: true is fine - continuous polling applies to reads."""
MODBUS_CLIENT_SEND_SCHEMA(
{
CONF_ADDRESS: 0x01,
CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x01],
CONF_CONTINUOUS: True,
}
)
# The standalone component block. The compile fixtures cover the accepted shapes end to end; these pin
# the parts a fixture cannot express - a rejection, and a module flag whose absence breaks other
# components rather than this one.
@@ -322,14 +322,14 @@ TEST(ModbusClientHubPriority, ContinuousReadRequeuesOnSuccessOnly) {
device.read_holding_registers(0x100, 2, {.continuous = true});
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_TRUE(hub.queued(0).continuous);
EXPECT_TRUE(hub.queued(0).options.continuous);
hub.force_send_next();
// A matching successful response cycles the continuous entry back to READY.
const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
hub.receive_frame_for_test(0x02, ok_response);
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_TRUE(hub.queued(0).continuous);
EXPECT_TRUE(hub.queued(0).options.continuous);
// An exception response ends the poll.
hub.force_send_next();
@@ -346,13 +346,13 @@ TEST(ModbusClientHubPriority, RetriedContinuousReadStaysContinuous) {
device.read_holding_registers(0x100, 2, {.continuous = true});
ASSERT_EQ(hub.queued_frames(), 1u);
ASSERT_TRUE(hub.queued(0).continuous);
ASSERT_TRUE(hub.queued(0).options.continuous);
hub.force_send_next();
hub.timeout_waiting(); // no response -> device requests retry
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_TRUE(hub.queued(0).continuous); // the retried poll stays continuous
EXPECT_TRUE(hub.queued(0).options.continuous); // the retried poll stays continuous
}
// A one-shot duplicate downgrades a continuous poll to a one-shot (the mirror of a continuous
@@ -363,16 +363,16 @@ TEST(ModbusClientHubPriority, DuplicateSendDowngradesContinuous) {
device.read_holding_registers(0x100, 2, {.continuous = true});
ASSERT_EQ(hub.queued_frames(), 1u);
ASSERT_TRUE(hub.queued(0).continuous);
ASSERT_TRUE(hub.queued(0).options.continuous);
device.read_holding_registers(0x100, 2); // one-shot duplicate downgrades the poll
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_FALSE(hub.queued(0).continuous);
EXPECT_FALSE(hub.queued(0).options.continuous);
EXPECT_EQ(hub.queued(0).pending, 1u);
// It runs one more cycle to serve the request, then stops - not re-queued as a poll.
hub.force_send_next();
EXPECT_FALSE(hub.waiting_command().continuous);
EXPECT_FALSE(hub.waiting_command().options.continuous);
const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
hub.receive_frame_for_test(0x02, ok_response);
EXPECT_EQ(hub.queued_frames(), 0u);
@@ -407,16 +407,16 @@ TEST(ModbusClientHubPriority, DowngradeAfterTerminalKeepsRequestAlive) {
device.read_holding_registers(0x100, 2, {.continuous = true});
ASSERT_EQ(hub.queued_frames(), 1u);
ASSERT_TRUE(hub.queued(0).continuous);
ASSERT_TRUE(hub.queued(0).options.continuous);
hub.force_send_next();
const uint8_t exception_response[] = {0x83, 0x02};
hub.receive_frame_for_test(0x02, exception_response); // exception ends the poll; on_error re-sends
EXPECT_EQ(device.error_count_, 1); // one terminal delivered so far
ASSERT_EQ(hub.queued_frames(), 1u); // the re-send survived the sweep instead of being erased
EXPECT_FALSE(hub.queued(0).continuous); // downgraded to a one-shot
EXPECT_EQ(hub.queued(0).pending, 1u); // debt restored so the request runs
EXPECT_EQ(device.error_count_, 1); // one terminal delivered so far
ASSERT_EQ(hub.queued_frames(), 1u); // the re-send survived the sweep instead of being erased
EXPECT_FALSE(hub.queued(0).options.continuous); // downgraded to a one-shot
EXPECT_EQ(hub.queued(0).pending, 1u); // debt restored so the request runs
// And it runs to its own terminal - a good response this time - then the entry is gone.
hub.force_send_next();
@@ -434,18 +434,18 @@ TEST(ModbusClientHubPriority, ContinuousRequestUpgradesQueuedDuplicate) {
device.read_holding_registers(0x100, 2);
ASSERT_EQ(hub.queued_frames(), 1u);
ASSERT_FALSE(hub.queued(0).continuous);
ASSERT_FALSE(hub.queued(0).options.continuous);
device.read_holding_registers(0x100, 2, {.continuous = true});
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_TRUE(hub.queued(0).continuous);
EXPECT_TRUE(hub.queued(0).options.continuous);
// And it behaves as a poll from here: success cycles it back to READY.
hub.force_send_next();
const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
hub.receive_frame_for_test(0x02, ok_response);
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_TRUE(hub.queued(0).continuous);
EXPECT_TRUE(hub.queued(0).options.continuous);
}
// The transmit order is one key with three levels: writes, then one-shot reads, then continuous
@@ -473,7 +473,7 @@ TEST(ModbusClientHubPriority, WritesThenOneShotReadsThenContinuousPolls) {
EXPECT_EQ(hub.waiting_command().frame.pdu()[1], 0x02); // then the one-shot read
hub.timeout_waiting();
hub.force_send_next();
EXPECT_TRUE(hub.waiting_command().continuous); // and the poll takes what is left
EXPECT_TRUE(hub.waiting_command().options.continuous); // and the poll takes what is left
}
// continuous is ignored for writes: the frame still sends at WRITE priority, once.
@@ -485,7 +485,7 @@ TEST(ModbusClientHubPriority, ContinuousIgnoredForWrites) {
device.queue_pdu(write_pdu, {.continuous = true});
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_EQ(hub.queued(0).priority(), CommandPriority::WRITE);
EXPECT_FALSE(hub.queued(0).continuous);
EXPECT_FALSE(hub.queued(0).options.continuous);
}
// A queued continuous poll does not count against immediate-send readiness: it ranks below every
@@ -496,7 +496,7 @@ TEST(ModbusClientHubPriority, ContinuousPollDoesNotBlockImmediateSend) {
EXPECT_TRUE(hub.tx_buffer_empty()); // nothing queued
device.read_holding_registers(0x100, 2, {.continuous = true});
ASSERT_TRUE(hub.queued(0).continuous);
ASSERT_TRUE(hub.queued(0).options.continuous);
EXPECT_TRUE(hub.tx_buffer_empty()); // a READY continuous poll still leaves room to send now
device.read_holding_registers(0x200, 2); // a one-shot does count
@@ -1878,8 +1878,8 @@ TEST(ModbusClientHubPriority, ResendFromOnResponseAbsorbsIntoCompletingCommand)
const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
hub.receive_frame_for_test(0x02, ok_response); // handler re-sends the identical frame mid-completion
ASSERT_EQ(hub.queued_frames(), 1u); // absorbed into the same entry, not a fresh twin
EXPECT_FALSE(hub.queued(0).continuous); // the one-shot re-send downgraded the poll
ASSERT_EQ(hub.queued_frames(), 1u); // absorbed into the same entry, not a fresh twin
EXPECT_FALSE(hub.queued(0).options.continuous); // the one-shot re-send downgraded the poll
}
// An exception-flagged function code is never silently re-sendable, even though the read check
@@ -51,6 +51,7 @@ button:
# A pdu lambda can hand-assemble bytes or return a modbus::helpers::create_*_pdu() builder result.
- modbus_client.send:
address: 0x01
continuous: true
pdu: !lambda "return modbus::helpers::create_read_pdu(modbus::FunctionCode::READ_HOLDING_REGISTERS, 0x0010, 1);"
- modbus_client.send:
address: !lambda "return 1;"
@@ -91,6 +92,7 @@ button:
address: !lambda "return 1;"
start_address: 0x10
count: 2
continuous: true
on_response:
then:
- lambda: 'ESP_LOGI("modbus_client.test", "first=%u n=%u", values[0], (unsigned) values.size());'
@@ -98,6 +100,7 @@ button:
then:
- logger.log: "typed read timeout"
- modbus_client.read_input_registers:
continuous: !lambda "return false;"
address: 0x01
start_address: 0x20
on_custom_response:
@@ -113,12 +116,14 @@ button:
address: 0x01
start_address: 0x03
count: 16
continuous: true
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
continuous: true
on_error:
then:
- lambda: 'ESP_LOGW("modbus_client.test", "fc 0x%X exception %d", request.empty() ? 0 : request[0], (int) exception_code);'