8000
Skip to content

[modbus] Continuous polling for reads - #12612

Closed
exciton wants to merge 72 commits into
esphome:devfrom
exciton:modbus_overhaul_priority_queue
Closed

[modbus] Continuous polling for reads#12612
exciton wants to merge 72 commits into
esphome:devfrom
exciton:modbus_overhaul_priority_queue

Conversation

@exciton
@exciton exciton commented Dec 21, 2025
Copy link
Copy Markdown
Contributor

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

The features in this branch were fully implemented in #17922 so this PR was closed.

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?

Stacked on #17886 and #17887 — the first commits in the diff belong to those PRs; this PR's own delta is continuous polling (split proposal).

Continuous polling for reads

device->read_holding_registers(0x100, 4, {.continuous = true});  // also on the other read helpers and send_pdu()

A continuous read re-queues itself at the lowest priority (READ_CONTINUOUS) after every successful response, filling idle bus time with polling; writes always outrank it and fresh one-shot reads run first.
Failures are not re-queued — the caller's normal update path recovers — and continuous is ignored for writes.

The dedup rules protect the poll: only a READ_ONCE entry is ever promoted, so a duplicate send never demotes a continuous entry into one that stops after two more runs, and an incoming {.continuous = true} request that matches a queued one-shot turns that entry into the continuous poll.
A retried continuous read stays continuous.

Cancelling from inside the response callback

The command under completion is exposed to clear_tx_queue_for_device()/clear_tx_queue_for_address() for the duration of its on_response()/on_error() callbacks (the completing_ slot), so "stop polling now" from inside the response callback actually cancels the pending re-queue — previously a documented limitation.

Every continuous lifecycle still balances: one terminal callback per accepted command, with each successful re-queue counting as a new command (ContinuousLifecyclesBalance).

This is the base for the upcoming continuous: option in modbus_controller (#17677).

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):

Test Environment

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

Example entry for config.yaml:

# No configuration changes.

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:

@github-actions
github-actions Bot commented Dec 21, 2025
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#12612
    components: [modbus]
    refresh: 1h

(Added by the PR bot)

@codecov-commenter
codecov-commenter commented Dec 21, 2025
Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 86.60%. Comparing base (fa8c7e6) to head (687f75b).
⚠️ Report is 31 commits behind head on dev.

Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff           @@
##              dev   #12612   +/-   ##
=======================================
  Coverage   86.59%   86.60%           
=======================================
  Files          60       60           
  Lines       14214    14217    +3     
  Branches     2146     2147    +1     
=======================================
+ Hits        12309    12312    +3     
  Misses       1594     1594           
  Partials      311      311           

see 1 file 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 Dec 21, 2025
Copy link
Copy Markdown
Contributor

Memory Impact Analysis

Components: modbus
Platform: esp8266-ard

Metric Target Branch This PR Change
RAM 29,160 bytes 29,160 bytes ➡️ +0 bytes (0.00%)
Flash 274,739 bytes 277,563 bytes 📈 🚨 +2,824 bytes (+1.03%)
📊 Component Memory Breakdown
Component Target Flash PR Flash Change
[esphome]modbus 4,719 bytes 7,506 bytes 📈 🚨 +2,787 bytes (+59.06%)
🔍 Symbol-Level Changes (click to expand)

Changed Symbols

