Skip to content

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.

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());'
  • 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 is 1-247, and 0 is 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, not std::vector): return {0x03, 0x00, 0x10, 0x00, 0x01}; works, and so does directly returning the result of a modbus::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, and on_not_sent fires.

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 in on_not_sent (nothing was transmitted). Exactly one of on_response/on_error/on_no_response/on_not_sent follows 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: request and response, both std::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 in on_response) and exception_code (modbus::ExceptionCode, an enum - cast to int/uint8_t to print). There is no response span, 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: true re-queues (retries) the frame, false gives 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 nested retry: 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: 0x01
      pdu: [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. tries above is a global, and it must be reset before this send fires, not from a handler: on_sent fires 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).

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 a modbus::FunctionCode enum 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 code 0x10 multi-write; values is a span of uint16_t.
  • create_write_coils_pdu(start_address, values): function code 0x0F multi-write; values is a span of bool. std::vector<bool> is bit-packed and won’t convert to a span - pass a std::array<bool, N> or other contiguous bool container instead. An overload taking PackedBits (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.

  • 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_sent fires 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 x of a containing on_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 (omit on_response). The hub doesn’t special-case address 0 - it still waits the full send_wait_time before 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 in on_no_response - a retry: lambda attached to it can never be satisfied, so without its own counter cap the frame retransmits forever.

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 command

A 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);'