mirror of
https://github.com/esphome/esphome.git
synced 2026-10-06 10:56:43 +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,
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user