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:
1 parent
71d4bf7b58
commit
8a84b42a3b
9 files changed
+873
-3
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user