8000
Skip to content

[modbus_client] Add component for ad-hoc modbus request/response - #17676

Merged
bdraco merged 24 commits into
esphome:devfrom
exciton:modbus_callback_client
Aug 6, 2026
Merged

[modbus_client] Add component for ad-hoc modbus request/response#17676
bdraco merged 24 commits into
esphome:devfrom
exciton:modbus_callback_client

Conversation

@exciton
@exciton exciton commented Jul 18, 2026
Copy link
Copy Markdown
Contributor

This PR is part of a series of fixes for modbus and related components.

Core modbus architecture:

Overhaul of modbus_controller

New features for server mode

New features for client mode

There are associated tests


What does this implement/fix?

Adds a new modbus_client action-only component: ad-hoc modbus request/response (or fire-and-forget) from YAML or a lambda, without hand-rolling a modbus::ModbusClientDevice subclass in C++.

The actions

Nothing is declared: the actions only need a modbus hub with role: client (the default).
Each action instance is its own modbus::ModbusClientDevice on the hub, so the hub routes the reply (or its absence) back to the exact action that sent it, by device pointer, with no request-matching table.
modbus_client.send fires an ad-hoc PDU (function code + data) at a templatable address; the hub prepends the address and appends the CRC.
The pdu is templatable too: a lambda returns a stack-allocated modbus::helpers::PduBuffer, either hand-assembled bytes or the result of a modbus::helpers::create_*_pdu() builder.

Per-send handlers

Every handler belongs to the send that fired it.
on_sent fires when the frame reaches the wire; then exactly one of on_response / on_error / on_no_response / on_not_sent delivers the outcome.
on_error exposes (request, exception_code) — an exception response is fixed-format, so the exception code, not a response PDU, is the useful payload.
on_no_response (a timeout) can grant a retry: a returning lambda — standalone, or nested as retry: under a then: automation — returns true to have the hub re-queue the frame.
on_not_sent (the frame never reached the wire: a refused duplicate write, a cleared queue, a full tx buffer) is distinguished from on_no_response, and sends the hub refuses at the door resolve through it as well, so no outcome is silently swallowed.
Because replies are matched by action identity rather than by address or request bytes, a templated address cannot mis-route an earlier reply, and overlapping sends do not cross wires.
Handlers must run to completion: deferring actions (delay, wait_until, script.wait, ...) are rejected at validation, because the request/response spans are only valid while the handler runs.
An action stores only its triggers and two pointer-sized templated fields; there is no handler table and nothing allocates after setup().

Supporting changes

esphome/core/helpers.h gains a StaticVector converting constructor from a smaller StaticVector of the same element type (with unit tests), so the fixed-size create_*_pdu() builder results convert to PduBuffer.
The modbus hub AUTO_LOADs modbus_client, so the actions are available whenever a client hub exists; they are registry entries only, and no code is generated unless a config uses one.

Covered by an integration test driving a mock UART pair (inline response decode, the timeout path, and the refused-duplicate on_not_sent path) plus component compile tests for esp32-idf / esp8266-ard / rp2040-ard.

Types of changes

  • Bugfix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected) — policy
  • Developer breaking change (an API change that could break external components) — policy
  • Undocumented C++ API change (removal or change of undocumented public methods that lambda users may depend on) — policy
  • Code quality improvements to existing code or addition of tests
  • Other

Related issue or feature (if applicable):

  • Part of the modbus overhaul series listed above

Pull request in esphome.io with documentation (if applicable): esphome/esphome.io#7106

Test Environment

  • ESP32
  • ESP32 IDF
  • ESP8266
  • RP2040/RP2350
  • BK72xx
  • RTL87xx
  • LN882x
  • nRF52840

Example entry for config.yaml:

# No component block: the actions use the modbus hub (client role) directly.
button:
  - platform: template
    name: "Read"
    on_press:
      # Ad-hoc read-holding request (fc 0x03, start 0x0010, count 1) with inline reply handlers.
      - modbus_client.send:
          address: 0x01
          pdu: [0x03, 0x00, 0x10, 0x00, 0x01]
          on_response:
            then:
              - lambda: |-
                  if (response.size() >= 4)
                    id(my_value).publish_state((response[2] << 8) | response[3]);
          on_no_response:
            then:
              - logger.log: "Device did not answer"
      # address and pdu are templatable; a pdu lambda can build the frame with the modbus helpers.
      - modbus_client.send:
          address: !lambda "return id(target_address);"
          pdu: !lambda "return modbus::helpers::create_read_pdu(modbus::FunctionCode::READ_HOLDING_REGISTERS, 0x0010, 2);"

