[modbus_client] Lambda sugar (#18146)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Bonne Eggleston
2026-08-07 16:09:15 -05:00
committed by GitHub
co-authored by Claude
parent 4a7d270494
commit b0aec6dc2f
4 changed files with 113 additions and 1 deletions
@@ -8,6 +8,7 @@ import esphome.config_validation as cv
from esphome.const import ( from esphome.const import (
CONF_ADDRESS, CONF_ADDRESS,
CONF_COUNT, CONF_COUNT,
CONF_ID,
CONF_ON_ERROR, CONF_ON_ERROR,
CONF_ON_RESPONSE, CONF_ON_RESPONSE,
CONF_VALUE, CONF_VALUE,
@@ -17,6 +18,10 @@ from esphome.types import ConfigType, TemplateArgsType
CODEOWNERS = ["@exciton"] CODEOWNERS = ["@exciton"]
DEPENDENCIES = ["modbus"] 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_CUSTOM_RESPONSE = "on_custom_response"
CONF_ON_NO_RESPONSE = "on_no_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. # 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") _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: def _packed_bit_bytes(bits: int) -> int:
"""Mirrors modbus::packed_bit_bytes(): bytes needed to hold this many coils on the wire.""" """Mirrors modbus::packed_bit_bytes(): bytes needed to hold this many coils on the wire."""
@@ -7,14 +7,16 @@ guard is a safety property: these tests pin it to every handler slot.
import pytest import pytest
from esphome import config_validation as cv from esphome import config_validation as cv
from esphome.components import modbus_client
from esphome.components.modbus_client import ( from esphome.components.modbus_client import (
CONF_ON_NO_RESPONSE, CONF_ON_NO_RESPONSE,
CONF_ON_NOT_SENT, CONF_ON_NOT_SENT,
CONF_ON_SENT, CONF_ON_SENT,
CONF_PDU, CONF_PDU,
CONFIG_SCHEMA,
MODBUS_CLIENT_SEND_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.core import Lambda
from esphome.types import ConfigType 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
@@ -5,6 +5,18 @@
# device is retried forever. Reset the counter before the send or on a terminal outcome (on_response) # 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 # 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. # 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: globals:
- id: read_retries - id: read_retries
type: int type: int
@@ -14,6 +26,15 @@ globals:
initial_value: "0" initial_value: "0"
button: 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 - platform: template
name: "Send Read" name: "Send Read"
on_press: on_press:
@@ -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