diff --git a/esphome/components/modbus_client/__init__.py b/esphome/components/modbus_client/__init__.py index 538936c93b..52a61cacad 100644 --- a/esphome/components/modbus_client/__init__.py +++ b/esphome/components/modbus_client/__init__.py @@ -8,6 +8,7 @@ import esphome.config_validation as cv from esphome.const import ( CONF_ADDRESS, CONF_COUNT, + CONF_ID, CONF_ON_ERROR, CONF_ON_RESPONSE, CONF_VALUE, @@ -17,6 +18,10 @@ from esphome.types import ConfigType, TemplateArgsType CODEOWNERS = ["@exciton"] DEPENDENCIES = ["modbus"] +MULTI_CONF = True +# The modbus hub auto-loads this component to make the actions available. Without this, that auto-load +# would try to create a device with no address. +MULTI_CONF_NO_DEFAULT = True CONF_ON_CUSTOM_RESPONSE = "on_custom_response" CONF_ON_NO_RESPONSE = "on_no_response" @@ -67,6 +72,34 @@ _PDU_SPAN = cg.std_span.template(cg.uint8.operator("const")) # modbus.MAX_PDU_SIZE without reporting it, so an over-long lambda PDU is silently truncated. _PDU_BUFFER = modbus.modbus_ns.namespace("helpers").class_("PduBuffer") +# A bare modbus::ModbusClientDevice bound to a hub and a device address, and nothing else - no polling, +# no entities, no automation wiring. It exists so a lambda can talk to a device directly: +# +# modbus_client: +# - id: my_client +# address: 0x01 +# +# - lambda: "id(my_client).write_single_register(0x10, 42);" +# +# Nothing here overrides the device callbacks, so every outcome takes the base class default, and those +# are no-ops: a successful reply, a Modbus exception, a timeout, and a frame that never reached the wire +# are all discarded without a log. The single exception is a reply the dispatch gate treats as +# non-standard, which warns once per device and logs at VERBOSE after that. So a lambda gets no feedback +# on an ordinary failure - use the modbus_client.* actions whenever the outcome matters, since they carry +# on_response/on_error/on_no_response/on_not_sent handlers. +# The id is required, not generated: the device is reachable only through id() in a lambda, so an +# entry without one builds something nothing can name. Better to say so than to accept dead config. +CONFIG_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.declare_id(modbus.ModbusClientDevice), + } +).extend(modbus.modbus_device_schema(None)) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await modbus.register_modbus_client_device(var, config) + def _packed_bit_bytes(bits: int) -> int: """Mirrors modbus::packed_bit_bytes(): bytes needed to hold this many coils on the wire.""" diff --git a/tests/component_tests/modbus_client/test_modbus_client.py b/tests/component_tests/modbus_client/test_modbus_client.py index 10f9bc588e..7966048dd7 100644 --- a/tests/component_tests/modbus_client/test_modbus_client.py +++ b/tests/component_tests/modbus_client/test_modbus_client.py @@ -7,14 +7,16 @@ 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.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_ON_ERROR, CONF_ON_RESPONSE +from esphome.const import CONF_ADDRESS, CONF_ID, CONF_ON_ERROR, CONF_ON_RESPONSE from esphome.core import Lambda from esphome.types import ConfigType @@ -114,3 +116,43 @@ def test_on_no_response_retry_lambda_accepted() -> None: }, } ) + + +# 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 diff --git a/tests/components/modbus_client/common.yaml b/tests/components/modbus_client/common.yaml index 19acbe9b0e..cae2002342 100644 --- a/tests/components/modbus_client/common.yaml +++ b/tests/components/modbus_client/common.yaml @@ -5,6 +5,18 @@ # device is retried forever. Reset the counter before the send or on a terminal outcome (on_response) # so the cap is per transaction, not per device lifetime. Never reset in on_sent: it fires again on # every retry, so the cap would never be reached. + +# The standalone component: a bare hub device with nothing but an address, so a lambda can drive the +# device directly. Two entries cover both hub-binding paths - the auto-resolved single hub and an +# explicit modbus_id - and the button below calls them, so the generated device has to be usable +# rather than merely constructed (an unreferenced one is optimised away entirely). +modbus_client: + - id: bare_client + address: 0x01 + - id: bare_client_explicit_hub + modbus_id: modbus_bus + address: 0x02 + globals: - id: read_retries type: int @@ -14,6 +26,15 @@ globals: initial_value: "0" button: + # The bare modbus_client devices: no handlers, so nothing reports the outcome - see the component + # comment. Calls here only pin that the device is bound to its hub and the helpers are reachable. + - platform: template + name: "Bare Client" + on_press: + - lambda: |- + id(bare_client).write_single_register(0x10, 42); + id(bare_client).write_single_coil(0x01, true); + id(bare_client_explicit_hub).read_holding_registers(0x20, 4); - platform: template name: "Send Read" on_press: diff --git a/tests/components/modbus_client/validate-autoload.esp32-idf.yaml b/tests/components/modbus_client/validate-autoload.esp32-idf.yaml new file mode 100644 index 0000000000..318d492717 --- /dev/null +++ b/tests/components/modbus_client/validate-autoload.esp32-idf.yaml @@ -0,0 +1,16 @@ +# The modbus hub auto-loads modbus_client so the modbus_client.* actions are registered. That must not +# create a device on its own, which is what MULTI_CONF_NO_DEFAULT in the component buys: without it the +# auto-load builds a default entry and fails on the required address, breaking every modbus config. +# So this file deliberately declares no modbus_client: block - it is the no-block path, kept as its own +# fixture because common.yaml now declares one. +packages: + modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml + +button: + - platform: template + name: "Action without a component block" + on_press: + - modbus_client.read_holding_registers: + address: 0x01 + start_address: 0x10 + count: 1