Checklist:

  • The code change is tested and works locally.
  • Tests have been added to verify that the new code works (under tests/ folder).

If user exposed functionality or configuration variables are added/changed:

@esphome
esphome Bot commented Jul 18, 2026
Copy link
Copy Markdown
Contributor

To use the changes from this PR as an external component, add the following to your ESPHome configuration YAML file:

external_components:
  - source: github://pr#17676
    components: [modbus, modbus_client]
    refresh: 1h

(Added by the PR bot)

@esphome
esphome Bot commented Jul 18, 2026
Copy link
Copy Markdown
Contributor

👋 Hi there! This PR modifies 24 file(s) with codeowners.

@leeuwte, @sourabhjaiswal, @ssieb, @martgras, @stegm, @jesserockz, @polyfaces - As codeowner(s) of the affected files, your review would be appreciated! 🙏

Note: Automatic review request may have failed, but you're still welcome to review.

esphome[bot]
esphome Bot previously requested changes Jul 18, 2026
@esphome esphome Bot left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📦 Pull Request Size

Hey @exciton, thanks for the contribution! Just a heads up, this PR is on the large side (1114 line changes excluding tests), which makes it harder for maintainers to review.

Smaller, focused PRs tend to be reviewed much faster since they fit into the short gaps between other maintainer work; large ones often have to wait for a rare long uninterrupted block of time. If you can break this up into smaller pieces that can be reviewed independently, it will almost certainly land faster overall.

Before putting more time in, it's also worth popping into #devs on Discord so we can help you scope things and flag anything already in flight.

For more details (including how to split the work up), see: https://developers.esphome.io/c 8000 ontributing/submitting-your-work/#how-to-approach-large-submissions

@esphome
esphome Bot commented Jul 18, 2026
Copy link
Copy Markdown
Contributor

Please take a look at the requested changes, and use the Ready for review button when you are done, thanks 👍

Learn more about our pull request process.

@exciton exciton mentioned this pull request Jul 18, 2026
18 tasks
@codecov
codecov Bot commented Jul 18, 2026
Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 87.28%. Comparing base (eb0fac9) to head (e539ffd).

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##              dev   #17676      +/-   ##
==========================================
+ Coverage   87.13%   87.28%   +0.15%     
==========================================
  Files          64       64              
  Lines       14697    14697              
  Branches     2217     2217              
==========================================
+ Hits        12806    12829      +23     
+ Misses       1578     1558      -20     
+ Partials      313      310       -3     

see 4 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions
github-actions Bot commented Jul 18, 2026
Copy link
Copy Markdown
Contributor

Memory Impact Analysis

Components: modbus, modbus_client
Platform: esp8266-ard

Metric Target Branch This PR Change
RAM 29,568 bytes 59,560 bytes 📈 🚨 +29,992 bytes (+101.43%)
Flash 274,971 bytes 559,258 bytes 📈 🚨 +284,287 bytes (+103.39%)
📊 Component Memory Breakdown
Component Target Flash PR Flash Change
[esphome]modbus 4,964 bytes 10,470 bytes 📈 🚨 +5,506 bytes (+110.92%)
[esphome]core 7,532 bytes 9,061 bytes 📈 🚨 +1,529 bytes (+20.30%)
[esphome]modbus_client 0 bytes 789 bytes 📈 🔸 +789 bytes (0.00%)
app_framework 1,366 bytes 1,809 bytes 📈 +443 bytes (+32.43%)
[esphome]uart 2,473 bytes 2,701 bytes 📈 🚨 +228 bytes (+9.22%)
rom_functions 5,441 bytes 5,505 bytes 📈 +64 bytes (+1.18%)
[esphome]globals 0 bytes 50 bytes 📈 🔸 +50 bytes (0.00%)
exception_handling 2,488 bytes 2,531 bytes 📈 +43 bytes (+1.73%)
[esphome]template 0 bytes 14 bytes 📈 🔸 +14 bytes (0.00%)
cpp_runtime 53 bytes 61 bytes 📈 +8 bytes (+15.09%)
[esphome]logger 1,623 bytes 1,628 bytes 📈 🔸 +5 bytes (+0.31%)
🔍 Symbol-Level Changes (click to expand)

Changed Symbols

