diff --git a/components/ble_elm327/README.md b/components/ble_elm327/README.md index 975fb544..f505e1c0 100644 --- a/components/ble_elm327/README.md +++ b/components/ble_elm327/README.md @@ -25,6 +25,7 @@ Requires an ESP32 with BLE. For a complete vehicle dashboard, see the - [Per-device Pre-commands](#per-device-pre-commands) - [Platforms](#platforms) - [Sensor](#sensor) + - [Diagnostics](#diagnostics) - [Actions](#actions) - [ble_elm327.send_command](#ble_elm327send_command) - [Presets](#presets) @@ -121,6 +122,8 @@ ble_elm327: init_commands: - "ATSP6" tx_delay: 50 + response_timeout: 5000 + auto_recovery: false ``` #### Options @@ -132,7 +135,44 @@ ble_elm327: | `rx_char_uuid` | UUID | `2AF0` | BLE notify characteristic UUID (adapter → ESP32) | | `tx_char_uuid` | UUID | `2AF1` | BLE write characteristic UUID (ESP32 → adapter) | | `init_commands` | list | *(none)* | Extra AT commands queued **after** the mandatory base sequence (see below). | -| `tx_delay` | int (ms) | `50` | Minimum delay between consecutive BLE writes | +| `tx_delay` | int (ms) | `50` | Extra wait after the previous command finished (its `>` prompt was received, it timed out, or its write failed) before the next command is written. `0` = no extra wait. See the note below. | +| `response_timeout` | int (ms) | `5000` | How long to wait for the `>` prompt. Init commands always get at least `5000` ms. See [Response Timeout](#response-timeout). Range `100`–`60000`. | +| `auto_recovery` | bool | `false` | Opt-in. Re-initialize the adapter and, if that does not help, reconnect BLE after repeated failures. Also switches to an activity-based timeout. See [Recovery](#recovery). | +| `recovery_threshold` | int | `3` | Failures in a row before each recovery step. Only valid with `auto_recovery: true`. Range `1`–`20`. | + +> [!NOTE] +> **`tx_delay` timing changed:** earlier versions measured `tx_delay` from the moment a command was *written*. If the reply took longer than 50 ms, the delay had already passed and had no effect. If the reply was faster, it only forced a 50 ms write-to-write interval. Now the component always waits `tx_delay` after the reply (or timeout / failed write). With the default `50`: +> +> - **slow adapters** (reply > 50 ms): each request cycle becomes 50 ms longer (`response time + 50 ms` instead of `response time`). This idle time is what lets an overloaded adapter recover. +> - **fast adapters** (reply < 50 ms): slightly slower per cycle. Before, the interval was `max(50 ms, response time)`; now it is `response time + 50 ms` (e.g. a 30 ms reply: 50 → 80 ms). Set `tx_delay: 0` for master-like throughput. +> +> Set `tx_delay: 0` to send the next command right after each reply, which gives the most throughput on a stable adapter. Raise it (e.g. `100`–`200`) for slow or unstable adapters that start timing out or answering `STOPPED` / `BUFFER FULL`. + +**Recommended setup for unstable adapters** (e.g. adapters that stop answering after a while): + +```yaml +ble_elm327: + ble_client_id: obd_client + init_commands: + - "ATSP6" # pin the protocol if you know it + tx_delay: 100 + response_timeout: 1500 + auto_recovery: true + +text_sensor: + - platform: ble_elm327 + last_error: + name: "OBD Last Error" + +sensor: + - platform: ble_elm327 + timeout_count: + name: "OBD Timeouts" + reconnect_count: + name: "OBD Reconnects" + response_time: + name: "OBD Response Time" +``` #### UUID Formats @@ -175,13 +215,90 @@ Do not repeat `ATZ`, `ATE0`, `ATL0`, `ATS0`, or `ATH0` in YAML unless you intent ``` IDLE → (BLE connect + service discovery + notify registered) → CONNECTED: base init + extra init_commands queued into tx_queue - → loop() drains tx_queue at tx_delay intervals - → READY + → loop() sends them one at a time (prompt / timeout, then tx_delay) + → READY once the last init command is answered ``` -In **READY** state, `loop()` collects per-device poll requests and drains them with `tx_delay` between each write — commands are sent without waiting for a response. When a response arrives it is broadcast to **all** registered devices; each device checks whether the mode + PID bytes match its own configuration and updates its state if they do. +If the last init command times out or its write fails, the default is to enter READY anyway. With `auto_recovery: true` it is re-sent instead (and counted as a failure, see [Recovery](#recovery)). + +Only **one command is in flight at a time**: after each write the component waits for the ELM327 `>` prompt (or `response_timeout`), then waits `tx_delay` before writing the next queued command. In **READY** state, `loop()` collects per-device poll requests into the same queue. When a response arrives it is broadcast to **all** registered devices; each device checks whether the mode + PID bytes match its own configuration and updates its state if they do. + +Init commands use the same flow. Each one, including the one after `ATZ`, is written only after the previous `>` prompt. The ELM327 prints that prompt once its reset has finished, so `ATZ` needs no extra delay (even with `tx_delay: 0`). Responses to AT commands during init don't match any sensor's mode + PID and are ignored. + +#### Response Timeout + +The active timeout is `response_timeout`, raised to `max(response_timeout, 5000)` ms in these cases: + +- for the init commands, and for a manual `ATZ` sent with `ble_elm327.send_command` +- for every command until a PID request gets a normal reply after a (re)init, a timeout, or an error reply other than `?`. With `ATSP0`, the next request after `UNABLE TO CONNECT` / `NO DATA` / `STOPPED` (for example while the ignition is being switched on) may start a protocol search that takes seconds. This keeps the search from being cut short. + +With the default `response_timeout: 5000`, every command therefore gets 5000 ms. + +How the timeout is measured depends on `auto_recovery`: + +| | `auto_recovery: false` (default) | `auto_recovery: true` | +|---|---|---| +| Timer starts | at the write (same as earlier versions) | at the write, restarted by every received byte, so slow multi-frame replies keep the exchange alive | +| While the reply shows `SEARCHING...` / `BUS INIT` | no change | idle timeout raised to `max(active timeout, 20000)` ms (an `ATSP0` search with the ignition off can take that long) | +| Hard limit from the write | — (the timer already starts at the write) | `max(30000, 2 × active timeout)` ms. While `SEARCHING` / `BUS INIT` is shown the idle timeout is at least 20000 ms, so the hard limit is at least **40000 ms** | + +On a timeout the partial reply is discarded, a warning is logged (`Timeout (5000ms) waiting for response to '…'`), and the next queued command is sent after `tx_delay`. Any data still buffered when a new command is written is also discarded. More than 4096 bytes without a prompt are discarded too (`RX OVERFLOW`). + +> [!TIP] +> **Ignition off + `ATSP0` (the default protocol):** every request starts a new protocol search that ends in `UNABLE TO CONNECT` / `NO DATA`. These count as vehicle-side errors, not failures, so they do not trigger recovery. They do keep the queue slow, though, and some adapters stay silent long enough to time out. If you see repeated timeouts while the car is off, pin the protocol (e.g. `init_commands: ["ATSP6"]` for 11-bit 500 kbps CAN) or increase `response_timeout`. + +#### Error Replies + +ELM327 status replies are detected and counted. They are not parsed as data. + +| Reply | Category | Effect | +|-------|----------|--------| +| `NO DATA` | vehicle / bus | Logged at DEBUG, counted in `no_data_count`. Not shown in `last_error`. | +| `CAN ERROR`, `BUS ERROR`, `BUS BUSY`, `BUS INIT ERROR`, `UNABLE TO CONNECT`, `DATA ERROR`, `FB ERROR`, `RX ERROR` | vehicle / bus | Logged at DEBUG, counted in `error_count`, shown in `last_error`. Not a failure, because these are normal with the ignition off and re-initializing would not help. | +| `STOPPED`, `BUFFER FULL`, `ERRxx`, `LV RESET`, `ACT ALERT` | adapter / link | Logged at WARN, counted in `error_count`, and counts as a **failure** (see below). Exception: `STOPPED` in reply to the first command after a timeout is not a failure. It only means the adapter aborted the request that timed out (e.g. a slow protocol search) when the new command arrived. | +| `?` | adapter | Counts as a failure for PID requests. In reply to an `AT` command it only means the adapter does not support that command, so it is just logged and counted. | + +If a device's `pre_commands` (e.g. `ATSH`) times out, fails to write, or gets an adapter/link error reply (`STOPPED`, `BUFFER FULL`, `ERRxx`, …), the tracked header is cleared and re-sent before the next PID request. A `?` reply (command not supported by this adapter) or a vehicle-side error does **not** clear it: the header stays sticky as before, so an unsupported pre-command is not re-sent on every poll. (Trade-off, same as earlier versions: with an unsupported pre-command, the PID goes out with whatever header the adapter has.) + +If the header is cleared while a PID request that depends on it is already queued, that request is not sent with the wrong header. It is skipped for this poll, and on its next `update_interval` its `pre_commands` and PID are queued again together. There is no extra polling. This only happens after a failure (a failed pre-command or an adapter reset). Without failures the traffic is the same as before. + +> [!NOTE] +> **Trade-off:** a device can miss updates for a while if its pre-command keeps failing. Example: two devices use different `ATSH` headers, so the header is re-sent on every switch; a slow ECU makes the previous request time out every time, and the adapter answers the following `ATSH` with `STOPPED`. Earlier versions sent the PID anyway with the wrong header, which produced a wrong value (another ECU's reply) or none. Now that poll is skipped, so no wrong value is published, but the sensor may not update until the pre-command succeeds. There is no extra retry. Remedies: +> - raise `response_timeout`, so slow ECUs (especially multi-frame replies) do not time out; +> - enable `auto_recovery: true`, so a persistent failure streak leads to a re-init / reconnect. + +The header is also cleared when the adapter reports `LV RESET` (it restarted), and when `ATZ`, `ATWS` or `ATD` is sent on its own (init or `ble_elm327.send_command`), because these reset the adapter's settings. Inside a device's own `pre_commands` (e.g. `["ATD", "ATSP6", "ATSH 7E4"]`) they do not clear it: the rest of that list re-applies the header, so the list is sent once and stays sticky as before. + +#### Recovery -Init commands are sent with the same `tx_delay` timing as sensor commands. Responses to AT commands during init are silently ignored (they don't match any sensor's mode + PID). +A **failure** is a timeout, a failed BLE write, or an adapter/link error reply (see [Error Replies](#error-replies)). Failures are always logged and counted in the [diagnostic sensors](#diagnostics), whatever `auto_recovery` is set to. + +These failure-handling steps apply in both modes: + +| Event | Action | +|-------|--------| +| Timeout | See [Response Timeout](#response-timeout). The command is dropped (the sensor is polled again on its next `update_interval`), and `tx_delay` starts from the timeout. | +| BLE write fails | The command is not waited on (earlier versions waited for the full timeout). A write the BLE stack reports as `CONGESTED` (status `0x8f`) was accepted and is **not** a failure; it is logged at DEBUG only. A command that cannot be sent because the link is not established counts as a failed write. | + +**`auto_recovery: false` (default):** nothing else happens. The component keeps sending the next queued command, as earlier versions did. `reinit_count` and `reconnect_count` stay `0`. + +**`auto_recovery: true`:** + +| Event | Action | +|-------|--------| +| `recovery_threshold` (default 3) failures in a row | The adapter is **re-initialized**: the queue is cleared, base + `init_commands` are re-sent (starting with `ATZ`), and the tracked `pre_commands` header is reset. | +| Another streak before PID replies recover | The BLE link is **disconnected**, and command traffic stops until the disconnect completes (the disconnect is re-issued every 10 s if needed). `ble_client` then reconnects and runs the full init sequence. | +| Further streaks after a recovery reconnect | Go straight to another reconnect (no extra re-init), with **exponential backoff** between recovery reconnects of 30 s, 60 s, 120 s … up to 10 min. A streak during the backoff is logged (`Reconnect held off for …`), and polling continues. | +| Last init command gets no reply | It is re-sent (counted as a failure) instead of entering READY. | + +The failure streak is reset only by a normal (non-error) reply to a PID request or init command. `NO DATA`, bus errors, `?` to AT commands, and `STOPPED` right after a timeout neither count nor reset it. After a re-init or reconnect, 5 normal PID replies in a row are needed to clear the recovery state: the next streak then starts with a re-init again, and the reconnect backoff is reset. A disconnect that the component did not cause (e.g. the adapter lost power) also makes the next streak start with a re-init, but keeps the backoff. + +A reply that arrives after its command already timed out is logged as `Response received with no command pending (late reply after a timeout?) — consider increasing response_timeout`. Its data is still delivered to matching sensors, but it does not reset the failure streak. + +If a device's `pre_commands` fail (timeout, failed write, `STOPPED`, …), its PID is skipped for that poll rather than sent with the wrong header. With a slow ECU that keeps timing out, a device that needs a header switch can therefore miss updates. See the trade-off note under [Error Replies](#error-replies). Raise `response_timeout` or enable `auto_recovery`. + +> [!IMPORTANT] +> The reconnect step relies on `ble_client` reconnecting by itself (`auto_connect: true`, the default). With `auto_connect: false`, the adapter stays disconnected after the recovery disconnect until you connect it again yourself (e.g. with `ble_client.connect`). --- @@ -199,7 +316,7 @@ All sub-platforms (sensor, …) share these options: | `formula` | lambda | - | Custom value parser (see [Formula Lambda](#formula-lambda)) | | `update_interval` | time | `60s` | Per-sensor polling interval | -> **Note**: Each sensor has an **independent** `update_interval`. Requests from all sensors are queued and sent one at a time with `tx_delay` between them. +> **Note**: Each sensor has an **independent** `update_interval`. Requests from all sensors are queued and sent one at a time: each is written after the previous reply (or timeout) plus `tx_delay`. #### Per-device Pre-commands @@ -233,7 +350,7 @@ sensor: update_interval: 1s ``` -On disconnect the tracked header is cleared, so the first device polled after a reconnect re-applies its `pre_commands`. +On disconnect or re-init, and when a `pre_commands` command times out, fails to write, or gets an adapter/link error reply (not `?`), the tracked header is cleared, so the first device polled after a reconnect re-applies its `pre_commands`. A PID whose header was lost this way is skipped for that poll instead of being sent with the wrong header. A device whose pre-command keeps failing (e.g. after a slow ECU times out) can therefore miss updates. See the trade-off note under [Error Replies](#error-replies). Raise `response_timeout` or enable `auto_recovery`. > **Note**: `pre_commands` is for per-device, on-demand setup (typically `ATSH`, `ATCRA`, `ATFCSH`). For one-shot adapter setup that should run once at connect time, use the top-level `init_commands` instead. @@ -257,13 +374,81 @@ sensor: All standard ESPHome sensor options (`unit_of_measurement`, `device_class`, `state_class`, `accuracy_decimals`, `filters`, …) are supported. +### Diagnostics + +Optional diagnostic entities show what is going wrong on the link (all use `entity_category: diagnostic`). They are configured as a separate `ble_elm327` platform entry: an entry that contains any of the diagnostic keys below is a diagnostic block and cannot also contain `pid` / `preset`. + +```yaml +sensor: + - platform: ble_elm327 + # ble_elm327_id: elm # only needed with more than one ble_elm327 + timeout_count: + name: "OBD Timeouts" + error_count: + name: "OBD Error Responses" + no_data_count: + name: "OBD NO DATA Responses" + write_failure_count: + name: "OBD Write Failures" + reinit_count: + name: "OBD Re-inits" + reconnect_count: + name: "OBD Reconnects" + response_time: + name: "OBD Response Time" + +text_sensor: + - platform: ble_elm327 + last_error: + name: "OBD Last Error" +``` + +| Key | Platform | Description | +|-----|----------|-------------| +| `timeout_count` | sensor | Commands that got no `>` prompt in time (see [Response Timeout](#response-timeout)) | +| `error_count` | sensor | ELM327 error replies other than `NO DATA` (see [Error Replies](#error-replies)), plus `RX OVERFLOW` | +| `no_data_count` | sensor | `NO DATA` replies (ECU did not answer — unsupported PID, wrong header, or ignition off) | +| `write_failure_count` | sensor | Failed BLE writes to the TX characteristic (rejected locally or reported failed by the BLE stack) | +| `reinit_count` | sensor | Automatic re-initializations (`auto_recovery: true` only, otherwise always `0`) | +| `reconnect_count` | sensor | Forced BLE disconnects after re-init did not help (`auto_recovery: true` only, otherwise always `0`) | +| `response_time` | sensor | Time in ms from a successful write to the `>` prompt of the most recent **normal PID reply**. Not sampled: AT / init / `pre_commands`, error replies (incl. `NO DATA`), timeouts, failed writes, late replies. `measurement`, unit `ms`. | +| `last_error` | text_sensor | Most recent problem, e.g. `TIMEOUT (010c)`, `STOPPED (010d)`, `CAN ERROR (010c)`, `? (010c)`, `WRITE FAILED (010c)`, `RX OVERFLOW (...)`, `REINIT`, `RECONNECT`, `DISCONNECTED`. `NO DATA` is not reported here (see `no_data_count`). | + +**Publish policy:** diagnostic entities are published at most once every **5 s**, and only when a value changed. Counters publish `0` at boot. Several events within one interval show up as the final counter value and the most recent `last_error`. Counters are `total_increasing` and reset to `0` on reboot. + +Each key accepts all standard sensor / text sensor options (`name`, `id`, `filters`, `internal`, …). Every key is optional, so configure only the ones you need. + +#### Using `response_time` + +`response_time` publishes the latest sample once per 5 s interval, so it jumps around. For a steadier value or a peak, use the standard ESPHome sensor filters: + +```yaml +sensor: + - platform: ble_elm327 + response_time: + name: "OBD Response Time" + filters: + - sliding_window_moving_average: + window_size: 6 # ~30 s at one publish every 5 s + send_every: 1 +``` + +Use it to tune the timing options: + +- **`response_timeout`:** keep it well above the typical response time. If the (filtered) value regularly exceeds about half of `response_timeout`, or `timeout_count` rises while response times are close to the limit, raise `response_timeout`. +- **`tx_delay`:** a response time that keeps creeping up during a session, often followed by timeouts, suggests the adapter is overloaded. Raise `tx_delay` or lengthen `update_interval`s. +- **Typical values:** a healthy BLE adapter on CAN usually answers in tens to a few hundred ms. A first request after `ATSP0` can take seconds (protocol search). + +> [!TIP] +> `NO DATA`, and with the ignition off also `CAN ERROR` / `UNABLE TO CONNECT`, are normal for PIDs your vehicle does not support. A steadily rising `timeout_count` points at an adapter that cannot keep up. Increase `tx_delay` or reduce polling rates, and consider `auto_recovery: true`. + --- ## Actions ### `ble_elm327.send_command` -Enqueues an arbitrary AT command or OBD PID request to be transmitted over BLE to the ELM327 adapter. Commands are queued and transmitted with the configured `tx_delay` timing. +Enqueues an arbitrary AT command or OBD PID request to be transmitted over BLE to the ELM327 adapter. Commands are queued and sent one at a time like sensor requests (after the previous reply plus `tx_delay`). This is especially useful for closing the active OBD/CAN protocol session (`AT PC` - Protocol Close) or putting the adapter into low-power mode (`AT LP`) before disconnecting BLE, ensuring vehicle ECUs can enter deep sleep and prevent 12V battery drain. @@ -895,7 +1080,13 @@ sensor: | Connects but no data | Wrong service / characteristic UUID | Scan the adapter's BLE services and update `service_uuid`, `rx_char_uuid`, `tx_char_uuid` | | `RX characteristic not found` | UUID mismatch | Some adapters use 128-bit UUIDs — use the full form in config | | Sensor never updates | PID/mode mismatch or ECU doesn't support it | Enable DEBUG logs and confirm the response byte matches `0x40 + mode`; check `ATDP` to verify protocol | -| Sensors never update after connect | Adapter slow to respond to init commands | Increase `tx_delay` | +| Sensors never update after connect | Adapter slow to respond to init commands | Check `last_error` / DEBUG logs; increase `response_timeout` if init commands time out, and `tx_delay` (extra wait after each reply) if the adapter answers `STOPPED` / `?` | +| Updates stop after a while, `Timeout (5000ms) waiting for response to '…'` in logs | Adapter overloaded or hung | Increase `tx_delay` (extra idle time after each reply, e.g. `100`–`200`) and lengthen `update_interval`s. Enable `auto_recovery: true` so the component re-inits and reconnects by itself (see [Recovery](#recovery)). Watch the [diagnostic sensors](#diagnostics) | +| `Response received with no command pending (late reply after a timeout?)` | Adapter answers slower than `response_timeout` | Increase `response_timeout` | +| `ELM327 replied 'STOPPED' to '…'` | A command was written while the adapter was still busy | Increase `tx_delay` / `response_timeout` | +| `Adapter still failing after re-init — disconnecting to force a BLE reconnect`, then stays disconnected | `ble_client` has `auto_connect: false` | Use the default `auto_connect: true`, or reconnect from an automation | +| Timeouts / `UNABLE TO CONNECT` while the ignition is off | `ATSP0` searches for a protocol on every request | Pin the protocol in `init_commands` (e.g. `ATSP6`) or increase `response_timeout` | +| `Reconnect held off for …` | Recovery reconnects keep failing (backoff active) | Check the adapter power / BLE range; see the diagnostic counters | | Data wrong / garbled | Wrong `formula` | Check raw response in DEBUG logs and adjust formula | | Sensors update too fast / slow | Per-sensor `update_interval` | Tune each sensor's `update_interval` independently | diff --git a/components/ble_elm327/__init__.py b/components/ble_elm327/__init__.py index 5c92727a..4e06087e 100644 --- a/components/ble_elm327/__init__.py +++ b/components/ble_elm327/__init__.py @@ -7,7 +7,8 @@ ) from .const import ( CONF_BLE_ELM327_ID, CONF_BLE_CLIENT_ID, CONF_RX_CHAR_UUID, CONF_TX_CHAR_UUID, - CONF_INIT_COMMANDS, CONF_PRE_COMMANDS, CONF_TX_DELAY, + CONF_INIT_COMMANDS, CONF_PRE_COMMANDS, CONF_TX_DELAY, CONF_RESPONSE_TIMEOUT, + CONF_AUTO_RECOVERY, CONF_RECOVERY_THRESHOLD, CONF_PID, CONF_MODE, CONF_FORMULA, CONF_PRESET, ) from .presets import OBD_PRESETS @@ -40,7 +41,16 @@ def _add_uuid(var, uuid_val, fn16, fn32, fn128): cg.add(getattr(var, fn128)(esp32_ble_tracker.as_reversed_hex_array(uuid_val))) -CONFIG_SCHEMA = cv.Schema( +def _validate_recovery(config): + if CONF_RECOVERY_THRESHOLD in config and not config[CONF_AUTO_RECOVERY]: + raise cv.Invalid( + f"'{CONF_RECOVERY_THRESHOLD}' only has an effect with '{CONF_AUTO_RECOVERY}: true'", + path=[CONF_RECOVERY_THRESHOLD], + ) + return config + + +CONFIG_SCHEMA = cv.All(cv.Schema( { cv.GenerateID(): cv.declare_id(BleElm327Component), cv.Required(CONF_BLE_CLIENT_ID): cv.use_id(ble_client.BLEClient), @@ -49,8 +59,14 @@ def _add_uuid(var, uuid_val, fn16, fn32, fn128): cv.Optional(CONF_TX_CHAR_UUID, default="2AF1"): esp32_ble_tracker.bt_uuid, cv.Optional(CONF_INIT_COMMANDS): cv.ensure_list(cv.string_strict), cv.Optional(CONF_TX_DELAY, default=50): cv.positive_int, # ms + # ms; raised to max(this, 5000) during init / until PID data flows again + # (see README "Response Timeout") + cv.Optional(CONF_RESPONSE_TIMEOUT, default=5000): cv.int_range(min=100, max=60000), + # Opt-in: re-init / reconnect after repeated failures (README "Recovery") + cv.Optional(CONF_AUTO_RECOVERY, default=False): cv.boolean, + cv.Optional(CONF_RECOVERY_THRESHOLD): cv.int_range(min=1, max=20), } -) +), _validate_recovery) async def to_code(config): @@ -71,6 +87,10 @@ async def to_code(config): cg.add(var.add_init_command(cmd)) cg.add(var.set_tx_delay(config[CONF_TX_DELAY])) + cg.add(var.set_response_timeout(config[CONF_RESPONSE_TIMEOUT])) + cg.add(var.set_auto_recovery(config[CONF_AUTO_RECOVERY])) + if CONF_RECOVERY_THRESHOLD in config: + cg.add(var.set_recovery_threshold(config[CONF_RECOVERY_THRESHOLD])) # ── Shared device schema (used by every sub-platform) ──────────────────────── diff --git a/components/ble_elm327/ble_elm327.cpp b/components/ble_elm327/ble_elm327.cpp index f2a4061e..ac106c0e 100644 --- a/components/ble_elm327/ble_elm327.cpp +++ b/components/ble_elm327/ble_elm327.cpp @@ -2,6 +2,8 @@ #include "response_parser.h" #include "esphome/core/log.h" +#include + #ifdef USE_ESP32 namespace esphome { @@ -10,6 +12,19 @@ namespace ble_elm327 { static const char *const TAG = "ble_elm327"; // Always sent first on connect, in this order, before any YAML init_commands. static const char *const BASE_INIT_COMMANDS[] = {"ATZ", "ATE0", "ATL0", "ATS0", "ATH0", "ATSP0"}; +// Minimum timeout for init commands (ATZ), and for every command until a PID +// request gets a normal reply after (re)init, a timeout or an error reply +// (an ATSP0 protocol search can take several seconds). +static const uint32_t INIT_RESPONSE_TIMEOUT_MS = 5000; +// Normal PID replies in a row needed after a re-init before the next failure +// streak starts with a re-init again instead of a reconnect. +static const uint8_t SUCCESSES_TO_CLEAR_RECOVERY = 5; +// Bytes buffered without a '>' prompt before the buffer is discarded. +static const size_t MAX_RX_BUFFER_SIZE = 4096; +// Minimum interval between diagnostic entity publishes. +static const uint32_t DIAG_PUBLISH_INTERVAL_MS = 5000; +// Re-issue the disconnect if no disconnect event arrived within this time. +static const uint32_t RECONNECT_RETRY_MS = 10000; // ── BleElm327Device ───────────────────────────────────────────────────────── @@ -81,30 +96,102 @@ void BleElm327Component::send_command(const std::string &cmd) { // ── BleElm327Component ────────────────────────────────────────────────────── +void BleElm327Component::setup() { +#ifdef USE_SENSOR + // Publish initial zeros so the diagnostic counters are not "unknown". + publish_count_(timeout_count_sensor_, 0); + publish_count_(error_count_sensor_, 0); + publish_count_(no_data_count_sensor_, 0); + publish_count_(write_failure_count_sensor_, 0); + publish_count_(reinit_count_sensor_, 0); + publish_count_(reconnect_count_sensor_, 0); +#endif +} + void BleElm327Component::loop() { + const uint32_t now = millis(); + flush_diagnostics_(now); + + if (reconnect_pending_) { + // Paused until ESP_GATTC_DISCONNECT_EVT. Retry if the disconnect got lost. + if (now - reconnect_requested_at_ > RECONNECT_RETRY_MS) { + ESP_LOGW(TAG, "Still connected %ums after requesting a disconnect — retrying", (unsigned) RECONNECT_RETRY_MS); + reconnect_requested_at_ = now; + this->parent_->disconnect(); + } + return; + } + if (elm_state_ == ElmState::IDLE) return; if (waiting_for_response_) { - if (millis() - last_tx_time_ > 5000) { - ESP_LOGW(TAG, "Timeout waiting for response to command: %s", last_sent_command_.c_str()); - waiting_for_response_ = false; + const TimeoutCheck check = + check_response_timeout(auto_recovery_, current_timeout_ms_, is_protocol_search_in_progress(rx_buffer_), + now - last_write_time_, now - last_activity_time_); + if (!check.expired) return; + + timeout_count_++; + if (check.hard_limit_hit) { + ESP_LOGW(TAG, "Timeout: no '>' prompt %ums after sending '%s' (%u failures in a row)", + (unsigned) check.hard_limit_ms, last_sent_command_.c_str(), (unsigned) (consecutive_failures_ + 1)); } else { - return; + ESP_LOGW(TAG, "Timeout (%ums) waiting for response to '%s' (%u failures in a row)", (unsigned) check.timeout_ms, + last_sent_command_.c_str(), (unsigned) (consecutive_failures_ + 1)); } + set_last_error_("TIMEOUT (" + last_sent_command_ + ")"); + // The adapter may have been searching for a protocol without saying so. + long_timeout_until_data_ = true; + last_exchange_timed_out_ = true; + fail_exchange_(now); + return; } - // Drain queued command (init or sensor) with tx_delay + // Drain queued command (init or sensor). tx_delay is idle time after the + // previous exchange finished (prompt received / timeout), not after the write. if (!tx_queue_.empty()) { - if (millis() - last_tx_time_ < tx_delay_ms_) return; + if (now - last_rx_time_ < tx_delay_ms_) return; auto item = tx_queue_.front(); tx_queue_.pop(); if (item.dev) item.dev->on_dequeue(); - send_command_raw_(item.cmd); - last_tx_time_ = millis(); - if (elm_state_ == ElmState::CONNECTED && tx_queue_.empty()) { - elm_state_ = ElmState::READY; - ESP_LOGI(TAG, "ELM327 initialized and ready"); + + const DequeueAction action = + decide_dequeue(item.pre, item.dev != nullptr ? &item.dev->get_pre_commands() : nullptr, + elm_state_ == ElmState::READY, current_pre_commands_, normalize_command(item.cmd)); + if (action == DequeueAction::HOLD_BACK) { + // The header this PID depends on was lost after it was queued (failed + // pre-command, adapter reset): don't send it with the wrong header. It is + // skipped for this cycle; its next update() queues pre_commands + PID + // again (no extra polling beyond update_interval). + ESP_LOGD(TAG, "Header for PID '%s' was lost — skipping this poll", item.dev->get_pid().c_str()); + return; + } + // ATZ / ATWS / ATD sent on their own reset the adapter, including ATSH. + if (action == DequeueAction::SEND_AND_CLEAR_HEADER) current_pre_commands_.clear(); + + if (elm_state_ == ElmState::CONNECTED) { + in_flight_kind_ = TxKind::INIT; + // READY is entered when this last init command is answered (on_notify). + final_init_in_flight_ = tx_queue_.empty(); + if (final_init_in_flight_) final_init_command_ = item.cmd; + } else if (item.pre) { + in_flight_kind_ = TxKind::PRE; + } else if (item.dev != nullptr) { + in_flight_kind_ = TxKind::DEVICE; + } else { + in_flight_kind_ = TxKind::USER; } + + // Init commands, a manual ATZ, and every command until a PID request gets a + // normal reply after (re)init / a timeout / an error reply use the long + // timeout, so an ATSP0 protocol search is never cut short. + bool long_timeout = in_flight_kind_ == TxKind::INIT || long_timeout_until_data_ || + normalize_command(item.cmd) == "atz"; + current_timeout_ms_ = long_timeout ? std::max(response_timeout_ms_, INIT_RESPONSE_TIMEOUT_MS) : response_timeout_ms_; + + follows_timeout_ = last_exchange_timed_out_; + last_exchange_timed_out_ = false; + + send_command_raw_(item.cmd); // failures are counted and escalated inside return; } @@ -118,7 +205,7 @@ void BleElm327Component::loop() { if (d->consume_enqueued()) { const auto &pre = d->get_pre_commands(); if (pre != current_pre_commands_) { - for (const auto &c : pre) tx_queue_.push({c, nullptr}); + for (const auto &c : pre) tx_queue_.push({c, nullptr, true}); current_pre_commands_ = pre; } tx_queue_.push({d->get_command(), d}); @@ -137,8 +224,27 @@ void BleElm327Component::dump_config() { ESP_LOGCONFIG(TAG, " RX Char UUID : %s", rx_char_uuid_.to_str(uuid_buf)); ESP_LOGCONFIG(TAG, " TX Char UUID : %s", tx_char_uuid_.to_str(uuid_buf)); ESP_LOGCONFIG(TAG, " TX delay : %lums", (unsigned long) tx_delay_ms_); + ESP_LOGCONFIG(TAG, " Response timeout : %lums (init: %lums)", (unsigned long) response_timeout_ms_, + (unsigned long) std::max(response_timeout_ms_, INIT_RESPONSE_TIMEOUT_MS)); + if (auto_recovery_) { + ESP_LOGCONFIG(TAG, " Auto recovery : on (threshold %u)", (unsigned) recovery_threshold_); + } else { + ESP_LOGCONFIG(TAG, " Auto recovery : off"); + } ESP_LOGCONFIG(TAG, " Base init commands : ATZ, ATE0, ATL0, ATS0, ATH0, ATSP0"); ESP_LOGCONFIG(TAG, " Extra init commands: %u", (unsigned)extra_init_commands_.size()); +#ifdef USE_SENSOR + LOG_SENSOR(" ", "Timeout count", timeout_count_sensor_); + LOG_SENSOR(" ", "Error response count", error_count_sensor_); + LOG_SENSOR(" ", "NO DATA count", no_data_count_sensor_); + LOG_SENSOR(" ", "Write failure count", write_failure_count_sensor_); + LOG_SENSOR(" ", "Re-init count", reinit_count_sensor_); + LOG_SENSOR(" ", "Reconnect count", reconnect_count_sensor_); + LOG_SENSOR(" ", "Response time", response_time_sensor_); +#endif +#ifdef USE_TEXT_SENSOR + LOG_TEXT_SENSOR(" ", "Last error", last_error_text_sensor_); +#endif ESP_LOGCONFIG(TAG, " Devices : %u", (unsigned)devices_.size()); for (auto *d : devices_) d->dump_config(); } @@ -162,12 +268,21 @@ void BleElm327Component::gattc_event_handler(esp_gattc_cb_event_t event, esp_gat client_state_ = espbt::ClientState::IDLE; rx_char_handle_ = 0; tx_char_handle_ = 0; - while (!tx_queue_.empty()) tx_queue_.pop(); - for (auto *d : devices_) d->on_dequeue(); + clear_tx_queue_(); current_pre_commands_.clear(); rx_buffer_.clear(); last_sent_command_.clear(); waiting_for_response_ = false; + consecutive_failures_ = 0; + // After our own recovery disconnect keep reinit_attempted_, so the next + // failure streak goes to the (backed-off) reconnect instead of starting + // another re-init -> reconnect cycle. + if (!reconnect_pending_) reinit_attempted_ = false; + successes_since_reinit_ = 0; + reconnect_pending_ = false; + final_init_in_flight_ = false; + last_exchange_timed_out_ = false; + set_last_error_("DISCONNECTED"); ESP_LOGW(TAG, "Disconnected from ELM327"); break; @@ -191,17 +306,16 @@ void BleElm327Component::gattc_event_handler(esp_gattc_cb_event_t event, esp_gat ESP_LOGW(TAG, "Notify registration failed: %d", param->reg_for_notify.status); break; } - last_tx_time_ = millis(); + last_rx_time_ = millis(); + last_activity_time_ = last_rx_time_; elm_state_ = ElmState::CONNECTED; waiting_for_response_ = false; - for (const char *cmd : BASE_INIT_COMMANDS) { - std::string framed = std::string(cmd) + "\r"; - tx_queue_.push({framed, nullptr}); - } - for (const auto &cmd : extra_init_commands_) tx_queue_.push({cmd, nullptr}); - ESP_LOGI(TAG, "Queued %u init commands (%u base + %u extra)", (unsigned) tx_queue_.size(), - (unsigned)(sizeof(BASE_INIT_COMMANDS) / sizeof(BASE_INIT_COMMANDS[0])), - (unsigned) extra_init_commands_.size()); + consecutive_failures_ = 0; + reconnect_pending_ = false; + final_init_in_flight_ = false; + last_exchange_timed_out_ = false; + rx_buffer_.clear(); + queue_init_commands_(); break; case ESP_GATTC_NOTIFY_EVT: @@ -210,9 +324,20 @@ void BleElm327Component::gattc_event_handler(esp_gattc_cb_event_t event, esp_gat break; case ESP_GATTC_WRITE_CHAR_EVT: - if (param->write.status != ESP_GATT_OK) { - ESP_LOGW(TAG, "Write failed, status=%d", param->write.status); + // Only our TX characteristic; other nodes on the same ble_client write too. + if (param->write.handle != tx_char_handle_ || param->write.status == ESP_GATT_OK) break; + if (param->write.status == ESP_GATT_CONGESTED) { + // For write-without-response, Bluedroid reports CONGESTED when the + // packet was accepted but the L2CAP channel is congested + // (att_protocol.c: "ATT congested, message accepted"). Not a failure. + ESP_LOGD(TAG, "Write of '%s' accepted, link congested", last_sent_command_.c_str()); + break; } + ESP_LOGW(TAG, "Write of '%s' failed, status=0x%02x", last_sent_command_.c_str(), param->write.status); + record_write_failure_(); + // The adapter never got the command: end the exchange now instead of + // also counting a timeout for it later. + if (waiting_for_response_) fail_exchange_(millis()); break; default: @@ -220,32 +345,288 @@ void BleElm327Component::gattc_event_handler(esp_gattc_cb_event_t event, esp_gat } } +void BleElm327Component::queue_init_commands_() { + for (const char *cmd : BASE_INIT_COMMANDS) { + std::string framed = std::string(cmd) + "\r"; + tx_queue_.push({framed, nullptr}); + } + for (const auto &cmd : extra_init_commands_) tx_queue_.push({cmd, nullptr}); + long_timeout_until_data_ = true; + ESP_LOGI(TAG, "Queued %u init commands (%u base + %u extra)", (unsigned) tx_queue_.size(), + (unsigned)(sizeof(BASE_INIT_COMMANDS) / sizeof(BASE_INIT_COMMANDS[0])), + (unsigned) extra_init_commands_.size()); +} + +void BleElm327Component::clear_tx_queue_() { + while (!tx_queue_.empty()) tx_queue_.pop(); + for (auto *d : devices_) d->on_dequeue(); +} + +// Ends the exchange in flight after a timeout or failed write and counts it. +void BleElm327Component::fail_exchange_(uint32_t now) { + waiting_for_response_ = false; + // Drop any partial reply so it cannot be glued to the next command's response. + rx_buffer_.clear(); + last_rx_time_ = now; // tx_delay counts from here + // A header command that did not go through: re-send it before the next PID. + if (in_flight_kind_ == TxKind::PRE) current_pre_commands_.clear(); + const bool retry_final_init = final_init_in_flight_; + final_init_in_flight_ = false; + + on_exchange_failure_(); + + if (!retry_final_init || elm_state_ != ElmState::CONNECTED || !tx_queue_.empty()) return; + if (auto_recovery_) { + // The last init command got no answer: re-send it rather than declaring + // READY (repeated failures escalate to a re-init / reconnect). + tx_queue_.push({final_init_command_, nullptr}); + } else { + // Default: carry on as before auto_recovery existed. + elm_state_ = ElmState::READY; + ESP_LOGI(TAG, "ELM327 initialized and ready (last init command got no reply)"); + } +} + +// Called for every timeout, failed write and adapter-level error reply. With +// auto_recovery, after recovery_threshold failures in a row the adapter is +// re-initialized (ATZ + init sequence); if the next streak comes before PID +// replies have recovered, the BLE link is dropped so ble_client reconnects. +// Without auto_recovery nothing else happens (the failures are only counted). +void BleElm327Component::on_exchange_failure_() { + successes_since_reinit_ = 0; + if (consecutive_failures_ < 255) consecutive_failures_++; + if (!auto_recovery_) return; + if (consecutive_failures_ < recovery_threshold_) return; + consecutive_failures_ = 0; + if (!reinit_attempted_) { + reinit_attempted_ = true; + reinitialize_(); + return; + } + const uint32_t backoff = reconnect_backoff_ms(recovery_reconnects_); + const uint32_t since_last = millis() - last_reconnect_at_; + if (recovery_reconnects_ > 0 && since_last < backoff) { + ESP_LOGW(TAG, "Reconnect held off for %us more (backoff after %u recovery reconnects)", + (unsigned) ((backoff - since_last) / 1000), (unsigned) recovery_reconnects_); + return; + } + reconnect_(); +} + +void BleElm327Component::handle_error_response_(const std::string &error, bool expected) { + // Low-voltage reset: the adapter restarted and lost the ATSH header, + // whatever command was in flight (and even for a late reply). + if (error == "LV RESET") current_pre_commands_.clear(); + if (error == "NO DATA") { + // Normal for unsupported PIDs / ignition off: counted, not a "last error". + no_data_count_++; + diag_dirty_ = true; + ESP_LOGD(TAG, "NO DATA for '%s'", last_sent_command_.c_str()); + } else { + error_count_++; + if (is_bus_error(error)) { + // Vehicle side (e.g. ignition off): expected, keep the log quiet. + ESP_LOGD(TAG, "ELM327 replied '%s' to '%s'", error.c_str(), last_sent_command_.c_str()); + } else { + ESP_LOGW(TAG, "ELM327 replied '%s' to '%s'", error.c_str(), last_sent_command_.c_str()); + } + set_last_error_(error + " (" + last_sent_command_ + ")"); + } + // A late reply belongs to an exchange that already timed out (and was counted). + if (!expected) return; + + // ATSP0 may (re)start a protocol search on the next request. + if (error != "?") long_timeout_until_data_ = true; + // An unsupported pre_command ("?") stays sticky like before; only replies that + // mean the header was not applied force a re-send. + if (in_flight_kind_ == TxKind::PRE && pre_command_reply_invalidates_header(error)) { + current_pre_commands_.clear(); + } + if (error == "STOPPED" && follows_timeout_) { + ESP_LOGD(TAG, "STOPPED right after a timeout: the adapter aborted the previous request"); + } + if (counts_as_link_failure(error, last_sent_command_, follows_timeout_)) on_exchange_failure_(); +} + +void BleElm327Component::record_write_failure_() { + write_failure_count_++; + set_last_error_("WRITE FAILED (" + last_sent_command_ + ")"); +} + +void BleElm327Component::reinitialize_() { + reinit_count_++; + successes_since_reinit_ = 0; + final_init_in_flight_ = false; + ESP_LOGW(TAG, "%u consecutive failures — re-initializing ELM327", (unsigned) recovery_threshold_); + set_last_error_("REINIT"); + clear_tx_queue_(); + // ATZ resets the adapter, including any ATSH header applied by pre_commands. + current_pre_commands_.clear(); + rx_buffer_.clear(); + waiting_for_response_ = false; + elm_state_ = ElmState::CONNECTED; + queue_init_commands_(); +} + +void BleElm327Component::reconnect_() { + reconnect_count_++; + final_init_in_flight_ = false; + if (recovery_reconnects_ < 255) recovery_reconnects_++; + last_reconnect_at_ = millis(); + ESP_LOGW(TAG, "Adapter still failing after re-init — disconnecting to force a BLE reconnect"); + set_last_error_("RECONNECT"); + clear_tx_queue_(); + current_pre_commands_.clear(); + rx_buffer_.clear(); + waiting_for_response_ = false; + // Stop all traffic until ESP_GATTC_DISCONNECT_EVT resets the state; the + // ble_client (auto_connect) then reconnects and runs the init sequence again. + elm_state_ = ElmState::IDLE; + reconnect_pending_ = true; + reconnect_requested_at_ = millis(); + this->parent_->disconnect(); +} + +void BleElm327Component::set_last_error_(const std::string &error) { + last_error_ = error; + diag_dirty_ = true; +} + +// Diagnostic entities are published at most every DIAG_PUBLISH_INTERVAL_MS and +// only when a value changed, so a noisy link cannot flood the API / MQTT. +void BleElm327Component::flush_diagnostics_(uint32_t now) { + if (!diag_dirty_ || now - last_diag_publish_ < DIAG_PUBLISH_INTERVAL_MS) return; + diag_dirty_ = false; + last_diag_publish_ = now; +#ifdef USE_SENSOR + publish_count_(timeout_count_sensor_, timeout_count_); + publish_count_(error_count_sensor_, error_count_); + publish_count_(no_data_count_sensor_, no_data_count_); + publish_count_(write_failure_count_sensor_, write_failure_count_); + publish_count_(reinit_count_sensor_, reinit_count_); + publish_count_(reconnect_count_sensor_, reconnect_count_); + // Only the latest sample is published per interval. + if (has_response_time_) publish_count_(response_time_sensor_, last_response_time_ms_); +#endif +#ifdef USE_TEXT_SENSOR + if (last_error_text_sensor_ != nullptr && !last_error_.empty() && + (!last_error_text_sensor_->has_state() || last_error_text_sensor_->state != last_error_)) { + last_error_text_sensor_->publish_state(last_error_); + } +#endif +} + +#ifdef USE_SENSOR +void BleElm327Component::publish_count_(sensor::Sensor *s, uint32_t value) { + if (s == nullptr) return; + const float v = static_cast(value); + if (s->has_state() && s->state == v) return; + s->publish_state(v); +} +#endif + bool BleElm327Component::send_command_raw_(const std::string &cmd) { - if (client_state_ != espbt::ClientState::ESTABLISHED || tx_char_handle_ == 0) return false; - auto *chr = this->parent_->get_characteristic(service_uuid_, tx_char_uuid_); - if (chr == nullptr) { ESP_LOGW(TAG, "TX characteristic missing"); return false; } - chr->write_value(reinterpret_cast(const_cast(cmd.data())), cmd.size(), - ESP_GATT_WRITE_TYPE_NO_RSP); - ESP_LOGD(TAG, ">> %s", cmd.c_str()); last_sent_command_ = normalize_command(cmd); + + esp_err_t err = ESP_FAIL; + auto *chr = (client_state_ == espbt::ClientState::ESTABLISHED && tx_char_handle_ != 0) + ? this->parent_->get_characteristic(service_uuid_, tx_char_uuid_) + : nullptr; + if (chr == nullptr) { + // Not connected (or TX characteristic gone): handled like a failed write so + // the command is not silently dropped and a stuck link escalates. + ESP_LOGW(TAG, "Cannot send '%s': not connected / TX characteristic missing", last_sent_command_.c_str()); + } else { + err = chr->write_value(reinterpret_cast(const_cast(cmd.data())), cmd.size(), + ESP_GATT_WRITE_TYPE_NO_RSP); + } + + const uint32_t now = millis(); + if (err != ESP_OK) { + // Do not wait for a reply that cannot come; back off by tx_delay instead. + ESP_LOGW(TAG, "Write of '%s' failed: %s", last_sent_command_.c_str(), esp_err_to_name(err)); + record_write_failure_(); + fail_exchange_(now); + return false; + } + + ESP_LOGD(TAG, ">> %s", cmd.c_str()); + // Anything still buffered arrived after the previous prompt and is stale. + rx_buffer_.clear(); waiting_for_response_ = true; + last_activity_time_ = now; + last_write_time_ = now; return true; } void BleElm327Component::on_notify(const uint8_t *data, uint16_t length) { rx_buffer_.append(reinterpret_cast(data), length); + last_activity_time_ = millis(); size_t pos; while ((pos = rx_buffer_.find('>')) != std::string::npos) { std::string response = rx_buffer_.substr(0, pos); rx_buffer_.erase(0, pos + 1); + bool expected = waiting_for_response_; waiting_for_response_ = false; - process_response(response); + last_rx_time_ = millis(); + if (!expected) { + ESP_LOGW(TAG, "Response received with no command pending (late reply after a timeout?) — " + "consider increasing response_timeout"); + } + if (expected && final_init_in_flight_) { + // Any reply (even "?" for an unsupported AT command) means the adapter + // processed the whole init sequence. + final_init_in_flight_ = false; + if (elm_state_ == ElmState::CONNECTED) { + elm_state_ = ElmState::READY; + ESP_LOGI(TAG, "ELM327 initialized and ready"); + } + } + process_response(response, expected); + } + + // A prompt never arrived and the adapter keeps talking: drop the data. + // The exchange itself ends via the absolute timeout in loop(). + if (rx_buffer_.size() > MAX_RX_BUFFER_SIZE) { + ESP_LOGW(TAG, "RX buffer exceeded %u bytes without a '>' prompt — discarding", (unsigned) MAX_RX_BUFFER_SIZE); + rx_buffer_.clear(); + error_count_++; + set_last_error_("RX OVERFLOW (" + last_sent_command_ + ")"); } } -void BleElm327Component::process_response(const std::string &response) { +void BleElm327Component::process_response(const std::string &response, bool expected) { ESP_LOGD(TAG, "<< %s", response.c_str()); + std::string error = classify_error_response(response, last_sent_command_); + if (!error.empty()) { + handle_error_response_(error, expected); + return; + } + + // A normal reply to a PID request or init command ends the failure streak. + // Late replies (expected == false) never do: they follow a counted timeout. + if (expected && (in_flight_kind_ == TxKind::DEVICE || in_flight_kind_ == TxKind::INIT)) { + consecutive_failures_ = 0; + } + if (expected && in_flight_kind_ == TxKind::DEVICE) { + // Response time: successful write → '>' of a normal PID reply. AT / init + // commands, error replies, timeouts and late replies are not sampled. + last_response_time_ms_ = millis() - last_write_time_; + has_response_time_ = true; +#ifdef USE_SENSOR + if (response_time_sensor_ != nullptr) diag_dirty_ = true; +#endif + long_timeout_until_data_ = false; + if ((reinit_attempted_ || recovery_reconnects_ > 0) && + ++successes_since_reinit_ >= SUCCESSES_TO_CLEAR_RECOVERY) { + // Healthy again: the next failure streak starts with a re-init, not a reconnect. + reinit_attempted_ = false; + recovery_reconnects_ = 0; + successes_since_reinit_ = 0; + } + } + if (elm_state_ != ElmState::READY) return; std::vector bytes = parse_response_bytes(response, last_sent_command_); diff --git a/components/ble_elm327/ble_elm327.h b/components/ble_elm327/ble_elm327.h index bde34832..91f9b291 100644 --- a/components/ble_elm327/ble_elm327.h +++ b/components/ble_elm327/ble_elm327.h @@ -1,8 +1,15 @@ #pragma once #include "esphome/core/component.h" +#include "esphome/core/defines.h" #include "esphome/components/ble_client/ble_client.h" #include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif +#ifdef USE_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif #include #include #include @@ -36,7 +43,6 @@ class BleElm327Device : public PollingComponent { // Called when the device is popped from tx_queue_, allowing re-enqueue on next update(). void on_dequeue() { in_queue_ = false; } - void set_pid(const std::string &pid) { pid_ = pid; } void set_mode(const std::string &mode) { mode_ = mode; } void set_formula(std::function &)> f) { formula_ = f; } @@ -70,7 +76,7 @@ class BleElm327Device : public PollingComponent { class BleElm327Component : public Component, public ble_client::BLEClientNode { public: - void setup() override {} + void setup() override; void loop() override; void dump_config() override; float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } @@ -95,6 +101,22 @@ class BleElm327Component : public Component, public ble_client::BLEClientNode { // Duplicate commands (case-insensitive, trimmed) are ignored. void add_init_command(const std::string &cmd); void set_tx_delay(uint32_t ms) { tx_delay_ms_ = ms; } + void set_response_timeout(uint32_t ms) { response_timeout_ms_ = ms; } + void set_auto_recovery(bool enabled) { auto_recovery_ = enabled; } + void set_recovery_threshold(uint8_t n) { recovery_threshold_ = n; } + +#ifdef USE_SENSOR + void set_timeout_count_sensor(sensor::Sensor *s) { timeout_count_sensor_ = s; } + void set_error_count_sensor(sensor::Sensor *s) { error_count_sensor_ = s; } + void set_no_data_count_sensor(sensor::Sensor *s) { no_data_count_sensor_ = s; } + void set_write_failure_count_sensor(sensor::Sensor *s) { write_failure_count_sensor_ = s; } + void set_reinit_count_sensor(sensor::Sensor *s) { reinit_count_sensor_ = s; } + void set_reconnect_count_sensor(sensor::Sensor *s) { reconnect_count_sensor_ = s; } + void set_response_time_sensor(sensor::Sensor *s) { response_time_sensor_ = s; } +#endif +#ifdef USE_TEXT_SENSOR + void set_last_error_text_sensor(text_sensor::TextSensor *s) { last_error_text_sensor_ = s; } +#endif // Enqueues a command (AT command, PID, etc.) to be transmitted to the ELM327 adapter. void send_command(const std::string &cmd); @@ -105,11 +127,32 @@ class BleElm327Component : public Component, public ble_client::BLEClientNode { struct TxItem { std::string cmd; BleElm327Device *dev{nullptr}; + bool pre{false}; // a device's pre_command (e.g. ATSH) }; + // What the command currently in flight was queued as. + enum class TxKind { INIT, PRE, DEVICE, USER }; + bool send_command_raw_(const std::string &cmd); void on_notify(const uint8_t *data, uint16_t length); - void process_response(const std::string &response); + void process_response(const std::string &response, bool expected); + + // Recovery + void queue_init_commands_(); + void on_exchange_failure_(); + void fail_exchange_(uint32_t now); + void handle_error_response_(const std::string &error, bool expected); + void record_write_failure_(); + void reinitialize_(); + void reconnect_(); + void clear_tx_queue_(); + + // Diagnostics + void set_last_error_(const std::string &error); + void flush_diagnostics_(uint32_t now); +#ifdef USE_SENSOR + static void publish_count_(sensor::Sensor *s, uint32_t value); +#endif // BLE handles espbt::ESPBTUUID service_uuid_; @@ -135,11 +178,83 @@ class BleElm327Component : public Component, public ble_client::BLEClientNode { // Timing uint32_t tx_delay_ms_{50}; - uint32_t last_tx_time_{0}; + uint32_t response_timeout_ms_{5000}; + // Opt-in: re-init / reconnect after recovery_threshold_ failures in a row, + // activity-based timeout with search extension. Off = previous behaviour. + bool auto_recovery_{false}; + uint8_t recovery_threshold_{3}; + // Timeout applied to the command currently in flight (see ble_elm327.cpp for + // when the longer init timeout is used). + uint32_t current_timeout_ms_{5000}; + // Last time a command was written or a notify chunk arrived (idle timeout base). + uint32_t last_activity_time_{0}; + // Time the command in flight was written (absolute timeout base). + uint32_t last_write_time_{0}; + // Last time the exchange finished: '>' received, timeout, or write failure. + // tx_delay is measured from here so a slow adapter gets idle time. + uint32_t last_rx_time_{0}; std::string rx_buffer_; std::string last_sent_command_; bool waiting_for_response_{false}; + TxKind in_flight_kind_{TxKind::USER}; + // The last init command is in flight: READY is entered only once it is + // answered; on timeout / failed write it is re-sent. + bool final_init_in_flight_{false}; + std::string final_init_command_; + // The previous exchange timed out / the command in flight is the first one + // written after a timeout (a STOPPED reply to it is not counted). + bool last_exchange_timed_out_{false}; + bool follows_timeout_{false}; + // Use the long init timeout until a PID request gets a normal (non-error) + // reply. Set on (re)init and after timeouts / error replies, because with + // ATSP0 the next request may start a protocol search that takes seconds. + bool long_timeout_until_data_{false}; + + // Recovery state + // Timeouts, failed writes and adapter-level error replies in a row. Reset only + // by a normal reply to a PID request or init command. + uint8_t consecutive_failures_{0}; + // Set once a re-init was done; the next failure streak escalates to a + // reconnect. Cleared after several PID replies in a row succeed again. + bool reinit_attempted_{false}; + uint8_t successes_since_reinit_{0}; + // reconnect_() requested a disconnect; loop() is paused until it happens. + bool reconnect_pending_{false}; + uint32_t reconnect_requested_at_{0}; + // Recovery reconnects in a row (exponential backoff), cleared when PID replies + // have recovered. Survives the disconnect it causes. + uint8_t recovery_reconnects_{0}; + uint32_t last_reconnect_at_{0}; + + // Diagnostics publishing is rate limited (see flush_diagnostics_()). + bool diag_dirty_{false}; + uint32_t last_diag_publish_{0}; + + // Diagnostic counters + uint32_t timeout_count_{0}; + uint32_t error_count_{0}; + uint32_t no_data_count_{0}; + uint32_t write_failure_count_{0}; + uint32_t reinit_count_{0}; + uint32_t reconnect_count_{0}; + // Write → '>' time of the last normal PID reply. + uint32_t last_response_time_ms_{0}; + bool has_response_time_{false}; + std::string last_error_; + +#ifdef USE_SENSOR + sensor::Sensor *timeout_count_sensor_{nullptr}; + sensor::Sensor *error_count_sensor_{nullptr}; + sensor::Sensor *no_data_count_sensor_{nullptr}; + sensor::Sensor *write_failure_count_sensor_{nullptr}; + sensor::Sensor *reinit_count_sensor_{nullptr}; + sensor::Sensor *reconnect_count_sensor_{nullptr}; + sensor::Sensor *response_time_sensor_{nullptr}; +#endif +#ifdef USE_TEXT_SENSOR + text_sensor::TextSensor *last_error_text_sensor_{nullptr}; +#endif }; } // namespace ble_elm327 diff --git a/components/ble_elm327/const.py b/components/ble_elm327/const.py index 8af358b5..d4c75b51 100644 --- a/components/ble_elm327/const.py +++ b/components/ble_elm327/const.py @@ -10,3 +10,16 @@ CONF_FORMULA = "formula" CONF_PRESET = "preset" CONF_COMMAND = "command" +CONF_RESPONSE_TIMEOUT = "response_timeout" +CONF_AUTO_RECOVERY = "auto_recovery" +CONF_RECOVERY_THRESHOLD = "recovery_threshold" + +# Diagnostic entities +CONF_TIMEOUT_COUNT = "timeout_count" +CONF_ERROR_COUNT = "error_count" +CONF_NO_DATA_COUNT = "no_data_count" +CONF_WRITE_FAILURE_COUNT = "write_failure_count" +CONF_REINIT_COUNT = "reinit_count" +CONF_RECONNECT_COUNT = "reconnect_count" +CONF_RESPONSE_TIME = "response_time" +CONF_LAST_ERROR = "last_error" diff --git a/components/ble_elm327/response_parser.cpp b/components/ble_elm327/response_parser.cpp index d2777081..1c3c9cdc 100644 --- a/components/ble_elm327/response_parser.cpp +++ b/components/ble_elm327/response_parser.cpp @@ -103,5 +103,126 @@ std::vector parse_response_bytes(const std::string &response, return bytes; } +static std::string to_upper(const std::string &in) { + std::string out; + out.reserve(in.size()); + for (char c : in) out.push_back(static_cast(std::toupper(static_cast(c)))); + return out; +} + +static const char *const BUS_ERROR_LABELS[] = { + "NO DATA", "CAN ERROR", "BUS ERROR", "BUS BUSY", "BUS INIT ERROR", "DATA ERROR", + "FB ERROR", "RX ERROR", "UNABLE TO CONNECT", +}; + +static const char *const ADAPTER_ERROR_LABELS[] = { + "STOPPED", "BUFFER FULL", "LV RESET", "ACT ALERT", +}; + +std::string classify_error_response(const std::string &response, + const std::string &last_sent_command_normalized) { + size_t start = 0; + while (start <= response.size()) { + size_t end = response.find_first_of("\r\n", start); + if (end == std::string::npos) end = response.size(); + std::string upper = to_upper(response.substr(start, end - start)); + start = end + 1; + + size_t first = upper.find_first_not_of(" \t"); + if (first == std::string::npos) continue; + size_t last = upper.find_last_not_of(" \t"); + upper = upper.substr(first, last - first + 1); + + if (!last_sent_command_normalized.empty() && normalize_command(upper) == last_sent_command_normalized) continue; + + if (upper == "?") return "?"; + // "BUS INIT: ...ERROR" (ISO 9141 / KWP slow or fast init failed) + if (upper.find("BUS INIT") != std::string::npos) { + if (upper.find("ERROR") != std::string::npos) return "BUS INIT ERROR"; + continue; // "BUS INIT: ...OK" / in progress + } + for (const char *label : BUS_ERROR_LABELS) { + if (upper.find(label) != std::string::npos) return label; + } + for (const char *label : ADAPTER_ERROR_LABELS) { + if (upper.find(label) != std::string::npos) return label; + } + // "ERRxx" internal error codes (e.g. ERR94) + if (upper.size() >= 4 && upper.compare(0, 3, "ERR") == 0 && + std::isdigit(static_cast(upper[3]))) { + return upper; + } + } + return ""; +} + +bool is_bus_error(const std::string &error) { + for (const char *label : BUS_ERROR_LABELS) { + if (error == label) return true; + } + return false; +} + +bool counts_as_link_failure(const std::string &error, const std::string &last_sent_command_normalized, + bool follows_timeout) { + if (error.empty() || is_bus_error(error)) return false; + if (error == "STOPPED" && follows_timeout) return false; + if (error == "?") return last_sent_command_normalized.compare(0, 2, "at") != 0; + return true; +} + +TimeoutCheck check_response_timeout(bool auto_recovery, uint32_t active_timeout_ms, bool search_in_progress, + uint32_t since_write_ms, uint32_t since_activity_ms) { + TimeoutCheck r{false, false, active_timeout_ms, active_timeout_ms}; + if (!auto_recovery) { + r.expired = since_write_ms > active_timeout_ms; + return r; + } + if (search_in_progress && r.timeout_ms < SEARCH_RESPONSE_TIMEOUT_MS) r.timeout_ms = SEARCH_RESPONSE_TIMEOUT_MS; + r.hard_limit_ms = 2 * r.timeout_ms > ABSOLUTE_RESPONSE_LIMIT_MS ? 2 * r.timeout_ms : ABSOLUTE_RESPONSE_LIMIT_MS; + const bool idle = since_activity_ms > r.timeout_ms; + r.hard_limit_hit = !idle && since_write_ms > r.hard_limit_ms; + r.expired = idle || r.hard_limit_hit; + return r; +} + +uint32_t reconnect_backoff_ms(uint8_t reconnects) { + static const uint32_t BASE_MS = 30000; + static const uint32_t MAX_MS = 600000; + if (reconnects == 0) return 0; + uint8_t shift = reconnects - 1; + if (shift > 5) shift = 5; + uint32_t ms = BASE_MS << shift; + return ms > MAX_MS ? MAX_MS : ms; +} + +bool pre_command_reply_invalidates_header(const std::string &error) { + return !error.empty() && error != "?" && !is_bus_error(error); +} + +bool pre_commands_applied(const std::vector &device_pre_commands, + const std::vector ¤t_pre_commands) { + return device_pre_commands == current_pre_commands; +} + +bool command_resets_adapter(const std::string &normalized_command) { + return normalized_command == "atz" || normalized_command == "atws" || normalized_command == "atd"; +} + +DequeueAction decide_dequeue(bool is_pre_item, const std::vector *device_pre_commands, bool ready, + const std::vector ¤t_pre_commands, + const std::string &normalized_command) { + if (device_pre_commands != nullptr && ready && !pre_commands_applied(*device_pre_commands, current_pre_commands)) { + return DequeueAction::HOLD_BACK; + } + if (!is_pre_item && command_resets_adapter(normalized_command)) return DequeueAction::SEND_AND_CLEAR_HEADER; + return DequeueAction::SEND; +} + +bool is_protocol_search_in_progress(const std::string &partial_response) { + std::string upper = to_upper(partial_response); + return upper.find("SEARCHING") != std::string::npos || upper.find("BUS INIT") != std::string::npos; +} + } // namespace ble_elm327 } // namespace esphome diff --git a/components/ble_elm327/response_parser.h b/components/ble_elm327/response_parser.h index 2bdffbc1..4a939fa2 100644 --- a/components/ble_elm327/response_parser.h +++ b/components/ble_elm327/response_parser.h @@ -34,5 +34,98 @@ std::string normalize_command(const std::string &cmd); std::vector parse_response_bytes(const std::string &response, const std::string &last_sent_command_normalized); +// Detects ELM327 status / error replies in a response (everything received +// before the '>' prompt). Returns a short label such as "NO DATA", "STOPPED", +// "?", "BUFFER FULL", "CAN ERROR", "UNABLE TO CONNECT" or "ERRxx", or an empty +// string when the response contains no error line. The command echo line is +// ignored. +std::string classify_error_response(const std::string &response, + const std::string &last_sent_command_normalized); + +// True for replies that describe the vehicle bus / ECU side (NO DATA, CAN +// ERROR, BUS ERROR, BUS INIT ERROR, UNABLE TO CONNECT, DATA ERROR, ...). These +// are normal with the ignition off, so they do not count as adapter failures. +bool is_bus_error(const std::string &error); + +// True if an error reply indicates an adapter / link problem that should count +// toward the consecutive-failure streak: STOPPED, BUFFER FULL, ERRxx, LV RESET, +// ACT ALERT, and "?" in reply to a non-AT command ("?" to an AT command only +// means that command is unsupported by this adapter). +// +// `follows_timeout` is true when the command was the first one written after +// the previous command timed out. A "STOPPED" then only means the adapter +// aborted the earlier, still-running request (e.g. a slow ATSP0 protocol +// search) when our next command arrived, so it is not counted. +bool counts_as_link_failure(const std::string &error, const std::string &last_sent_command_normalized, + bool follows_timeout = false); + +// Response timeout decision for the command in flight. +// +// auto_recovery == false (default, same as earlier versions): the command +// times out `active_timeout_ms` after the write. +// auto_recovery == true: the timer restarts with every received byte; while a +// protocol search is reported the idle timeout is at least +// SEARCH_RESPONSE_TIMEOUT_MS; a hard limit of max(ABSOLUTE_RESPONSE_LIMIT_MS, +// 2 x idle timeout) from the write always applies. +static const uint32_t SEARCH_RESPONSE_TIMEOUT_MS = 20000; +static const uint32_t ABSOLUTE_RESPONSE_LIMIT_MS = 30000; + +struct TimeoutCheck { + bool expired; + bool hard_limit_hit; // expired by the hard limit (data kept arriving) + uint32_t timeout_ms; // idle timeout that applied + uint32_t hard_limit_ms; // hard limit that applied (== timeout_ms without auto_recovery) +}; + +TimeoutCheck check_response_timeout(bool auto_recovery, uint32_t active_timeout_ms, bool search_in_progress, + uint32_t since_write_ms, uint32_t since_activity_ms); + +// Minimum time between two recovery reconnects after `reconnects` recovery +// reconnects in a row: 30 s, 60 s, 120 s, ... capped at 10 min. 0 for 0. +uint32_t reconnect_backoff_ms(uint8_t reconnects); + +// True if an error reply to a pre_command (e.g. ATSH) means the header may not +// be applied, so it must be re-sent before the next PID request. "?" (command +// not supported by this adapter) and vehicle-side errors leave the adapter +// state unchanged, so the header stays sticky and is not re-sent every poll. +// STOPPED always counts here (the command was aborted), even right after a +// timeout. +bool pre_command_reply_invalidates_header(const std::string &error); + +// True if a queued PID request may be sent: the device's pre_commands match +// the header currently tracked as applied on the adapter. False only after the +// tracked header was cleared (pre-command failure, adapter reset) between +// queueing the device's pre_commands and its PID request. +bool pre_commands_applied(const std::vector &device_pre_commands, + const std::vector ¤t_pre_commands); + +// True for commands that reset the adapter's settings, including any ATSH +// header: ATZ (reset), ATWS (warm start), ATD (defaults). Expects a +// normalize_command() result. +bool command_resets_adapter(const std::string &normalized_command); + +// What loop() does with a command taken off the TX queue. +enum class DequeueAction { + SEND, // write it + SEND_AND_CLEAR_HEADER, // write it and forget the tracked ATSH header + HOLD_BACK, // drop it this cycle (its header was lost) +}; + +// Pure decision for a dequeued command. +// - A device's PID request (`device_pre_commands` != nullptr) while READY is +// held back if its pre_commands are not the tracked header. +// - ATZ / ATWS / ATD sent as a standalone command (init or send_command) +// reset the adapter, so the tracked header is cleared. Sent as one of a +// device's own pre_commands (`is_pre_item`) they are not: the tracked +// header already is that device's full pre_commands list, which the +// following pre items re-apply, so its PID is sent exactly as before. +DequeueAction decide_dequeue(bool is_pre_item, const std::vector *device_pre_commands, bool ready, + const std::vector ¤t_pre_commands, + const std::string &normalized_command); + +// True while a partial reply shows the adapter is still searching for / +// initializing the OBD protocol ("SEARCHING...", "BUS INIT: ..."). +bool is_protocol_search_in_progress(const std::string &partial_response); + } // namespace ble_elm327 } // namespace esphome diff --git a/components/ble_elm327/sensor/__init__.py b/components/ble_elm327/sensor/__init__.py index 2fde97b0..c36a4bf6 100644 --- a/components/ble_elm327/sensor/__init__.py +++ b/components/ble_elm327/sensor/__init__.py @@ -1,14 +1,31 @@ import esphome.codegen as cg import esphome.config_validation as cv from esphome.components import sensor -from esphome.const import CONF_ID +from esphome.const import ( + DEVICE_CLASS_DURATION, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + UNIT_MILLISECOND, +) from .. import ( ble_elm327_ns, + BleElm327Component, BleElm327Device, BLE_ELM327_DEVICE_SCHEMA, register_ble_elm327_device, inject_preset, ) +from ..const import ( + CONF_BLE_ELM327_ID, + CONF_TIMEOUT_COUNT, + CONF_ERROR_COUNT, + CONF_NO_DATA_COUNT, + CONF_WRITE_FAILURE_COUNT, + CONF_REINIT_COUNT, + CONF_RECONNECT_COUNT, + CONF_RESPONSE_TIME, +) DEPENDENCIES = ["ble_elm327"] @@ -16,13 +33,77 @@ # inject_preset runs first: fills in pid/mode/formula/unit/etc. from OBD_PRESETS, # then sensor_schema + BLE_ELM327_DEVICE_SCHEMA validate the completed config. -CONFIG_SCHEMA = cv.All( +DEVICE_SCHEMA = cv.All( inject_preset, sensor.sensor_schema(BleElm327Sensor).extend(BLE_ELM327_DEVICE_SCHEMA), ) +# ── Diagnostic sensors (component health, not OBD data) ────────────────────── + + +def _counter_schema(icon): + return sensor.sensor_schema( + icon=icon, + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ) + + +def _response_time_schema(icon): + return sensor.sensor_schema( + icon=icon, + unit_of_measurement=UNIT_MILLISECOND, + accuracy_decimals=0, + device_class=DEVICE_CLASS_DURATION, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ) + + +# key -> (setter on BleElm327Component, schema) +DIAGNOSTIC_SENSORS = { + CONF_TIMEOUT_COUNT: ("set_timeout_count_sensor", _counter_schema("mdi:timer-alert-outline")), + CONF_ERROR_COUNT: ("set_error_count_sensor", _counter_schema("mdi:alert-circle-outline")), + CONF_NO_DATA_COUNT: ("set_no_data_count_sensor", _counter_schema("mdi:database-off-outline")), + CONF_WRITE_FAILURE_COUNT: ("set_write_failure_count_sensor", _counter_schema("mdi:upload-off-outline")), + CONF_REINIT_COUNT: ("set_reinit_count_sensor", _counter_schema("mdi:restart")), + CONF_RECONNECT_COUNT: ("set_reconnect_count_sensor", _counter_schema("mdi:bluetooth-connect")), + CONF_RESPONSE_TIME: ("set_response_time_sensor", _response_time_schema("mdi:timer-outline")), +} + + +DIAGNOSTIC_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_BLE_ELM327_ID): cv.use_id(BleElm327Component), + **{ + cv.Optional(key): schema + for key, (_, schema) in DIAGNOSTIC_SENSORS.items() + }, + } +) + + +def _validate(config): + """A platform entry is either a diagnostic block (any diagnostic key) or a PID sensor.""" + if isinstance(config, dict) and any(key in config for key in DIAGNOSTIC_SENSORS): + return DIAGNOSTIC_SCHEMA(config) + return DEVICE_SCHEMA(config) + + +CONFIG_SCHEMA = _validate + + async def to_code(config): + if any(key in config for key in DIAGNOSTIC_SENSORS): + parent = await cg.get_variable(config[CONF_BLE_ELM327_ID]) + for key, (setter, _) in DIAGNOSTIC_SENSORS.items(): + if key in config: + sens = await sensor.new_sensor(config[key]) + cg.add(getattr(parent, setter)(sens)) + return + var = await sensor.new_sensor(config) await cg.register_component(var, config) # sets update_interval on the PollingComponent await register_ble_elm327_device(var, config) diff --git a/components/ble_elm327/text_sensor/__init__.py b/components/ble_elm327/text_sensor/__init__.py new file mode 100644 index 00000000..bdd27cba --- /dev/null +++ b/components/ble_elm327/text_sensor/__init__.py @@ -0,0 +1,25 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import text_sensor +from esphome.const import ENTITY_CATEGORY_DIAGNOSTIC +from .. import BleElm327Component +from ..const import CONF_BLE_ELM327_ID, CONF_LAST_ERROR + +DEPENDENCIES = ["ble_elm327"] + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_BLE_ELM327_ID): cv.use_id(BleElm327Component), + cv.Optional(CONF_LAST_ERROR): text_sensor.text_sensor_schema( + icon="mdi:alert-outline", + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } +) + + +async def to_code(config): + parent = await cg.get_variable(config[CONF_BLE_ELM327_ID]) + if CONF_LAST_ERROR in config: + sens = await text_sensor.new_text_sensor(config[CONF_LAST_ERROR]) + cg.add(parent.set_last_error_text_sensor(sens)) diff --git a/tests/components/ble_elm327/test.esp32-idf.yaml b/tests/components/ble_elm327/test.esp32-idf.yaml index a222d0ae..cd4b17bc 100644 --- a/tests/components/ble_elm327/test.esp32-idf.yaml +++ b/tests/components/ble_elm327/test.esp32-idf.yaml @@ -20,8 +20,33 @@ ble_client: ble_elm327: ble_client_id: obd_client + tx_delay: 50 + response_timeout: 1500 + auto_recovery: true + recovery_threshold: 3 + +text_sensor: + - platform: ble_elm327 + last_error: + name: "OBD Last Error" sensor: + - platform: ble_elm327 + timeout_count: + name: "OBD Timeouts" + error_count: + name: "OBD Error Responses" + no_data_count: + name: "OBD NO DATA Responses" + write_failure_count: + name: "OBD Write Failures" + reinit_count: + name: "OBD Re-inits" + reconnect_count: + name: "OBD Reconnects" + response_time: + name: "OBD Response Time" + - platform: ble_elm327 name: "Engine RPM" pid: "0C" diff --git a/tests/native/ble_elm327_parser/test_parser.cpp b/tests/native/ble_elm327_parser/test_parser.cpp index db02328a..1d0436af 100644 --- a/tests/native/ble_elm327_parser/test_parser.cpp +++ b/tests/native/ble_elm327_parser/test_parser.cpp @@ -9,6 +9,17 @@ using esphome::ble_elm327::normalize_command; using esphome::ble_elm327::parse_response_bytes; +using esphome::ble_elm327::classify_error_response; +using esphome::ble_elm327::counts_as_link_failure; +using esphome::ble_elm327::is_bus_error; +using esphome::ble_elm327::is_protocol_search_in_progress; +using esphome::ble_elm327::reconnect_backoff_ms; +using esphome::ble_elm327::check_response_timeout; +using esphome::ble_elm327::pre_command_reply_invalidates_header; +using esphome::ble_elm327::pre_commands_applied; +using esphome::ble_elm327::command_resets_adapter; +using esphome::ble_elm327::decide_dequeue; +using esphome::ble_elm327::DequeueAction; namespace { @@ -128,6 +139,228 @@ void test_normalize_command_strips_whitespace_and_case() { require(normalize_command("010C") == "010c", "normalize_command: lowercases hex command"); } +void test_classify_error_responses() { + require(classify_error_response("NO DATA\r\r", "010c") == "NO DATA", "classify: NO DATA"); + require(classify_error_response("STOPPED\r", "010c") == "STOPPED", "classify: STOPPED"); + require(classify_error_response("?\r", "atxx") == "?", "classify: unknown command '?'"); + require(classify_error_response("BUFFER FULL\r", "2201") == "BUFFER FULL", "classify: BUFFER FULL"); + require(classify_error_response("CAN ERROR\r", "010c") == "CAN ERROR", "classify: CAN ERROR"); + require(classify_error_response("SEARCHING...\rUNABLE TO CONNECT\r", "010c") == "UNABLE TO CONNECT", + "classify: UNABLE TO CONNECT after SEARCHING"); + require(classify_error_response("ERR94\r", "010c") == "ERR94", "classify: ERRxx code"); + require(classify_error_response("no data", "010c") == "NO DATA", "classify: lowercase input"); +} + +void test_classify_ignores_normal_responses() { + require(classify_error_response("410C1AF8\r", "010c").empty(), "classify: data line is not an error"); + require(classify_error_response("SEARCHING...\r410C1AF8\r", "010c").empty(), "classify: SEARCHING is not an error"); + require(classify_error_response("OK\r", "ate0").empty(), "classify: OK is not an error"); + require(classify_error_response("ELM327 v1.5\r", "atz").empty(), "classify: ATZ banner is not an error"); + require(classify_error_response("0: 62 E0 02\r1: AA BB\r", "22e002").empty(), "classify: multiline data"); + require(classify_error_response("", "010c").empty(), "classify: empty response"); + require(classify_error_response("010C\r410C1AF8\r", "010c").empty(), "classify: echo line is skipped"); +} + +void test_classify_bus_init_and_act_alert() { + require(classify_error_response("BUS INIT: ...ERROR\r", "0100") == "BUS INIT ERROR", "classify: BUS INIT ERROR"); + require(classify_error_response("BUS INIT: ...OK\r4100BE3FA813\r", "0100").empty(), "classify: BUS INIT OK is not an error"); + require(classify_error_response("ACT ALERT\r", "010c") == "ACT ALERT", "classify: ACT ALERT"); + require(classify_error_response("LV RESET\r", "010c") == "LV RESET", "classify: LV RESET"); + require(classify_error_response(" tcm = {"ATSH 7E2\r"}; + const std::vector none; + require(pre_commands_applied(tcm, tcm), "header: normal path (pre_commands tracked when queued) sends the PID"); + require(pre_commands_applied(none, none), "header: device without pre_commands is sent"); + require(!pre_commands_applied(tcm, none), "header: cleared after a pre-command failure -> PID is held back"); + require(!pre_commands_applied(tcm, {"ATSH 7E0\r"}), "header: different header -> PID is held back"); +} + +void test_command_resets_adapter() { + require(command_resets_adapter(normalize_command("ATZ\r")), "reset: ATZ"); + require(command_resets_adapter(normalize_command("AT WS")), "reset: ATWS"); + require(command_resets_adapter(normalize_command("at d")), "reset: ATD"); + require(!command_resets_adapter(normalize_command("ATDP")), "reset: ATDP (describe protocol) is not a reset"); + require(!command_resets_adapter(normalize_command("ATSH 7E0")), "reset: ATSH is not a reset"); + require(!command_resets_adapter(normalize_command("010C")), "reset: PID request is not a reset"); +} + +// Mirrors loop(): at collection the device's pre_commands become the tracked +// header and pre items + PID are queued; then each item is dequeued in order. +// Returns how many items were written and whether the PID was written. +struct CycleResult { + int written; + bool pid_sent; +}; +// `pre_failure`: outcome of the device's pre items in this cycle. "" = all +// answered normally, "TIMEOUT" = timed out / write failed (loop() clears the +// tracked header in fail_exchange_), otherwise an error reply that is passed +// through pre_command_reply_invalidates_header() like handle_error_response_(). +CycleResult run_poll_cycle(const std::vector &pre, std::vector ¤t, + const std::string &pre_failure = "") { + struct Item { + std::string cmd; + bool pre; + bool is_device; + }; + std::vector queue; + if (pre != current) { + for (const auto &c : pre) queue.push_back({c, true, false}); + current = pre; + } + queue.push_back({"2219401\r", false, true}); + CycleResult r{0, false}; + for (const auto &item : queue) { + auto action = decide_dequeue(item.pre, item.is_device ? &pre : nullptr, true, current, normalize_command(item.cmd)); + if (action == DequeueAction::HOLD_BACK) continue; + if (action == DequeueAction::SEND_AND_CLEAR_HEADER) current.clear(); + r.written++; + if (item.is_device) r.pid_sent = true; + if (item.pre && !pre_failure.empty() && + (pre_failure == "TIMEOUT" || pre_command_reply_invalidates_header(pre_failure))) { + current.clear(); + } + } + return r; +} + +void test_failed_pre_command_cycle() { + // ATSH fails -> tracked header cleared -> PID held back (not sent with the + // wrong header) -> next poll re-sends ATSH + PID -> then PID only. + const std::vector pre = {"ATSH 7E4\r"}; + for (const char *failure : {"TIMEOUT", "STOPPED", "BUFFER FULL"}) { + std::vector current; + auto failed = run_poll_cycle(pre, current, failure); + require(!failed.pid_sent && failed.written == 1, "failed ATSH: only ATSH written, PID held back"); + require(current.empty(), "failed ATSH: tracked header cleared"); + auto retry = run_poll_cycle(pre, current); + require(retry.pid_sent && retry.written == 2, "failed ATSH: next poll re-sends ATSH + PID"); + auto steady = run_poll_cycle(pre, current); + require(steady.pid_sent && steady.written == 1, "failed ATSH: header sticky again, PID only"); + } + // '?' (unsupported) keeps the header sticky: PID still sent, no re-send loop. + std::vector current; + auto unsupported = run_poll_cycle(pre, current, "?"); + require(unsupported.pid_sent && unsupported.written == 2, "'?' to ATSH: PID sent, header kept"); + auto next = run_poll_cycle(pre, current); + require(next.pid_sent && next.written == 1, "'?' to ATSH: no ATSH re-send on the next poll"); +} + +void test_reset_commands_inside_pre_commands_do_not_livelock() { + // Regression: a reset command inside a device's own pre_commands used to + // clear the tracked header and hold the PID back on every poll. + const std::vector> configs = { + {"ATD\r", "ATSP6\r", "ATSH 7E4\r"}, + {"ATZ\r", "ATSH 7E4\r"}, + {"AT WS\r", "ATSH 7E2\r"}, + }; + for (const auto &pre : configs) { + std::vector current; + auto first = run_poll_cycle(pre, current); + require(first.pid_sent, "reset in pre_commands: PID is sent on the first poll"); + require(first.written == static_cast(pre.size()) + 1, "reset in pre_commands: pre list + PID written once"); + auto second = run_poll_cycle(pre, current); + require(second.pid_sent && second.written == 1, "reset in pre_commands: header sticky, next poll sends only the PID"); + } +} + +void test_decide_dequeue() { + const std::vector tcm = {"ATSH 7E2\r"}; + const std::vector none; + require(decide_dequeue(false, &tcm, true, tcm, "221940") == DequeueAction::SEND, "dequeue: normal PID is sent"); + require(decide_dequeue(false, &tcm, true, none, "221940") == DequeueAction::HOLD_BACK, + "dequeue: PID whose header was lost is held back"); + require(decide_dequeue(false, &tcm, false, none, "221940") == DequeueAction::SEND, + "dequeue: no header check outside READY"); + require(decide_dequeue(false, nullptr, true, tcm, "atz") == DequeueAction::SEND_AND_CLEAR_HEADER, + "dequeue: standalone ATZ (send_command / init) clears the header"); + require(decide_dequeue(true, nullptr, true, tcm, "atz") == DequeueAction::SEND, + "dequeue: ATZ as a device pre item keeps the header tracking"); + require(decide_dequeue(false, nullptr, true, tcm, "atsh7e0") == DequeueAction::SEND, "dequeue: other AT command"); +} + } // namespace int main() { @@ -143,6 +376,21 @@ int main() { test_blank_lines_and_whitespace_are_ignored(); test_empty_response_yields_no_bytes(); test_normalize_command_strips_whitespace_and_case(); + test_classify_error_responses(); + test_classify_ignores_normal_responses(); + test_classify_bus_init_and_act_alert(); + test_error_categories(); + test_protocol_search_detection(); + test_stopped_after_timeout_is_not_a_failure(); + test_reconnect_backoff(); + test_default_timeout_matches_previous_behaviour(); + test_auto_recovery_timeout_rules(); + test_pre_command_header_invalidation(); + test_pre_commands_applied(); + test_command_resets_adapter(); + test_reset_commands_inside_pre_commands_do_not_livelock(); + test_decide_dequeue(); + test_failed_pre_command_cycle(); if (g_failures > 0) { std::cerr << g_failures << " ble_elm327 parser test(s) failed\n";