mirror of
https://github.com/esphome/esphome.git
synced 2026-08-22 22:26:21 +00:00
[modbus_client] Lambda sugar (#18146)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude
parent
4a7d270494
commit
b0aec6dc2f
@@ -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
|
||||||
Reference in New Issue
Block a user