Modbus Client
The modbus_client component provides actions for ad-hoc Modbus request/response exchanges from automations and
lambdas, using an existing Modbus hub in the client role.
There is no modbus_client: configuration block: the component loads automatically together with the hub. Each
action instance is its own client device on the hub, and the hub routes the reply back to the exact action that sent
the request. Because replies are matched by action identity rather than by device address, the address can be
templatable and overlapping sends from different actions do not interfere with each other.
A Modbus hub in the client role (the default) is required. modbus_id is only needed when
multiple hubs are configured.
modbus_client.send Action
Section titled “modbus_client.send Action”This action queues a Modbus frame for transmission and optionally handles the outcome.
on_...: - modbus_client.send: address: 0x01 pdu: [0x03, 0x00, 0x10, 0x00, 0x01] on_response: - lambda: 'ESP_LOGI("modbus", "got %u bytes", response.size());'Configuration Variables
Section titled “Configuration Variables”- modbus_id (Optional, ID): The Modbus hub to send on. Automatically assigned when a single hub is configured.
- address (Required, templatable, int): The device address the frame is sent to. Accepts
0-255; the valid Modbus device range is1-247, and0is broadcast (no reply will ever arrive; see Behavior). - pdu (Required, templatable, list of bytes): The PDU to send: function code followed by data. The device
address and CRC are added automatically - do not include them. Maximum 253 bytes (the Modbus PDU limit): an
empty or over-long YAML list fails config validation. As a lambda it must return a
modbus::helpers::PduBuffer(a fixed-capacity stack vector, notstd::vector):return {0x03, 0x00, 0x10, 0x00, 0x01};works, and so does directly returning the result of amodbus::helpers::create_*_pdu()builder - see Building PDUs with Helpers. Unlike the YAML list, a lambda result isn’t length-checked: one over 253 bytes is silently truncated to the limit rather than rejected. An empty lambda result (or a builder that fails - see below) also produces an empty PDU; the hub refuses to send it, andon_not_sentfires.
NOTE
None of the handlers below (on_sent, on_response, on_error, on_no_response, on_not_sent) may contain
deferring actions (delay, wait_until, script.wait, …) - the request/response spans are only valid while the
handler runs. Copy any bytes you need into a global first, then defer from a separate script or automation.
-
on_sent (Optional, Automation): Fires when the frame is written to the wire. Lambda variable:
request(std::span<const uint8_t>, the PDU sent). This fires once per transmission, before any reply, and does not fire when the send ends inon_not_sent(nothing was transmitted). Exactly one ofon_response/on_error/on_no_response/on_not_sentfollows each transmission attempt - a retry reopens this cycle for its own attempt; see Behavior. -
on_response (Optional, Automation): Fires when a matching reply arrives. Lambda variables:
requestandresponse, bothstd::span<const uint8_t>(PDUs: function code plus data, no address/CRC). The spans are only valid inside the handler - copy bytes out if they must outlive it. -
on_error (Optional, Automation): Fires on a Modbus exception response. Lambda variables:
request(std::span<const uint8_t>, the request PDU this action sent - same as inon_response) andexception_code(modbus::ExceptionCode, an enum - cast toint/uint8_tto print). There is noresponsespan, as an exception carries no data payload. -
on_no_response (Optional): Fires when the device does not answer within the hub’s
send_wait_time.request(std::span<const uint8_t>, the PDU that got no reply) is available in every form. Three ways to write it:-
a returning lambda (
on_no_response: !lambda "return <bool>;") - its boolean is returned to the hub:truere-queues (retries) the frame,falsegives up. The lambda can also log or bump a counter inline; -
a
then:automation (list of actions) - runs on timeout, no retry; -
a
then:automation with a nestedretry:returning lambda - runs the actions and decides the retry:- globals.set: { id: tries, value: '0' } # reset before each new send, not in a handler- modbus_client.send:address: 0x01pdu: [0x03, 0x00, 0x10, 0x00, 0x01]on_no_response:then:- logger.log: "no reply, retrying"retry: !lambda "return id(tries)++ < 3;"
The retry decision must be a lambda because an automation cannot return a value. The hub does not bound retries - the lambda must (for example by capping on a counter), otherwise the frame is retried forever.
triesabove is a global, and it must be reset before this send fires, not from a handler:on_sentfires on every retransmission (including retries), so resetting there would zero the counter on each retry and defeat the cap; resetting only on success (on_response) leaves a stale, already-capped counter for the next independent send if this one gives up. -
-
on_not_sent (Optional, Automation): Fires when the frame never reached the wire - for example, an empty PDU, a duplicate write already pending (see Duplicate suppression), a full send queue, or cancelled by another device or automation clearing the queue for the same address. Distinct from a timeout: nothing was transmitted. Lambda variable:
request(std::span<const uint8_t>, the PDU that was not sent).
Building PDUs with Helpers
Section titled “Building PDUs with Helpers”Instead of hand-assembling bytes, a pdu lambda can call the builders in modbus::helpers and return the result
directly (all return types convert to PduBuffer):
create_read_pdu(function_code, start_address, count)- reads; takes amodbus::FunctionCodeenum value (READ_COILS,READ_DISCRETE_INPUTS,READ_HOLDING_REGISTERS,READ_INPUT_REGISTERS).create_write_single_register_pdu(address, value)/create_write_single_coil_pdu(address, on_off): single writes.create_write_registers_pdu(start_address, values): function code0x10multi-write;valuesis a span ofuint16_t.create_write_coils_pdu(start_address, values): function code0x0Fmulti-write;valuesis a span ofbool.std::vector<bool>is bit-packed and won’t convert to a span - pass astd::array<bool, N>or other contiguous bool container instead. An overload takingPackedBits(bits already packed as they go on the wire) is also available for callers who need to write a large number of coils.
The builders validate their inputs per the Modbus specification (for example read counts and payload sizes) and
return an empty PDU on invalid input, logging the reason. An empty PDU is never sent - the action’s on_not_sent
fires instead, so bad inputs fail safe, and loud enough to show up in the logs.
- modbus_client.send: address: 0x01 pdu: !lambda |- return modbus::helpers::create_read_pdu( modbus::FunctionCode::READ_HOLDING_REGISTERS, 0x0010, 2); on_response: - lambda: |- if (response.size() >= 4) ESP_LOGI("app", "reg 0x10 = %u", (response[2] << 8) | response[3]);The enum and the builders live in the modbus component’s namespaces (modbus::FunctionCode,
modbus::helpers::*); no extra include is needed in YAML lambdas.
Behavior
Section titled “Behavior”- Fire-and-continue: the action queues the frame and the enclosing automation continues immediately; the reply
handlers run later, when the outcome is known. Exactly one of
on_response/on_error/on_no_response/on_not_sentfires per transmission attempt; a retried frame runs the timeout branch again on each attempt. - The reply handlers run with the action’s own context - they do not see the enclosing automation’s local
variables (such as the
xof a containingon_value). - Duplicate suppression: sending an identical frame while one from the same action is already in flight is
safe. For a read, the second send is rescheduled for a second transmission at the back of the queue once the
first completes, and still gets its own terminal. For a write or custom command, only one may be in flight - a
second identical send is refused (
on_not_sent). - Broadcast (address
0): no device replies to a broadcast, so use it fire-and-forget (omiton_response). The hub doesn’t special-case address0- it still waits the fullsend_wait_timebefore considering the send done, so a broadcast blocks the bus for that long even though nothing will ever reply. Since exactly one terminal always fires, a broadcast always ends inon_no_response- aretry:lambda attached to it can never be satisfied, so without its own counter cap the frame retransmits forever.
Examples
Section titled “Examples”A minimal fire-and-forget write:
button: - platform: template name: "Reset energy counter" on_press: - modbus_client.send: address: 0x01 pdu: [0x42] # vendor-specific reset commandA read with reply handling. A read response PDU is [function code][byte count][data...], so the first register is
in bytes 2-3 (big-endian). Decode manually as below, or extract the payload with
modbus::helpers::server_pdu_payload(response) instead of hardcoding the offset yourself - it applies the
read-vs-exception offset for you and returns an empty span for a too-short PDU:
button: - platform: template name: "Read holding register 0x10" on_press: - modbus_client.send: address: 0x01 pdu: [0x03, 0x00, 0x10, 0x00, 0x01] # fc 0x03, start 0x0010, count 1 on_response: - lambda: |- if (response.size() >= 4) id(my_value).publish_state((response[2] << 8) | response[3]);A templated device address:
- modbus_client.send: address: !lambda "return id(target_address);" pdu: [0x03, 0x00, 0x00, 0x00, 0x02]Handling the error and timeout branches:
- modbus_client.send: address: 0x01 pdu: [0x03, 0x00, 0x10, 0x00, 0x01] on_error: - lambda: |- // request[0] is the function code we sent ESP_LOGW("modbus", "fc 0x%02X exception %d", request.empty() ? 0 : request[0], (int) exception_code); on_no_response: - lambda: 'id(device_online).publish_state(false);'See Also
Section titled “See Also”- For polling sensors mapped to registers, use Modbus Controller; for serving
registers, use Modbus Server.
modbus_clientis the ad-hoc escape hatch. - C++ component authors should subclass
modbus::ModbusClientDeviceinstead of using these actions. - Modbus Component
- API Reference: modbus.h