mirror of
https://github.com/esphome/esphome.git
synced 2026-09-18 02:28:42 +00:00
330 lines
12 KiB
Python
330 lines
12 KiB
Python
"""Tests for modbus_client configuration validation.
|
|
|
|
Handler PDU spans point into hub buffers reused once the handler returns, so the deferring-actions
|
|
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, modbus_client
|
|
from esphome.components.modbus_client import (
|
|
CONF_ON_NO_RESPONSE,
|
|
CONF_ON_NOT_SENT,
|
|
CONF_ON_SENT,
|
|
CONF_PDU,
|
|
CONFIG_SCHEMA,
|
|
MODBUS_CLIENT_SEND_SCHEMA,
|
|
)
|
|
from esphome.const import (
|
|
CONF_ADDRESS,
|
|
CONF_CONTINUOUS,
|
|
CONF_ID,
|
|
CONF_ON_ERROR,
|
|
CONF_ON_RESPONSE,
|
|
)
|
|
from esphome.core import Lambda
|
|
from esphome.types import ConfigType
|
|
|
|
# Every handler slot on modbus_client.send. All five must reject deferring actions.
|
|
HANDLER_KEYS = [
|
|
CONF_ON_SENT,
|
|
CONF_ON_RESPONSE,
|
|
CONF_ON_ERROR,
|
|
CONF_ON_NO_RESPONSE,
|
|
CONF_ON_NOT_SENT,
|
|
]
|
|
|
|
# A deferring action (registered synchronous=False) and a synchronous one, for contrast.
|
|
DEFERRING_ACTION = {"delay": "1s"}
|
|
SYNCHRONOUS_ACTION = {"lambda": Lambda('ESP_LOGD("test", "ran");')}
|
|
TRUE_CONDITION = {"lambda": Lambda("return true;")}
|
|
|
|
# The same deferring action buried inside nested control flow, which the guard must still find.
|
|
NESTED_ACTIONS = [
|
|
pytest.param(
|
|
[{"if": {"condition": TRUE_CONDITION, "then": [DEFERRING_ACTION]}}],
|
|
id="if",
|
|
),
|
|
pytest.param([{"repeat": {"count": 2, "then": [DEFERRING_ACTION]}}], id="repeat"),
|
|
pytest.param(
|
|
[
|
|
{
|
|
"repeat": {
|
|
"count": 2,
|
|
"then": [
|
|
{
|
|
"if": {
|
|
"condition": TRUE_CONDITION,
|
|
"then": [DEFERRING_ACTION],
|
|
}
|
|
}
|
|
],
|
|
}
|
|
}
|
|
],
|
|
id="repeat_if",
|
|
),
|
|
]
|
|
|
|
DEFER_MESSAGE = "Deferring actions"
|
|
|
|
|
|
def _config(handler_key: str, actions: list) -> ConfigType:
|
|
"""A minimal valid modbus_client.send config with one handler populated."""
|
|
return {
|
|
CONF_ADDRESS: 0x01,
|
|
CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x01],
|
|
handler_key: {"then": actions},
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize("handler_key", HANDLER_KEYS)
|
|
def test_synchronous_handler_accepted(handler_key: str) -> None:
|
|
# The guard must not get in the way of an ordinary inline handler.
|
|
MODBUS_CLIENT_SEND_SCHEMA(_config(handler_key, [SYNCHRONOUS_ACTION]))
|
|
|
|
|
|
@pytest.mark.parametrize("handler_key", HANDLER_KEYS)
|
|
def test_deferring_action_rejected(handler_key: str) -> None:
|
|
with pytest.raises(cv.Invalid, match=DEFER_MESSAGE):
|
|
MODBUS_CLIENT_SEND_SCHEMA(_config(handler_key, [DEFERRING_ACTION]))
|
|
|
|
|
|
@pytest.mark.parametrize("handler_key", HANDLER_KEYS)
|
|
@pytest.mark.parametrize("actions", NESTED_ACTIONS)
|
|
def test_nested_deferring_action_rejected(handler_key: str, actions: list) -> None:
|
|
# has_non_synchronous_actions recurses, so a delay buried in if:/repeat: is still caught.
|
|
with pytest.raises(cv.Invalid, match=DEFER_MESSAGE):
|
|
MODBUS_CLIENT_SEND_SCHEMA(_config(handler_key, actions))
|
|
|
|
|
|
def test_on_no_response_lambda_form_accepted() -> None:
|
|
# The returning-lambda form has no action list; the guard is a no-op on it.
|
|
MODBUS_CLIENT_SEND_SCHEMA(
|
|
{
|
|
CONF_ADDRESS: 0x01,
|
|
CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x01],
|
|
CONF_ON_NO_RESPONSE: Lambda("return false;"),
|
|
}
|
|
)
|
|
|
|
|
|
def test_on_no_response_retry_lambda_accepted() -> None:
|
|
# The automation form may also carry a nested retry: lambda.
|
|
MODBUS_CLIENT_SEND_SCHEMA(
|
|
{
|
|
CONF_ADDRESS: 0x01,
|
|
CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x01],
|
|
CONF_ON_NO_RESPONSE: {
|
|
"then": [SYNCHRONOUS_ACTION],
|
|
"retry": Lambda("return true;"),
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
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 function code"):
|
|
MODBUS_CLIENT_SEND_SCHEMA(
|
|
{
|
|
CONF_ADDRESS: 0x01,
|
|
CONF_PDU: [0x06, 0x00, 0x01, 0x00, 0x0A],
|
|
CONF_CONTINUOUS: True,
|
|
}
|
|
)
|
|
|
|
|
|
def test_continuous_on_read_pdu_accepted() -> None:
|
|
"""A literal read-code PDU with continuous: true is fine - continuous polling applies to reads."""
|
|
MODBUS_CLIENT_SEND_SCHEMA(
|
|
{
|
|
CONF_ADDRESS: 0x01,
|
|
CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x01],
|
|
CONF_CONTINUOUS: True,
|
|
}
|
|
)
|
|
|
|
|
|
# The standalone component block. The compile fixtures cover the accepted shapes end to end; these pin
|
|
# the parts a fixture cannot express - a rejection, and a module flag whose absence breaks other
|
|
# components rather than this one.
|
|
|
|
|
|
def test_component_requires_an_address() -> None:
|
|
"""The address identifies the device on the bus, so there is no sensible default."""
|
|
with pytest.raises(cv.Invalid, match=CONF_ADDRESS):
|
|
CONFIG_SCHEMA({CONF_ID: "bare_client"})
|
|
|
|
|
|
def test_component_requires_an_id() -> None:
|
|
"""The device is reachable only through id() in a lambda, so a generated id would be dead config."""
|
|
with pytest.raises(cv.Invalid, match=CONF_ID):
|
|
CONFIG_SCHEMA({CONF_ADDRESS: 0x01})
|
|
|
|
|
|
def test_component_accepts_an_id_and_address() -> None:
|
|
"""modbus_id stays optional: it resolves to the single hub when only one is declared."""
|
|
config = CONFIG_SCHEMA({CONF_ID: "bare_client", CONF_ADDRESS: 0x01})
|
|
assert config[CONF_ADDRESS] == 0x01
|
|
|
|
|
|
def test_component_rejects_an_out_of_range_address() -> None:
|
|
"""A Modbus device address is one byte."""
|
|
with pytest.raises(cv.Invalid):
|
|
CONFIG_SCHEMA({CONF_ID: "bare_client", CONF_ADDRESS: 0x100})
|
|
|
|
|
|
def test_multi_conf_no_default_is_set() -> None:
|
|
"""Load-bearing: the modbus hub auto-loads this component to register its actions.
|
|
|
|
Without MULTI_CONF_NO_DEFAULT that auto-load builds a default entry, which then fails the required
|
|
address above - breaking every configuration that uses modbus but never declares a modbus_client
|
|
block. validate-autoload.esp32-idf.yaml covers the same path end to end; this names the reason.
|
|
"""
|
|
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
|