- ATRV every 2 s; voltage arc in the bottom gap (11-15 V) + number, colored by state (low / weak / resting / charging / overcharge) - idle after Off/2/5/10 min without speed data (settings page, NVS): screen off, GIF paused, OBD polling stopped, ATRV every 30 s; wakes on touch (swallowed by a top-layer overlay) or > 13.2 V - status pill moved up to make room for the voltage Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
491 lines
13 KiB
C++
491 lines
13 KiB
C++
#include "obd_link.h"
|
|
|
|
#include <NimBLEDevice.h>
|
|
#include <Preferences.h>
|
|
|
|
#define RETRY_DELAY_MS 3000
|
|
#define IDLE_RETRY_DELAY_MS 30000
|
|
#define POLL_FAIL_LIMIT 3
|
|
#define VOLTAGE_INTERVAL_MS 2000 // ATRV while driving
|
|
#define IDLE_VOLTAGE_INTERVAL_MS 30000 // ATRV while idle (adapter-internal, doesn't wake the car's bus)
|
|
#define WAKE_VOLTAGE 13.2f // above this the alternator is charging: engine running
|
|
|
|
// ---------- shared state (protected by `mtx`) ----------
|
|
static SemaphoreHandle_t mtx;
|
|
static volatile ObdState state = ObdState::NoDevice;
|
|
static volatile int speed_kmh = -1;
|
|
static volatile float voltage = -1;
|
|
static volatile bool idle = false;
|
|
static volatile uint32_t idle_timeout_ms = 2 * 60 * 1000;
|
|
static volatile uint32_t last_data_ms = 0; // last valid speed (or wake-up)
|
|
static String status_text = "starting";
|
|
static String saved_addr, saved_name;
|
|
static uint8_t saved_type = 0;
|
|
static std::vector<ObdDevInfo> devices;
|
|
static uint32_t devices_version = 1;
|
|
static String elm_rx; // bytes received from the ELM (notifications)
|
|
|
|
// ---------- requests from the UI ----------
|
|
static volatile bool req_select = false, req_forget = false, req_wake = false;
|
|
static volatile bool scan_wanted = false;
|
|
static ObdDevInfo req_dev;
|
|
|
|
// ---------- worker-owned ----------
|
|
static NimBLEClient *client = nullptr;
|
|
static NimBLERemoteCharacteristic *chr_write = nullptr, *chr_notify = nullptr;
|
|
static Preferences prefs;
|
|
|
|
static void lock() { xSemaphoreTake(mtx, portMAX_DELAY); }
|
|
static void unlock() { xSemaphoreGive(mtx); }
|
|
|
|
static void set_status(const String &s)
|
|
{
|
|
lock();
|
|
bool changed = status_text != s;
|
|
status_text = s;
|
|
unlock();
|
|
if (changed)
|
|
Serial.println("[obd] " + s);
|
|
}
|
|
|
|
// Services that cheap BLE OBD adapters use for their serial (UART-like) link
|
|
static const uint16_t ELM_SERVICE_HINTS[] = {0xFFF0, 0xFFE0, 0x18F0};
|
|
|
|
static bool name_looks_like_obd(String n)
|
|
{
|
|
n.toUpperCase();
|
|
return n.indexOf("OBD") >= 0 || n.indexOf("ELM") >= 0 || n.indexOf("VLINK") >= 0 ||
|
|
n.indexOf("V-LINK") >= 0 || n.indexOf("VEEPEAK") >= 0 || n.indexOf("VGATE") >= 0;
|
|
}
|
|
|
|
// ---------- scanning (menu) ----------
|
|
class ScanCallbacks : public NimBLEScanCallbacks
|
|
{
|
|
void onResult(const NimBLEAdvertisedDevice *adv) override
|
|
{
|
|
if (!adv->isConnectable())
|
|
return;
|
|
String addr = adv->getAddress().toString().c_str();
|
|
String name = adv->haveName() ? String(adv->getName().c_str()) : String();
|
|
bool hint = name_looks_like_obd(name);
|
|
for (uint8_t i = 0; i < adv->getServiceUUIDCount(); i++)
|
|
for (uint16_t h : ELM_SERVICE_HINTS)
|
|
if (adv->getServiceUUID(i).equals(NimBLEUUID(h)))
|
|
hint = true;
|
|
|
|
lock();
|
|
ObdDevInfo *d = nullptr;
|
|
for (auto &e : devices)
|
|
if (e.address == addr)
|
|
d = &e;
|
|
if (!d)
|
|
{
|
|
devices.push_back(ObdDevInfo());
|
|
d = &devices.back();
|
|
d->address = addr;
|
|
d->obd_hint = false;
|
|
}
|
|
d->addr_type = adv->getAddress().getType();
|
|
if (name.length())
|
|
d->name = name;
|
|
d->rssi = adv->getRSSI();
|
|
d->obd_hint = d->obd_hint || hint;
|
|
devices_version++;
|
|
unlock();
|
|
}
|
|
};
|
|
static ScanCallbacks scan_callbacks;
|
|
|
|
static void scan_set(bool on)
|
|
{
|
|
NimBLEScan *scan = NimBLEDevice::getScan();
|
|
if (on && !scan->isScanning())
|
|
{
|
|
scan->setScanCallbacks(&scan_callbacks, true);
|
|
scan->setActiveScan(true);
|
|
scan->setInterval(100);
|
|
scan->setWindow(90);
|
|
scan->start(0, false, true);
|
|
}
|
|
else if (!on && scan->isScanning())
|
|
{
|
|
scan->stop();
|
|
}
|
|
}
|
|
|
|
// ---------- ELM327 communication ----------
|
|
static void on_notify(NimBLERemoteCharacteristic *c, uint8_t *data, size_t len, bool is_notify)
|
|
{
|
|
lock();
|
|
for (size_t i = 0; i < len; i++)
|
|
elm_rx += (char)data[i];
|
|
unlock();
|
|
}
|
|
|
|
// Sends a command, waits for the '>' prompt. Returns the reply without prompt/CR, or "" on timeout.
|
|
static String elm_command(const char *cmd, uint32_t timeout_ms)
|
|
{
|
|
lock();
|
|
elm_rx = "";
|
|
unlock();
|
|
String line = String(cmd) + "\r";
|
|
if (!chr_write->writeValue((const uint8_t *)line.c_str(), line.length(), !chr_write->canWriteNoResponse()))
|
|
return "";
|
|
uint32_t t0 = millis();
|
|
String rx;
|
|
while (millis() - t0 < timeout_ms)
|
|
{
|
|
lock();
|
|
rx = elm_rx;
|
|
unlock();
|
|
if (rx.indexOf('>') >= 0)
|
|
{
|
|
rx.replace(">", "");
|
|
rx.replace("\r", "\n");
|
|
rx.trim();
|
|
return rx;
|
|
}
|
|
if (!client->isConnected())
|
|
return "";
|
|
vTaskDelay(pdMS_TO_TICKS(5));
|
|
}
|
|
return "";
|
|
}
|
|
|
|
// "410D3C" (spaces/headers removed by ATS0/ATH0; multiple ECUs possible) -> 60
|
|
static int parse_speed(String reply)
|
|
{
|
|
reply.replace(" ", "");
|
|
reply.toUpperCase();
|
|
int i = reply.indexOf("410D");
|
|
if (i < 0 || reply.length() < (unsigned)i + 6)
|
|
return -1;
|
|
return (int)strtol(reply.substring(i + 4, i + 6).c_str(), nullptr, 16);
|
|
}
|
|
|
|
// "12.6V" -> 12.6, -1 if not a voltage
|
|
static float parse_voltage(const String &reply)
|
|
{
|
|
int v = reply.indexOf('V');
|
|
if (v <= 0)
|
|
return -1;
|
|
float f = reply.substring(0, v).toFloat();
|
|
return f > 0 ? f : -1;
|
|
}
|
|
|
|
static void read_voltage()
|
|
{
|
|
float v = parse_voltage(elm_command("ATRV", 1500));
|
|
if (v > 0)
|
|
voltage = v;
|
|
}
|
|
|
|
// ---------- connection ----------
|
|
static void disconnect()
|
|
{
|
|
chr_write = chr_notify = nullptr;
|
|
if (client && client->isConnected())
|
|
client->disconnect();
|
|
speed_kmh = -1;
|
|
}
|
|
|
|
// Finds the ELM UART characteristics: service FFF0 (verified: FFF1 notify + write), else any notify+write pair
|
|
static bool find_characteristics()
|
|
{
|
|
chr_write = chr_notify = nullptr;
|
|
NimBLERemoteService *svc = client->getService(NimBLEUUID((uint16_t)0xFFF0));
|
|
if (svc)
|
|
{
|
|
chr_notify = svc->getCharacteristic(NimBLEUUID((uint16_t)0xFFF1));
|
|
if (chr_notify && (chr_notify->canWrite() || chr_notify->canWriteNoResponse()))
|
|
chr_write = chr_notify;
|
|
else
|
|
chr_write = svc->getCharacteristic(NimBLEUUID((uint16_t)0xFFF2));
|
|
}
|
|
if (!chr_write || !chr_notify)
|
|
{
|
|
chr_write = chr_notify = nullptr;
|
|
for (NimBLERemoteService *s : client->getServices(true))
|
|
{
|
|
NimBLERemoteCharacteristic *w = nullptr, *n = nullptr;
|
|
for (NimBLERemoteCharacteristic *c : s->getCharacteristics(true))
|
|
{
|
|
if (!n && (c->canNotify() || c->canIndicate()))
|
|
n = c;
|
|
if (!w && (c->canWrite() || c->canWriteNoResponse()))
|
|
w = c;
|
|
}
|
|
if (w && n)
|
|
{
|
|
chr_write = w;
|
|
chr_notify = n;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return chr_write && chr_notify;
|
|
}
|
|
|
|
static bool connect_and_init()
|
|
{
|
|
bool was_scanning = NimBLEDevice::getScan()->isScanning();
|
|
scan_set(false); // NimBLE can't connect while scanning
|
|
|
|
state = ObdState::Connecting;
|
|
set_status("Connecting " + (saved_name.length() ? saved_name : saved_addr));
|
|
if (!client)
|
|
{
|
|
client = NimBLEDevice::createClient();
|
|
client->setConnectTimeout(8000);
|
|
}
|
|
bool ok = client->connect(NimBLEAddress(std::string(saved_addr.c_str()), saved_type));
|
|
if (was_scanning && scan_wanted)
|
|
scan_set(true);
|
|
if (!ok)
|
|
{
|
|
set_status("Adapter not found");
|
|
return false;
|
|
}
|
|
if (!find_characteristics() || !chr_notify->subscribe(chr_notify->canNotify(), on_notify))
|
|
{
|
|
set_status("Not an ELM adapter?");
|
|
disconnect();
|
|
return false;
|
|
}
|
|
|
|
state = ObdState::Initializing;
|
|
set_status("Initializing ELM327");
|
|
if (elm_command("ATZ", 4000).length() == 0)
|
|
{
|
|
set_status("ELM327 not responding");
|
|
disconnect();
|
|
return false;
|
|
}
|
|
// echo off, no linefeeds, no spaces, no headers, automatic protocol
|
|
const char *init[] = {"ATE0", "ATL0", "ATS0", "ATH0", "ATSP0"};
|
|
for (const char *c : init)
|
|
elm_command(c, 2000);
|
|
set_status("Searching protocol");
|
|
elm_command("0100", 10000); // the first OBD request triggers the protocol search
|
|
state = ObdState::Running;
|
|
return true;
|
|
}
|
|
|
|
// ---------- worker task ----------
|
|
static void load_saved()
|
|
{
|
|
prefs.begin("obd", true);
|
|
saved_addr = prefs.getString("addr", "");
|
|
saved_name = prefs.getString("name", "");
|
|
saved_type = prefs.getUChar("type", 0);
|
|
prefs.end();
|
|
}
|
|
|
|
static void worker_task(void *)
|
|
{
|
|
uint32_t next_attempt = 0;
|
|
uint32_t next_voltage = 0;
|
|
int fails = 0;
|
|
last_data_ms = millis();
|
|
for (;;)
|
|
{
|
|
if (req_wake)
|
|
{
|
|
req_wake = false;
|
|
if (idle)
|
|
Serial.println("[obd] wake (touch)");
|
|
idle = false;
|
|
last_data_ms = millis();
|
|
next_attempt = 0;
|
|
}
|
|
// no speed data for a while (ignition off / adapter gone): go idle
|
|
if (!idle && idle_timeout_ms && millis() - last_data_ms > idle_timeout_ms)
|
|
{
|
|
idle = true;
|
|
speed_kmh = -1;
|
|
Serial.println("[obd] idle");
|
|
}
|
|
|
|
// requests from the UI
|
|
if (req_forget)
|
|
{
|
|
req_forget = false;
|
|
disconnect();
|
|
prefs.begin("obd", false);
|
|
prefs.clear();
|
|
prefs.end();
|
|
lock();
|
|
saved_addr = saved_name = "";
|
|
unlock();
|
|
}
|
|
if (req_select)
|
|
{
|
|
req_select = false;
|
|
disconnect();
|
|
prefs.begin("obd", false);
|
|
prefs.putString("addr", req_dev.address);
|
|
prefs.putString("name", req_dev.name);
|
|
prefs.putUChar("type", req_dev.addr_type);
|
|
prefs.end();
|
|
lock();
|
|
saved_addr = req_dev.address;
|
|
saved_name = req_dev.name;
|
|
saved_type = req_dev.addr_type;
|
|
unlock();
|
|
next_attempt = 0;
|
|
}
|
|
if (state != ObdState::Connecting) // never scan during a connect attempt
|
|
scan_set(scan_wanted);
|
|
|
|
if (saved_addr.length() == 0)
|
|
{
|
|
state = ObdState::NoDevice;
|
|
idle = false;
|
|
last_data_ms = millis(); // no idle while no adapter is chosen (user is setting up)
|
|
set_status("No adapter - swipe left");
|
|
vTaskDelay(pdMS_TO_TICKS(100));
|
|
continue;
|
|
}
|
|
|
|
if (!client || !client->isConnected() || !chr_write)
|
|
{
|
|
if (state == ObdState::Running)
|
|
set_status("Connection lost");
|
|
speed_kmh = -1;
|
|
if ((int32_t)(millis() - next_attempt) < 0)
|
|
{
|
|
state = ObdState::Waiting;
|
|
vTaskDelay(pdMS_TO_TICKS(50));
|
|
continue;
|
|
}
|
|
fails = 0;
|
|
if (!connect_and_init())
|
|
{
|
|
disconnect();
|
|
state = ObdState::Waiting;
|
|
next_attempt = millis() + (idle ? IDLE_RETRY_DELAY_MS : RETRY_DELAY_MS);
|
|
}
|
|
next_voltage = 0;
|
|
continue;
|
|
}
|
|
|
|
if (idle)
|
|
{
|
|
// only the battery voltage now and then; an engine start (charging voltage) wakes us up
|
|
if ((int32_t)(millis() - next_voltage) >= 0)
|
|
{
|
|
next_voltage = millis() + IDLE_VOLTAGE_INTERVAL_MS;
|
|
read_voltage();
|
|
if (voltage > WAKE_VOLTAGE)
|
|
{
|
|
idle = false;
|
|
last_data_ms = millis();
|
|
Serial.printf("[obd] wake (%.1f V)\n", voltage);
|
|
}
|
|
}
|
|
vTaskDelay(pdMS_TO_TICKS(100));
|
|
continue;
|
|
}
|
|
|
|
if ((int32_t)(millis() - next_voltage) >= 0)
|
|
{
|
|
next_voltage = millis() + VOLTAGE_INTERVAL_MS;
|
|
read_voltage();
|
|
}
|
|
|
|
// connected: poll the vehicle speed
|
|
String reply = elm_command("010D", 1500);
|
|
int v = parse_speed(reply);
|
|
speed_kmh = v;
|
|
if (v >= 0)
|
|
{
|
|
fails = 0;
|
|
last_data_ms = millis();
|
|
set_status(saved_name.length() ? saved_name : saved_addr);
|
|
}
|
|
else if (reply.length() == 0)
|
|
{
|
|
// no prompt at all: adapter hung or link dead
|
|
if (++fails >= POLL_FAIL_LIMIT)
|
|
{
|
|
set_status("Adapter not answering");
|
|
disconnect();
|
|
next_attempt = millis() + RETRY_DELAY_MS;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// "NO DATA", "UNABLE TO CONNECT", "STOPPED"...: ignition off or ECU busy
|
|
fails = 0;
|
|
String r = reply;
|
|
r.replace("\n", " ");
|
|
set_status(r.substring(0, 24));
|
|
vTaskDelay(pdMS_TO_TICKS(500));
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------- public API ----------
|
|
void obd_begin()
|
|
{
|
|
mtx = xSemaphoreCreateMutex();
|
|
load_saved();
|
|
NimBLEDevice::init("JC3636W518-OBD");
|
|
NimBLEDevice::setPower(9);
|
|
xTaskCreatePinnedToCore(worker_task, "obd_link", 8192, nullptr, 2, nullptr, 0);
|
|
}
|
|
|
|
ObdState obd_state() { return state; }
|
|
int obd_speed() { return speed_kmh; }
|
|
float obd_voltage() { return voltage; }
|
|
bool obd_is_idle() { return idle; }
|
|
void obd_wake() { req_wake = true; }
|
|
void obd_set_idle_timeout(uint32_t minutes) { idle_timeout_ms = minutes * 60 * 1000; }
|
|
|
|
String obd_status_text()
|
|
{
|
|
lock();
|
|
String s = status_text;
|
|
unlock();
|
|
return s;
|
|
}
|
|
|
|
String obd_saved_name()
|
|
{
|
|
lock();
|
|
String s = saved_name;
|
|
unlock();
|
|
return s;
|
|
}
|
|
|
|
String obd_saved_address()
|
|
{
|
|
lock();
|
|
String s = saved_addr;
|
|
unlock();
|
|
return s;
|
|
}
|
|
|
|
void obd_scan_enable(bool on) { scan_wanted = on; }
|
|
|
|
bool obd_get_devices(std::vector<ObdDevInfo> &out, uint32_t &version)
|
|
{
|
|
lock();
|
|
bool changed = version != devices_version;
|
|
if (changed)
|
|
{
|
|
out = devices;
|
|
version = devices_version;
|
|
}
|
|
unlock();
|
|
return changed;
|
|
}
|
|
|
|
void obd_select_device(const ObdDevInfo &dev)
|
|
{
|
|
req_dev = dev;
|
|
req_select = true;
|
|
}
|
|
|
|
void obd_forget_device() { req_forget = true; }
|