Add 11-BLE-Scanner: on-screen BLE scanner, inspector and ELM327 test

- NimBLE-Arduino 2.5.1, BLE work in a background task on core 0
- LVGL 9 UI for the round screen: device list (OBD-like devices in green),
  tap to list services/characteristics, ELM test (ATZ/ATE0/ATI/ATRV/0100)
- results saved to LittleFS /ble_log.txt; serial d/c/s commands
- shared lv_conf: Montserrat 16 and 20 enabled

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
patriandClaude Opus 5.5 committed 2026-10-23 01:54:36 +02:00
1 parent 71d4bf7b58
commit 8a84b42a3b
9 files changed
+873 -3

No files matched your search

+29
View File
@@ -0,0 +1,29 @@
# BLE Scanner (JC3636W518)
Stand-alone BLE scanner with on-screen UI, made to find out how the user's ELM327 ("ELM327 Mini V2.1 Dual Mode 5.1") talks over BLE.
Uses the shared board library and LVGL 9.6 (`../shared`), NimBLE-Arduino 2.5.1.
## On the device
1. **Scan screen** — live list of BLE devices: name, MAC, signal (dBm). Devices that look like an OBD adapter (name contains OBD/ELM/VLINK/…, or advertises service FFF0/FFE0/18F0) are shown in **green** with `OBD?`. `(nc)` = not connectable.
- **Pause / Scan** — stop/resume scanning. **Save** — write the current list to flash.
2. **Tap a device** — connects and lists all services and characteristics with their properties
(`R` read, `W` write, `WnR` write without response, `N` notify, `I` indicate).
If a service has a notify + write pair (ELM-style UART), it is shown as **ELM candidate** and **ELM test** becomes active.
3. **ELM test** — sends `ATZ`, `ATE0`, `ATI`, `ATRV` (battery voltage) and `0100` (supported PIDs, needs ignition on) and shows the replies.
4. **Back** — disconnect and return to scanning.
Every inspect and ELM test is saved automatically (together with the scan list) to `/ble_log.txt` in internal flash (LittleFS, ~900 KB).
## Reading the results later (USB, serial 115200)
Send a single character:
- `d` — dump `/ble_log.txt`
- `c` — clear the log
- `s` — print the current device list
e.g. `pio device monitor` in this folder, then type `d`.
## Build / flash
```
pio run -t upload
```
Note: the ELM327 is only powered when plugged into the car's OBD port.
+13
View File
@@ -0,0 +1,13 @@
; BLE scanner / inspector / ELM327 tester with on-screen UI — JC3636W518, common settings in shared/jc3636w518.ini
[platformio]
extra_configs = ../shared/jc3636w518.ini
[shared]
dir = ../shared
[env:jc3636w518]
extends = jc3636w518
lib_deps =
${jc3636w518.lib_deps}
h2zero/NimBLE-Arduino@2.5.1
+436
View File
@@ -0,0 +1,436 @@
#include "ble_worker.h"
#include <NimBLEDevice.h>
#include "result_log.h"
// ---------- shared state (protected by `mtx`) ----------
static SemaphoreHandle_t mtx;
static std::vector<BleDeviceInfo> devices;
static uint32_t devices_version = 1;
static String text;
static uint32_t text_version = 1;
static volatile BleState state = BleState::Scanning;
static volatile bool elm_candidate = false;
// ---------- requests from the UI to the worker task ----------
enum class Req
{
None,
Inspect,
ElmTest,
Back,
Pause,
Resume,
};
static volatile Req request = Req::None;
static BleDeviceInfo request_dev;
// ---------- worker-owned BLE objects ----------
static NimBLEClient *client = nullptr;
static NimBLERemoteCharacteristic *elm_write = nullptr;
static NimBLERemoteCharacteristic *elm_notify = nullptr;
static String elm_rx; // notifications received from the ELM (guarded by mtx)
static void lock() { xSemaphoreTake(mtx, portMAX_DELAY); }
static void unlock() { xSemaphoreGive(mtx); }
static void set_text(const String &t)
{
lock();
text = t;
text_version++;
unlock();
}
static void add_text(const String &t)
{
lock();
text += t;
text_version++;
unlock();
}
static String upper(String s)
{
s.toUpperCase();
return s;
}
// Services that cheap BLE OBD adapters typically use for their serial (UART-like) link
static const uint16_t ELM_SERVICE_HINTS[] = {0xFFF0, 0xFFE0, 0x18F0};
static bool name_looks_like_obd(const String &name)
{
String n = upper(name);
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 ----------
class ScanCallbacks : public NimBLEScanCallbacks
{
void onResult(const NimBLEAdvertisedDevice *adv) override
{
String addr = adv->getAddress().toString().c_str();
String name = adv->haveName() ? String(adv->getName().c_str()) : String();
String svcs;
bool hint = name_looks_like_obd(name);
for (uint8_t i = 0; i < adv->getServiceUUIDCount(); i++)
{
NimBLEUUID uuid = adv->getServiceUUID(i);
if (svcs.length())
svcs += ",";
svcs += uuid.toString().c_str();
for (uint16_t h : ELM_SERVICE_HINTS)
if (uuid.equals(NimBLEUUID(h)))
hint = true;
}
lock();
BleDeviceInfo *d = nullptr;
for (auto &e : devices)
if (e.address == addr)
d = &e;
if (!d)
{
devices.push_back(BleDeviceInfo());
d = &devices.back();
d->address = addr;
}
d->addr_type = adv->getAddress().getType();
if (name.length())
d->name = name; // keep a name once seen (scan responses don't always repeat it)
if (svcs.length())
d->services = svcs;
d->rssi = adv->getRSSI();
d->elm_hint = d->elm_hint || hint;
d->connectable = adv->isConnectable();
devices_version++;
unlock();
}
};
static ScanCallbacks scan_callbacks;
static void scan_start()
{
NimBLEScan *scan = NimBLEDevice::getScan();
scan->setScanCallbacks(&scan_callbacks, true); // want duplicates: keeps RSSI live
scan->setActiveScan(true); // ask for scan responses (names)
scan->setInterval(100);
scan->setWindow(90);
scan->start(0, false, true); // 0 = scan until stopped
state = BleState::Scanning;
}
static void scan_stop()
{
NimBLEDevice::getScan()->stop();
}
// ---------- inspect ----------
static String props(const NimBLERemoteCharacteristic *c)
{
String p;
if (c->canRead())
p += "R ";
if (c->canWrite())
p += "W ";
if (c->canWriteNoResponse())
p += "WnR ";
if (c->canNotify())
p += "N ";
if (c->canIndicate())
p += "I ";
p.trim();
return p;
}
static void disconnect_client()
{
elm_candidate = false;
elm_write = nullptr;
elm_notify = nullptr;
if (client)
{
if (client->isConnected())
client->disconnect();
NimBLEDevice::deleteClient(client);
client = nullptr;
}
}
static void do_inspect(const BleDeviceInfo &dev)
{
scan_stop();
state = BleState::Connecting;
String title = dev.name.length() ? dev.name : String("(no name)");
set_text(title + "\n" + dev.address + " " + dev.rssi + " dBm\nConnecting...\n");
client = NimBLEDevice::createClient();
client->setConnectTimeout(10000);
NimBLEAddress addr(std::string(dev.address.c_str()), dev.addr_type);
if (!client->connect(addr))
{
add_text(String("Connect FAILED (error ") + client->getLastError() + ")\n");
log_append("Inspect " + dev.address, title + "\nconnect failed, error " + client->getLastError());
disconnect_client();
state = BleState::Failed;
return;
}
add_text(String("Connected, MTU ") + client->getMTU() + "\n");
// list services and characteristics, and look for an ELM-style UART pair (notify + write)
String out;
int best_score = -1;
const std::vector<NimBLERemoteService *> &svcs = client->getServices(true);
for (NimBLERemoteService *s : svcs)
{
out += String("\nSVC ") + s->getUUID().toString().c_str() + "\n";
NimBLERemoteCharacteristic *w = nullptr, *n = nullptr;
for (NimBLERemoteCharacteristic *c : s->getCharacteristics(true))
{
out += String(" ") + c->getUUID().toString().c_str() + " [" + props(c) + "]\n";
if (!n && (c->canNotify() || c->canIndicate()))
n = c;
if (!w && (c->canWrite() || c->canWriteNoResponse()))
w = c;
}
if (w && n)
{
int score = 1;
for (uint16_t h : ELM_SERVICE_HINTS)
if (s->getUUID().equals(NimBLEUUID(h)))
score = 2;
if (score > best_score)
{
best_score = score;
elm_write = w;
elm_notify = n;
}
}
}
if (elm_write && elm_notify)
{
elm_candidate = true;
out += String("\nELM candidate:\n write ") + elm_write->getUUID().toString().c_str() +
"\n notify " + elm_notify->getUUID().toString().c_str() + "\n";
}
else
{
out += "\nNo notify+write pair found (not an ELM-style UART).\n";
}
add_text(out);
state = BleState::Connected;
log_append("Scan list", ble_devices_as_text());
log_append("Inspect " + dev.address, title + " " + dev.rssi + " dBm\n" + out);
}
// ---------- ELM327 test ----------
static void on_elm_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 one command and waits for the ELM prompt '>' (or a timeout). Returns the reply.
static String elm_command(const char *cmd, uint32_t timeout_ms)
{
lock();
elm_rx = "";
unlock();
String line = String(cmd) + "\r";
bool with_response = !elm_write->canWriteNoResponse(); // prefer write-without-response if offered
if (!elm_write->writeValue((const uint8_t *)line.c_str(), line.length(), with_response))
return "(write failed)";
uint32_t t0 = millis();
String rx;
while (millis() - t0 < timeout_ms)
{
lock();
rx = elm_rx;
unlock();
if (rx.indexOf('>') >= 0)
break;
vTaskDelay(pdMS_TO_TICKS(20));
}
if (rx.indexOf('>') < 0)
rx += " (timeout)";
// tidy: CR -> newline, drop prompt
rx.replace("\r", "\n");
rx.replace(">", "");
while (rx.indexOf("\n\n") >= 0)
rx.replace("\n\n", "\n");
rx.trim();
return rx;
}
static void do_elm_test()
{
if (!client || !client->isConnected() || !elm_write || !elm_notify)
{
add_text("\nELM test: not connected / no candidate\n");
return;
}
state = BleState::ElmTesting;
add_text("\n--- ELM327 test ---\n");
if (!elm_notify->subscribe(elm_notify->canNotify(), on_elm_notify))
{
add_text("subscribe FAILED\n");
state = BleState::Connected;
return;
}
struct
{
const char *cmd;
uint32_t timeout;
const char *what;
} cmds[] = {
{"ATZ", 4000, "reset"},
{"ATE0", 2000, "echo off"},
{"ATI", 2000, "version"},
{"ATRV", 2000, "battery voltage"},
{"0100", 8000, "supported PIDs (needs ignition on)"},
};
String log_text;
for (auto &c : cmds)
{
if (!client->isConnected())
{
add_text("disconnected!\n");
break;
}
String reply = elm_command(c.cmd, c.timeout);
String block = String("> ") + c.cmd + " (" + c.what + ")\n" + reply + "\n";
add_text(block);
log_text += block;
}
elm_notify->unsubscribe();
add_text("--- done ---\n");
log_append("ELM test " + String(client->getPeerAddress().toString().c_str()), log_text);
state = BleState::Connected;
}
// ---------- worker task ----------
static void worker_task(void *)
{
for (;;)
{
Req r = request;
if (r != Req::None)
{
request = Req::None;
switch (r)
{
case Req::Inspect:
do_inspect(request_dev);
break;
case Req::ElmTest:
do_elm_test();
break;
case Req::Back:
disconnect_client();
set_text("");
scan_start();
break;
case Req::Pause:
if (state == BleState::Scanning)
{
scan_stop();
state = BleState::Paused;
}
break;
case Req::Resume:
if (state == BleState::Paused)
scan_start();
break;
default:
break;
}
}
// a connection can drop by itself (adapter unplugged / out of range)
if (client && !client->isConnected() && (state == BleState::Connected))
{
add_text("\nConnection lost.\n");
state = BleState::Failed;
}
vTaskDelay(pdMS_TO_TICKS(20));
}
}
// ---------- public API ----------
void ble_begin()
{
mtx = xSemaphoreCreateMutex();
NimBLEDevice::init("JC3636W518-scanner");
NimBLEDevice::setPower(9); // dBm, max for ESP32-S3
scan_start();
// core 0: Arduino loop/LVGL run on core 1
xTaskCreatePinnedToCore(worker_task, "ble_worker", 8192, nullptr, 2, nullptr, 0);
}
void ble_scan_pause(bool pause) { request = pause ? Req::Pause : Req::Resume; }
bool ble_get_devices(std::vector<BleDeviceInfo> &out, uint32_t &version)
{
lock();
bool changed = version != devices_version;
if (changed)
{
out = devices;
version = devices_version;
}
unlock();
return changed;
}
void ble_request_inspect(const BleDeviceInfo &dev)
{
request_dev = dev;
request = Req::Inspect;
}
void ble_request_elm_test() { request = Req::ElmTest; }
void ble_request_back() { request = Req::Back; }
BleState ble_state() { return state; }
bool ble_has_elm_candidate() { return elm_candidate; }
bool ble_get_text(String &out, uint32_t &version)
{
lock();
bool changed = version != text_version;
if (changed)
{
out = text;
version = text_version;
}
unlock();
return changed;
}
String ble_devices_as_text()
{
String s;
lock();
for (auto &d : devices)
{
s += d.address + " " + d.rssi + " dBm " + (d.name.length() ? d.name : String("(no name)"));
if (d.elm_hint)
s += " [OBD?]";
if (!d.connectable)
s += " [not connectable]";
if (d.services.length())
s += " svc: " + d.services;
s += "\n";
}
unlock();
return s;
}
+53
View File
@@ -0,0 +1,53 @@
// BLE scanning, device inspection and ELM327 test, running in a background task.
// All functions are safe to call from the UI (loop) task.
#pragma once
#include <Arduino.h>
#include <vector>
struct BleDeviceInfo
{
String address; // "aa:bb:cc:dd:ee:ff"
uint8_t addr_type; // public / random (needed to connect)
String name; // advertised name ("" if none)
String services; // advertised service UUIDs, comma separated
int rssi; // dBm
bool elm_hint; // name or advertised service looks like an OBD/ELM327 adapter
bool connectable;
};
enum class BleState
{
Scanning,
Paused,
Connecting,
Connected, // inspected; services listed
ElmTesting,
Failed, // connect/discovery failed (see text)
};
void ble_begin();
// Scan control (only while not connected)
void ble_scan_pause(bool pause);
// Copies the device list if it changed since `version` (returns true and updates `version`).
bool ble_get_devices(std::vector<BleDeviceInfo> &out, uint32_t &version);
// Connect to the device and list its services.
void ble_request_inspect(const BleDeviceInfo &dev);
// Run the ELM327 command test on the connected device (needs an ELM candidate).
void ble_request_elm_test();
// Disconnect and resume scanning.
void ble_request_back();
BleState ble_state();
bool ble_has_elm_candidate();
// Inspect / ELM test output text; returns true if it changed since `version`.
bool ble_get_text(String &out, uint32_t &version);
// Plain-text snapshot of the device list (for the log).
String ble_devices_as_text();
+239
View File
@@ -0,0 +1,239 @@
// BLE scanner for the JC3636W518: live device list, tap a device to list its services,
// then run an ELM327 command test. Results are saved to internal flash (dump over serial with 'd').
#include <Arduino.h>
#include "jc3636w518.h"
#include "ble_worker.h"
#include "result_log.h"
#define MAX_LIST_ITEMS 50
static lv_obj_t *scr_scan, *scan_title, *scan_status, *scan_list, *btn_pause, *btn_pause_label;
static lv_obj_t *scr_inspect, *insp_title, *insp_text, *insp_text_cont, *btn_elm;
static lv_obj_t *list_items[MAX_LIST_ITEMS];
static int list_count = 0;
static std::vector<BleDeviceInfo> devs;
static uint32_t devs_version = 0;
static uint32_t text_version = 0;
static bool paused = false;
static uint32_t status_msg_until = 0;
static const lv_color_t COLOR_BG = lv_color_hex(0x000000);
static const lv_color_t COLOR_TEXT = lv_color_hex(0xE0E0E0);
static const lv_color_t COLOR_DIM = lv_color_hex(0x8A8A8A);
static const lv_color_t COLOR_OBD = lv_color_hex(0x4CD964);
// ---------- 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 lv_obj_t *make_screen()
{
lv_obj_t *s = lv_obj_create(lv_screen_active());
lv_obj_remove_style_all(s);
lv_obj_set_size(s, SCREEN_RES_HOR, SCREEN_RES_VER);
lv_obj_set_style_bg_color(s, COLOR_BG, 0);
lv_obj_set_style_bg_opa(s, LV_OPA_COVER, 0);
lv_obj_remove_flag(s, LV_OBJ_FLAG_SCROLLABLE);
return s;
}
static void show_scan_screen(bool scan)
{
if (scan)
{
lv_obj_remove_flag(scr_scan, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(scr_inspect, LV_OBJ_FLAG_HIDDEN);
}
else
{
lv_obj_add_flag(scr_scan, LV_OBJ_FLAG_HIDDEN);
lv_obj_remove_flag(scr_inspect, LV_OBJ_FLAG_HIDDEN);
}
}
// ---------- event handlers ----------
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)devs.size())
return;
const BleDeviceInfo &d = devs[idx];
lv_label_set_text(insp_title, d.name.length() ? d.name.c_str() : d.address.c_str());
lv_label_set_text(insp_text, "Connecting...");
lv_obj_add_state(btn_elm, LV_STATE_DISABLED);
show_scan_screen(false);
ble_request_inspect(d);
}
static void on_pause_clicked(lv_event_t *e)
{
paused = !paused;
ble_scan_pause(paused);
lv_label_set_text(btn_pause_label, paused ? "Scan" : "Pause");
}
static void on_save_clicked(lv_event_t *e)
{
log_append("Scan list (saved by button)", ble_devices_as_text());
lv_label_set_text(scan_status, "Saved to flash");
status_msg_until = millis() + 2000;
}
static void on_back_clicked(lv_event_t *e)
{
ble_request_back();
paused = false;
lv_label_set_text(btn_pause_label, "Pause");
show_scan_screen(true);
}
static void on_elm_clicked(lv_event_t *e)
{
lv_obj_add_state(btn_elm, LV_STATE_DISABLED);
ble_request_elm_test();
}
// ---------- UI construction ----------
static void build_ui()
{
lv_obj_set_style_bg_color(lv_screen_active(), COLOR_BG, 0);
// scan screen
scr_scan = make_screen();
scan_title = lv_label_create(scr_scan);
lv_label_set_text(scan_title, "BLE scan");
lv_obj_set_style_text_font(scan_title, &lv_font_montserrat_20, 0);
lv_obj_set_style_text_color(scan_title, COLOR_TEXT, 0);
lv_obj_align(scan_title, LV_ALIGN_TOP_MID, 0, 20);
scan_status = lv_label_create(scr_scan);
lv_label_set_text(scan_status, "starting...");
lv_obj_set_style_text_color(scan_status, COLOR_DIM, 0);
lv_obj_align(scan_status, LV_ALIGN_TOP_MID, 0, 46);
scan_list = lv_list_create(scr_scan);
lv_obj_set_size(scan_list, 270, 222);
lv_obj_align(scan_list, LV_ALIGN_TOP_MID, 0, 68);
lv_obj_set_style_bg_color(scan_list, lv_color_hex(0x101418), 0);
lv_obj_set_style_border_width(scan_list, 0, 0);
lv_obj_set_style_pad_all(scan_list, 4, 0);
lv_obj_t *b = make_button(scr_scan, "Pause", -48, 296, 88, on_pause_clicked);
btn_pause = b;
btn_pause_label = lv_obj_get_child(b, 0);
make_button(scr_scan, "Save", 48, 296, 88, on_save_clicked);
// inspect screen
scr_inspect = make_screen();
insp_title = lv_label_create(scr_inspect);
lv_obj_set_width(insp_title, 220);
lv_label_set_long_mode(insp_title, LV_LABEL_LONG_MODE_DOTS);
lv_obj_set_style_text_align(insp_title, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_style_text_font(insp_title, &lv_font_montserrat_20, 0);
lv_obj_set_style_text_color(insp_title, COLOR_TEXT, 0);
lv_obj_align(insp_title, LV_ALIGN_TOP_MID, 0, 26);
insp_text_cont = lv_obj_create(scr_inspect);
lv_obj_set_size(insp_text_cont, 284, 228);
lv_obj_align(insp_text_cont, LV_ALIGN_TOP_MID, 0, 60);
lv_obj_set_style_bg_color(insp_text_cont, lv_color_hex(0x101418), 0);
lv_obj_set_style_border_width(insp_text_cont, 0, 0);
lv_obj_set_style_pad_all(insp_text_cont, 6, 0);
insp_text = lv_label_create(insp_text_cont);
lv_obj_set_width(insp_text, lv_pct(100));
lv_label_set_long_mode(insp_text, LV_LABEL_LONG_MODE_WRAP);
lv_obj_set_style_text_color(insp_text, COLOR_TEXT, 0);
lv_label_set_text(insp_text, "");
make_button(scr_inspect, "Back", -52, 296, 92, on_back_clicked);
btn_elm = make_button(scr_inspect, "ELM test", 52, 296, 104, on_elm_clicked);
lv_obj_add_state(btn_elm, LV_STATE_DISABLED);
show_scan_screen(true);
}
// ---------- periodic UI updates ----------
static void update_scan_list()
{
if (!ble_get_devices(devs, devs_version))
return;
int n = min((int)devs.size(), MAX_LIST_ITEMS);
for (int i = 0; i < n; i++)
{
const BleDeviceInfo &d = devs[i];
if (i >= list_count)
{
lv_obj_t *btn = lv_list_add_button(scan_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(0x1C232B), 0);
lv_obj_set_style_pad_ver(btn, 6, 0);
list_items[i] = btn;
list_count = i + 1;
}
lv_obj_t *label = lv_obj_get_child_by_type(list_items[i], 0, &lv_label_class);
String line = (d.elm_hint ? "OBD? " : "") + (d.name.length() ? d.name : String("(no name)")) +
"\n" + d.address + " " + d.rssi + " dBm" + (d.connectable ? "" : " (nc)");
if (strcmp(lv_label_get_text(label), line.c_str()) != 0)
{
lv_label_set_text(label, line.c_str());
lv_obj_set_style_text_color(label, d.elm_hint ? COLOR_OBD : COLOR_TEXT, 0);
}
}
if (millis() > status_msg_until)
{
String status = String(devs.size()) + " devices" + (paused ? " (paused)" : " scanning");
lv_label_set_text(scan_status, status.c_str());
}
}
static void update_inspect()
{
String t;
if (ble_get_text(t, text_version))
{
lv_label_set_text(insp_text, t.c_str());
lv_obj_scroll_to_y(insp_text_cont, LV_COORD_MAX, LV_ANIM_OFF); // follow new output
}
bool can_test = ble_state() == BleState::Connected && ble_has_elm_candidate();
if (can_test)
lv_obj_remove_state(btn_elm, LV_STATE_DISABLED);
else
lv_obj_add_state(btn_elm, LV_STATE_DISABLED);
}
void setup()
{
delay(200);
Serial.begin(115200);
scr_lvgl_init();
build_ui();
log_begin();
ble_begin();
}
void loop()
{
static uint32_t last_ui = 0;
lv_timer_handler();
if (millis() - last_ui > 250)
{
last_ui = millis();
if (lv_obj_has_flag(scr_scan, LV_OBJ_FLAG_HIDDEN))
update_inspect();
else
update_scan_list();
}
log_handle_serial();
vTaskDelay(2);
}
+85
View File
@@ -0,0 +1,85 @@
#include "result_log.h"
#include <LittleFS.h>
#include "ble_worker.h"
static const char *LOG_PATH = "/ble_log.txt";
static bool log_ok = false;
static void print_help()
{
Serial.println("BLE scanner: send 'd' to dump /ble_log.txt, 'c' to clear it, 's' to print the current device list.");
}
void log_begin()
{
// uses the "spiffs" data partition of huge_app.csv; formats it on first use
log_ok = LittleFS.begin(true);
if (!log_ok)
{
Serial.println("LittleFS mount failed - results will not be saved");
return;
}
unsigned size = 0;
if (LittleFS.exists(LOG_PATH))
{
File f = LittleFS.open(LOG_PATH, FILE_READ);
size = f.size();
f.close();
}
Serial.printf("Log: %u bytes stored (flash %u / %u bytes used)\n", size,
(unsigned)LittleFS.usedBytes(), (unsigned)LittleFS.totalBytes());
print_help();
}
void log_append(const String &title, const String &text)
{
if (!log_ok)
return;
File f = LittleFS.open(LOG_PATH, FILE_APPEND);
if (!f)
return;
// no RTC: time since boot
f.printf("===== %s (t=%lus since boot) =====\n", title.c_str(), millis() / 1000);
f.print(text);
if (!text.endsWith("\n"))
f.print("\n");
f.print("\n");
f.close();
}
void log_handle_serial()
{
while (Serial.available())
{
char c = Serial.read();
if (c == 'd' || c == 'D')
{
Serial.println("----- BEGIN /ble_log.txt -----");
if (LittleFS.exists(LOG_PATH))
{
File f = LittleFS.open(LOG_PATH, FILE_READ);
while (f.available())
Serial.write(f.read());
f.close();
}
Serial.println("----- END /ble_log.txt -----");
}
else if (c == 'c' || c == 'C')
{
LittleFS.remove(LOG_PATH);
Serial.println("Log cleared.");
}
else if (c == 's' || c == 'S')
{
Serial.println("----- devices -----");
Serial.print(ble_devices_as_text());
Serial.println("-------------------");
}
else if (c == 'h' || c == 'H' || c == '?')
{
print_help();
}
}
}
+12
View File
@@ -0,0 +1,12 @@
// Result log in internal flash (LittleFS on the "spiffs" partition), readable later over serial.
#pragma once
#include <Arduino.h>
void log_begin();
// Appends a block of text (with a timestamp header) to /ble_log.txt.
void log_append(const String &title, const String &text);
// Serial commands: 'd' = dump the log, 'c' = clear it, 's' = print the device list. Call every loop.
void log_handle_serial();
+4 -1
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` |
| `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` | **Earlier OBD speed-display project**, split into separate sketches — see its `README.md`: `display_speed_wifi`, `display_gif_sd`, `display_gif_embedded`, `elm327_wifi_bridge` (classic ESP32), `elmulator`, `assets/gifs`, `_archive/lvgl9_broken` |
| `3-Specification` | Spec sheet (factory firmware features, interface photo) |
| `4-Chip manual` | ESP32-S3R8 datasheet, PCM5100A datasheet |
@@ -94,7 +95,7 @@ Re-flash it with Flash Download Tool: ESP32-S3, `1.85_demo.bin` @ 0x0.
## OBD adapter (user's)
AliExpress "ELM327 Mini V2.1 Bluetooth OBD **Dual Mode** 5.1" — dual mode = Bluetooth Classic SPP **and BLE**, so the ESP32-S3 should be able to connect directly over BLE (no bridge ESP32).
Cheap dual-mode clones typically expose BLE service `FFF0` (notify `FFF1`, write `FFF2`) — **not yet verified** on this adapter; scan it first. Adapter is powered from the car's OBD port (pin 16, 12 V).
Cheap dual-mode clones typically expose BLE service `FFF0` (notify `FFF1`, write `FFF2`) — **not yet verified** on this adapter; use `11-BLE-Scanner` in the car (results in its flash log). Adapter is powered from the car's OBD port (pin 16, 12 V).
## Gotchas / observations
- **LVGL 8 GIF bug (patched locally, only relevant if LVGL 8 is used again):** LVGL 8.3.11 `gifdec.c` (`gd_open_gif`) filled the canvas *opaque* with the GIF's background color. GIFs with a transparent background (e.g. `loligod.gif`, bg = RGB 28,29,49) showed that dark gray except where "restore to background" frames had cleared to transparent (→ screen black). **Patched** in the vendor copy `2-Program Example/Demo_Arduino/library/lvgl/src/extra/libs/gif/gifdec.c`: initial canvas alpha 0x00 (marked `LOCAL PATCH`). Re-apply if LVGL is ever replaced/upgraded.
@@ -113,3 +114,5 @@ Cheap dual-mode clones typically expose BLE service `FFF0` (notify `FFF1`, write
- Compiling a 13.9 MB GIF into the firmware will not fit "Huge APP" (3 MB); large media should live on SD (or in a custom partition).
- `display_gif_sd` / `display_speed_wifi` `setup()` return early if the SD card fails to mount (black screen); `display_gif_embedded` starts its GIF first.
- 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.
+2 -2
View File
@@ -1016,13 +1016,13 @@
#define LV_FONT_MONTSERRAT_14 1
/** Montserrat 16 */
#define LV_FONT_MONTSERRAT_16 0
#define LV_FONT_MONTSERRAT_16 1 /* JC3636W518 */
/** Montserrat 18 */
#define LV_FONT_MONTSERRAT_18 0
/** Montserrat 20 */
#define LV_FONT_MONTSERRAT_20 0
#define LV_FONT_MONTSERRAT_20 1 /* JC3636W518 */
/** Montserrat 22 */
#define LV_FONT_MONTSERRAT_22 0