Add 12-OBD-Speed: ELM327 speed over BLE on a GIF background

- speed page: 120 px antialiased number (lv_font_conv) + 0-200 km/h arc
  gauge over ezgif-5-cb56986bc7.gif (embedded via board_build.embed_files)
- swipe-left menu: scan, OBD-like adapters first, tap to select; adapter
  remembered in NVS, auto-connect at boot and reconnect every 3 s
- ELM327 init ATZ/ATE0/ATL0/ATS0/ATH0/ATSP0/0100, then 010D polling
- shared ini: -DLV_LVGL_H_INCLUDE_SIMPLE for generated fonts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
patriandClaude Opus 5.5 committed 2026-10-23 16:49:37 +02:00
1 parent 9e9c0fac68
commit 66f70c0cc1
9 files changed
+4391

No files matched your search

+29
View File
@@ -0,0 +1,29 @@
# OBD Speed (JC3636W518)
Vehicle speed from an ELM327 over BLE, shown as a big number + arc gauge on top of an animated GIF.
Uses the shared board library and LVGL 9.6 (`../shared`), NimBLE-Arduino 2.5.1.
## Use
- **Speed page** — speed in km/h (`--` = no data: not connected, or ignition off), arc gauge 0–200 km/h,
status at the bottom (adapter name when running, otherwise what it is doing).
- **Swipe left → adapter menu** — scans while open. OBD-like adapters (name OBD/ELM/…, or service FFF0/FFE0/18F0)
are listed first in green. **Tap one** to select it: it is remembered (NVS) and connected immediately.
**Forget** clears the remembered adapter, **Done** (or swipe right) returns to the speed page.
- On power-up the remembered adapter is connected automatically; if the link drops or the adapter is not
there (car off), it retries every 3 s. With no adapter remembered, the device starts on the menu page.
## How it talks to the ELM327
Service `FFF0`, notifications + commands on `FFF1` (verified with the user's "OBDII" adapter, see
`../11-BLE-Scanner/results/2026-10-23_car_test.txt`); other adapters: first notify+write pair.
Init: `ATZ`, `ATE0`, `ATL0`, `ATS0`, `ATH0`, `ATSP0`, `0100`; then `010D` (speed) is polled continuously.
Serial (115200) prints status changes as `[obd] ...`.
## Files
| File | What |
|---|---|
| `src/main.cpp` | LVGL UI (tileview: speed page + menu page) |
| `src/obd_link.cpp/.h` | BLE scan, connect/reconnect, ELM327 init and speed polling (background task on core 0) |
| `src/font_speed_120.c` | Montserrat Medium 120 px, digits and `-`, 4 bpp (generated with `lv_font_conv`, see header) |
| `data/bg.gif` | Background (`ezgif-5-cb56986bc7.gif`, 360×360), linked into the firmware via `board_build.embed_files` |
Replace `data/bg.gif` with any 360×360 GIF to change the background (keep it under ~3 MB).
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