Symbol Target Size PR Size Change
esphome::modbus::ModbusClientHub::requeue_waiting_frame_(esphome::modbus::ModbusDeviceCommand&, b...esphome::modbus::ModbusClientHub::requeue_waiting_frame_(esphome::modbus::ModbusDeviceCommand&, bool)
295 bytes 221 bytes 📉 -74 bytes (-25.08%)
esphome::modbus::ModbusClientHub::process_modbus_server_frame(unsigned char, std::span<unsigned c...esphome::modbus::ModbusClientHub::process_modbus_server_frame(unsigned char, std::span<unsigned char const, 4294967295u>)
402 bytes 428 bytes 📈 +26 bytes (+6.47%)
esphome::modbus::ModbusClientHub::notify_no_response_(esphome::modbus::ModbusDeviceCommand&) 77 bytes 89 bytes 📈 +12 bytes (+15.58%)
esphome::modbus::ModbusClientHub::send_next_frame_() 204 bytes 216 bytes 📈 +12 bytes (+5.88%)
esphome::modbus::ModbusClientHub::loop() 131 bytes 123 bytes 📉 -8 bytes (-6.11%)
esphome::modbus::ModbusDeviceCommand::ModbusDeviceCommand(esphome::modbus::ModbusDeviceCommand&&) 75 bytes 81 bytes 📈 +6 bytes (+8.00%)
modbus__mod_bus1__pstorage 116 bytes 120 bytes 📈 +4 bytes (+3.45%)
esphome::modbus::Modbus::receive_bytes_() 161 bytes 157 bytes 📉 -4 bytes (-2.48%)

New Symbols (top 15)

Symbol Size
esphome::modbus::ModbusClientHub::insert_by_priority_(esphome::modbus::ModbusDeviceCommand&&) 479 bytes
std::_Deque_iterator<esphome::modbus::ModbusDeviceCommand, esphome::modbus::ModbusDeviceCommand&,...std::_Deque_iterator<esphome::modbus::ModbusDeviceCommand, esphome::modbus::ModbusDeviceCommand&, esphome::modbus::ModbusDeviceCommand*> std::deque<esphome::modbus::ModbusDeviceCommand, std::allocatoresphome::modbus::ModbusDeviceCommand >::_M_insert_auxesphome::modbus::ModbusDeviceCommand(std::_Deque_iterator<esphome::modbus::ModbusDeviceCommand, esphome::modbus::ModbusDeviceCommand&, esphome::modbus::ModbusDeviceCommand*>, esphome::modbus::ModbusDeviceCommand&&)
377 bytes
std::_Deque_iterator<esphome::modbus::ModbusDeviceCommand, esphome::modbus::ModbusDeviceCommand&,...std::_Deque_iterator<esphome::modbus::ModbusDeviceCommand, esphome::modbus::ModbusDeviceCommand&, esphome::modbus::ModbusDeviceCommand*> std::move_backward<std::_Deque_iterator<esphome::modbus::ModbusDeviceCommand, esphome::modbus::ModbusDeviceCommand&, esphome::modbus::ModbusDeviceCommand*>, std::_Deque_iterator<esphome::modbus::ModbusDeviceCommand, esphome::modbus::ModbusDeviceCommand&, esphome::modbus::ModbusDeviceCommand*> >(std::_Deque_iterator<esphome::modbus::ModbusDeviceCommand, esphome::modbus::ModbusDeviceCommand&, esphome::modbus::ModbusDeviceCommand*>, std::_Deque_iterator<esphome::modbus::ModbusDeviceCommand, esphome::modbus::ModbusDeviceCommand&, esphome::modbus::ModbusDeviceCommand*>, std::_Deque_iterator<esphome::modbus::ModbusDeviceCommand, esphome::modbus::ModbusDeviceCommand&, esphome::modbus::ModbusDeviceCommand*>)
376 bytes
std::_Deque_iterator<esphome::modbus::ModbusDeviceCommand, esphome::modbus::ModbusDeviceCommand&,...std::_Deque_iterator<esphome::modbus::ModbusDeviceCommand, esphome::modbus::ModbusDeviceCommand&, esphome::modbus::ModbusDeviceCommand*> std::move<std::_Deque_iterator<esphome::modbus::ModbusDeviceCommand, esphome::modbus::ModbusDeviceCommand&, esphome::modbus::ModbusDeviceCommand*>, std::_Deque_iterator<esphome::modbus::ModbusDeviceCommand, esphome::modbus::ModbusDeviceCommand&, esphome::modbus::ModbusDeviceCommand*> >(std::_Deque_iterator<esphome::modbus::ModbusDeviceCommand, esphome::modbus::ModbusDeviceCommand&, esphome::modbus::ModbusDeviceCommand*>, std::_Deque_iterator<esphome::modbus::ModbusDeviceCommand, esphome::modbus::ModbusDeviceCommand&, esphome::modbus::ModbusDeviceCommand*>, std::_Deque_iterator<esphome::modbus::ModbusDeviceCommand, esphome::modbus::ModbusDeviceCommand&, esphome::modbus::ModbusDeviceCommand*>)
352 bytes
std::deque<esphome::modbus::ModbusDeviceCommand, std::allocator<esphome::modbus::ModbusDeviceComm...std::deque<esphome::modbus::ModbusDeviceCommand, std::allocatoresphome::modbus::ModbusDeviceCommand >::_M_reallocate_map(unsigned int, bool)
242 bytes
esphome::modbus::ModbusClientHub::maybe_requeue_completed_(esphome::modbus::ModbusDeviceCommand&,...esphome::modbus::ModbusClientHub::maybe_requeue_completed_(esphome::modbus::ModbusDeviceCommand&, bool)
199 bytes
__gnu_cxx::__enable_if<std::__is_random_access_iter<esphome::modbus::ModbusDeviceCommand*, std::i...__gnu_cxx::__enable_if<std::__is_random_access_iter<esphome::modbus::ModbusDeviceCommand*, std::iterator_traitsesphome::modbus::ModbusDeviceCommand*::iterator_category>::__value, std::_Deque_iterator<esphome::modbus::ModbusDeviceCommand, esphome::modbus::ModbusDeviceCommand&, esphome::modbus::ModbusDeviceCommand*> >::__type std::__copy_move_backward_a1<true, esphome::modbus::ModbusDeviceCommand*, esphome::modbus::ModbusDeviceCommand>(esphome::modbus::ModbusDeviceCommand*, esphome::modbus::ModbusDeviceCommand*, std::_Deque_iterator<esphome::modbus::ModbusDeviceCommand, esphome::modbus::ModbusDeviceCommand&, esphome::modbus::ModbusDeviceCommand*>)
187 bytes
__gnu_cxx::__enable_if<std::__is_random_access_iter<esphome::modbus::ModbusDeviceCommand*, std::i...__gnu_cxx::__enable_if<std::__is_random_access_iter<esphome::modbus::ModbusDeviceCommand*, std::iterator_traitsesphome::modbus::ModbusDeviceCommand*::iterator_category>::__value, std::_Deque_iterator<esphome::modbus::ModbusDeviceCommand, esphome::modbus::ModbusDeviceCommand&, esphome::modbus::ModbusDeviceCommand*> >::__type std::__copy_move_a1<true, esphome::modbus::ModbusDeviceCommand*, esphome::modbus::ModbusDeviceCommand>(esphome::modbus::ModbusDeviceCommand*, esphome::modbus::ModbusDeviceCommand*, std::_Deque_iterator<esphome::modbus::ModbusDeviceCommand, esphome::modbus::ModbusDeviceCommand&, esphome::modbus::ModbusDeviceCommand*>)
161 bytes
esphome::modbus::ModbusDeviceCommand& std::deque<esphome::modbus::ModbusDeviceCommand, std::alloc...esphome::modbus::ModbusDeviceCommand& std::deque<esphome::modbus::ModbusDeviceCommand, std::allocatoresphome::modbus::ModbusDeviceCommand >::emplace_backesphome::modbus::ModbusDeviceCommand(esphome::modbus::ModbusDeviceCommand&&) [$isra$0]
147 bytes
esphome::modbus::ModbusDeviceCommand& std::deque<esphome::modbus::ModbusDeviceCommand, std::alloc...esphome::modbus::ModbusDeviceCommand& std::deque<esphome::modbus::ModbusDeviceCommand, std::allocatoresphome::modbus::ModbusDeviceCommand >::emplace_frontesphome::modbus::ModbusDeviceCommand(esphome::modbus::ModbusDeviceCommand&&) [$isra$0]
141 bytes
std::_Deque_iterator<esphome::modbus::ModbusDeviceCommand, esphome::modbus::ModbusDeviceCommand&,...std::_Deque_iterator<esphome::modbus::ModbusDeviceCommand, esphome::modbus::ModbusDeviceCommand&, esphome::modbus::ModbusDeviceCommand*>::operator+=(int) [$isra$0]
128 bytes
esphome::modbus::ModbusDeviceCommand::operator=(esphome::modbus::ModbusDeviceCommand&&) [$isra$0] 105 bytes
esphome::modbus::ModbusClientHub::maybe_requeue_completed_(esphome::modbus::ModbusDeviceCommand&,...esphome::modbus::ModbusClientHub::maybe_requeue_completed_(esphome::modbus::ModbusDeviceCommand&, bool)::pstr
53 bytes
std::_Deque_iterator<esphome::modbus::ModbusDeviceCommand, esphome::modbus::ModbusDeviceCommand&,...std::_Deque_iterator<esphome::modbus::ModbusDeviceCommand, esphome::modbus::ModbusDeviceCommand&, esphome::modbus::ModbusDeviceCommand*>::operator--() [$isra$0]
37 bytes
std::_Deque_iterator<esphome::modbus::ModbusDeviceCommand, esphome::modbus::ModbusDeviceCommand&,...std::_Deque_iterator<esphome::modbus::ModbusDeviceCommand, esphome::modbus::ModbusDeviceCommand&, esphome::modbus::ModbusDeviceCommand*>::operator++() [$isra$0]
34 bytes
1 more new symbols... Total: 3,037 bytes

Removed Symbols (top 15)

Symbol Size
std::deque<esphome::modbus::ModbusDeviceCommand, std::allocator<esphome::modbus::ModbusDeviceComm...std::deque<esphome::modbus::Modbu 8000 sDeviceCommand, std::allocatoresphome::modbus::ModbusDeviceCommand >::_M_reserve_map_at_back(unsigned int)
220 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 representative test configuration.

@github-actions
Copy link
Copy Markdown
Contributor

There hasn't been any activity on this pull request recently. This pull request has been automatically marked as stale because of that and will be closed if no further activity occurs within 7 days.
If you are the author of this PR, please leave a comment if you want to keep it open. Also, please rebase your PR onto the latest dev branch to ensure that it's up to date with the latest changes.
Thank you for your contribution!

@github-actions github-actions Bot added the stale label May 23, 2026
@exciton
exciton commented May 23, 2026
Copy link
Copy Markdown
Contributor Author

Still relevant. Waiting on earlier PRs to merge.

claude added 15 commits July 27, 2026 20:49
An anonymous send (device == nullptr, the YAML-lambda path) matching
a queued identical frame is now always dropped, never promoted: with
no callback there is no lifecycle to absorb the request into, and no
owner to route the READ_AGAIN re-run to - previously the promotion
happened but the re-run never did, silently swallowing the request.
The waiting-slot scan keeps its nullptr exclusion, now documented: a
null device IN FLIGHT means a detached shell, not an anonymous send.

Both drop branches (anonymous and unabsorbable owned duplicates) now
log at DEBUG with the address and function code, so a discarded write
leaves evidence.

maybe_requeue_completed_() documents its divergence from
notify_no_response_() around mid-callback clears (closed by the
continuous-polling follow-up), and on_not_sent()'s doc points at the
lifecycle contract instead of contradicting it.

New tests: AnonymousDuplicateDroppedNotPromoted,
ReadAgainOutranksFreshReads (the previously untested middle rung of
the priority enum, against a non-empty queue), and
FullQueueCompletedRequeueRefusalDeliversNotSent (the completed
re-queue's refusal branch, PDU included).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts:
#	esphome/components/modbus/modbus.cpp
WritesSendBeforeQueuedReads reads frames through the pdu() accessor
instead of hard-coded layout offsets, and the queue-filling comments
say why the frames are distinct - on this branch the dedup is real
and the distinct start addresses are load-bearing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The three cosmetic fixes from the esphome#17888 review round were pushed as
the PR merged and did not make the squash: packed_bit_bytes() moves
above the PackedBits doc block so that documentation stays adjacent
to its class, mask_trailing_pad_bits() states its whole-PDU
precondition (the coil data is always the PDU's tail), and the
predicates keep named start_address/quantity locals so the PDU
offsets stay self-documenting at the call sites.

No behavior change; content already verified by review on esphome#17888.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The last hand-rolled bits-to-bytes ceiling, deferred from esphome#17888
because this file was under churn here - it lands where the churn is.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A READ_AGAIN command stands for exactly two accepted requests; the
transmit-failure path now delivers on_not_sent() twice, keeping the
same books as the clear sweep - previously the absorbed request lost
its terminal. TransmitFailureReadAgainDeliversBothNotSent pins it via
the FlakyBlockHub gate flip.

Comment fixes from the same round: insert_by_priority_() no longer
claims a sorted queue (in-place promotion keeps the entry's position;
only the insert paths honor priority order), and the queue cap
records its pathological worst case (~26 kB of transient heap at 128
distinct max-size frames) for whoever tunes it on ESP8266.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The previous commit resolved both READ_AGAIN lifecycles with two
on_not_sent() deliveries, over-generalizing from the clear sweep. A
transmit failure is transient, not a cancellation: the failed attempt
resolves one request, and the absorbed request still owes a run - the
frame re-queues demoted to READ_ONCE via requeue_waiting_frame_(),
keeping the same books as the timeout path (including the full-queue
refusal that delivers the second on_not_sent).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When the device requests a retry, on_no_response() resolved nothing -
so a refused re-queue of a READ_AGAIN entry must deliver both pending
requests' terminals, not one. Without device_retry the single
delivery stays correct: the on_no_response() was the first request's
terminal. Composes with the transmit-failure path, which re-queues
via device_retry=false.

Anonymous duplicate drops split by idempotency class: reads stay at
DEBUG, writes and custom function codes warn - the caller registered
no device, so the log line is the only place the loss is observable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MASK_WRITE_REGISTER and READ_WRITE_MULTIPLE_REGISTERS mutate
registers, so they now take WRITE transmit priority (they were
already non-requeueable via the read allow-list).

The transmit-failure path re-queues the demoted READ_AGAIN frame
BEFORE delivering the failed attempt's on_not_sent(): the popped
command is a stack local a mid-callback clear could never detach, but
queued first the frame is swept - and resolved - by such a clear like
any other queued frame.

The dedup comment no longer claims frames are never queued twice
outright: the internal re-queue paths insert directly, and an
identical handler re-send can momentarily coexist - wire order only,
the per-entry request accounting is unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@esphbot
esphbot commented Jul 27, 2026
Copy link
Copy Markdown
Contributor

@exciton: All findings addressed in the three commits ending at 44c7b5c.

Confirmed resolved, all four.

Promotion now targets READ_ONCE only. DuplicateSendKeepsContinuous plus ContinuousRequestUpgradesQueuedDuplicate pin both directions. completing_ slot cancels mid-callback re-queue correctly. Allow-list inversion matches stated rationale.

Two residual gaps, both non-blocking:

  • Dedup scan skips completing_. Send from inside on_response() queues fresh, then maybe_requeue_completed_() inserts a twin. Two identical polls result.
  • resolve_duplicate() tests incoming-continuous before entry state. READ_AGAINREAD_CONTINUOUS erases the two-request accounting; a later sweep delivers one on_not_sent(), not two.

Also: KNOWN LIMITATION comment above ClearDeviceDuringDataCancelsContinuousRequeue still describes pre-fix behavior.

@esphbot
esphbot commented Jul 27, 2026
Copy link
Copy Markdown
Contributor

PR Review — [modbus] Continuous polling for reads

Both prior findings are genuinely fixed and the continuous-polling semantics now hold up; five non-blocking follow-ups, none of which gate merge.

What's solid:

  • The promotion rule is the right fix for the killed-poll bug, and it avoids the extra field I suggested: only a READ_ONCE entry is ever promoted, so READ_CONTINUOUS survives a duplicate untouched, and an incoming {.continuous = true} upgrades a queued one-shot. DuplicateSendKeepsContinuous and ContinuousRequestUpgradesQueuedDuplicate pin both directions, including the follow-on "and it still polls after that" assertion.
  • The completing_ slot turns a documented limitation into working behavior with three lines and no ownership change — the command is already a local, and nulling device reuses the detach path maybe_requeue_completed_() was already checking. Both clear variants get a test.
  • The requeueability allow-list inversion (is_function_code_read()) matches the stated idempotency rationale, and DuplicateCustomFunctionCodeDroppedNotPromoted pins that a custom code now takes the safe drop path.
  • ContinuousLifecyclesBalance is the test that actually matters here: sent-count, terminal-count and queue depth all checked across three lifecycles ending on an exception, so the "one terminal per accepted command" claim is CI-enforced rather than prose.
  • The heap_probe_test fix (distinct start addresses) keeps that probe honest now that identical frames dedup instead of enqueue — easy to miss, and it would have silently turned into a 1-command probe.

What to look at (all non-blocking):

  • The dedup scan covers tx_buffer_ and waiting_for_response_ but not the new completing_ slot, so a send issued from inside on_response() queues fresh and maybe_requeue_completed_() then adds a twin — two identical continuous polls on the bus.
  • resolve_duplicate() checks the incoming-continuous branch before the READ_AGAIN state, so converting a READ_AGAIN entry to READ_CONTINUOUS erases its "stands for two requests" accounting (visible as one on_not_sent() where the sweep test expects two).
  • The address-scoped clear now detaches the completing command silently — a third silent case in a variant documented as always notifying; add it to the exception list.
  • The KNOWN LIMITATION comment above ClearDeviceDuringDataCancelsContinuousRequeue still describes the pre-fix behavior and contradicts its own test body.
  • The write guard for continuous misses 0x17 and, via FUNCTION_CODE_MASK, treats exception-flagged codes as requeueable reads — the same masking hazard create_client_pdu() already calls out.

One bookkeeping note: the type-of-change boxes mark this "code quality / tests", but it widens the public device API (CommandOptions, a defaulted parameter on send_pdu() and every read helper). Non-breaking, but "New feature" describes it better for the release notes.


✅ Resolved since last review (5)

Previously-flagged issues verified fixed
  • esphome/components/modbus/modbus.cpp:698 Continuous polling is silently and permanently killed by the duplicate-promotion path
  • esphome/components/modbus/modbus.cpp:635 A F2A2 continuous poll cannot be stopped from on_response()/on_error() — the natural call site
  • esphome/components/modbus/modbus.cpp:676 Recyclability is decided by a deny-list, so custom/diagnostic function codes default to recyclable
  • esphome/components/modbus/modbus.cpp:744 Recursion guard is hub-global, so it can swallow an unrelated device's refusal
  • esphome/components/modbus_controller/modbus_controller.h:20 Unrelated reordering of using-declarations

🟢 Suggestions

1. Converting a READ_AGAIN entry to READ_CONTINUOUS drops the absorbed request's second resolution
esphome/components/modbus/modbus.cpp:716-719

resolve_duplicate() checks the incoming priority == READ_CONTINUOUS branch first, so it also fires when the existing entry is already READ_AGAIN. READ_AGAIN is the encoding for "this entry stands for exactly two accepted requests" — the whole point of the double trigger_not_sent() in the sweep and in the transmit-failure path. Overwriting it with READ_CONTINUOUS erases that second request without a trace.

Sequence:

  • send_pdu(read)READ_ONCE
  • send_pdu(read) → promoted to READ_AGAIN (2 pending requests)
  • send_pdu(read, {.continuous = true}) → entry becomes READ_CONTINUOUS (3 pending requests, no extra bookkeeping)
  • The server answers with an exception → one on_error(), and maybe_requeue_completed_(…, success=false) returns without re-queueing. Two accepted requests never get a terminal.

The same erasure changes the sweep books: ClearAddressSweptReadAgainDeliversBothNotSent pins two on_not_sent() for a swept READ_AGAIN; after the conversion the identical situation delivers one.

This is arguably within the spirit of the new contract bullet ("a duplicate absorbed into a CONTINUOUS entry is served by the poll's next response"), but the contract sentence assumes the poll keeps running — an exception response ends it. Either reorder the branches so an existing READ_AGAIN is never downgraded (absorb the continuous request into it and only convert on a later READ_ONCE state), or extend the contract bullet to say plainly that converting to continuous collapses any previously absorbed requests. A test for the READ_AGAIN + continuous ordering would pin whichever you choose.

    } else if (priority == CommandPriority::READ_CONTINUOUS) {
      // The caller asked for continuous polling: the existing identical entry becomes the poll.
      item.priority = CommandPriority::READ_CONTINUOUS;
2. The address-scoped clear detaches the completing command silently — not listed in the lifecycle exceptions
esphome/components/modbus/modbus.cpp:806-809

clear_tx_queue_for_address() is documented as the variant that does resolve every frame it drops via on_not_sent(); only clear_tx_queue_for_device() and the in-flight detach are listed as silent. The new completing_ detach adds a third silent case to the address variant: a READ_AGAIN command whose re-run is cancelled here loses its second request's terminal with no callback.

It is defensible (the completing command is morally the in-flight frame), but the contract text in modbus.h enumerates its exceptions precisely, and a reader auditing "exactly one terminal" will not find this one. Add it to the exception list — e.g. extend "both clear variants detach the in-flight frame silently" to "…the in-flight frame, and a frame whose response callbacks are still running, silently".

  if (this->completing_ != nullptr && this->completing_->frame.address() == address)
    this->completing_->device = nullptr;
3. Stale `KNOWN LIMITATION` comment contradicts the test it now heads
tests/components/modbus/modbus_client_hub_test.cpp:1210-1213

The comment block above ClearDeviceDuringDataCancelsContinuousRequeue still describes the old behavior ("does NOT cancel that command's pending continuous re-queue … Pin the current behavior"), while the test name, the body, and the inline comment three lines further down all assert the opposite — that the clear does cancel it.

This is the exact comment a future reader will trust when deciding whether the cancellation is intentional, so leaving it inverted is worse than having no comment. Replace it with the behavior being pinned (the completing_ slot makes "stop polling now" from inside on_response() work).

While there: the stale text says the limitation is "documented on ModbusClientDevice" — worth grepping modbus.h for the matching note and removing it too if it survived the fix (I could not check the PR head from the diff alone).

// KNOWN LIMITATION (documented on ModbusClientDevice): clearing a device's own queue from inside
// on_response() does NOT cancel that command's pending continuous re-queue, because the command was
// moved out of the waiting slot before the callback ran. Pin the current behavior so any change is deliberate.
TEST(ModbusClientHubPriority, ClearDeviceDuringDataCancelsContinuousRequeue) {

Checklist

  • Continuous polling survives the dedup paths (prior blocking finding)
  • Command lifecycle: one terminal per accepted request — suggestion #1, suggestion #2
  • Queue invariant: no identical frame queued twice
  • Re-entrancy safety (callbacks that send/clear from inside handlers)
  • Unbounded growth / heap allocation after setup()
  • Function-code classification is conservative for non-idempotent PDUs
  • Tests cover the new branches (continuous re-queue, cancellation, full-queue refusal)
  • Comments and contract text match the implemented behavior — suggestion #2, suggestion #3
  • No backward-incompatible change to the public C++ API

Silent Failure Analysis

🟠 **HIGH** — silent drop of a user request (debug-log only)
esphome/components/modbus/modbus.cpp:700-716

Risk: The new dedup applies to the anonymous (YAML-lambda) path for every function code including writes, so a deliberate repeat of an identical write (e.g. sending the same command/trigger register value twice) is now discarded with no callback and only an ESP_LOGD, which is below the default log level — the send just never happens and nothing reports it.

const auto resolve_duplicate = [&](ModbusDeviceCommand &item, const char *state) {
  if (device == nullptr) {
    ESP_LOGD(TAG, "Anonymous duplicate of frame already %s for %" PRIu8 " (function 0x%X), dropped", state, address,
             pdu[0]);
  } else if (...)

Fix: Restrict the anonymous-duplicate drop to re-queueable read function codes (queue non-idempotent/unknown frames fresh as before), and raise the drop log to ESP_LOGW so a discarded request is visible at default log level.

🟠 **HIGH** — silent cancellation in the notifying clear variant
esphome/components/modbus/modbus.cpp:806-809

Risk: clear_tx_queue_for_address() is contractually the variant that resolves every frame it drops via the owner's on_not_sent(), but this new detach matches on address only and silently kills a different device's pending READ_AGAIN/continuous re-queue — the request that promotion absorbed then never receives any terminal callback, so an owner tracking outstanding requests hangs forever.

// Detach a command that is mid-completion (this clear was called from inside its own
// on_response()/on_error()), cancelling its pending continuous/READ_AGAIN re-queue.
if (this->completing_ != nullptr && this->completing_->frame.address() == address)
  this->completing_->device = nullptr;

Fix: Before nulling completing_->device in the address-scoped clear, deliver trigger_not_sent() for the pending re-queue it cancels (matching the sweep's accounting: two deliveries for READ_AGAIN), keeping the silent detach only in clear_tx_queue_for_device().

🟡 **MEDIUM** — under-delivered terminal on refusal path
esphome/components/modbus/modbus.cpp:612-621

Risk: By this PR's own accounting a READ_AGAIN entry stands for two still-pending requests when the device asked to retry ("nothing was resolved, both requests still pending"), yet the full-buffer refusal delivers only one on_not_sent() — unlike the clear sweep and the transmit-failure path, which the PR explicitly changed to deliver twice — so one accepted request silently loses its terminal.

if (this->tx_buffer_.size() >= MODBUS_TX_BUFFER_SIZE) {
  ESP_LOGE(TAG, "Write buffer full, dropped retry for address %" PRIu8, frame.address());
  if (wfr.device != nullptr)
    wfr.device->trigger_not_sent(frame.pdu());
  return;
}

Fix: Mirror the other two drop paths here: emit a second trigger_not_sent() when wfr.priority == CommandPriority::READ_AGAIN and device_retry is true.


Automated review by Kōan (Claude) HEAD=017be7f 5 min 55s

@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.

claude added 5 commits July 28, 2026 00:09
# Conflicts:
#	esphome/components/modbus/modbus.cpp
…mantics

A send from inside on_response()/on_error() can match the command
whose callbacks are running; it now resolves against that command
like any other duplicate, so the post-callback re-queue serves it
instead of coexisting with a fresh twin.

Exception-flagged function codes are never silently re-sendable:
is_function_code_read() masks the exception bit, so the requeueable
check excludes them explicitly and their duplicates take the drop
path.

Documented rather than changed, per review discussion: converting a
queued entry to continuous SUPERSEDES any request it had absorbed -
the caller opted into streaming semantics and the poll's responses
are the accounting from then on. The lifecycle contract also lists
the mid-completion detach among the silent clear cases, and the
stale pre-fix KNOWN LIMITATION comment above the cancel test now
describes what the test actually proves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A sweep handler re-sending the in-flight frame was absorbed into a
waiting slot the very same clear then detached silently, leaving that
request with zero callbacks. Detached first, the dedup's existing
nullptr exclusion makes the re-send queue fresh - and, unmarked, it
survives the clear like any other reentrant re-send.

New coverage from the same round: maybe_requeue_completed_() re-runs
a READ_AGAIN after an error response, and 0x16 ranks as WRITE for
transmit ordering.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The state descriptor exists only for the log lines, so it takes the
same flash-resident LogString form clear_rx_buffer_() uses instead of
a RAM-resident const char* literal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts:
#	esphome/components/modbus/modbus.cpp
#	tests/components/modbus/modbus_client_hub_test.cpp
@exciton
exciton commented Jul 28, 2026
Copy link
Copy Markdown
Contributor Author

Round addressed at 5c31e0e (this branch now also carries #17887's current tip, so the resolve_duplicate region here is final-form):

  • completing_ joins the dedup scan: a send from inside on_response()/on_error() matching the command mid-completion resolves against it, so the post-callback re-queue serves it instead of coexisting with a fresh twin — ResendFromOnResponseAbsorbsIntoCompletingCommand pins it. The address-scoped clear also detaches completing_ BEFORE the sweep now, mirroring the in-flight hoist from [modbus] Priority-ordered transmit queue with duplicate recycling #17887: a sweep handler's re-send must not absorb into a command the clear is about to detach.
  • READ_AGAIN → continuous conversion: documented rather than changed, per discussion — converting an entry to continuous SUPERSEDES any request it had absorbed; the caller opted into streaming semantics and the poll's responses are the accounting from then on. Stated in the lifecycle contract and at the conversion branch. (An on_not_sent at conversion would be a strange signal for "your request upgraded", and mixing one-shots with continuous on the same register is not a practical pattern.)
  • Exception-flagged codes are never requeueable: is_function_code_read() masks the exception bit, so the requeueable check excludes 0x8x explicitly — ExceptionFlaggedDuplicateDroppedNotPromoted pins the drop path.
  • The mid-completion detach joins the silent-clear exception list, and the stale pre-fix KNOWN LIMITATION comment above the cancel test now describes what the test proves.
  • Type-of-change boxes: staying as-is by series convention — "New feature" is reserved for YAML-syntax changes; CommandOptions and the defaulted parameters are C++ developer surface, covered by the release-notes text in the body.

Brings in the final 17887 review round (exception-mask requeueability
exclusion, cap-comment math, front-insert heap probe, doc nits); the
duplicated ExceptionFlaggedDuplicateDroppedNotPromoted test from the
earlier backport is collapsed to one copy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@exciton exciton closed this Aug 2, 2026
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 4, 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.

5 participants

0