Skip to content

fix(ble_elm327): tx_delay/timeout fixes, opt-in auto recovery and diagnostic sensors - #328

Merged
eigger merged 10 commits into
masterfrom
fix/ble-elm327-response-timeout-recovery
Oct 19, 2026
Merged

eigger merged 10 commits into
masterfrom
fix/ble-elm327-response-timeout-recovery

Conversation

@eigger

@eigger eigger commented Oct 19, 2026 •

Copy link
Copy Markdown
Owner

Addresses #327 (iCar Pro: updates stop after ~30 s, no recovery until reboot).

With the default settings the component behaves like master: there is no new re-init or reconnect, and the response timeout is still 5000 ms from the write. Recovery is opt-in with auto_recovery: true. The only default-on changes are the bug fixes listed below.

Options

Option Default Notes
response_timeout 5000 ms (same as master's fixed value) Range 100–60000. Init commands always get at least 5000 ms.
auto_recovery false Opt-in: escalate repeated failures to an ATZ re-init, then a BLE reconnect with backoff. Also enables the activity-based timeout.
recovery_threshold 3 Failures in a row per step. Only valid with auto_recovery: true (rejected otherwise).

Default behavior changes (no new options set) vs master

Change Type Notes
tx_delay (default still 50) is now an extra wait after the reply / timeout / failed write instead of being measured from the write Bug fix (core of #327), with a timing effect On master the wait counted from the write: replies slower than 50 ms got no idle time at all, and faster replies just had a 50 ms write-to-write interval. Now there is always 50 ms after the reply. Slow adapters: each cycle is +50 ms (response + 50 instead of response). Fast adapters: slightly slower per cycle, response + 50 instead of max(50, response) (e.g. a 30 ms reply: 50 → 80 ms). Set tx_delay: 0 for master-like throughput. The README explains that tx_delay: 0 removes the wait and that slow or unstable adapters should raise it.
Error replies (NO DATA, STOPPED, ?, CAN ERROR, UNABLE TO CONNECT, …) are no longer parsed as hex data Bug fix e.g. UNABLE TO CONNECT used to produce bogus bytes AB EC CE.
Error replies are logged: adapter errors at WARN; NO DATA and bus errors at DEBUG Logging Master logged every reply at DEBUG only. With the ignition off the logs stay quiet.
Partial reply discarded on timeout; stale RX data discarded before each write; RX buffer capped at 4 KB Bug fix Prevents a late fragment from being glued to the next response and stops the buffer growing without limit.
write_value result checked: a failed write is not waited on Bug fix Master waited the full timeout for a reply that could not come.
Sending while not connected is logged and counted Bug fix (logging) Master dropped the command silently.
WRITE_CHAR_EVT: only our TX handle; ESP_GATT_CONGESTED is not a failure (DEBUG log); other statuses end the exchange immediately Bug fix Bluedroid reports CONGESTED for accepted no-response writes (att_protocol.c: "ATT congested, message accepted"). Master logged all non-OK statuses as failures.
Tracked pre_commands header cleared when a pre-command times out, fails to write, or gets an adapter/link error reply (STOPPED, BUFFER FULL, ERRxx, …) Bug fix Can cause one extra ATSH after such a failure; otherwise a PID could be sent with the wrong header. A ? (unsupported) or vehicle-side error reply does not clear it, so the header stays sticky as on master and is not re-sent every poll.
A queued PID whose pre_commands header was cleared after it was queued is skipped for that poll; its next update() queues pre_commands + PID again Bug fix Previously, after a failed ATSH the PID could go to the wrong ECU and that ECU's reply could be published. It only happens after a failure (failed pre-command / adapter reset). There is no extra polling beyond update_interval, and without failures the traffic is the same as master. Trade-off: if the pre-command keeps failing, that device can miss updates. Example: two devices with different ATSH, and a slow ECU whose request times out every time, so the next ATSH gets STOPPED. Master sent the PID with the wrong header, which gave a wrong value or none. Now the poll is skipped, so nothing wrong is published, but the sensor may not update. There is no retry logic. Remedies: a larger response_timeout (enough for slow multi-frame ECUs), or auto_recovery: true. Documented in the README.
Header tracking cleared on an LV RESET reply (any command, even a late reply) and when ATZ / ATWS / ATD is sent on its own (init or send_command) Bug fix These reset the adapter, including ATSH. Inside a device's own pre_commands (e.g. ["ATD","ATSP6","ATSH 7E4"]) they do not clear it, so that list is sent once and stays sticky, as on master (native test covers this).
READY entered when the last init command is answered or times out (master: when it was sent) Bug fix (minor) No PID is sent before init has finished. With auto_recovery: false a timeout still proceeds to READY, as before.
Timeout log text now includes the timeout and failure count Logging Timeout (5000ms) waiting for response to '010c' (n failures in a row)
Late reply (no command pending) logs a WARN hint Logging Data is still delivered to matching sensors, as before.

Only with auto_recovery: true:

  • an activity-based idle timer;
  • a 20 s idle timeout while SEARCHING / BUS INIT is reported, and a hard limit of max(30 s, 2× timeout) (at least 40 s during a search);
  • a failure streak → ATZ re-init → BLE disconnect/reconnect, with exponential backoff (30 s … 10 min) and reconnect retry;
  • STOPPED right after a timeout is not counted;
  • the final init command is retried instead of moving to READY.

Review of the issue's points

Point Verdict
tx_delay from the write time Fixed (default-on, see above).
Hard-coded 5000 ms timeout, no recovery response_timeout option (default 5000) + opt-in auto_recovery.
rx_buffer_ not cleared on timeout Fixed.
write_value result ignored Fixed.
Error replies not detected Fixed: detection, logging and counting (native-tested classifier).
README "sends without waiting for a response" Fixed.
Re-init must clear current_pre_commands_ Done (re-init is opt-in).
NO DATA back-off per PID Not done: out of scope. NO DATA is a fast answer and doesn't stall the queue, and ignition-off makes every PID return it. Counted instead.
Matching late replies to the in-flight PID Not done: it would break ATH1 / negative-response setups.

Error categories (used for counting, and for escalation with auto_recovery)

  • Vehicle / bus (neutral, never escalate): NO DATA, CAN ERROR, BUS ERROR, BUS BUSY, BUS INIT ERROR, UNABLE TO CONNECT, DATA ERROR, FB ERROR, RX ERROR.
  • Adapter / link (count as failures): STOPPED (except right after a timeout), BUFFER FULL, ERRxx, LV RESET, ACT ALERT, and ? to non-AT commands (? to an AT command only means "unsupported").

Diagnostics (optional, entity_category: diagnostic)

  • sensor: timeout_count, error_count, no_data_count, write_failure_count, reinit_count, reconnect_count. The last two stay 0 without auto_recovery.
  • sensor: response_time (ms, measurement): time from a successful write to the > of the most recent normal PID reply. AT / init / pre_commands, error replies (incl. NO DATA), timeouts, failed writes and late replies are not sampled. Only the latest sample is published per 5 s interval. Averaging is left to ESPHome filters (the README shows a sliding_window_moving_average example), so there is no avg/max logic in the component.
  • text_sensor: last_error. NO DATA is excluded from it.
  • Publish policy: at most every 5 s and only on change. Counters publish 0 at boot. The diagnostics work in both modes.

README

  • Options table with the new defaults.
  • tx_delay timing note, with the effect vs master and when to use 0 or raise it.
  • A note that ATZ needs no extra delay: the next init command waits for the > the ELM327 prints after its reset, so no internal post-ATZ delay was added.
  • response_time interpretation and tuning guide.
  • Recommended auto_recovery: true example with last_error + reconnect_count.
  • Default vs auto_recovery timeout table.
  • Error categories and recovery tables.
  • Ignition-off + ATSP0 guidance, plus an auto_connect: false caveat.
  • Troubleshooting rows with the exact log text.

Verification

  • Native tests: a new check_response_timeout() test shows the default path matches master (timeout from the write, no activity / SEARCHING extension). Also covers the auto_recovery timeout rules, the error categories, the STOPPED-after-timeout rule, and the backoff schedule. All pass.
  • esphome config (local): the test YAML (with auto_recovery: true, recovery_threshold: 3) is valid. A config with no options gets response_timeout: 5000, auto_recovery: false. recovery_threshold without auto_recovery is rejected.
  • CI: see the checks on the latest commit (ESP-IDF compile of the test YAML).
  • Not verified: runtime behaviour on hardware (no iCar Pro / vehicle test).

🤖 Generated with Claude Code

eigger and others added 4 commits October 19, 2026 20:04
…nsors

- Measure tx_delay from the end of the previous exchange (prompt received,
  timeout or failed write) instead of the write time, so slow adapters get
  idle time between commands (#327).
- Add configurable response_timeout (default 1500 ms, measured from the last
  received byte); init commands, a manual ATZ and the first PID request after
  init use at least 5000 ms.
- On timeout drop the partial reply and clear stale RX data before the next
  write so a late prompt cannot be glued to the next command.
- Check write_value() results; a failed write is not waited on.
- After 3 consecutive timeouts/write failures re-initialize the adapter
  (queue cleared, init sequence re-sent, tracked ATSH header reset); if the
  next streak happens before any PID is answered, disconnect so ble_client
  reconnects.
- Detect ELM327 status replies (NO DATA, STOPPED, ?, BUFFER FULL, CAN ERROR,
  UNABLE TO CONNECT, ERRxx, ...), log and count them instead of parsing them.
- Add optional diagnostic entities: timeout/error/NO DATA/write-failure/
  re-init/reconnect counters (sensor) and last_error (text_sensor).
- README: fix the state machine description, document response_timeout,
  recovery and diagnostics.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- Keep the long (init) timeout until a PID request gets a normal reply after
  (re)init, a timeout or an error reply, and while the partial reply shows
  SEARCHING / BUS INIT, so an ATSP0 protocol search is never cut short.
- Count adapter-level error replies (STOPPED, BUFFER FULL, ERRxx, LV RESET,
  ACT ALERT, '?' to non-AT commands) as failures; reset the streak only on a
  normal reply to a PID request or init command, never on late replies. Bus
  errors (NO DATA, CAN ERROR, UNABLE TO CONNECT, ...) are neutral so an
  ignition-off car does not cause re-init/reconnect churn.
- Clear the tracked pre_commands header when a pre-command times out, fails to
  write or gets an error reply.
- Rate-limit diagnostic publishes (5 s, on change); NO DATA no longer
  overwrites last_error.
- Pause traffic after a recovery disconnect until the disconnect event, and
  retry the disconnect if it never arrives.
- Add an absolute response limit and a 4 KB RX buffer cap.
- Require 5 normal PID replies after a re-init before resetting escalation.
- Only handle WRITE_CHAR_EVT for the TX handle and end the exchange there
  instead of also counting a timeout.
- Classify BUS INIT ERROR and ACT ALERT; native tests for categories.
- README: timeout rules, error categories, recovery, publish policy,
  auto_connect note and exact log messages.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- Treat WRITE_CHAR_EVT status ESP_GATT_CONGESTED as accepted (Bluedroid
  att_protocol.c returns GATT_CONGESTED for "message accepted" and gatt_cl.c
  ends NO_RSP writes with it); log at DEBUG, only other statuses fail.
- Allow a 20 s idle timeout while the adapter reports SEARCHING / BUS INIT.
- Do not count STOPPED in reply to the first command after a timeout (the
  adapter aborted the timed-out request).
- Exponential backoff (30 s .. 10 min) between recovery reconnects; keep
  reinit_attempted_ across our own recovery disconnect so streaks after a
  reconnect do not restart the re-init -> reconnect cycle. Recovery state and
  backoff are cleared after 5 normal PID replies.
- Enter READY only when the last init command is answered; re-send it on
  timeout / failed write.
- Count "not connected / TX characteristic missing" as a failed write instead
  of silently dropping the command.
- README: remove leftover of the old recovery table, document search timeout,
  STOPPED exception, backoff, READY rule, CONGESTED handling and ATSP0 /
  ignition-off guidance.
- Native tests for the STOPPED exception and the backoff schedule.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- New auto_recovery option (default false). Without it, timeouts and other
  failures are only logged and counted and the next command is sent, as
  before: no re-init, no reconnect, and the last init command entering READY
  even without a reply.
- New recovery_threshold (1-20, default 3), only valid with auto_recovery.
- response_timeout default back to 5000 ms (the previous fixed value). With
  auto_recovery off the timeout is measured from the write; the
  activity-based timer, the 20 s SEARCHING extension and the hard limit only
  apply with auto_recovery.
- Bus-side error replies (CAN ERROR, UNABLE TO CONNECT, ...) are logged at
  DEBUG to keep ignition-off logs quiet.
- Move the timeout decision into check_response_timeout() and test that the
  default path matches the previous behaviour.
- README: option table, default vs auto_recovery tables, tx_delay timing
  note, recommended auto_recovery setup with diagnostics.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@eigger eigger changed the title fix(ble_elm327): recover from response timeouts and add diagnostic sensors fix(ble_elm327): tx_delay/timeout fixes, opt-in auto recovery and diagnostic sensors Oct 19, 2026
eigger and others added 6 commits October 19, 2026 20:52
- New diagnostic sensor key response_time (ms, measurement, duration):
  successful write -> '>' of the most recent normal PID reply. AT / init /
  pre_commands, error replies (incl. NO DATA), timeouts, failed writes and
  late replies are not sampled.
- Published with the existing diagnostics policy (at most every 5 s, on
  change; latest sample only). Averaging is left to ESPHome filters.
- README: key description, sliding_window_moving_average example and tuning
  guidance for response_timeout / tx_delay; added to the recommended set.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Keep the default of 50 ms; document the effect versus the previous
write-based timing (slow adapters +50 ms per cycle, fast adapters about the
same), that tx_delay: 0 removes the wait, when to raise it, and that ATZ needs
no extra delay because the next init command waits for its prompt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- Only clear the tracked pre_commands header when a pre-command times out,
  fails to write, or gets an adapter/link error reply (STOPPED, BUFFER FULL,
  ERRxx, ...). A '?' (unsupported) or vehicle-side reply keeps the header
  sticky as before, so it is not re-sent on every poll. Logic moved to
  pre_command_reply_invalidates_header() with native tests.
- README/PR: fast adapters go from max(50, response) to response + 50 per
  cycle (e.g. 30 ms reply: 50 -> 80 ms); tx_delay: 0 for master-like
  throughput. Hard limit is at least 40 s during SEARCHING / BUS INIT.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- When the tracked header was cleared after a device's pre_commands and PID
  were queued (failed pre-command, adapter reset), hold the PID back and
  re-queue the device so pre_commands + PID are queued together at the next
  collection. Never triggers on the normal path, so traffic is unchanged.
- Clear header tracking on an LV RESET reply (any command) and when ATZ, ATWS
  or ATD is sent, e.g. via send_command.
- Native tests for pre_commands_applied() and command_resets_adapter().
- README/PR: document the above and the '?' sticky trade-off.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- Only clear header tracking for ATZ / ATWS / ATD sent on their own (init or
  send_command), not when they are one of a device's own pre_commands: the
  rest of that list re-applies the header, so e.g. ["ATD","ATSP6","ATSH 7E4"]
  is sent once and the PID follows, as before.
- A PID whose header was lost is skipped for that poll instead of being
  re-polled immediately; its next update() queues pre_commands + PID again,
  so a failing ATSH cannot cause extra traffic beyond update_interval.
- Move the dequeue decision into decide_dequeue() and test full poll cycles
  for pre_commands containing ATD / ATZ / ATWS.
- README/PR: correct the description of both behaviours.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- Native test: ATSH fails (timeout / STOPPED / BUFFER FULL) -> tracked header
  cleared -> PID held back -> next poll re-sends ATSH + PID -> PID only; '?'
  keeps the header sticky. Uses the same decide_dequeue() /
  pre_command_reply_invalidates_header() as loop(). No component code change.
- README/PR: describe the trade-off that a device whose pre-command keeps
  failing (e.g. slow ECU timing out, alternating ATSH headers) skips polls
  instead of publishing another ECU's value, and the remedies
  (response_timeout, auto_recovery).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@eigger
eigger merged commit 0438ab0 into master Oct 19, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant