mirror of
https://github.com/esphome/esphome.git
synced 2026-10-05 18:41:32 +00:00
[modbus] Add allow_broadcast_read and expect_broadcast_write_response options (#19304)
This commit is contained in:
@@ -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