Symbol Target Size PR Size Change
setup 497 bytes 1,091 bytes 📈 +594 bytes (+119.52%)
esphome::uart::ESP8266UartComponent::setup() 89 bytes 305 bytes 📈 +216 bytes (+242.70%)
esphome::App 120 bytes 136 bytes 📈 +16 bytes (+13.33%)
esphome::uart::ESP8266UartComponent::get_config() 133 bytes 137 bytes 📈 +4 bytes (+3.01%)
esphome::uart::ESP8266UartComponent::load_settings(bool) 142 bytes 138 bytes 📉 -4 bytes (-2.82%)
esphome::COMP_SRC_TABLE 16 bytes 20 bytes 📈 +4 bytes (+25.00%)

New Symbols (top 15)

Symbol Size
esphome::modbus::ModbusClientHub::send_pdu(unsigned char, std::span<unsigned char const, 42949672...esphome::modbus::ModbusClientHub::send_pdu(unsigned char, std::span<unsigned char const, 4294967295u>, esphome::modbus::ModbusClientDevice*, esphome::modbus::CommandOptions)
952 bytes
esphome::modbus::ModbusClientDevice::dispatch_response_(std::span<unsigned char const, 4294967295...esphome::modbus::ModbusClientDevice::dispatch_response_(std::span<unsigned char const, 4294967295u>, std::span<unsigned char const, 4294967295u>, std::optionalesphome::modbus::ExceptionCode)
878 bytes
esphome::modbus::helpers::is_client_pdu_standard(unsigned char const*, unsigned int) 417 bytes
esphome::modbus::helpers::is_server_pdu_standard(unsigned char const*, unsigned int) 313 bytes
esphome::modbus::helpers::client_pdu_length(unsigned char const*, unsigned int) 254 bytes
esphome::modbus::helpers::create_read_pdu(esphome::modbus::FunctionCode, unsigned short, unsigned...esphome::modbus::helpers::create_read_pdu(esphome::modbus::FunctionCode, unsigned short, unsigned short)
215 bytes
esphome::EntityBase::configure_entity_(char const*, unsigned int, unsigned int) 165 bytes
vtable for esphome::modbus_client::ModbusClientSendAction<> 144 bytes
vtable for esphome::modbus_client::ClientActionBase<> 140 bytes
esphome::modbus::ModbusFrame::ModbusFrame(unsigned char, unsigned char const*, unsigned short) 135 bytes
esphome::modbus::ModbusClientHub::clear_tx_queue_for_device(esphome::modbus::ModbusClientDevice*) 115 bytes
esphome::modbus_client::ModbusClientSendAction<>::play() 109 bytes
modbus__modbus_bus__pstorage 96 bytes
vtable for esphome::modbus::ModbusClientDevice 88 bytes
void esphome::modbus::helpers::append_pdu_header<5u>(esphome::StaticVector<unsigned char, 5u>&, e...void esphome::modbus::helpers::append_pdu_header<5u>(esphome::StaticVector<unsigned char, 5u>&, esphome::modbus::FunctionCode, unsigned short, unsigned short) [$constprop$0]
87 bytes
119 more new symbols... Total: 7,165 bytes

Removed Symbols (top 15)

Symbol Size
modbus__mod_bus1__pstorage 96 bytes

Note: This analysis measures static RAM and Flash usage only (compile-time allocation).
Dynamic memory (heap) cannot be measured automatically.
⚠️ You must test this PR on a real device to measure free heap and ensure no runtime memory issues.

This analysis runs automatically when components change. Memory usage is measured from a merged configuration with 2 components.

@esphbot
esphbot commented Aug 4, 2026
Copy link
Copy Markdown
Contributor

Previous review — superseded by a newer review below.

@esphbot esphbot left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Important issues found.

  • Empty PDU still exits play() with no outcome trigger

exciton added 2 commits August 4, 2026 22:39
… structure

An empty lambda PDU now resolves via on_not_sent instead of vanishing (the hub already refuses it with a warning); pdu lists must be 1-253 bytes at validation, with the limit shared from the modbus package.
The on_error override moves into ClientActionBase beside the trigger register_client_action() wires for every action, and the retry function drops its redundant optional wrapper.
The test retry lambdas now bound their retries with a counter.
@esphbot
esphbot commented Aug 5, 2026 A3E2
Copy link
Copy Markdown
Contributor

Previous review — superseded by a newer review below.

@esphbot esphbot left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Important issues found.

  • Deferring actions inside the reply handlers make the PDU spans dangle (use-after-free read)

…etry counter

The request/response spans are only valid while a handler runs, and DelayAction captures trigger args by value for later replay, so delay/wait_until/script.wait inside any handler now fail validation (same guard the api component uses).
The combined-retry example resets its counter on success so the cap applies per transaction.
/// Re-firing an action while its identical frame is still pending follows the hub's dedup rules: a
/// duplicate read is absorbed into the pending transaction (one reply serves both), a duplicate write is
/// dropped and resolves via on_not_sent.
template<typename... Ts> class ClientActionBase : public Action<Ts...>, public modbus::ModbusClientDevice {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

esphbot: be sure to check for simplification opportunities

https://developers.esphome.io/architecture/components/automations/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read the automations doc. Checked both options here.

build_callback_automation targets components exposing an add_on_*_callback template over a callback manager. These outcomes arrive as virtual overrides on modbus::ModbusClientDevice (on_sent/on_response/on_error/on_no_response/on_not_sent), not callbacks. No manager to register into.

Cost also favours triggers. Trigger holds one automation_parent_ pointer — 4 bytes (automation.h:482). LazyCallbackManager also 4; CallbackManager 12. So a forwarder saves nothing and adds a registration path. Five triggers = 20 bytes per send action.

One real simplification exists: play_complex (line 68) overrides only to stamp target_address_. Fold that into ModbusClientSendAction::play() and drop a vtable slot. Stated rationale — subclasses cannot forget — pays off only once sibling actions land in #17467 / #17676.

Separate simplification worth taking now: _synchronous_handler sits inside cv.Any on on_no_response, which swallows its message. Hoisting it outside removes a nested cv.All. Detail in the inline finding.

@esphbot
esphbot commented Aug 5, 2026
Copy link
Copy Markdown
Contributor

Previous review — superseded by a newer review below.

@esphbot esphbot left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tip

No blocking issues found — ready to merge.

- Hoist the deferring-actions guard outside the on_no_response cv.Any: inside
  it, the lambda branch's error always won and the real message never showed.
- Note that a lambda-built PDU over MAX_PDU_SIZE is silently truncated.
- Drop the incorrect "no reply" claim from the target_address comment; a
  broadcast still resolves through on_no_response.
- Reset the retry counter in the first common.yaml example so its cap is per
  transaction rather than per device lifetime.
- Type-hint register_client_action().
- Add config-validation tests pinning the guard to every handler slot,
  including deferring actions nested in if:/repeat: blocks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Awrm2eoWiMsJzsDUdiyLik
@esphbot
esphbot commented Aug 6, 2026
Copy link
Copy Markdown
Contributor

Previous review — superseded by a newer review below.

@esphbot esphbot left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tip

No blocking issues found — ready to merge.

@exciton
exciton commented Aug 6, 2026
Copy link
Copy Markdown
Contributor Author

Fixed 1&2
3 is more of a core issue - all StaticVector uses suffer from silent overflow
4 means putting the address stamp in every subclass which is error prone. Will revisit after the typed actions PR which creates many subclasses.

The retry counter was reset in on_sent, which fires again on every retry, so
the cap was never reached and a dead device was retried forever. Reset it
before the send instead, and say in the header comment why on_sent is the one
place it must not go.

Also drop the class comment's duplicate-read claim: a duplicate read is not
served by one reply. It increments the pending count and the sweep re-queues
the frame, so each pending request gets its own on_response.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Awrm2eoWiMsJzsDUdiyLik
Comment thread esphome/core/helpers.h
Comment on lines +259 to +263
// Converting constructor from a smaller StaticVector of the same element type
template<size_t M> StaticVector(const StaticVector<T, M> &other) : StaticVector(other.begin(), other.end()) {
static_assert(M <= N, "Source StaticVector cannot be larger than the destination");
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should get a separate PR since it changes core helpers

@esphbot
esphbot commented Aug 6, 2026
Copy link
Copy Markdown
Contributor

@exciton: Fixed 1&2

Confirmed 1 and 2 fixed. read_retries now resets before send, not on_sent. Duplicate-read comment corrected.

3: agreed, core scope. Dropped, not re-raising.

4: agreed, deferred. Recorded non-blocking.

One new item, same family as 1: combined_retries (line 41) resets only from on_response. Silent device never reaches that terminal. After one failed transaction counter sits at 4, so every later press retries zero times. Reset before send, like read_retries.

@esphbot
esphbot commented Aug 6, 2026
Copy link
Copy Markdown
Contributor

PR Review — [modbus_client] Add component for ad-hoc modbus request/response

Merge-ready. Everything that mattered from the previous rounds is fixed; what remains is nits, one of them in the example YAML.

Specific things done well this round. The read_retries reset moved out of on_sent and in front of the send (common.yaml:20), which is the correct hook — on_sent re-fires on every retransmission via requeue(), so the old placement made the cap unreachable. The misleading "one reply serves both" duplicate-read comment is gone from modbus_client.h. The new tests/component_tests/modbus_client/test_modbus_client.py pins the memory-safety guard with real teeth: all five handler slots, direct and nested, and I verified the nesting cases are meaningful rather than accidental — if and repeat are both registered synchronous=True (automation.py:420, :476) while delay is synchronous=False (:390), so has_non_synchronous_actions really is recursing to find the buried delay.

I also re-verified the plumbing end to end rather than trusting the diff: cg.templatable wraps a constant pdu: list in a stateless lambda (cpp_generator.py:868-874), and PduBuffer stays trivially copyable despite the new template constructor, so TEMPLATABLE_VALUE resolves to TemplatableFn and really does store 4 bytes; process_lambda's default capture is "" (cpp_generator.py:765), so the retry: lambda legitimately converts to the raw bool (*)(std::span<const uint8_t>) function pointer; ModbusFrame memcpy's the PDU into its own buffer (modbus.h:39-46), so the stack-local PduBuffer in play() cannot dangle; and the AUTO_LOAD path is sound — config_schema is None and to_code is None are both skipped, so no code is generated for a server-only hub. The duplicate-write path the integration test asserts is real: increment_pending() refuses at max_pending() == 1 for a write (modbus.cpp:778-783), returning false into on_not_sent.

One correction to an earlier round of mine: the "failures are invisible without opt-in YAML" concern does not hold. The hub already logs timeouts (modbus.cpp:77), exception replies (:334), empty and oversize PDUs (:728, :733), refused duplicates (:763, :781) and a full queue (:797). Nothing is silent at the hub level. Not re-raising it.

  • combined_retries in common.yaml resets only from on_response, so a device that never answers exhausts its budget permanently after one transaction — contradicting the file's own header claim that the cap is per transaction
  • modbus_client would be the only component in the tree with no CONFIG_SCHEMA, so a stray modbus_client: block is accepted and silently ignored
  • The StaticVector converting constructor static_asserts in its body instead of constraining, turning an over-capacity conversion into a hard error inside overload resolution in a header the whole tree includes
  • ConvertingConstructorSameSize resolves to the implicit copy constructor, so it doesn't test the new code
  • MAX_PDU_SIZE is hand-mirrored from modbus_definitions.h with only a comment guarding drift
  • play_complex fold — deferred by the author to the typed-actions PR, recorded only

✅ Resolved since last review (2)

Previously-flagged issues verified fixed
  • tests/components/modbus_client/common.yaml:24 Resetting read_retries in on_sent makes the retry cap unreachable
  • esphome/components/modbus_client/modbus_client.h:17 Class comment misstates the duplicate-read behaviour ("one reply serves both")

🟢 Suggestions

1. `combined_retries` is reset only in `on_response`, so a never-answering device permanently exhausts its retry budget
tests/components/modbus_client/common.yaml:38-41

The first example now resets read_retries before the send (line 20) — that fix is correct and I verified it against the hub. The third example still resets combined_retries only from on_response (line 41), which is the one terminal that never fires for the case retries exist for.

Trace it against this file's own header claim ("so the cap is per transaction, not per device lifetime"):

  • Press 1, device silent: 0<3 → retry, 1<3 → retry, 2<3 → retry, 3<3 false → give up. combined_retries ends at 4.
  • Press 2, device still silent: 4<3 is false on the very first timeout → zero retries, and combined_retries climbs to 5.
  • on_response never runs, so the counter is never reset again.

So the cap here is per device lifetime, not per transaction — the opposite of what lines 4-7 tell the reader. It matters because this file is the in-repo example a YAML author copies, and the failure is silent: the retry logic simply stops working after the first fully-failed transaction.

Cheapest fix is the same one the first example already got — reset before the send:

      - lambda: "id(combined_retries) = 0;"
      - modbus_client.send:
          address: !lambda "return 1;"
          ...

The on_response reset can stay (harmless) or go.

          on_response:
            then:
              - lambda: |-
                  id(combined_retries) = 0;

Checklist

  • Handler PDU spans cannot outlive the handler (deferring actions rejected, and the rejection is tested)
  • Every send resolves to exactly one outcome, including refusals at the door
  • No heap allocation after setup() on the new path
  • Input validation at config boundaries
  • Core header change is justified, tested, and safely constrained
  • In-repo example YAML is safe to copy — suggestion #1
  • Diff matches the PR description; no scope creep
  • Test coverage: config validation, C++ unit, integration, and per-platform compile
ℹ️ Triage summary

4 pre-existing finding(s) on unchanged code suppressed (freeze).


Silent Failure Analysis

🟠 **HIGH** — silent truncation of oversized input
esphome/components/modbus_client/modbus_client.h:80-86

Risk: A pdu: lambda returning more than 253 bytes is silently clipped by PduBuffer (StaticVector's initializer-list/range constructors break at capacity, see esphome/core/helpers.h:245-257), and because the buffer's capacity is MAX_PDU_SIZE the hub's pdu.size() > MAX_PDU_SIZE guard (esphome/components/modbus/modbus.cpp:732) can never fire — so a truncated frame is transmitted with a valid CRC and a slave may act on a malformed write; the diff's own comments acknowledge this but nothing reports it.

void play(const Ts &...x) override {
  auto pdu = this->pdu_.value(x...);
  const std::span<const uint8_t> span(pdu.data(), pdu.size());
  if (!this->send_pdu(span))
    this->on_not_sent(span);
}

Fix: Have the builders/lambda path signal overflow (e.g. give StaticVector a truncated()/try_push_back() flag, or size PduBuffer at MAX_PDU_SIZE+1 so the hub's oversize check can fire) and refuse the send with a log instead of shipping a clipped PDU.

🟡 **MEDIUM** — error cause discarded / opt-in-only failure surface
esphome/components/modbus_client/modbus_client.h:83-85

Risk: Four distinct refusal reasons — empty/invalid PDU (a failed create_*_pdu returns empty), oversize, tx-queue full, and duplicate-write dedup — collapse into one argument-less on_not_sent, so a handler cannot tell a transient backpressure drop from a permanently malformed request; with no on_not_sent: configured, the dedup refusal (the exact case the new integration test exercises) is only visible at ESP_LOGD.

// The hub refuses some sends at the door with no callback (an empty PDU, a duplicate write already
// pending, a full queue). Every send still gets exactly one outcome, so resolve those via on_not_sent.
if (!this->send_pdu(span))
  this->on_not_sent(span);

Fix: Pass the refusal reason through to on_not_sent (an enum from send_pdu) and log at warning level in play() when the send is refused, so the failure is visible without an opt-in handler.

🟡 **MEDIUM** — hand-mirrored constant with no parity check
esphome/components/modbus/__init__.py:17-22

Risk: The only link between this validation bound and modbus_definitions.h:112 is a comment, so if the C++ constant ever changes the Python cv.Length(max=...) silently diverges — a too-large bound lets configs validate and then get truncated on the wire (see the finding above), a too-small one rejects valid configs.

# Mirrors modbus::MAX_PDU_SIZE in modbus_definitions.h: 256-byte RTU frame minus address and CRC.
MAX_PDU_SIZE = 253

Fix: Add a unit test that parses MAX_PDU_SIZE out of modbus_definitions.h and asserts it equals the Python constant (or derive one from the other).

🟡 **MEDIUM** — test silently exercises the wrong code path
tests/components/core/helpers_test.cpp:77-83

Risk: A constructor template is never a copy constructor, and a non-template exact match beats a template specialization, so StaticVector<int,3> dst = src; calls the implicitly-declared copy constructor — the M==N case of the new converting constructor is not covered despite the test name claiming it is.

TEST(StaticVectorTest, ConvertingConstructorSameSize) {
  StaticVector<int, 3> src{1, 2, 3};
  StaticVector<int, 3> dst = src;
  ASSERT_EQ(dst.size(), 3u);
  EXPECT_EQ(dst[2], 3);
}

Fix: Rename the test or construct explicitly through the template (e.g. via a helper taking const StaticVector<T, M> &) so a regression in the M==N converting path actually fails.


Automated review by Kōan (Claude) HEAD=8940150 12 min 53s

@esphbot esphbot left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tip

No blocking issues found — ready to merge.

@bdraco bdraco left a comment
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @exciton

@esphome esphome Bot removed the core label Aug 6, 2026
@bdraco
bdraco merged commit 5668253 into esphome:dev Aug 6, 2026
43 of 44 checks passed
@exciton
exciton deleted the modbus_callback_client branch August 6, 2026 20:03
@exciton exciton mentioned this pull request Aug 7, 2026
19 tasks
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 8, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

0