Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 200 additions & 9 deletions components/ble_elm327/README.md

Large diffs are not rendered by default.

26 changes: 23 additions & 3 deletions components/ble_elm327/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand All @@ -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):
Expand All @@ -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) ────────────────────────
Expand Down
447 changes: 414 additions & 33 deletions components/ble_elm327/ble_elm327.cpp

Large diffs are not rendered by default.

123 changes: 119 additions & 4 deletions components/ble_elm327/ble_elm327.h
Original file line number Diff line number Diff line change
@@ -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 <functional>
#include <queue>
#include <string>
Expand Down Expand Up @@ -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<float(uint8_t, uint8_t, uint8_t, uint8_t, uint8_t, uint8_t, const std::vector<uint8_t> &)> f) { formula_ = f; }
Expand Down Expand Up @@ -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; }
Expand All @@ -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);
Expand All @@ -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_;
Expand All @@ -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
Expand Down
13 changes: 13 additions & 0 deletions components/ble_elm327/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
121 changes: 121 additions & 0 deletions components/ble_elm327/response_parser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -103,5 +103,126 @@ std::vector<uint8_t> 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<char>(std::toupper(static_cast<unsigned char>(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<unsigned char>(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<std::string> &device_pre_commands,
const std::vector<std::string> &current_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<std::string> *device_pre_commands, bool ready,
const std::vector<std::string> &current_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
Loading
Loading