+18
View File
@@ -0,0 +1,18 @@
; OBD speed display: ELM327 over BLE, GIF background, swipe-left menu to pick/remember the adapter.
; JC3636W518, common settings in shared/jc3636w518.ini
[platformio]
extra_configs = ../shared/jc3636w518.ini
[shared]
dir = ../shared
[env:jc3636w518]
extends = jc3636w518
; firmware + 1.6 MB background GIF does not fit huge_app (3 MB): 6.25 MB app slots
board_build.partitions = default_16MB.csv
; background GIF linked into the firmware as-is (symbols _binary_data_bg_gif_start/_end)
board_build.embed_files = data/bg.gif
lib_deps =
${jc3636w518.lib_deps}
h2zero/NimBLE-Arduino@2.5.1
File diff suppressed because it is too large. Load diff
+308
View File
@@ -0,0 +1,308 @@
// OBD speed display for the JC3636W518: vehicle speed from an ELM327 over BLE on top of an animated GIF.
// Swipe left for the adapter menu (scan, select, forget); the selected adapter is remembered and reconnected.
#include <Arduino.h>
#include "jc3636w518.h"
#include "obd_link.h"
#define SPEED_MAX 200
#define MAX_MENU_ITEMS 30
LV_FONT_DECLARE(font_speed_120);
// background GIF linked into the firmware (board_build.embed_files = data/bg.gif)
extern const uint8_t bg_gif_start[] asm("_binary_data_bg_gif_start");
extern const uint8_t bg_gif_end[] asm("_binary_data_bg_gif_end");
static const lv_color_t COLOR_TEXT = lv_color_hex(0xFFFFFF);
static const lv_color_t COLOR_DIM = lv_color_hex(0x9AA3AD);
static const lv_color_t COLOR_ARC = lv_color_hex(0x00C8FF);
static const lv_color_t COLOR_OBD = lv_color_hex(0x4CD964);
static const lv_color_t COLOR_PANEL = lv_color_hex(0x161C22);
static lv_obj_t *tileview, *tile_speed, *tile_menu;
static lv_obj_t *speed_arc, *speed_label, *speed_shadow, *status_label;
static lv_obj_t *menu_saved, *menu_status, *menu_list;
static lv_obj_t *menu_items[MAX_MENU_ITEMS];
static int menu_item_count = 0;
static std::vector<ObdDevInfo> scan_devs; // as received from obd_link
static std::vector<ObdDevInfo> menu_devs; // display order: OBD-like first
static uint32_t scan_version = 0;
static String menu_order; // addresses in display order (detects reordering)
// ---------- helpers ----------
static lv_obj_t *make_button(lv_obj_t *parent, const char *text, int x, int y, int w, lv_event_cb_t cb)
{
lv_obj_t *b = lv_button_create(parent);
lv_obj_set_size(b, w, 34);
lv_obj_align(b, LV_ALIGN_TOP_MID, x, y);
lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, NULL);
lv_obj_t *l = lv_label_create(b);
lv_label_set_text(l, text);
lv_obj_set_style_text_font(l, &lv_font_montserrat_16, 0);
lv_obj_center(l);
return b;
}
static void show_tile(lv_obj_t *tile)
{
lv_tileview_set_tile(tileview, tile, LV_ANIM_ON);
}
static bool menu_visible()
{
return lv_tileview_get_tile_active(tileview) == tile_menu;
}
// ---------- events ----------
static void on_tile_changed(lv_event_t *e)
{
obd_scan_enable(menu_visible()); // scan only while the menu is open
}
static void on_device_clicked(lv_event_t *e)
{
int idx = (int)(intptr_t)lv_event_get_user_data(e);
if (idx < 0 || idx >= (int)menu_devs.size())
return;
obd_select_device(menu_devs[idx]);
show_tile(tile_speed);
}
static void on_forget_clicked(lv_event_t *e)
{
obd_forget_device();
}
static void on_done_clicked(lv_event_t *e)
{
show_tile(tile_speed);
}
// ---------- speed page ----------
static void build_speed_tile()
{
// background GIF, decoded straight into the display format (no transparency needed on black)
static lv_image_dsc_t gif_dsc;
gif_dsc.header.magic = LV_IMAGE_HEADER_MAGIC;
gif_dsc.header.cf = LV_COLOR_FORMAT_RAW;
gif_dsc.header.w = bg_gif_start[6] | (bg_gif_start[7] << 8);
gif_dsc.header.h = bg_gif_start[8] | (bg_gif_start[9] << 8);
gif_dsc.data_size = bg_gif_end - bg_gif_start;
gif_dsc.data = bg_gif_start;
lv_obj_t *gif = lv_gif_create(tile_speed);
lv_gif_set_color_format(gif, lv_display_get_color_format(lv_display_get_default()));
lv_gif_set_src(gif, &gif_dsc);
lv_obj_center(gif);
// arc gauge along the round edge: 270° sweep, gap at the bottom
speed_arc = lv_arc_create(tile_speed);
lv_obj_set_size(speed_arc, 344, 344);
lv_obj_center(speed_arc);
lv_arc_set_rotation(speed_arc, 135);
lv_arc_set_bg_angles(speed_arc, 0, 270);
lv_arc_set_range(speed_arc, 0, SPEED_MAX);
lv_arc_set_value(speed_arc, 0);
lv_obj_remove_style(speed_arc, NULL, LV_PART_KNOB);
lv_obj_remove_flag(speed_arc, LV_OBJ_FLAG_CLICKABLE); // let swipes reach the tileview
lv_obj_set_style_arc_width(speed_arc, 12, LV_PART_MAIN);
lv_obj_set_style_arc_width(speed_arc, 12, LV_PART_INDICATOR);
lv_obj_set_style_arc_color(speed_arc, lv_color_hex(0xFFFFFF), LV_PART_MAIN);
lv_obj_set_style_arc_opa(speed_arc, LV_OPA_30, LV_PART_MAIN);
lv_obj_set_style_arc_color(speed_arc, COLOR_ARC, LV_PART_INDICATOR);
// speed number with a soft drop shadow so it stays readable on the GIF
speed_shadow = lv_label_create(tile_speed);
lv_obj_set_style_text_font(speed_shadow, &font_speed_120, 0);
lv_obj_set_style_text_color(speed_shadow, lv_color_hex(0x000000), 0);
lv_obj_set_style_text_opa(speed_shadow, LV_OPA_70, 0);
lv_label_set_text(speed_shadow, "--");
lv_obj_align(speed_shadow, LV_ALIGN_CENTER, 4, -6);
speed_label = lv_label_create(tile_speed);
lv_obj_set_style_text_font(speed_label, &font_speed_120, 0);
lv_obj_set_style_text_color(speed_label, COLOR_TEXT, 0);
lv_label_set_text(speed_label, "--");
lv_obj_align(speed_label, LV_ALIGN_CENTER, 0, -10);
lv_obj_t *unit = lv_label_create(tile_speed);
lv_obj_set_style_text_font(unit, &lv_font_montserrat_20, 0);
lv_obj_set_style_text_color(unit, COLOR_TEXT, 0);
lv_label_set_text(unit, "km/h");
lv_obj_align(unit, LV_ALIGN_CENTER, 0, 62);
// status pill at the bottom
status_label = lv_label_create(tile_speed);
lv_obj_set_style_text_font(status_label, &lv_font_montserrat_14, 0);
lv_obj_set_style_text_color(status_label, COLOR_TEXT, 0);
lv_obj_set_style_bg_color(status_label, lv_color_hex(0x000000), 0);
lv_obj_set_style_bg_opa(status_label, LV_OPA_50, 0);
lv_obj_set_style_radius(status_label, 12, 0);
lv_obj_set_style_pad_hor(status_label, 10, 0);
lv_obj_set_style_pad_ver(status_label, 3, 0);
lv_label_set_text(status_label, "");
lv_obj_align(status_label, LV_ALIGN_BOTTOM_MID, 0, -34);
}
// ---------- menu page ----------
static void build_menu_tile()
{
lv_obj_t *title = lv_label_create(tile_menu);
lv_obj_set_style_text_font(title, &lv_font_montserrat_20, 0);
lv_obj_set_style_text_color(title, COLOR_TEXT, 0);
lv_label_set_text(title, "OBD adapter");
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 20);
menu_saved = lv_label_create(tile_menu);
lv_obj_set_style_text_color(menu_saved, COLOR_TEXT, 0);
lv_obj_set_width(menu_saved, 240);
lv_label_set_long_mode(menu_saved, LV_LABEL_LONG_MODE_DOTS);
lv_obj_set_style_text_align(menu_saved, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_align(menu_saved, LV_ALIGN_TOP_MID, 0, 48);
menu_status = lv_label_create(tile_menu);
lv_obj_set_style_text_color(menu_status, COLOR_DIM, 0);
lv_obj_set_width(menu_status, 260);
lv_label_set_long_mode(menu_status, LV_LABEL_LONG_MODE_DOTS);
lv_obj_set_style_text_align(menu_status, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_align(menu_status, LV_ALIGN_TOP_MID, 0, 68);
menu_list = lv_list_create(tile_menu);
lv_obj_set_size(menu_list, 276, 196);
lv_obj_align(menu_list, LV_ALIGN_TOP_MID, 0, 92);
lv_obj_set_style_bg_color(menu_list, COLOR_PANEL, 0);
lv_obj_set_style_border_width(menu_list, 0, 0);
lv_obj_set_style_pad_all(menu_list, 4, 0);
make_button(tile_menu, "Forget", -50, 296, 92, on_forget_clicked);
make_button(tile_menu, "Done", 50, 296, 92, on_done_clicked);
}
static void build_ui()
{
lv_obj_set_style_bg_color(lv_screen_active(), lv_color_hex(0x000000), 0);
tileview = lv_tileview_create(lv_screen_active());
lv_obj_set_style_bg_color(tileview, lv_color_hex(0x000000), 0);
lv_obj_set_scrollbar_mode(tileview, LV_SCROLLBAR_MODE_OFF);
tile_speed = lv_tileview_add_tile(tileview, 0, 0, LV_DIR_RIGHT);
tile_menu = lv_tileview_add_tile(tileview, 1, 0, LV_DIR_LEFT);
lv_obj_add_event_cb(tileview, on_tile_changed, LV_EVENT_VALUE_CHANGED, NULL);
build_speed_tile();
build_menu_tile();
}
// ---------- periodic updates ----------
static void update_speed()
{
static int shown = -2;
static String shown_status;
int v = obd_speed();
if (v != shown)
{
shown = v;
char buf[8];
if (v < 0)
strcpy(buf, "--");
else
snprintf(buf, sizeof(buf), "%d", v);
lv_label_set_text(speed_label, buf);
lv_label_set_text(speed_shadow, buf);
lv_arc_set_value(speed_arc, v < 0 ? 0 : min(v, SPEED_MAX));
}
String st = obd_status_text();
if (st != shown_status)
{
shown_status = st;
lv_label_set_text(status_label, st.c_str());
}
}
static void update_menu()
{
String saved = obd_saved_name();
String addr = obd_saved_address();
String line = addr.length() ? "Saved: " + (saved.length() ? saved : String("(no name)")) + " " + addr
: String("No adapter saved - tap one below");
if (line != lv_label_get_text(menu_saved))
lv_label_set_text(menu_saved, line.c_str());
String st = obd_status_text();
if (st != lv_label_get_text(menu_status))
lv_label_set_text(menu_status, st.c_str());
if (!obd_get_devices(scan_devs, scan_version))
return;
// display order: OBD-like adapters first, then the rest (each in first-seen order)
menu_devs.clear();
for (auto &d : scan_devs)
if (d.obd_hint)
menu_devs.push_back(d);
for (auto &d : scan_devs)
if (!d.obd_hint)
menu_devs.push_back(d);
if ((int)menu_devs.size() > MAX_MENU_ITEMS)
menu_devs.resize(MAX_MENU_ITEMS);
String order;
for (auto &d : menu_devs)
order += d.address;
if (order != menu_order)
{
// order changed (new device or new OBD hint): rebuild the list
menu_order = order;
lv_obj_clean(menu_list);
menu_item_count = 0;
for (int i = 0; i < (int)menu_devs.size(); i++)
{
lv_obj_t *btn = lv_list_add_button(menu_list, NULL, "");
lv_obj_add_event_cb(btn, on_device_clicked, LV_EVENT_CLICKED, (void *)(intptr_t)i);
lv_obj_set_style_bg_color(btn, lv_color_hex(0x222A33), 0);
lv_obj_set_style_pad_ver(btn, 6, 0);
menu_items[i] = btn;
menu_item_count = i + 1;
}
}
for (int i = 0; i < menu_item_count; i++)
{
const ObdDevInfo &d = menu_devs[i];
lv_obj_t *label = lv_obj_get_child_by_type(menu_items[i], 0, &lv_label_class);
String text = (d.name.length() ? d.name : String("(no name)")) + "\n" + d.address + " " + d.rssi + " dBm";
if (text != lv_label_get_text(label))
{
lv_label_set_text(label, text.c_str());
lv_obj_set_style_text_color(label, d.obd_hint ? COLOR_OBD : COLOR_TEXT, 0);
}
}
}
void setup()
{
delay(200);
Serial.begin(115200);
scr_lvgl_init();
build_ui();
obd_begin();
if (obd_saved_address().length() == 0)
{
// first start: go straight to the adapter menu
lv_tileview_set_tile(tileview, tile_menu, LV_ANIM_OFF);
obd_scan_enable(true);
}
}
void loop()
{
static uint32_t last_speed = 0, last_menu = 0;
lv_timer_handler();
uint32_t now = millis();
if (now - last_speed >= 100)
{
last_speed = now;
update_speed();
}
if (now - last_menu >= 500 && menu_visible())
{
last_menu = now;
update_menu();
}
vTaskDelay(1);
}
+414
View File
@@ -0,0 +1,414 @@
#include "obd_link.h"
#include <NimBLEDevice.h>
#include <Preferences.h>
#define RETRY_DELAY_MS 3000
#define POLL_FAIL_LIMIT 3
// ---------- shared state (protected by `mtx`) ----------
static SemaphoreHandle_t mtx;
static volatile ObdState state = ObdState::NoDevice;
static volatile int speed_kmh = -1;
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;
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);
}
// ---------- 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;
int fails = 0;
for (;;)
{
// 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;
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() + RETRY_DELAY_MS;
}
continue;
}
// connected: poll the vehicle speed
String reply = elm_command("010D", 1500);
int v = parse_speed(reply);
speed_kmh = v;
if (v >= 0)
{
fails = 0;
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; }
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; }
+40
View File
@@ -0,0 +1,40 @@
// ELM327-over-BLE link: remembers the selected adapter, auto-connects/reconnects and polls the vehicle speed.
// Runs in a background task; all functions are safe to call from the UI (loop) task.
#pragma once
#include <Arduino.h>
#include <vector>
enum class ObdState
{
NoDevice, // no adapter selected yet
Connecting, // BLE connect in progress
Initializing, // ELM327 init (ATZ, protocol search)
Running, // polling speed
Waiting, // connect/init failed or link lost; retrying shortly
};
struct ObdDevInfo
{
String address;
uint8_t addr_type;
String name;
int rssi;
bool obd_hint; // looks like an OBD adapter (name or service FFF0/FFE0/18F0)
};
void obd_begin();
ObdState obd_state();
int obd_speed(); // km/h, -1 = no data (ignition off / not connected)
String obd_status_text(); // short human-readable status line
String obd_saved_name(); // "" if none
String obd_saved_address();
// Menu: scan for adapters while the menu is open
void obd_scan_enable(bool on);
bool obd_get_devices(std::vector<ObdDevInfo> &out, uint32_t &version);
// Select (and remember) an adapter, or forget the remembered one
void obd_select_device(const ObdDevInfo &dev);
void obd_forget_device();
+5
View File
@@ -79,6 +79,7 @@ Display color format is `RGB565_SWAPPED` (ST77916 byte order, set in `lv_conf.h`
| `1-Instructions` | Spec PDF (duplicate), AIDA64 remote-sensor layout `aida_remote_1.85.rslcd`, MJPEG conversion tool (Windows .NET, converts video to 360×360 MJPEG 25 fps) |
| `2-Program Example/Demo_Arduino` | **Vendor only:** `library/` (3 libs), `lv_conf.h` (vendor original), `sd/` (SD listing example; its lv_conf.h has FATFS edits), `docs/` (setup screenshots + compile instructions). The vendor's original LVGL demo sketch no longer exists unmodified. |
| `shared` | **Shared code for all JC3636W518 projects:** `jc3636w518.ini` (PlatformIO base config), `include/lv_conf.h` (LVGL 9.6), `lib/JC3636W518` (board library). See its `README.md` |
| `12-OBD-Speed` | **The new program (in progress):** speed from the ELM327 over BLE as big number + arc gauge on a GIF background; swipe left for the adapter menu (scan, select → remembered in NVS, auto-reconnect every 3 s). See its `README.md` |
| `11-BLE-Scanner` | **BLE scanner with on-screen UI** (NimBLE-Arduino 2.5.1): live device list, tap to list services/characteristics, ELM327 test (ATZ/ATE0/ATI/ATRV/0100). Results saved to LittleFS `/ble_log.txt`; serial `d` dump, `c` clear, `s` device list. See its `README.md` |
| `10-OBD-Display-2024` | **Leftovers of the 2024 OBD project:** `display_gif_embedded`, `display_gif_sd`, `assets/gifs`, `_archive/lvgl9_broken` (bridge/ELMulator/WiFi speed sketch removed 2026-10-23) |
| `3-Specification` | Spec sheet (factory firmware features, interface photo) |
@@ -121,3 +122,7 @@ AliExpress "ELM327 Mini V2.1 Bluetooth OBD **Dual Mode** 5.1" — dual mode = Bl
- Draw buffer is in internal RAM; PSRAM is available for larger buffers / images.
- **LittleFS** works on the `spiffs` partition of `huge_app.csv` (~900 KB); the first mount after flashing formats it (prints `Corrupted dir pair` / `mount failed (-84)` and some `task_wdt ... task not found` once — harmless).
- BLE (NimBLE) in the user's home: ~12 devices visible within 10 s.
- Big fonts: generate with `npx lv_font_conv` (Node 22 installed) from `lvgl/scripts/generators/built_in_font/Montserrat-Medium.ttf`; `-DLV_LVGL_H_INCLUDE_SIMPLE` (in shared ini) makes the generated `#include` work.
- Binary assets (GIFs): `board_build.embed_files = data/x.gif` → `extern const uint8_t x_start[] asm("_binary_data_x_gif_start")` (no huge C arrays).
- Serial output right after a reset can be lost/garbled (USB-CDC re-enumeration, prints from two cores interleave) — don't conclude a task isn't running from a missing early line.
- `Preferences` prints `nvs_open failed: NOT_FOUND` the first time a namespace is read before it was written — harmless.
+2
View File
@@ -28,6 +28,8 @@ build_flags =
-DARDUINO_USB_CDC_ON_BOOT=1
; LVGL (also when compiled as a library) uses shared/include/lv_conf.h
-DLV_CONF_INCLUDE_SIMPLE
; generated LVGL fonts/images include "lvgl.h" directly
-DLV_LVGL_H_INCLUDE_SIMPLE
-I${shared.dir}/include
; shared/lib: JC3636W518 board library