mirror of
https://github.com/esphome/esphome.git
synced 2026-09-17 18:18:43 +00:00
[modbus] Add allow_broadcast_read and expect_broadcast_write_response options (#19304)
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import logging
|
||||
from typing import Any, Literal, NamedTuple
|
||||
|
||||
@@ -48,6 +49,8 @@ ModbusServerDevice = modbus_ns.class_("ModbusServerDevice")
|
||||
CommandOptions = modbus_ns.struct("CommandOptions")
|
||||
MULTI_CONF = True
|
||||
|
||||
CONF_ALLOW_BROADCAST_READ = "allow_broadcast_read"
|
||||
CONF_EXPECT_BROADCAST_WRITE_RESPONSE = "expect_broadcast_write_response"
|
||||
CONF_ROLE = "role"
|
||||
CONF_MODBUS_ID = "modbus_id"
|
||||
CONF_SEND_WAIT_TIME = "send_wait_time"
|
||||
@@ -56,6 +59,28 @@ CONF_TURNAROUND_TIME = "turnaround_time"
|
||||
MODBUS_ROLES = ["client", "server"]
|
||||
|
||||
|
||||
# The write (mutating) function codes, matching modbus::helpers::is_function_code_write(). 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})
|
||||
|
||||
# Codes the hub refuses at address 0; keep in sync with modbus::helpers::is_function_code_broadcastable().
|
||||
_NON_BROADCASTABLE_FUNCTION_CODES = frozenset(
|
||||
{0x01, 0x02, 0x03, 0x04, 0x14, 0x15, 0x17, 0x18}
|
||||
)
|
||||
|
||||
|
||||
def is_function_code_write(function_code: int) -> bool:
|
||||
"""True if the Modbus function code writes (mutates). The exception bit (0x80) is masked off first,
|
||||
so an exception-flagged code still classifies by its base code (the runtime hub never queues one:
|
||||
queue_pdu() refuses them). Keep in sync with modbus::helpers::is_function_code_write()."""
|
||||
return function_code & 0x7F in _WRITE_FUNCTION_CODES
|
||||
|
||||
|
||||
def is_function_code_broadcastable(function_code: int) -> bool:
|
||||
"""True if the hub accepts the function code at address 0 without allow_broadcast_read."""
|
||||
return function_code & 0x7F not in _NON_BROADCASTABLE_FUNCTION_CODES
|
||||
|
||||
|
||||
class _CommandOption(NamedTuple):
|
||||
"""One per-command option forwarded to the hub (modbus::CommandOptions)."""
|
||||
|
||||
@@ -64,14 +89,47 @@ class _CommandOption(NamedTuple):
|
||||
validator: Any # the static (non-templatable) validator for the key
|
||||
cpp_type: Any # the C++ type the value is generated as
|
||||
default: Any
|
||||
# Function codes the hub honours the option on; it is stripped from any other.
|
||||
applies_to: Callable[[int], bool]
|
||||
requires_broadcast_address: bool = False
|
||||
|
||||
|
||||
# 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).
|
||||
def _not_write(function_code: int) -> bool:
|
||||
return not is_function_code_write(function_code)
|
||||
|
||||
|
||||
def _not_broadcastable(function_code: int) -> bool:
|
||||
return not is_function_code_broadcastable(function_code)
|
||||
|
||||
|
||||
# Per-direction command options, single-sourced so the schema, setters and applicability rule cannot
|
||||
# drift; the C++ side adds the matching field per the rules on CommandOptions (modbus.h).
|
||||
_COMMAND_OPTIONS: dict[str, list[_CommandOption]] = {
|
||||
"read": [_CommandOption(CONF_CONTINUOUS, "continuous", cv.boolean, bool, False)],
|
||||
"write": [],
|
||||
"read": [
|
||||
_CommandOption(
|
||||
CONF_CONTINUOUS, "continuous", cv.boolean, bool, False, _not_write
|
||||
),
|
||||
_CommandOption(
|
||||
CONF_ALLOW_BROADCAST_READ,
|
||||
"allow_broadcast_read",
|
||||
cv.boolean,
|
||||
bool,
|
||||
False,
|
||||
_not_broadcastable,
|
||||
requires_broadcast_address=True,
|
||||
),
|
||||
],
|
||||
"write": [
|
||||
_CommandOption(
|
||||
CONF_EXPECT_BROADCAST_WRITE_RESPONSE,
|
||||
"expect_broadcast_write_response",
|
||||
cv.boolean,
|
||||
bool,
|
||||
False,
|
||||
is_function_code_broadcastable,
|
||||
requires_broadcast_address=True,
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -82,32 +140,75 @@ def _command_options(direction: str) -> list[_CommandOption]:
|
||||
raise ValueError(f"unknown command-options direction {direction!r}") from None
|
||||
|
||||
|
||||
# The write (mutating) function codes, matching modbus::helpers::is_function_code_write(). 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 broadcast_only_option_keys() -> list[str]:
|
||||
return [
|
||||
option.conf_key
|
||||
for options in _COMMAND_OPTIONS.values()
|
||||
for option in options
|
||||
if option.requires_broadcast_address
|
||||
]
|
||||
|
||||
|
||||
def is_function_code_write(function_code: int) -> bool:
|
||||
"""True if the Modbus function code writes (mutates). The exception bit (0x80) is masked off first,
|
||||
so an exception-flagged code still classifies by its base code (the runtime hub never queues one:
|
||||
queue_pdu() refuses them). Keep in sync with modbus::helpers::is_function_code_write()."""
|
||||
return function_code & 0x7F in _WRITE_FUNCTION_CODES
|
||||
def reject_broadcast_options_for_unicast(
|
||||
address_key: str,
|
||||
) -> Callable[[ConfigType], ConfigType]:
|
||||
"""Reject a broadcast-only option set true on a literal address other than 0."""
|
||||
|
||||
def validator(config: ConfigType) -> ConfigType:
|
||||
address = config.get(address_key)
|
||||
if not isinstance(address, int) or address == BROADCAST_ADDRESS:
|
||||
return config
|
||||
for key in broadcast_only_option_keys():
|
||||
if config.get(key) is True:
|
||||
raise cv.Invalid(
|
||||
f"'{key}' only applies to the broadcast address; set '{address_key}: 0' or "
|
||||
f"remove the option.",
|
||||
path=[key],
|
||||
)
|
||||
return config
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
def reject_inapplicable_command_options(
|
||||
pdu_key: str,
|
||||
) -> Callable[[ConfigType], ConfigType]:
|
||||
"""Reject an option set true that the hub would strip from a literal PDU's function code."""
|
||||
|
||||
def validator(config: ConfigType) -> ConfigType:
|
||||
pdu = config[pdu_key]
|
||||
if not isinstance(pdu, list):
|
||||
return config
|
||||
for direction in _COMMAND_OPTIONS:
|
||||
for option in _command_options(direction):
|
||||
if config.get(option.conf_key) is True and not option.applies_to(
|
||||
pdu[0]
|
||||
):
|
||||
raise cv.Invalid(
|
||||
f"'{option.conf_key}: true' does not apply to function code "
|
||||
f"0x{pdu[0]:02X}",
|
||||
path=[option.conf_key],
|
||||
)
|
||||
return config
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
def command_options_schema(
|
||||
*, direction: Literal["read", "write"], templatable: bool = False
|
||||
*,
|
||||
direction: Literal["read", "write"],
|
||||
templatable: bool = False,
|
||||
function_code: int | None = None,
|
||||
) -> 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().
|
||||
"""Schema fragment for the per-command options of one direction; `function_code` (a typed
|
||||
action's fixed code) leaves out the options that do not apply to it.
|
||||
"""
|
||||
return {
|
||||
cv.Optional(option.conf_key, default=option.default): (
|
||||
cv.templatable(option.validator) if templatable else option.validator
|
||||
)
|
||||
for option in _command_options(direction)
|
||||
if function_code is None or option.applies_to(function_code)
|
||||
}
|
||||
|
||||
|
||||
@@ -130,6 +231,25 @@ def command_options_expression(
|
||||
)
|
||||
|
||||
|
||||
def add_command_options(
|
||||
var: MockObj,
|
||||
setter: str,
|
||||
config: ConfigType,
|
||||
*,
|
||||
direction: Literal["read", "write"],
|
||||
) -> None:
|
||||
"""Emit `var.<setter>(<options>)` for a config validated with command_options_schema() of the
|
||||
same direction, skipped when every option is at its C++ default."""
|
||||
if all(
|
||||
config.get(option.conf_key, option.default) == option.default
|
||||
for option in _command_options(direction)
|
||||
):
|
||||
return
|
||||
cg.add(
|
||||
getattr(var, setter)(command_options_expression(config, direction=direction))
|
||||
)
|
||||
|
||||
|
||||
async def register_templatable_command_options(
|
||||
var: MockObj, config: ConfigType, args: TemplateArgsType, direction: str
|
||||
) -> None:
|
||||
|
||||
@@ -832,7 +832,7 @@ void ModbusClientHub::send_next_frame_() {
|
||||
}
|
||||
|
||||
cmd->sent();
|
||||
if (cmd->frame.address() == BROADCAST_ADDRESS) {
|
||||
if (cmd->fire_and_forget()) {
|
||||
// A broadcast (address 0) is never answered (Modbus 4.1), so it is fire-and-forget: on_sent above
|
||||
// reports the transmission, and the entry then retires with no terminal callback instead of
|
||||
// occupying the waiting slot until the send-wait timeout expires. The turnaround delay already
|
||||
@@ -1074,11 +1074,6 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span<const uint8_t> pdu, M
|
||||
return false;
|
||||
}
|
||||
|
||||
if (address == BROADCAST_ADDRESS && !helpers::is_function_code_broadcastable(pdu[0])) {
|
||||
ESP_LOGW(TAG, "Broadcast refused for function 0x%X: a broadcast (address 0) is never answered", pdu[0]);
|
||||
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).
|
||||
@@ -1086,6 +1081,24 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span<const uint8_t> pdu, M
|
||||
ESP_LOGW(TAG, "continuous is ignored for a mutating function (0x%X, address %" PRIu8 ")", pdu[0], address);
|
||||
options.continuous = false;
|
||||
}
|
||||
if (address != BROADCAST_ADDRESS) {
|
||||
options.allow_broadcast_read = false;
|
||||
options.expect_broadcast_write_response = false;
|
||||
} else {
|
||||
const bool broadcastable = helpers::is_function_code_broadcastable(pdu[0]);
|
||||
if (options.allow_broadcast_read && broadcastable) {
|
||||
ESP_LOGV(TAG, "allow_broadcast_read is ignored for function 0x%X: it is broadcastable", pdu[0]);
|
||||
options.allow_broadcast_read = false;
|
||||
}
|
||||
if (options.expect_broadcast_write_response && !broadcastable) {
|
||||
ESP_LOGV(TAG, "expect_broadcast_write_response is ignored for function 0x%X: it is not broadcastable", pdu[0]);
|
||||
options.expect_broadcast_write_response = false;
|
||||
}
|
||||
if (!broadcastable && !options.allow_broadcast_read) {
|
||||
ESP_LOGW(TAG, "Broadcast refused for function 0x%X: a broadcast (address 0) is never answered", pdu[0]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// A duplicate of a live entry with the same owner is not queued twice; it resolves against that
|
||||
// entry: anonymous -> dropped; continuous incoming -> convert the entry to a poll; one-shot onto a
|
||||
@@ -1126,6 +1139,7 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span<const uint8_t> pdu, M
|
||||
ESP_LOGV(TAG, "Frame already active for %" PRIu8 ", request absorbed (pending %" PRIu8 ")", address,
|
||||
item.pending);
|
||||
}
|
||||
item.options.expect_broadcast_write_response |= options.expect_broadcast_write_response;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -111,11 +111,15 @@ enum class FrameState : uint8_t {
|
||||
// Per-command send options. Append-only; pass via designated initializers ({.continuous = true}).
|
||||
// A new field reaches the queue with no plumbing but arrives inert until it defines three rules:
|
||||
// normalization in queue_pdu(), a merge rule for duplicate absorption, and teardown in
|
||||
// retire()/silent_retire().
|
||||
// retire()/silent_retire(). Bit-packed: stored per entry, controller and writer entity, passed by value.
|
||||
struct CommandOptions {
|
||||
// A continuous poll lives in the queue until cancelled or failed; ignored for mutating codes.
|
||||
bool continuous{false};
|
||||
bool continuous : 1 {false};
|
||||
// Wait for the reply to a read sent to address 0, for a device that answers the broadcast address.
|
||||
bool allow_broadcast_read : 1 {false};
|
||||
bool expect_broadcast_write_response : 1 {false};
|
||||
};
|
||||
static_assert(sizeof(CommandOptions) == 1, "CommandOptions must stay one byte");
|
||||
|
||||
struct ModbusDeviceCommand {
|
||||
ModbusClientDevice *device;
|
||||
@@ -158,6 +162,10 @@ struct ModbusDeviceCommand {
|
||||
this->pending = 0;
|
||||
this->device = nullptr;
|
||||
}
|
||||
bool fire_and_forget() const {
|
||||
return this->frame.address() == BROADCAST_ADDRESS && !this->options.allow_broadcast_read &&
|
||||
!this->options.expect_broadcast_write_response;
|
||||
}
|
||||
// Fire-and-forget completion for a broadcast (address 0): the frame was transmitted (on_sent already
|
||||
// fired), but a broadcast is never answered (Modbus 4.1), so the entry retires with no terminal callback.
|
||||
void complete_broadcast() {
|
||||
@@ -191,7 +199,8 @@ struct ModbusDeviceCommand {
|
||||
} else if (!this->waiting_state()) { // an already-retired shell stays put; off the wire -> RETIRED
|
||||
this->state = FrameState::RETIRED;
|
||||
}
|
||||
this->options = {}; // reset every option
|
||||
// Only continuous ends with the clear; the delivery flags must survive for a granted retry.
|
||||
this->options.continuous = false;
|
||||
}
|
||||
|
||||
// True while the entry is still waiting for a response
|
||||
@@ -534,27 +543,27 @@ class ModbusClientDevice {
|
||||
return this->queue_pdu(
|
||||
helpers::create_read_pdu(FunctionCode::READ_DISCRETE_INPUTS, start_address, number_of_inputs), options);
|
||||
}
|
||||
bool write_single_register(uint16_t start_address, uint16_t value) {
|
||||
return this->queue_pdu(helpers::create_write_single_register_pdu(start_address, value));
|
||||
bool write_single_register(uint16_t start_address, uint16_t value, CommandOptions options = {}) {
|
||||
return this->queue_pdu(helpers::create_write_single_register_pdu(start_address, value), options);
|
||||
}
|
||||
bool write_single_coil(uint16_t address, bool value) {
|
||||
return this->queue_pdu(helpers::create_write_single_coil_pdu(address, value));
|
||||
bool write_single_coil(uint16_t address, bool value, CommandOptions options = {}) {
|
||||
return this->queue_pdu(helpers::create_write_single_coil_pdu(address, value), options);
|
||||
}
|
||||
bool write_multiple_registers(uint16_t start_address, std::span<const uint16_t> values) {
|
||||
bool write_multiple_registers(uint16_t start_address, std::span<const uint16_t> values, CommandOptions options = {}) {
|
||||
// Empty goes to the full-size builder so the rejection log names this method's limit, not the small one's.
|
||||
if (!values.empty() && values.size() <= helpers::MAX_FEW_REGISTERS)
|
||||
return this->queue_pdu(helpers::create_write_few_registers_pdu(start_address, values));
|
||||
return this->queue_pdu(helpers::create_write_registers_pdu(start_address, values));
|
||||
return this->queue_pdu(helpers::create_write_few_registers_pdu(start_address, values), options);
|
||||
return this->queue_pdu(helpers::create_write_registers_pdu(start_address, values), options);
|
||||
}
|
||||
/// Note: std::vector<bool> cannot bind to std::span<const bool>; use a contiguous bool container or the packed
|
||||
/// overload.
|
||||
bool write_multiple_coils(uint16_t start_address, std::span<const bool> values) {
|
||||
return this->queue_pdu(helpers::create_write_coils_pdu(start_address, values));
|
||||
bool write_multiple_coils(uint16_t start_address, std::span<const bool> values, CommandOptions options = {}) {
|
||||
return this->queue_pdu(helpers::create_write_coils_pdu(start_address, values), options);
|
||||
}
|
||||
/// Packed variant: a PackedBits view (the same layout on_read_coils() delivers), so
|
||||
/// read-modify-write needs no unpack/repack.
|
||||
bool write_multiple_coils(uint16_t start_address, PackedBits bits) {
|
||||
return this->queue_pdu(helpers::create_write_coils_pdu(start_address, bits));
|
||||
bool write_multiple_coils(uint16_t start_address, PackedBits bits, CommandOptions options = {}) {
|
||||
return this->queue_pdu(helpers::create_write_coils_pdu(start_address, bits), options);
|
||||
}
|
||||
/// FC 0x17: the read-back is delivered through on_read_holding_registers(), and a device exception
|
||||
/// (typically a rejected write half) arrives there too via its status - one callback handles both
|
||||
|
||||
@@ -7,7 +7,6 @@ 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,
|
||||
@@ -158,24 +157,6 @@ _ACTION_BASE_SCHEMA = cv.Schema(
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
and modbus.is_function_code_write(pdu[0])
|
||||
):
|
||||
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(
|
||||
{
|
||||
@@ -186,10 +167,12 @@ MODBUS_CLIENT_SEND_SCHEMA = cv.All(
|
||||
)
|
||||
),
|
||||
**modbus.command_options_schema(direction="read", templatable=True),
|
||||
**modbus.command_options_schema(direction="write", templatable=True),
|
||||
cv.Optional(CONF_ON_RESPONSE): _handler_schema(),
|
||||
}
|
||||
),
|
||||
_no_continuous_on_write,
|
||||
modbus.reject_inapplicable_command_options(CONF_PDU),
|
||||
modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS),
|
||||
)
|
||||
|
||||
|
||||
@@ -261,8 +244,7 @@ async def register_client_action(
|
||||
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.
|
||||
# matching direction so a write action never generates a read option's setter.
|
||||
await modbus.register_templatable_command_options(
|
||||
var, config, args, command_direction
|
||||
)
|
||||
@@ -279,6 +261,8 @@ 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_))
|
||||
# The read set is wired by register_client_action() below.
|
||||
await modbus.register_templatable_command_options(var, config, args, "write")
|
||||
return await register_client_action(
|
||||
var,
|
||||
config,
|
||||
@@ -353,6 +337,7 @@ def _read_schema(max_count: int) -> cv.All:
|
||||
}
|
||||
),
|
||||
_no_address_overflow(CONF_COUNT),
|
||||
modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS),
|
||||
)
|
||||
|
||||
|
||||
@@ -364,21 +349,35 @@ def _write_multiple_schema(item: Callable[[Any], Any], max_values: int) -> cv.Al
|
||||
cv.Required(CONF_VALUES): cv.templatable(
|
||||
cv.All(cv.ensure_list(item), cv.Length(min=1, max=max_values))
|
||||
),
|
||||
**modbus.command_options_schema(direction="write", templatable=True),
|
||||
}
|
||||
),
|
||||
_no_address_overflow(CONF_VALUES),
|
||||
modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS),
|
||||
)
|
||||
|
||||
|
||||
_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)}
|
||||
_WRITE_SINGLE_REGISTER_SCHEMA = cv.All(
|
||||
_TYPED_ACTION_SCHEMA.extend(
|
||||
{
|
||||
cv.Required(CONF_VALUE): cv.templatable(cv.hex_uint16_t),
|
||||
**modbus.command_options_schema(direction="write", templatable=True),
|
||||
}
|
||||
),
|
||||
modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS),
|
||||
)
|
||||
|
||||
# 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)}
|
||||
_WRITE_SINGLE_COIL_SCHEMA = cv.All(
|
||||
_TYPED_ACTION_SCHEMA.extend(
|
||||
{
|
||||
cv.Required(CONF_VALUE): cv.templatable(cv.boolean),
|
||||
**modbus.command_options_schema(direction="write", templatable=True),
|
||||
}
|
||||
),
|
||||
modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS),
|
||||
)
|
||||
|
||||
|
||||
@@ -542,10 +541,15 @@ _READ_WRITE_MULTIPLE_REGISTERS_SCHEMA = cv.All(
|
||||
cv.Length(min=1, max=modbus.MAX_NUM_OF_REGISTERS_TO_WRITE_RW),
|
||||
)
|
||||
),
|
||||
# 0x17 counts as a read at address 0, so it takes allow_broadcast_read only.
|
||||
**modbus.command_options_schema(
|
||||
direction="read", templatable=True, function_code=0x17
|
||||
),
|
||||
}
|
||||
),
|
||||
_no_address_overflow(CONF_READ_COUNT, CONF_READ_ADDRESS),
|
||||
_no_address_overflow(CONF_VALUES, CONF_WRITE_ADDRESS),
|
||||
modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -85,18 +85,36 @@ template<typename... Ts> class ClientActionBase : public Action<Ts...>, public m
|
||||
/// 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.
|
||||
/// (command_options_schema(direction="read") adds exactly these keys); WriteCommandOptions is the twin.
|
||||
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)
|
||||
TEMPLATABLE_VALUE(bool, allow_broadcast_read)
|
||||
|
||||
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...)};
|
||||
return {.continuous = this->continuous_.value(x...),
|
||||
.allow_broadcast_read = this->allow_broadcast_read_.value(x...)};
|
||||
}
|
||||
};
|
||||
|
||||
/// The write-side per-command options (command_options_schema(direction="write") adds exactly these keys).
|
||||
template<typename... Ts> class WriteCommandOptions {
|
||||
public:
|
||||
TEMPLATABLE_VALUE(bool, expect_broadcast_write_response)
|
||||
|
||||
protected:
|
||||
/// Resolves every write option into `options`, so send's merge of both sets stays exhaustive.
|
||||
void apply_write_command_options_(modbus::CommandOptions &options, const Ts &...x) const {
|
||||
options.expect_broadcast_write_response = this->expect_broadcast_write_response_.value(x...);
|
||||
}
|
||||
modbus::CommandOptions write_command_options_(const Ts &...x) const {
|
||||
modbus::CommandOptions options{};
|
||||
this->apply_write_command_options_(options, x...);
|
||||
return options;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -107,8 +125,11 @@ template<typename... Ts> class ReadCommandOptions {
|
||||
/// 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.
|
||||
/// A raw PDU may be a read or a write, so this action carries both option sets.
|
||||
template<typename... Ts>
|
||||
class ModbusClientSendAction : public ClientActionBase<Ts...>, public ReadCommandOptions<Ts...> {
|
||||
class ModbusClientSendAction : public ClientActionBase<Ts...>,
|
||||
public ReadCommandOptions<Ts...>,
|
||||
public WriteCommandOptions<Ts...> {
|
||||
public:
|
||||
TEMPLATABLE_VALUE(modbus::helpers::PduBuffer, pdu)
|
||||
|
||||
@@ -116,7 +137,11 @@ class ModbusClientSendAction : public ClientActionBase<Ts...>, public ReadComman
|
||||
return &this->response_trigger_;
|
||||
}
|
||||
|
||||
void play(const Ts &...x) override { this->send_or_resolve_(this->pdu_.value(x...), this->command_options_(x...)); }
|
||||
void play(const Ts &...x) override {
|
||||
modbus::CommandOptions options = this->command_options_(x...);
|
||||
this->apply_write_command_options_(options, x...);
|
||||
this->send_or_resolve_(this->pdu_.value(x...), options);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -218,7 +243,8 @@ template<typename... Ts> class ReadBitsAction : public TypedClientActionBase<Ts.
|
||||
|
||||
/// 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...> {
|
||||
template<typename... Ts>
|
||||
class WriteSingleRegisterAction : public TypedClientActionBase<Ts...>, public WriteCommandOptions<Ts...> {
|
||||
public:
|
||||
TEMPLATABLE_VALUE(uint16_t, start_address)
|
||||
TEMPLATABLE_VALUE(uint16_t, value)
|
||||
@@ -227,7 +253,8 @@ template<typename... Ts> class WriteSingleRegisterAction : public TypedClientAct
|
||||
|
||||
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...)));
|
||||
modbus::helpers::create_write_single_register_pdu(this->start_address_.value(x...), this->value_.value(x...)),
|
||||
this->write_command_options_(x...));
|
||||
}
|
||||
void on_write_single_register(uint16_t address, uint16_t value, modbus::ResponseStatus status) override {
|
||||
if (modbus::succeeded(status))
|
||||
@@ -240,7 +267,8 @@ template<typename... Ts> class WriteSingleRegisterAction : public TypedClientAct
|
||||
|
||||
/// 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...> {
|
||||
template<typename... Ts>
|
||||
class WriteSingleCoilAction : public TypedClientActionBase<Ts...>, public WriteCommandOptions<Ts...> {
|
||||
public:
|
||||
TEMPLATABLE_VALUE(uint16_t, start_address)
|
||||
TEMPLATABLE_VALUE(bool, value)
|
||||
@@ -249,7 +277,8 @@ template<typename... Ts> class WriteSingleCoilAction : public TypedClientActionB
|
||||
|
||||
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...)));
|
||||
modbus::helpers::create_write_single_coil_pdu(this->start_address_.value(x...), this->value_.value(x...)),
|
||||
this->write_command_options_(x...));
|
||||
}
|
||||
void on_write_single_coil(uint16_t address, bool value, modbus::ResponseStatus status) override {
|
||||
if (modbus::succeeded(status))
|
||||
@@ -264,7 +293,8 @@ template<typename... Ts> class WriteSingleCoilAction : public TypedClientActionB
|
||||
/// 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...> {
|
||||
template<typename... Ts>
|
||||
class WriteMultipleRegistersAction : public TypedClientActionBase<Ts...>, public WriteCommandOptions<Ts...> {
|
||||
public:
|
||||
TEMPLATABLE_VALUE(uint16_t, start_address)
|
||||
|
||||
@@ -288,11 +318,13 @@ template<typename... Ts> class WriteMultipleRegistersAction : public TypedClient
|
||||
// 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_))));
|
||||
start, std::span<const uint16_t>(this->values_.data, static_cast<size_t>(this->len_))),
|
||||
this->write_command_options_(x...));
|
||||
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)));
|
||||
this->send_or_resolve_(modbus::helpers::create_write_registers_pdu(start, std::span<const uint16_t>(values)),
|
||||
this->write_command_options_(x...));
|
||||
}
|
||||
void on_write_multiple_registers(uint16_t start_address, std::span<const uint16_t> registers,
|
||||
modbus::ResponseStatus status) override {
|
||||
@@ -313,7 +345,8 @@ template<typename... Ts> class WriteMultipleRegistersAction : public TypedClient
|
||||
/// 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...> {
|
||||
template<typename... Ts>
|
||||
class WriteMultipleCoilsAction : public TypedClientActionBase<Ts...>, public WriteCommandOptions<Ts...> {
|
||||
public:
|
||||
TEMPLATABLE_VALUE(uint16_t, start_address)
|
||||
|
||||
@@ -334,13 +367,16 @@ template<typename... Ts> class WriteMultipleCoilsAction : public TypedClientActi
|
||||
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)));
|
||||
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)),
|
||||
this->write_command_options_(x...));
|
||||
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...)));
|
||||
this->send_or_resolve_(modbus::helpers::create_write_coils_pdu(start, this->values_.func(x...)),
|
||||
this->write_command_options_(x...));
|
||||
}
|
||||
void on_write_multiple_coils(uint16_t start_address, modbus::PackedBits bits,
|
||||
modbus::ResponseStatus status) override {
|
||||
@@ -359,7 +395,8 @@ template<typename... Ts> class WriteMultipleCoilsAction : public TypedClientActi
|
||||
|
||||
/// modbus_client.read_write_multiple_registers (FC 0x17): writes one register block and reads another back in
|
||||
/// one transaction (write first, per Modbus 6.17). on_response delivers the read-back words as `values`.
|
||||
template<typename... Ts> class ReadWriteMultipleRegistersAction : public TypedClientActionBase<Ts...> {
|
||||
template<typename... Ts>
|
||||
class ReadWriteMultipleRegistersAction : public TypedClientActionBase<Ts...>, public ReadCommandOptions<Ts...> {
|
||||
public:
|
||||
TEMPLATABLE_VALUE(uint16_t, read_address)
|
||||
TEMPLATABLE_VALUE(uint16_t, read_count)
|
||||
@@ -385,13 +422,15 @@ template<typename... Ts> class ReadWriteMultipleRegistersAction : public TypedCl
|
||||
// An out-of-range read/write count builds an empty PDU (the builder logs why), resolving via on_not_sent.
|
||||
if (this->len_ >= 0) {
|
||||
this->send_or_resolve_(modbus::helpers::create_read_write_multiple_registers_pdu(
|
||||
read_start, read_count, write_start,
|
||||
std::span<const uint16_t>(this->values_.data, static_cast<size_t>(this->len_))));
|
||||
read_start, read_count, write_start,
|
||||
std::span<const uint16_t>(this->values_.data, static_cast<size_t>(this->len_))),
|
||||
this->command_options_(x...));
|
||||
return;
|
||||
}
|
||||
const std::vector<uint16_t> values = this->values_.func(x...);
|
||||
this->send_or_resolve_(modbus::helpers::create_read_write_multiple_registers_pdu(
|
||||
read_start, read_count, write_start, std::span<const uint16_t>(values)));
|
||||
read_start, read_count, write_start, std::span<const uint16_t>(values)),
|
||||
this->command_options_(x...));
|
||||
}
|
||||
// The 0x17 response carries only the read block, so the hub dispatch delivers it as a holding-register read.
|
||||
void on_read_registers(modbus::EntityType entity_type, uint16_t start_address, std::span<const uint16_t> registers,
|
||||
|
||||
@@ -103,12 +103,20 @@ def _warn_removed_options(config: ConfigType) -> ConfigType:
|
||||
|
||||
|
||||
def _reject_broadcast_address(config: ConfigType) -> ConfigType:
|
||||
"""A modbus_controller polls one device, so its address cannot be the broadcast address (0):
|
||||
a broadcast is never answered (Modbus 4.1), so no register could ever read back."""
|
||||
"""Address 0 is rejected unless allow_broadcast_read, which in turn requires address 0."""
|
||||
if config[modbus.CONF_ALLOW_BROADCAST_READ]:
|
||||
if config.get(CONF_ADDRESS) != modbus.BROADCAST_ADDRESS:
|
||||
raise cv.Invalid(
|
||||
f"'{modbus.CONF_ALLOW_BROADCAST_READ}' only applies to the broadcast address; "
|
||||
f"set 'address: 0' or remove the option.",
|
||||
[modbus.CONF_ALLOW_BROADCAST_READ],
|
||||
)
|
||||
return config
|
||||
modbus.reject_broadcast_address(
|
||||
config.get(CONF_ADDRESS),
|
||||
"a modbus_controller device address",
|
||||
"Assign the unit address of the device you want to poll.",
|
||||
"Assign the unit address of the device you want to poll, or set allow_broadcast_read if "
|
||||
"it answers address 0.",
|
||||
[CONF_ADDRESS],
|
||||
)
|
||||
return config
|
||||
@@ -346,12 +354,52 @@ def _reject_continuous_write_custom_pdu(config: ConfigType) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _reject_broadcastable_custom_pdu(config: ConfigType) -> None:
|
||||
"""A broadcastable custom_pdu under an address-0 controller is a real broadcast, never answered."""
|
||||
pdu = config.get(CONF_CUSTOM_PDU)
|
||||
if pdu is None or not modbus.is_function_code_broadcastable(pdu[0]):
|
||||
return
|
||||
fconf = fv.full_config.get()
|
||||
path = fconf.get_path_for_id(config[CONF_MODBUS_CONTROLLER_ID])[:-1]
|
||||
controller = fconf.get_config_for_path(path)
|
||||
if (
|
||||
controller.get(CONF_ADDRESS) == modbus.BROADCAST_ADDRESS
|
||||
and controller.get(modbus.CONF_ALLOW_BROADCAST_READ) is True
|
||||
):
|
||||
raise cv.Invalid(
|
||||
f"a '{CONF_CUSTOM_PDU}' with function code 0x{pdu[0] & 0x7F:02X} is a real broadcast at "
|
||||
f"address 0 and is never answered, so it can't be polled through the "
|
||||
f"'{controller[CONF_ID]}' modbus_controller; use a read function code.",
|
||||
[CONF_CUSTOM_PDU],
|
||||
)
|
||||
|
||||
|
||||
def validate_custom_pdu_item(config: ConfigType) -> None:
|
||||
"""Final-validate for the read platforms that accept custom_pdu (sensor, binary_sensor,
|
||||
text_sensor): migrate the deprecated custom_command, then reject a write-coded custom_pdu under a
|
||||
continuously-polling controller."""
|
||||
"""Final-validate for the platforms that accept custom_pdu."""
|
||||
migrate_custom_command(config)
|
||||
_reject_continuous_write_custom_pdu(config)
|
||||
_reject_broadcastable_custom_pdu(config)
|
||||
|
||||
|
||||
def _reject_write_option_off_broadcast(config: ConfigType) -> None:
|
||||
if not any(config.get(key) is True for key in modbus.broadcast_only_option_keys()):
|
||||
return
|
||||
fconf = fv.full_config.get()
|
||||
path = fconf.get_path_for_id(config[CONF_MODBUS_CONTROLLER_ID])[:-1]
|
||||
controller = fconf.get_config_for_path(path)
|
||||
if controller.get(CONF_ADDRESS) != modbus.BROADCAST_ADDRESS:
|
||||
raise cv.Invalid(
|
||||
f"'{modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE}' only applies when the "
|
||||
f"'{controller[CONF_ID]}' modbus_controller is at address 0; remove the option.",
|
||||
[modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE],
|
||||
)
|
||||
|
||||
|
||||
def validate_writer_item(config: ConfigType) -> None:
|
||||
"""Final-validate for the writer platforms (number, output, select, switch)."""
|
||||
if CONF_CUSTOM_PDU in config or CONF_CUSTOM_COMMAND in config:
|
||||
validate_custom_pdu_item(config)
|
||||
_reject_write_option_off_broadcast(config)
|
||||
|
||||
|
||||
def _final_validate(config: ConfigType) -> None:
|
||||
@@ -448,11 +496,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
await cg.register_component(var, config)
|
||||
cg.add(var.set_max_cmd_retries(config[CONF_MAX_CMD_RETRIES]))
|
||||
cg.add(var.set_offline_skip_updates(config[CONF_OFFLINE_SKIP_UPDATES]))
|
||||
cg.add(
|
||||
var.set_read_options(
|
||||
modbus.command_options_expression(config, direction="read")
|
||||
)
|
||||
)
|
||||
modbus.add_command_options(var, "set_read_options", config, direction="read")
|
||||
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
|
||||
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ void WriterDevice::warn_write_buffer_deprecated(const LogString *platform, uint1
|
||||
bool WriterDevice::send_raw_frame_deprecated(std::span<const uint8_t> frame) {
|
||||
if (frame.empty())
|
||||
return false;
|
||||
return this->parent_->queue_pdu(frame[0], frame.subspan(1), this);
|
||||
return this->parent_->queue_pdu(frame[0], frame.subspan(1), this, this->write_options_);
|
||||
}
|
||||
|
||||
void ControllerDevice::set_controller(ModbusController *controller) {
|
||||
@@ -234,10 +234,13 @@ void ModbusCommandItem::on_sent(std::span<const uint8_t> request_pdu) {
|
||||
// (frame[0]), which may differ from this controller's. (unqueue_command() is a no-op for a poll.)
|
||||
// A custom polling command sends its PDU to this controller's own address, so only a factory custom
|
||||
// command (a raw frame staged in payload) can carry a different address byte.
|
||||
// An address-0 read with allow_broadcast_read is answered, so it keeps its terminal callback.
|
||||
uint8_t wire_address = this->address_;
|
||||
if (this->function_code_ == FunctionCode::CUSTOM && !this->payload.empty())
|
||||
wire_address = this->payload.data()[0];
|
||||
if (wire_address == modbus::BROADCAST_ADDRESS)
|
||||
const bool answered = this->controller_->read_options().allow_broadcast_read &&
|
||||
!modbus::helpers::is_function_code_broadcastable(request_pdu[0]);
|
||||
if (wire_address == modbus::BROADCAST_ADDRESS && !answered)
|
||||
this->controller_->unqueue_command(this);
|
||||
}
|
||||
|
||||
@@ -285,8 +288,8 @@ void ModbusController::queue_command(ModbusCommandItem command) {
|
||||
this->one_shot_command_items_.push_back(make_unique<ModbusCommandItem>(std::move(command)));
|
||||
// A refused frame gets no terminal callback (see the hub contract), so reclaim the item here.
|
||||
auto &item = this->one_shot_command_items_.back();
|
||||
// We intentionally do not pass read_options_ here, because one-shot commands are usually writes, and are non-polling.
|
||||
if (!item->send()) {
|
||||
// One-shots never poll, so only the broadcast flag is passed (the hub strips it from writes).
|
||||
if (!item->send({.allow_broadcast_read = this->read_options_.allow_broadcast_read})) {
|
||||
// The caller (e.g. a write entity) has usually already published optimistically - surface the loss.
|
||||
ESP_LOGW(TAG, "Command refused by hub: type=0x%X address=0x%X", static_cast<uint8_t>(item->register_type()),
|
||||
item->register_address());
|
||||
@@ -340,7 +343,7 @@ void ModbusController::update() {
|
||||
if (this->can_send()) {
|
||||
for (auto &poll : this->polling_devices_) {
|
||||
ESP_LOGVV(TAG, "Updating range 0x%X", poll.register_address());
|
||||
// read_options_ carries the controller's continuous flag (the offline probe above sends it too).
|
||||
// read_options_ carries the controller's read-side flags (the offline probe above sends them too).
|
||||
// A refusal is already logged by the hub; note the affected range for controller-level diagnostics.
|
||||
if (!poll.queue(this->read_options_)) {
|
||||
ESP_LOGD(TAG, "Poll refused by hub for range 0x%X", poll.register_address());
|
||||
|
||||
@@ -280,10 +280,11 @@ class ControllerDevice : protected modbus::ModbusClientDevice {
|
||||
|
||||
void notify_online_(std::span<const uint8_t> request_pdu);
|
||||
|
||||
/// Write-path state owned by WriterEntity's forwarders, stored here so both bools land in the base's
|
||||
/// tail padding instead of adding a word to every writer entity. The warn flag leaves in 2027.3.0.
|
||||
bool dispatched_{false};
|
||||
bool write_buffer_deprecated_warned_{false};
|
||||
/// Write-path state for WriterEntity's forwarders, packed into the base's tail padding. The warn flag
|
||||
/// leaves in 2027.3.0.
|
||||
bool dispatched_ : 1 {false};
|
||||
bool write_buffer_deprecated_warned_ : 1 {false};
|
||||
modbus::CommandOptions write_options_{};
|
||||
ModbusController *controller_{nullptr};
|
||||
};
|
||||
|
||||
@@ -305,6 +306,8 @@ class WriterDevice final : public ControllerDevice {
|
||||
bool dispatched() const { return this->dispatched_; }
|
||||
void set_dispatched() { this->dispatched_ = true; }
|
||||
void clear_dispatched() { this->dispatched_ = false; }
|
||||
modbus::CommandOptions write_options() const { return this->write_options_; }
|
||||
void set_write_options(modbus::CommandOptions options) { this->write_options_ = options; }
|
||||
/// Warn once per entity that filling the write_lambda buffer parameter is deprecated (the entity is now the
|
||||
/// command - call a write helper / queue_pdu() on `item` instead). The buffer parameter is removed in 2027.3.0.
|
||||
void warn_write_buffer_deprecated(const LogString *platform, uint16_t address);
|
||||
@@ -326,27 +329,29 @@ class WriterEntity {
|
||||
/// Whether the lambda called a request helper since the last clear_dispatched_(). Deliberately records
|
||||
/// the call, not the hub's accept/refuse: a refused lambda write must not fall through to the default write.
|
||||
bool dispatched() const { return this->device_.dispatched(); }
|
||||
void set_write_options(modbus::CommandOptions options) { this->device_.set_write_options(options); }
|
||||
bool write_single_register(uint16_t address, uint16_t value) {
|
||||
this->device_.set_dispatched();
|
||||
return this->device_.write_single_register(address, value);
|
||||
return this->device_.write_single_register(address, value, this->device_.write_options());
|
||||
}
|
||||
bool write_single_coil(uint16_t address, bool value) {
|
||||
this->device_.set_dispatched();
|
||||
return this->device_.write_single_coil(address, value);
|
||||
return this->device_.write_single_coil(address, value, this->device_.write_options());
|
||||
}
|
||||
bool write_multiple_registers(uint16_t address, std::span<const uint16_t> values) {
|
||||
this->device_.set_dispatched();
|
||||
return this->device_.write_multiple_registers(address, values);
|
||||
return this->device_.write_multiple_registers(address, values, this->device_.write_options());
|
||||
}
|
||||
bool write_multiple_coils(uint16_t address, std::span<const bool> values) {
|
||||
this->device_.set_dispatched();
|
||||
return this->device_.write_multiple_coils(address, values);
|
||||
return this->device_.write_multiple_coils(address, values, this->device_.write_options());
|
||||
}
|
||||
bool write_multiple_coils(uint16_t address, modbus::PackedBits bits) {
|
||||
this->device_.set_dispatched();
|
||||
return this->device_.write_multiple_coils(address, bits);
|
||||
return this->device_.write_multiple_coils(address, bits, this->device_.write_options());
|
||||
}
|
||||
bool queue_pdu(std::span<const uint8_t> pdu, modbus::CommandOptions options = {}) {
|
||||
bool queue_pdu(std::span<const uint8_t> pdu) { return this->queue_pdu(pdu, this->device_.write_options()); }
|
||||
bool queue_pdu(std::span<const uint8_t> pdu, modbus::CommandOptions options) {
|
||||
this->device_.set_dispatched();
|
||||
return this->device_.queue_pdu(pdu, options);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import number
|
||||
from esphome.components import modbus, number
|
||||
from esphome.components.modbus.helpers import (
|
||||
MODBUS_WRITE_REGISTER_TYPE,
|
||||
SENSOR_VALUE_TYPE,
|
||||
@@ -23,8 +23,8 @@ from .. import (
|
||||
add_modbus_base_properties,
|
||||
modbus_calc_properties,
|
||||
modbus_controller_ns,
|
||||
validate_custom_pdu_item,
|
||||
validate_range_reuse_migration,
|
||||
validate_writer_item,
|
||||
)
|
||||
from ..const import (
|
||||
CONF_BITMASK,
|
||||
@@ -84,6 +84,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Optional(CONF_STEP, default=1): cv.positive_float,
|
||||
cv.Optional(CONF_MULTIPLY, default=1.0): cv.float_,
|
||||
cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean,
|
||||
**modbus.command_options_schema(direction="write"),
|
||||
}
|
||||
),
|
||||
validate_min_max,
|
||||
@@ -91,7 +92,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
validate_range_reuse_migration,
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item
|
||||
FINAL_VALIDATE_SCHEMA = validate_writer_item
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
@@ -122,6 +123,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.add(parent.add_sensor_item(var))
|
||||
await add_modbus_base_properties(var, config, ModbusNumber)
|
||||
cg.add(var.set_use_write_mutiple(config[CONF_USE_WRITE_MULTIPLE]))
|
||||
modbus.add_command_options(var, "set_write_options", config, direction="write")
|
||||
if CONF_WRITE_LAMBDA in config:
|
||||
template_ = await cg.process_lambda(
|
||||
config[CONF_WRITE_LAMBDA],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import output
|
||||
from esphome.components import modbus, output
|
||||
from esphome.components.modbus.helpers import (
|
||||
SENSOR_VALUE_TYPE,
|
||||
PduBuffer,
|
||||
@@ -18,6 +18,7 @@ from .. import (
|
||||
modbus_calc_properties,
|
||||
modbus_controller_ns,
|
||||
reject_odd_holding_write_offset,
|
||||
validate_writer_item,
|
||||
)
|
||||
from ..const import (
|
||||
CONF_CUSTOM_COMMAND,
|
||||
@@ -79,6 +80,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
),
|
||||
cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda,
|
||||
cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean,
|
||||
**modbus.command_options_schema(direction="write"),
|
||||
}
|
||||
),
|
||||
"holding": cv.All(
|
||||
@@ -98,6 +100,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda,
|
||||
cv.Optional(CONF_MULTIPLY, default=1.0): cv.float_,
|
||||
cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean,
|
||||
**modbus.command_options_schema(direction="write"),
|
||||
}
|
||||
),
|
||||
reject_odd_holding_write_offset,
|
||||
@@ -111,6 +114,9 @@ CONFIG_SCHEMA = cv.All(
|
||||
)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = validate_writer_item
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
byte_offset = modbus_calc_properties(config)
|
||||
# Binary Output
|
||||
@@ -153,6 +159,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
await output.register_output(var, config)
|
||||
parent = await cg.get_variable(config[CONF_MODBUS_CONTROLLER_ID])
|
||||
cg.add(var.set_use_write_mutiple(config[CONF_USE_WRITE_MULTIPLE]))
|
||||
modbus.add_command_options(var, "set_write_options", config, direction="write")
|
||||
cg.add(var.set_parent(parent))
|
||||
if write_template:
|
||||
cg.add(var.set_write_template(write_template))
|
||||
|
||||
@@ -2,7 +2,7 @@ from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import select
|
||||
from esphome.components import modbus, select
|
||||
from esphome.components.modbus.helpers import SENSOR_VALUE_TYPE, RegisterValues
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ADDRESS, CONF_ID, CONF_LAMBDA, CONF_OPTIMISTIC
|
||||
@@ -15,6 +15,7 @@ from .. import (
|
||||
modbus_controller_ns,
|
||||
validate_range_reuse_migration,
|
||||
validate_skip_updates_deprecated,
|
||||
validate_writer_item,
|
||||
)
|
||||
from ..const import (
|
||||
CONF_FORCE_NEW_RANGE,
|
||||
@@ -77,6 +78,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Optional(CONF_REGISTER_COUNT): cv.positive_int,
|
||||
cv.Required(CONF_OPTIONSMAP): ensure_option_map(),
|
||||
cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean,
|
||||
**modbus.command_options_schema(direction="write"),
|
||||
cv.Optional(CONF_OPTIMISTIC, default=False): cv.boolean,
|
||||
cv.Optional(CONF_LAMBDA): cv.returning_lambda,
|
||||
cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda,
|
||||
@@ -86,6 +88,9 @@ CONFIG_SCHEMA = cv.All(
|
||||
)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = validate_writer_item
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
options_map = config[CONF_OPTIONSMAP]
|
||||
|
||||
@@ -104,6 +109,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.add(parent.add_sensor_item(var))
|
||||
cg.add(var.set_parent(parent))
|
||||
cg.add(var.set_use_write_mutiple(config[CONF_USE_WRITE_MULTIPLE]))
|
||||
modbus.add_command_options(var, "set_write_options", config, direction="write")
|
||||
cg.add(var.set_optimistic(config[CONF_OPTIMISTIC]))
|
||||
|
||||
if CONF_LAMBDA in config:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import switch
|
||||
from esphome.components import modbus, switch
|
||||
from esphome.components.modbus.helpers import MODBUS_REGISTER_TYPE, PduBuffer
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ADDRESS, CONF_ASSUMED_STATE, CONF_ID
|
||||
@@ -13,9 +13,9 @@ from .. import (
|
||||
modbus_calc_properties,
|
||||
modbus_controller_ns,
|
||||
reject_odd_holding_write_offset,
|
||||
validate_custom_pdu_item,
|
||||
validate_modbus_register,
|
||||
validate_range_reuse_migration,
|
||||
validate_writer_item,
|
||||
)
|
||||
from ..const import (
|
||||
CONF_BITMASK,
|
||||
@@ -51,6 +51,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Optional(CONF_ASSUMED_STATE, default=False): cv.boolean,
|
||||
cv.Optional(CONF_REGISTER_TYPE): cv.enum(MODBUS_REGISTER_TYPE),
|
||||
cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean,
|
||||
**modbus.command_options_schema(direction="write"),
|
||||
cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda,
|
||||
}
|
||||
),
|
||||
@@ -59,7 +60,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
validate_range_reuse_migration,
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item
|
||||
FINAL_VALIDATE_SCHEMA = validate_writer_item
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
@@ -78,6 +79,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
paren = await cg.get_variable(config[CONF_MODBUS_CONTROLLER_ID])
|
||||
cg.add(var.set_parent(paren))
|
||||
cg.add(var.set_use_write_mutiple(config[CONF_USE_WRITE_MULTIPLE]))
|
||||
modbus.add_command_options(var, "set_write_options", config, direction="write")
|
||||
assumed_state = config[CONF_ASSUMED_STATE]
|
||||
cg.add(var.set_assumed_state(assumed_state))
|
||||
if not assumed_state:
|
||||
|
||||
@@ -33,7 +33,6 @@ def test_server_schema_rejects_address_zero() -> None:
|
||||
|
||||
|
||||
def test_client_schema_still_accepts_address_zero() -> None:
|
||||
# Not rejected for clients today, but not supported either: a client broadcast gets no reply and
|
||||
# stalls the hub for the full send-wait.
|
||||
# A client may address 0: writes are broadcast, and reads are allowed with allow_broadcast_read.
|
||||
schema = modbus.modbus_device_schema(0x01)
|
||||
assert schema({CONF_MODBUS_ID: "hub", CONF_ADDRESS: 0})[CONF_ADDRESS] == 0
|
||||
|
||||
@@ -7,7 +7,7 @@ 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 import modbus_client
|
||||
from esphome.components import modbus, modbus_client
|
||||
from esphome.components.modbus_client import (
|
||||
CONF_ON_NO_RESPONSE,
|
||||
CONF_ON_NOT_SENT,
|
||||
@@ -126,7 +126,7 @@ 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"):
|
||||
with pytest.raises(cv.Invalid, match="does not apply to function code"):
|
||||
MODBUS_CLIENT_SEND_SCHEMA(
|
||||
{
|
||||
CONF_ADDRESS: 0x01,
|
||||
@@ -185,3 +185,145 @@ def test_multi_conf_no_default_is_set() -> None:
|
||||
"""
|
||||
assert modbus_client.MULTI_CONF is True
|
||||
assert modbus_client.MULTI_CONF_NO_DEFAULT is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", [CONF_CONTINUOUS, modbus.CONF_ALLOW_BROADCAST_READ])
|
||||
def test_send_rejects_read_option_on_static_write_pdu(key: str) -> None:
|
||||
# A read option set true on a static write PDU is refused at validation, naming the key.
|
||||
config = {
|
||||
CONF_ADDRESS: 1,
|
||||
CONF_PDU: [0x06, 0x00, 0x10, 0x00, 0x01],
|
||||
key: True,
|
||||
}
|
||||
with pytest.raises(
|
||||
cv.Invalid, match=f"'{key}: true' does not apply to function code"
|
||||
):
|
||||
MODBUS_CLIENT_SEND_SCHEMA(config)
|
||||
|
||||
|
||||
def test_send_accepts_allow_broadcast_read_on_read_pdu() -> None:
|
||||
# allow_broadcast_read defaults to False and is accepted on a read PDU to address 0.
|
||||
config = MODBUS_CLIENT_SEND_SCHEMA(
|
||||
{CONF_ADDRESS: 0, CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x02]}
|
||||
)
|
||||
assert config[modbus.CONF_ALLOW_BROADCAST_READ] is False
|
||||
config = MODBUS_CLIENT_SEND_SCHEMA(
|
||||
{
|
||||
CONF_ADDRESS: 0,
|
||||
CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x02],
|
||||
modbus.CONF_ALLOW_BROADCAST_READ: True,
|
||||
}
|
||||
)
|
||||
assert config[modbus.CONF_ALLOW_BROADCAST_READ] is True
|
||||
|
||||
|
||||
def test_send_rejects_write_option_on_static_read_pdu() -> None:
|
||||
# The write-side option is refused on a static read PDU, the mirror of the read-option check.
|
||||
key = modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE
|
||||
with pytest.raises(
|
||||
cv.Invalid, match=f"'{key}: true' does not apply to function code"
|
||||
):
|
||||
MODBUS_CLIENT_SEND_SCHEMA(
|
||||
{CONF_ADDRESS: 0, CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x02], key: True}
|
||||
)
|
||||
|
||||
|
||||
def test_send_accepts_write_option_on_static_write_pdu() -> None:
|
||||
config = MODBUS_CLIENT_SEND_SCHEMA(
|
||||
{
|
||||
CONF_ADDRESS: 0,
|
||||
CONF_PDU: [0x06, 0x00, 0x10, 0x00, 0x01],
|
||||
modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE: True,
|
||||
}
|
||||
)
|
||||
assert config[modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE] is True
|
||||
|
||||
|
||||
def test_write_actions_offer_write_option_only() -> None:
|
||||
# Every write action takes expect_broadcast_write_response and none of the read options.
|
||||
from esphome.components.modbus_client import (
|
||||
_WRITE_MULTIPLE_COILS_SCHEMA,
|
||||
_WRITE_MULTIPLE_REGISTERS_SCHEMA,
|
||||
_WRITE_SINGLE_COIL_SCHEMA,
|
||||
_WRITE_SINGLE_REGISTER_SCHEMA,
|
||||
CONF_START_ADDRESS,
|
||||
CONF_VALUE,
|
||||
CONF_VALUES,
|
||||
)
|
||||
|
||||
write_key = modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE
|
||||
base = {CONF_ADDRESS: 0, CONF_START_ADDRESS: 0x10, write_key: True}
|
||||
for schema, extra in (
|
||||
(_WRITE_SINGLE_REGISTER_SCHEMA, {CONF_VALUE: 1}),
|
||||
(_WRITE_SINGLE_COIL_SCHEMA, {CONF_VALUE: True}),
|
||||
(_WRITE_MULTIPLE_REGISTERS_SCHEMA, {CONF_VALUES: [1, 2]}),
|
||||
(_WRITE_MULTIPLE_COILS_SCHEMA, {CONF_VALUES: [True, False]}),
|
||||
):
|
||||
config = schema({**base, **extra})
|
||||
assert config[write_key] is True
|
||||
assert modbus.CONF_ALLOW_BROADCAST_READ not in config
|
||||
with pytest.raises(cv.Invalid):
|
||||
schema({**base, **extra, modbus.CONF_ALLOW_BROADCAST_READ: True})
|
||||
|
||||
|
||||
def test_send_options_follow_the_hub_classification() -> None:
|
||||
# A vendor code is broadcastable, so it takes the write-side flag and refuses the read-side one;
|
||||
# 0x17 is a read for broadcast purposes, so the reverse holds.
|
||||
write_key = modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE
|
||||
read_key = modbus.CONF_ALLOW_BROADCAST_READ
|
||||
assert MODBUS_CLIENT_SEND_SCHEMA(
|
||||
{CONF_ADDRESS: 0, CONF_PDU: [0x41, 0x01], write_key: True}
|
||||
)[write_key]
|
||||
with pytest.raises(cv.Invalid, match=f"'{read_key}: true' does not apply"):
|
||||
MODBUS_CLIENT_SEND_SCHEMA(
|
||||
{CONF_ADDRESS: 0, CONF_PDU: [0x41, 0x01], read_key: True}
|
||||
)
|
||||
pdu_0x17 = [0x17, 0x00, 0x10, 0x00, 0x01, 0x00, 0x20, 0x00, 0x01, 0x02, 0x00, 0x01]
|
||||
assert MODBUS_CLIENT_SEND_SCHEMA(
|
||||
{CONF_ADDRESS: 0, CONF_PDU: pdu_0x17, read_key: True}
|
||||
)[read_key]
|
||||
with pytest.raises(cv.Invalid, match=f"'{write_key}: true' does not apply"):
|
||||
MODBUS_CLIENT_SEND_SCHEMA(
|
||||
{CONF_ADDRESS: 0, CONF_PDU: pdu_0x17, write_key: True}
|
||||
)
|
||||
|
||||
|
||||
def test_read_write_multiple_offers_allow_broadcast_read_only() -> None:
|
||||
from esphome.components.modbus_client import (
|
||||
_READ_WRITE_MULTIPLE_REGISTERS_SCHEMA,
|
||||
CONF_READ_ADDRESS,
|
||||
CONF_VALUES,
|
||||
CONF_WRITE_ADDRESS,
|
||||
)
|
||||
|
||||
config = _READ_WRITE_MULTIPLE_REGISTERS_SCHEMA(
|
||||
{
|
||||
CONF_ADDRESS: 0,
|
||||
CONF_READ_ADDRESS: 0x10,
|
||||
CONF_WRITE_ADDRESS: 0x20,
|
||||
CONF_VALUES: [1],
|
||||
modbus.CONF_ALLOW_BROADCAST_READ: True,
|
||||
}
|
||||
)
|
||||
assert config[modbus.CONF_ALLOW_BROADCAST_READ] is True
|
||||
assert CONF_CONTINUOUS not in config
|
||||
assert modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE not in config
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"key",
|
||||
[modbus.CONF_ALLOW_BROADCAST_READ, modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE],
|
||||
)
|
||||
def test_broadcast_options_rejected_on_literal_unicast_address(key: str) -> None:
|
||||
# A broadcast-only option on a literal non-zero address would be silently dropped by the hub.
|
||||
if key == modbus.CONF_ALLOW_BROADCAST_READ:
|
||||
pdu = [0x03, 0x00, 0x10, 0x00, 0x01]
|
||||
else:
|
||||
pdu = [0x06, 0x00, 0x10, 0x00, 0x01]
|
||||
with pytest.raises(cv.Invalid, match="only applies to the broadcast address"):
|
||||
MODBUS_CLIENT_SEND_SCHEMA({CONF_ADDRESS: 1, CONF_PDU: pdu, key: True})
|
||||
# A templated address is not decidable at validation and passes through.
|
||||
config = MODBUS_CLIENT_SEND_SCHEMA(
|
||||
{CONF_ADDRESS: Lambda("return 1;"), CONF_PDU: pdu, key: True}
|
||||
)
|
||||
assert config[key] is True
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""A modbus_controller cannot poll the broadcast address (0) unless allow_broadcast_read says the
|
||||
device answers it."""
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components import modbus
|
||||
from esphome.components.modbus_controller import CONFIG_SCHEMA
|
||||
from esphome.const import CONF_ADDRESS
|
||||
from esphome.types import ConfigType
|
||||
|
||||
|
||||
def _controller(address: int, **extra: object) -> ConfigType:
|
||||
return CONFIG_SCHEMA({modbus.CONF_MODBUS_ID: "bus", CONF_ADDRESS: address, **extra})
|
||||
|
||||
|
||||
def test_address_zero_rejected_by_default() -> None:
|
||||
with pytest.raises(cv.Invalid, match="broadcast address"):
|
||||
_controller(0)
|
||||
|
||||
|
||||
def test_address_zero_accepted_with_allow_broadcast_read() -> None:
|
||||
config = _controller(0, **{modbus.CONF_ALLOW_BROADCAST_READ: True})
|
||||
assert config[CONF_ADDRESS] == 0
|
||||
assert config[modbus.CONF_ALLOW_BROADCAST_READ] is True
|
||||
|
||||
|
||||
def test_allow_broadcast_read_defaults_false() -> None:
|
||||
assert _controller(1)[modbus.CONF_ALLOW_BROADCAST_READ] is False
|
||||
|
||||
|
||||
def test_writer_entity_takes_expect_broadcast_write_response() -> None:
|
||||
# The write-side option lives on the writing platforms, not the controller.
|
||||
from esphome.components.modbus_controller.const import CONF_MODBUS_CONTROLLER_ID
|
||||
from esphome.components.modbus_controller.switch import (
|
||||
CONFIG_SCHEMA as SWITCH_SCHEMA,
|
||||
)
|
||||
from esphome.const import CONF_NAME
|
||||
|
||||
key = modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE
|
||||
base = {
|
||||
CONF_MODBUS_CONTROLLER_ID: "ctl",
|
||||
CONF_NAME: "Switch",
|
||||
"register_type": "coil",
|
||||
CONF_ADDRESS: 0x20,
|
||||
}
|
||||
assert SWITCH_SCHEMA(base)[key] is False
|
||||
assert SWITCH_SCHEMA({**base, CONF_NAME: "Switch 2", key: True})[key] is True
|
||||
with pytest.raises(cv.Invalid):
|
||||
CONFIG_SCHEMA({modbus.CONF_MODBUS_ID: "bus", CONF_ADDRESS: 1, key: True})
|
||||
|
||||
|
||||
def test_allow_broadcast_read_requires_address_zero() -> None:
|
||||
# The option only means something at address 0; elsewhere it would be silently inert.
|
||||
with pytest.raises(cv.Invalid, match="only applies to the broadcast address"):
|
||||
_controller(5, **{modbus.CONF_ALLOW_BROADCAST_READ: True})
|
||||
|
||||
|
||||
def test_add_command_options_skips_defaults() -> None:
|
||||
# The setter is only emitted when an option differs from its C++ default.
|
||||
import esphome.codegen as cg
|
||||
from esphome.const import CONF_CONTINUOUS
|
||||
|
||||
var = cg.MockObj("ctl")
|
||||
emitted: list = []
|
||||
original = cg.add
|
||||
cg.add = emitted.append
|
||||
try:
|
||||
modbus.add_command_options(
|
||||
var, "set_read_options", {CONF_CONTINUOUS: False}, direction="read"
|
||||
)
|
||||
assert emitted == []
|
||||
modbus.add_command_options(
|
||||
var, "set_read_options", {CONF_CONTINUOUS: True}, direction="read"
|
||||
)
|
||||
assert len(emitted) == 1
|
||||
assert "set_read_options" in str(emitted[0])
|
||||
finally:
|
||||
cg.add = original
|
||||
@@ -9,6 +9,7 @@ test cannot: a write-coded custom_pdu polled continuously is rejected there.
|
||||
import pytest
|
||||
from voluptuous import Invalid, MultipleInvalid
|
||||
|
||||
from esphome.components import modbus
|
||||
from esphome.components.modbus_controller import (
|
||||
ModbusItemBaseSchema,
|
||||
validate_custom_pdu_item,
|
||||
@@ -55,14 +56,21 @@ def test_custom_pdu_rejects_non_byte_values() -> None:
|
||||
ModbusItemBaseSchema({CONF_CUSTOM_PDU: [0x0103, 0x002A]})
|
||||
|
||||
|
||||
def _controller_full_config(*, continuous: bool) -> Config:
|
||||
def _controller_full_config(
|
||||
*, continuous: bool, allow_broadcast_read: bool = False
|
||||
) -> Config:
|
||||
"""A minimal full-config graph with one modbus_controller declaring id 'ctl', enough for the
|
||||
final-validate to resolve the controller (and its continuous flag) from an item's
|
||||
final-validate to resolve the controller (and its option flags) from an item's
|
||||
modbus_controller_id."""
|
||||
ctl_id = ID("ctl", is_declaration=True)
|
||||
config = Config()
|
||||
config["modbus_controller"] = [
|
||||
{CONF_ID: ctl_id, CONF_ADDRESS: 1, CONF_CONTINUOUS: continuous}
|
||||
{
|
||||
CONF_ID: ctl_id,
|
||||
CONF_ADDRESS: 0 if allow_broadcast_read else 1,
|
||||
CONF_CONTINUOUS: continuous,
|
||||
modbus.CONF_ALLOW_BROADCAST_READ: allow_broadcast_read,
|
||||
}
|
||||
]
|
||||
config.declare_ids.append((ctl_id, ["modbus_controller", 0, CONF_ID]))
|
||||
return config
|
||||
@@ -98,3 +106,64 @@ def test_continuous_read_custom_pdu_allowed(reset_full_config) -> None:
|
||||
CONF_CUSTOM_PDU: [0x03, 0x00, 0x2A, 0x00, 0x01],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_broadcastable_custom_pdu_rejected_under_broadcast_controller(
|
||||
reset_full_config,
|
||||
) -> None:
|
||||
"""A vendor-coded custom_pdu under an allow_broadcast_read controller would be a real broadcast,
|
||||
never answered, so it is rejected at final validate."""
|
||||
fv.full_config.set(
|
||||
_controller_full_config(continuous=False, allow_broadcast_read=True)
|
||||
)
|
||||
with pytest.raises(Invalid, match="is a real broadcast at address 0"):
|
||||
validate_custom_pdu_item(
|
||||
{
|
||||
CONF_MODBUS_CONTROLLER_ID: ID("ctl"),
|
||||
CONF_CUSTOM_PDU: [0x41, 0x00, 0x03],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_read_custom_pdu_allowed_under_broadcast_controller(reset_full_config) -> None:
|
||||
"""A read-coded custom_pdu (0x03) is answered under allow_broadcast_read, so it is fine."""
|
||||
fv.full_config.set(
|
||||
_controller_full_config(continuous=False, allow_broadcast_read=True)
|
||||
)
|
||||
validate_custom_pdu_item(
|
||||
{
|
||||
CONF_MODBUS_CONTROLLER_ID: ID("ctl"),
|
||||
CONF_CUSTOM_PDU: [0x03, 0x00, 0x2A, 0x00, 0x01],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_write_option_rejected_under_unicast_controller(reset_full_config) -> None:
|
||||
"""expect_broadcast_write_response on a writer entity whose controller is not at address 0 is
|
||||
rejected at final validate, where the controller's address is known."""
|
||||
from esphome.components.modbus_controller import validate_writer_item
|
||||
|
||||
fv.full_config.set(_controller_full_config(continuous=False))
|
||||
with pytest.raises(
|
||||
Invalid, match="only applies when the 'ctl' modbus_controller is at address 0"
|
||||
):
|
||||
validate_writer_item(
|
||||
{
|
||||
CONF_MODBUS_CONTROLLER_ID: ID("ctl"),
|
||||
modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE: True,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_write_option_allowed_under_broadcast_controller(reset_full_config) -> None:
|
||||
from esphome.components.modbus_controller import validate_writer_item
|
||||
|
||||
fv.full_config.set(
|
||||
_controller_full_config(continuous=False, allow_broadcast_read=True)
|
||||
)
|
||||
validate_writer_item(
|
||||
{
|
||||
CONF_MODBUS_CONTROLLER_ID: ID("ctl"),
|
||||
modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE: True,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -792,6 +792,261 @@ TEST(ModbusClientHubBroadcast, RefusesReadBroadcast) {
|
||||
EXPECT_EQ(device.sent_count_, 0); // never transmitted
|
||||
}
|
||||
|
||||
// allow_broadcast_read lifts the refusal for a device that answers address 0: the read is queued, sent,
|
||||
// and waits for a reply like a unicast read, so a reply from address 0 completes it with on_response.
|
||||
TEST(ModbusClientHubBroadcast, AllowBroadcastReadWaitsAndAcceptsReplyFromZero) {
|
||||
NullUART uart;
|
||||
NoResponseProbeHub hub;
|
||||
hub.set_uart_parent(&uart);
|
||||
hub.setup();
|
||||
BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS);
|
||||
|
||||
const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; // read holding registers 0x0010, count 2
|
||||
ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true}));
|
||||
EXPECT_TRUE(hub.queued(0).options.allow_broadcast_read);
|
||||
EXPECT_FALSE(hub.queued(0).fire_and_forget());
|
||||
|
||||
hub.send_next_for_test();
|
||||
EXPECT_EQ(device.sent_count_, 1);
|
||||
EXPECT_TRUE(hub.waiting()); // not fire-and-forget: the reply is expected
|
||||
EXPECT_EQ(hub.entries(), 1u);
|
||||
|
||||
const uint8_t reply[] = {0x03, 0x04, 0x00, 0x01, 0x00, 0x02};
|
||||
hub.receive_frame_for_test(BROADCAST_ADDRESS, reply);
|
||||
EXPECT_EQ(device.response_count_, 1);
|
||||
EXPECT_EQ(device.last_response_size_, sizeof(reply));
|
||||
EXPECT_FALSE(hub.waiting());
|
||||
EXPECT_EQ(hub.entries(), 0u);
|
||||
}
|
||||
|
||||
// The address-0 read waits like a unicast one, so the reply must come from address 0 too: a reply from
|
||||
// another unit id is an unexpected frame and interrupts the transaction as it would for any address.
|
||||
TEST(ModbusClientHubBroadcast, AllowBroadcastReadRejectsReplyFromOtherAddress) {
|
||||
NullUART uart;
|
||||
NoResponseProbeHub hub;
|
||||
hub.set_uart_parent(&uart);
|
||||
hub.setup();
|
||||
BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS);
|
||||
|
||||
const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02};
|
||||
ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true}));
|
||||
hub.send_next_for_test();
|
||||
ASSERT_TRUE(hub.waiting());
|
||||
|
||||
const uint8_t reply[] = {0x03, 0x04, 0x00, 0x01, 0x00, 0x02};
|
||||
hub.receive_frame_for_test(0x07, reply);
|
||||
EXPECT_EQ(device.response_count_, 0);
|
||||
EXPECT_EQ(hub.waiting_command().state, FrameState::INTERRUPTED);
|
||||
}
|
||||
|
||||
// An address-scoped clear must not turn a live address-0 entry back into a fire-and-forget broadcast: a
|
||||
// retry granted after the clear is re-sent with the flag intact, so it still waits and gets its terminal.
|
||||
TEST(ModbusClientHubBroadcast, AllowBroadcastReadSurvivesClearBeforeRetry) {
|
||||
NullUART uart;
|
||||
NoResponseProbeHub hub;
|
||||
hub.set_uart_parent(&uart);
|
||||
hub.setup();
|
||||
RetryingDevice device(&hub, BROADCAST_ADDRESS, true);
|
||||
|
||||
const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02};
|
||||
ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true}));
|
||||
hub.send_next_for_test();
|
||||
ASSERT_TRUE(hub.waiting());
|
||||
|
||||
hub.clear_tx_queue_for_address(BROADCAST_ADDRESS);
|
||||
EXPECT_EQ(hub.waiting_command().state, FrameState::WAITING_RETIRED);
|
||||
EXPECT_TRUE(hub.waiting_command().options.allow_broadcast_read);
|
||||
|
||||
hub.timeout_waiting(); // retry granted: the entry is READY again
|
||||
ASSERT_EQ(hub.queued_frames(), 1u);
|
||||
EXPECT_FALSE(hub.queued(0).fire_and_forget());
|
||||
|
||||
hub.send_next_for_test();
|
||||
EXPECT_TRUE(hub.waiting()); // the retry still waits for its reply
|
||||
EXPECT_EQ(hub.entries(), 1u);
|
||||
}
|
||||
|
||||
// The function code check is unchanged by the relaxed address match: a mismatched reply still interrupts.
|
||||
TEST(ModbusClientHubBroadcast, AllowBroadcastReadStillRejectsWrongFunctionCode) {
|
||||
NullUART uart;
|
||||
NoResponseProbeHub hub;
|
||||
hub.set_uart_parent(&uart);
|
||||
hub.setup();
|
||||
BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS);
|
||||
|
||||
const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02};
|
||||
ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true}));
|
||||
hub.send_next_for_test();
|
||||
ASSERT_TRUE(hub.waiting());
|
||||
|
||||
const uint8_t wrong_reply[] = {0x04, 0x04, 0x00, 0x01, 0x00, 0x02};
|
||||
hub.receive_frame_for_test(BROADCAST_ADDRESS, wrong_reply); // right address, wrong function code
|
||||
EXPECT_EQ(device.response_count_, 0);
|
||||
EXPECT_EQ(hub.waiting_command().state, FrameState::INTERRUPTED);
|
||||
}
|
||||
|
||||
// A silent device leaves the read to the normal send-wait timeout, so on_no_response is delivered.
|
||||
TEST(ModbusClientHubBroadcast, AllowBroadcastReadTimesOutLikeUnicast) {
|
||||
NullUART uart;
|
||||
NoResponseProbeHub hub;
|
||||
hub.set_uart_parent(&uart);
|
||||
hub.setup();
|
||||
BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS);
|
||||
|
||||
const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02};
|
||||
ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true}));
|
||||
hub.send_next_for_test();
|
||||
ASSERT_TRUE(hub.waiting());
|
||||
|
||||
hub.timeout_waiting();
|
||||
EXPECT_EQ(device.no_response_count_, 1);
|
||||
EXPECT_EQ(device.response_count_, 0);
|
||||
EXPECT_FALSE(hub.waiting());
|
||||
EXPECT_EQ(hub.entries(), 0u);
|
||||
}
|
||||
|
||||
// allow_broadcast_read is stripped from a broadcastable code (a write or custom code to address 0 is a real broadcast,
|
||||
// still fire-and-forget) and from a unicast frame (nothing to allow).
|
||||
TEST(ModbusClientHubBroadcast, AllowBroadcastReadIgnoredForWritesAndUnicast) {
|
||||
NullUART uart;
|
||||
NoResponseProbeHub hub;
|
||||
hub.set_uart_parent(&uart);
|
||||
hub.setup();
|
||||
BroadcastProbeDevice broadcast_device(&hub, BROADCAST_ADDRESS);
|
||||
BroadcastProbeDevice unicast_device(&hub, 0x01);
|
||||
|
||||
const uint8_t write[] = {0x06, 0x00, 0x10, 0x00, 0x01};
|
||||
ASSERT_TRUE(broadcast_device.queue_pdu(write, {.allow_broadcast_read = true}));
|
||||
EXPECT_FALSE(hub.queued(0).options.allow_broadcast_read);
|
||||
EXPECT_TRUE(hub.queued(0).fire_and_forget());
|
||||
hub.send_next_for_test();
|
||||
EXPECT_EQ(broadcast_device.sent_count_, 1);
|
||||
EXPECT_FALSE(hub.waiting());
|
||||
EXPECT_EQ(hub.entries(), 0u);
|
||||
|
||||
const uint8_t custom[] = {0x41, 0x01, 0x02};
|
||||
ASSERT_TRUE(broadcast_device.queue_pdu(custom, {.allow_broadcast_read = true}));
|
||||
EXPECT_FALSE(hub.queued(0).options.allow_broadcast_read);
|
||||
EXPECT_TRUE(hub.queued(0).fire_and_forget());
|
||||
hub.send_next_for_test();
|
||||
EXPECT_FALSE(hub.waiting());
|
||||
EXPECT_EQ(hub.entries(), 0u);
|
||||
|
||||
const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02};
|
||||
ASSERT_TRUE(unicast_device.queue_pdu(read, {.allow_broadcast_read = true}));
|
||||
EXPECT_FALSE(hub.queued(0).options.allow_broadcast_read);
|
||||
EXPECT_FALSE(hub.queued(0).fire_and_forget());
|
||||
}
|
||||
|
||||
// expect_broadcast_write_response is the write-side twin: a write to address 0 waits for its reply instead
|
||||
// of retiring at transmission, and the reply (from address 0) completes it.
|
||||
TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseWaitsAndAcceptsReply) {
|
||||
NullUART uart;
|
||||
NoResponseProbeHub hub;
|
||||
hub.set_uart_parent(&uart);
|
||||
hub.setup();
|
||||
BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS);
|
||||
|
||||
const uint8_t write[] = {0x06, 0x00, 0x10, 0x00, 0x01};
|
||||
ASSERT_TRUE(device.write_single_register(0x0010, 0x0001, {.expect_broadcast_write_response = true}));
|
||||
EXPECT_TRUE(hub.queued(0).options.expect_broadcast_write_response);
|
||||
EXPECT_FALSE(hub.queued(0).fire_and_forget());
|
||||
|
||||
hub.send_next_for_test();
|
||||
EXPECT_EQ(device.sent_count_, 1);
|
||||
EXPECT_TRUE(hub.waiting());
|
||||
EXPECT_EQ(hub.entries(), 1u);
|
||||
|
||||
hub.receive_frame_for_test(BROADCAST_ADDRESS, write); // the echo, as address 0
|
||||
EXPECT_EQ(device.response_count_, 1);
|
||||
EXPECT_EQ(device.last_response_size_, sizeof(write));
|
||||
EXPECT_FALSE(hub.waiting());
|
||||
EXPECT_EQ(hub.entries(), 0u);
|
||||
}
|
||||
|
||||
// Two requests for the same address-0 write may disagree on expect_broadcast_write_response (a
|
||||
// broadcastable frame is accepted either way), but a write duplicate is refused at its cap of one in
|
||||
// flight rather than absorbed, so the queued entry's delivery mode is never changed under it.
|
||||
TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseDuplicateRefusedNotMerged) {
|
||||
NullUART uart;
|
||||
NoResponseProbeHub hub;
|
||||
hub.set_uart_parent(&uart);
|
||||
hub.setup();
|
||||
BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS);
|
||||
|
||||
ASSERT_TRUE(device.write_single_register(0x0010, 0x0001)); // fire-and-forget as queued
|
||||
EXPECT_TRUE(hub.queued(0).fire_and_forget());
|
||||
EXPECT_FALSE(device.write_single_register(0x0010, 0x0001, {.expect_broadcast_write_response = true}));
|
||||
EXPECT_EQ(hub.entries(), 1u);
|
||||
EXPECT_TRUE(hub.queued(0).fire_and_forget()); // the refused request left the entry untouched
|
||||
|
||||
hub.send_next_for_test();
|
||||
EXPECT_FALSE(hub.waiting());
|
||||
EXPECT_EQ(hub.entries(), 0u);
|
||||
}
|
||||
|
||||
// A custom-code poll at address 0 is a fire-and-forget broadcast that a one-shot duplicate downgrades and
|
||||
// is absorbed into; if that duplicate wants the reply, the entry waits for it instead of retiring at the
|
||||
// send, so the absorbed request still gets its terminal callback.
|
||||
TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseMergesIntoDowngradedPoll) {
|
||||
NullUART uart;
|
||||
NoResponseProbeHub hub;
|
||||
hub.set_uart_parent(&uart);
|
||||
hub.setup();
|
||||
BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS);
|
||||
|
||||
const uint8_t custom[] = {0x41, 0x01, 0x02};
|
||||
ASSERT_TRUE(device.queue_pdu(custom, {.continuous = true}));
|
||||
EXPECT_TRUE(hub.queued(0).fire_and_forget());
|
||||
ASSERT_TRUE(device.queue_pdu(custom, {.expect_broadcast_write_response = true})); // downgrades, absorbed
|
||||
EXPECT_EQ(hub.entries(), 1u);
|
||||
EXPECT_FALSE(hub.queued(0).options.continuous);
|
||||
EXPECT_FALSE(hub.queued(0).fire_and_forget());
|
||||
|
||||
hub.send_next_for_test();
|
||||
EXPECT_TRUE(hub.waiting());
|
||||
hub.receive_frame_for_test(BROADCAST_ADDRESS, custom);
|
||||
EXPECT_EQ(device.response_count_, 1);
|
||||
}
|
||||
|
||||
// A silent device leaves an expected write response to the normal send-wait timeout.
|
||||
TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseTimesOutLikeUnicast) {
|
||||
NullUART uart;
|
||||
NoResponseProbeHub hub;
|
||||
hub.set_uart_parent(&uart);
|
||||
hub.setup();
|
||||
BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS);
|
||||
|
||||
ASSERT_TRUE(device.write_single_coil(0x0010, true, {.expect_broadcast_write_response = true}));
|
||||
hub.send_next_for_test();
|
||||
ASSERT_TRUE(hub.waiting());
|
||||
|
||||
hub.timeout_waiting();
|
||||
EXPECT_EQ(device.no_response_count_, 1);
|
||||
EXPECT_EQ(device.response_count_, 0);
|
||||
EXPECT_FALSE(hub.waiting());
|
||||
EXPECT_EQ(hub.entries(), 0u);
|
||||
}
|
||||
|
||||
// expect_broadcast_write_response is stripped from a read (allow_broadcast_read is the read-side flag, so
|
||||
// the broadcast guard still refuses it) and from a unicast frame (nothing to expect).
|
||||
TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseIgnoredForReadsAndUnicast) {
|
||||
NullUART uart;
|
||||
NoResponseProbeHub hub;
|
||||
hub.set_uart_parent(&uart);
|
||||
hub.setup();
|
||||
BroadcastProbeDevice broadcast_device(&hub, BROADCAST_ADDRESS);
|
||||
BroadcastProbeDevice unicast_device(&hub, 0x01);
|
||||
|
||||
const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02};
|
||||
EXPECT_FALSE(broadcast_device.queue_pdu(read, {.expect_broadcast_write_response = true}));
|
||||
EXPECT_EQ(hub.entries(), 0u);
|
||||
|
||||
ASSERT_TRUE(unicast_device.write_single_register(0x0010, 0x0001, {.expect_broadcast_write_response = true}));
|
||||
EXPECT_FALSE(hub.queued(0).options.expect_broadcast_write_response);
|
||||
EXPECT_FALSE(hub.queued(0).fire_and_forget());
|
||||
}
|
||||
|
||||
// The counterpart to RefusesReadBroadcast: a custom (user-defined) function code carries no reply the
|
||||
// hub knows how to expect, so a broadcast of one is accepted and completes fire-and-forget like a write.
|
||||
TEST(ModbusClientHubBroadcast, AcceptsCustomBroadcast) {
|
||||
|
||||
@@ -79,7 +79,8 @@ button:
|
||||
name: "Typed Actions"
|
||||
on_press:
|
||||
- modbus_client.write_single_register:
|
||||
address: 0x01
|
||||
address: !lambda "return 1;"
|
||||
expect_broadcast_write_response: true
|
||||
start_address: 0x0102
|
||||
value: !lambda "return 42;"
|
||||
on_response:
|
||||
@@ -93,6 +94,7 @@ button:
|
||||
start_address: 0x10
|
||||
count: 2
|
||||
continuous: true
|
||||
allow_broadcast_read: !lambda "return false;"
|
||||
on_response:
|
||||
then:
|
||||
- lambda: 'ESP_LOGI("modbus_client.test", "first=%u n=%u", values[0], (unsigned) values.size());'
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Config-only: actions that address the broadcast address (0) and wait for a reply, for a device that
|
||||
# answers it. Never compiled, so the extra action objects do not inflate the memory-impact baseline.
|
||||
packages:
|
||||
modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: Broadcast probe
|
||||
on_press:
|
||||
- modbus_client.read_holding_registers:
|
||||
address: 0
|
||||
allow_broadcast_read: true
|
||||
start_address: 0x10
|
||||
count: 1
|
||||
on_response:
|
||||
then:
|
||||
- lambda: 'ESP_LOGI("modbus_client.test", "broadcast read first=%u", values[0]);'
|
||||
- modbus_client.write_single_register:
|
||||
address: 0
|
||||
expect_broadcast_write_response: true
|
||||
start_address: 0x0102
|
||||
value: 42
|
||||
on_response:
|
||||
then:
|
||||
- logger.log: "broadcast write acked"
|
||||
- modbus_client.read_write_multiple_registers:
|
||||
address: 0
|
||||
allow_broadcast_read: true
|
||||
read_address: 0x10
|
||||
read_count: 1
|
||||
write_address: 0x20
|
||||
values: [1]
|
||||
- modbus_client.send:
|
||||
address: 0
|
||||
expect_broadcast_write_response: true
|
||||
pdu: [0x41, 0x01]
|
||||
@@ -6,7 +6,6 @@ modbus_controller:
|
||||
on_online:
|
||||
then:
|
||||
logger.log: "Module Online"
|
||||
|
||||
binary_sensor:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller1
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Config-only: a controller polling the broadcast address (0), for a device that answers it, with a
|
||||
# writer entity expecting the reply to its broadcast writes. Never compiled, so the extra entities do
|
||||
# not inflate the memory-impact baseline.
|
||||
packages:
|
||||
modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml
|
||||
|
||||
modbus_controller:
|
||||
- id: modbus_controller_broadcast
|
||||
address: 0
|
||||
allow_broadcast_read: true
|
||||
modbus_id: modbus_bus
|
||||
|
||||
sensor:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_broadcast
|
||||
id: modbus_broadcast_sensor
|
||||
name: Broadcast Read Sensor
|
||||
register_type: holding
|
||||
address: 0x0010
|
||||
value_type: U_WORD
|
||||
|
||||
switch:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_broadcast
|
||||
id: modbus_broadcast_switch
|
||||
name: Broadcast Write Switch
|
||||
register_type: coil
|
||||
address: 0x20
|
||||
expect_broadcast_write_response: true
|
||||
Reference in New Issue
Block a user