Files
flipper-bluepp/bt_inspector/bt_inspector.c
T
andyandClaude Fable 5.1 def0fc070b Resolve device name over GATT for devices that advertise none
After connecting and discovering services, if the device advertised no name,
read the GAP Device Name characteristic (0x2A00) and store it, so the device
page and service-list header show the real name instead of "(no name)".
Runs once on the worker thread right after service discovery, reusing the
existing read path; skipped when an advertised name is already present.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LEaD4ZeAfvWLBUxXp8G11r
2026-09-10 23:50:00 +01:00

1278 lines
45 KiB
C

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2026 Andy Dixon <andy@dixon.cx>
// BT Inspector: BLE scanner + GATT explorer for Flipper Zero (full-stack firmware build).
#include <furi.h>
#include <furi_hal.h>
#include <gui/gui.h>
#include <gui/view.h>
#include <gui/view_dispatcher.h>
#include <gui/elements.h>
#include <gui/modules/submenu.h>
#include <gui/modules/popup.h>
#include <gui/modules/text_input.h>
#include <gui/modules/number_input.h>
#include <storage/storage.h>
#include <ble/core/ble_defs.h>
#include <string.h>
#include <stdarg.h>
#include "ble_central.h"
#include "ble_names.h"
#define TAG "BtInspector"
#define LOG_DIR EXT_PATH("apps_data/bt_inspector")
#define HIST_MAX 16 // ponytail: history kept for the open characteristic only; the .log has everything
#define HIST_DATA 24
#define ROWS 3u
#define INFO_ITEM 0xFFFF
typedef enum {
ViewScan,
ViewPage,
ViewPopup,
ViewServices,
ViewChars,
ViewWriteMenu,
ViewTextInput,
ViewNumberInput,
} ViewId;
typedef enum {
EvtTick,
EvtScanSelect,
EvtPageLeft,
EvtPageCenter,
EvtPageRight,
EvtJobDone,
EvtNotify,
EvtPopupDone,
} AppEvent;
typedef enum {
JobConnect,
JobServices,
JobChars,
JobRead,
JobWrite,
JobNotify,
JobInfo,
JobDisconnect,
JobQuit,
} JobType;
typedef enum { PageError, PageDetails, PageChar, PageInfo } PageKind;
typedef enum { AfterPopupDetails, AfterPopupDisconnect, AfterPopupServices } AfterPopup;
typedef struct {
BcDevice devs[BC_MAX_DEVICES];
size_t count, sel, top;
bool scanning;
uint8_t scan_err;
} ScanModel;
typedef struct {
FuriString* text;
size_t scroll, max_scroll;
const char *left, *center, *right;
} PageModel;
typedef struct {
char time[12];
uint8_t len;
uint8_t data[HIST_DATA];
} HistEntry;
typedef struct {
uint16_t handle;
uint8_t len;
uint8_t data[HIST_DATA];
} NotifyMsg;
typedef struct {
Gui* gui;
ViewDispatcher* vd;
View* scan_view;
View* page_view;
Popup* popup;
Submenu* services_menu;
Submenu* chars_menu;
Submenu* write_menu;
TextInput* text_input;
NumberInput* number_input;
FuriTimer* timer;
Storage* storage;
File* log;
BleCentral* bc;
FuriThread* worker;
FuriMessageQueue* jobs;
FuriMessageQueue* notify_q;
ViewId cur_view;
PageKind page_kind;
AfterPopup after_popup;
bool was_advertising;
BcDevice dev;
BcService services[BC_MAX_SERVICES];
int svc_count, svc_sel;
BcChar chars[BC_MAX_CHARS];
int chr_count, chr_sel;
uint8_t value[BC_VALUE_MAX];
int value_len;
bool notify_on;
HistEntry hist[HIST_MAX];
int hist_count;
uint8_t write_buf[BC_VALUE_MAX];
uint8_t write_len;
int write_mode; // 0 text, 1 hex, 2 number
char text_buf[128];
JobType job;
bool job_ok;
volatile bool cancel;
FuriString* status;
FuriString* info_text;
} App;
static App* nav_app; // navigation callbacks only receive the view's own context
// ---- logging -----------------------------------------------------------------
static void now_str(char* out, size_t n) {
DateTime dt;
furi_hal_rtc_get_datetime(&dt);
snprintf(out, n, "%02u:%02u:%02u", dt.hour, dt.minute, dt.second);
}
static void app_log(App* app, const char* fmt, ...) {
if(!app->log) return;
DateTime dt;
furi_hal_rtc_get_datetime(&dt);
FuriString* s = furi_string_alloc_printf(
"[%04u-%02u-%02u %02u:%02u:%02u] ", dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second);
va_list va;
va_start(va, fmt);
furi_string_cat_vprintf(s, fmt, va);
va_end(va);
furi_string_cat_str(s, "\n");
storage_file_write(app->log, furi_string_get_cstr(s), furi_string_size(s));
storage_file_sync(app->log);
furi_string_free(s);
}
static void log_open(App* app) {
DateTime dt;
furi_hal_rtc_get_datetime(&dt);
storage_simply_mkdir(app->storage, LOG_DIR);
FuriString* p = furi_string_alloc_printf(
LOG_DIR "/bti_%04u%02u%02u_%02u%02u%02u.log", dt.year, dt.month, dt.day, dt.hour, dt.minute,
dt.second);
app->log = storage_file_alloc(app->storage);
if(!storage_file_open(app->log, furi_string_get_cstr(p), FSAM_WRITE, FSOM_CREATE_ALWAYS)) {
FURI_LOG_W(TAG, "log open failed: %s", furi_string_get_cstr(p));
storage_file_free(app->log);
app->log = NULL;
}
furi_string_free(p);
}
// ---- text helpers ------------------------------------------------------------
static const char* vendor_of(const BcDevice* d, uint16_t* id) {
uint8_t l = 0;
const uint8_t* m = bc_adv_find(d->adv, d->adv_len, 0xFF, &l);
if(!m) m = bc_adv_find(d->rsp, d->rsp_len, 0xFF, &l);
if(!m || l < 2) return NULL;
*id = m[0] | (m[1] << 8);
return ble_company_name(*id);
}
static const char* dev_label(const BcDevice* d) {
return d->name[0] ? d->name : "(no name)";
}
static void char_label(FuriString* out, const BcChar* c) {
const char* n = ble_uuid_name(c->uuid, c->uuid_len);
if(n) {
furi_string_set_str(out, n);
} else {
furi_string_reset(out);
ble_uuid_str(out, c->uuid, c->uuid_len);
}
}
static void cat_uuid_list(FuriString* out, const uint8_t* p, uint8_t len, uint8_t ulen) {
for(uint8_t i = 0; i + ulen <= len; i += ulen) {
const char* n = ulen == 4 ? NULL : ble_uuid_name(p + i, ulen);
furi_string_cat_str(out, " ");
if(ulen == 4)
furi_string_cat_printf(out, "%02X%02X%02X%02X", p[i + 3], p[i + 2], p[i + 1], p[i]);
else
ble_uuid_str(out, p + i, ulen);
if(n) furi_string_cat_printf(out, " %s", n);
furi_string_cat_str(out, "\n");
}
}
static void walk_ad(FuriString* out, const uint8_t* data, uint8_t len) {
uint8_t i = 0;
while(i < len) {
uint8_t l = data[i];
if(!l || i + 1 + l > len) break;
uint8_t t = data[i + 1], pl = l - 1;
const uint8_t* p = data + i + 2;
switch(t) {
case 0x01:
if(pl) {
furi_string_cat_printf(out, "Flags: %02X", p[0]);
if(p[0] & 0x01) furi_string_cat_str(out, " LtdDisc");
if(p[0] & 0x02) furi_string_cat_str(out, " GenDisc");
if(p[0] & 0x04) furi_string_cat_str(out, " NoBR/EDR");
furi_string_cat_str(out, "\n");
}
break;
case 0x02:
case 0x03:
furi_string_cat_str(out, "Services (16-bit):\n");
cat_uuid_list(out, p, pl, 2);
break;
case 0x04:
case 0x05:
furi_string_cat_str(out, "Services (32-bit):\n");
cat_uuid_list(out, p, pl, 4);
break;
case 0x06:
case 0x07:
furi_string_cat_str(out, "Services (128-bit):\n");
cat_uuid_list(out, p, pl, 16);
break;
case 0x08:
case 0x09: break; // name shown in the title
case 0x0A:
if(pl) furi_string_cat_printf(out, "Tx power: %d dBm\n", (int8_t)p[0]);
break;
case 0x16:
if(pl >= 2) {
uint16_t u = p[0] | (p[1] << 8);
const char* n = ble_uuid_name(p, 2);
furi_string_cat_printf(out, "Svc data %04X%s%s: ", u, n ? " " : "", n ? n : "");
ble_hex(out, p + 2, pl - 2);
furi_string_cat_str(out, "\n");
ble_decode_svc_data(out, u, p + 2, pl - 2);
}
break;
case 0x19:
if(pl >= 2) {
uint16_t a = p[0] | (p[1] << 8);
const char* n = ble_appearance_name(a);
furi_string_cat_printf(out, "Appearance: %s (%04X)\n", n ? n : "?", a);
}
break;
case 0xFF:
if(pl >= 2) {
uint16_t c = p[0] | (p[1] << 8);
const char* n = ble_company_name(c);
furi_string_cat_printf(out, "Mfg %04X %s: ", c, n ? n : "?");
ble_hex(out, p + 2, pl - 2);
furi_string_cat_str(out, "\n");
ble_decode_mfg(out, c, p + 2, pl - 2);
}
break;
default:
furi_string_cat_printf(out, "AD %02X: ", t);
ble_hex(out, p, pl);
furi_string_cat_str(out, "\n");
break;
}
i += 1 + l;
}
}
static void build_details(FuriString* out, const BcDevice* d) {
furi_string_printf(out, "%s\n", dev_label(d));
ble_addr_str(out, d->addr);
furi_string_cat_printf(out, " %s\n", d->addr_type ? "random" : "public");
furi_string_cat_printf(
out, "RSSI %d dBm (best %d) %lu pkts\n", d->rssi, d->rssi_max, (unsigned long)d->packets);
furi_string_cat_printf(
out, "%s%s, %lus ago\n", ble_adv_type_name(d->evt_type), d->evt_type <= 1 ? " connectable" : "",
(unsigned long)((furi_get_tick() - d->last_seen) / 1000));
walk_ad(out, d->adv, d->adv_len);
walk_ad(out, d->rsp, d->rsp_len);
furi_string_cat_str(out, "Raw adv: ");
ble_hex(out, d->adv, d->adv_len);
if(d->rsp_len) {
furi_string_cat_str(out, "\nRaw rsp: ");
ble_hex(out, d->rsp, d->rsp_len);
}
}
static void log_device(App* app, const BcDevice* d) {
FuriString* s = furi_string_alloc();
ble_addr_str(s, d->addr);
uint16_t cid = 0;
const char* v = vendor_of(d, &cid);
FuriString* adv = furi_string_alloc();
ble_hex(adv, d->adv, d->adv_len);
app_log(
app, "DEVICE %s %s rssi=%d name=\"%s\" vendor=%s adv=%s", furi_string_get_cstr(s),
ble_adv_type_name(d->evt_type), d->rssi, d->name, v ? v : "?", furi_string_get_cstr(adv));
furi_string_free(adv);
furi_string_free(s);
}
// ---- scan view ---------------------------------------------------------------
static void scan_draw(Canvas* c, void* m_) {
ScanModel* m = m_;
FuriString* s = furi_string_alloc();
canvas_clear(c);
canvas_set_font(c, FontPrimary);
canvas_draw_str(c, 2, 9, "BT Inspector");
canvas_set_font(c, FontSecondary);
if(m->scanning)
furi_string_printf(s, "%u found", (unsigned)m->count);
else
furi_string_printf(s, "err 0x%02X", m->scan_err);
canvas_draw_str_aligned(c, 126, 9, AlignRight, AlignBottom, furi_string_get_cstr(s));
canvas_draw_line(c, 0, 11, 127, 11);
if(!m->count) canvas_draw_str_aligned(c, 64, 40, AlignCenter, AlignCenter, "Scanning...");
for(size_t r = 0; r < ROWS && m->top + r < m->count; r++) {
const BcDevice* d = &m->devs[m->top + r];
int y = 13 + r * 17;
bool sel = (m->top + r == m->sel);
if(sel) {
canvas_draw_rbox(c, 0, y, 122, 17, 2);
canvas_set_color(c, ColorWhite);
}
canvas_set_font(c, FontPrimary);
furi_string_set_str(s, dev_label(d));
elements_string_fit_width(c, s, 90);
canvas_draw_str(c, 3, y + 8, furi_string_get_cstr(s));
canvas_set_font(c, FontSecondary);
furi_string_printf(s, "%d", d->rssi);
canvas_draw_str_aligned(c, 119, y + 8, AlignRight, AlignBottom, furi_string_get_cstr(s));
int bars = d->rssi > -55 ? 4 : d->rssi > -65 ? 3 : d->rssi > -75 ? 2 : d->rssi > -85 ? 1 : 0;
for(int b = 0; b < bars; b++) canvas_draw_box(c, 96 + b * 3, y + 8 - (b + 1) * 2, 2, (b + 1) * 2);
furi_string_reset(s);
ble_addr_str(s, d->addr);
uint16_t cid = 0;
const char* v = vendor_of(d, &cid);
if(v) furi_string_cat_printf(s, " %s", v);
elements_string_fit_width(c, s, 116);
canvas_draw_str(c, 3, y + 16, furi_string_get_cstr(s));
if(sel) canvas_set_color(c, ColorBlack);
}
if(m->count > ROWS) elements_scrollbar_pos(c, 125, 13, 51, m->sel, m->count);
furi_string_free(s);
}
static bool scan_input(InputEvent* e, void* ctx) {
App* app = ctx;
if(e->type != InputTypeShort && e->type != InputTypeRepeat) return false;
if(e->key == InputKeyOk) {
if(e->type == InputTypeShort) view_dispatcher_send_custom_event(app->vd, EvtScanSelect);
return true;
}
if(e->key != InputKeyUp && e->key != InputKeyDown) return false;
with_view_model(
app->scan_view,
ScanModel * m,
{
if(e->key == InputKeyUp && m->sel > 0) m->sel--;
if(e->key == InputKeyDown && m->sel + 1 < m->count) m->sel++;
if(m->sel < m->top) m->top = m->sel;
if(m->sel >= m->top + ROWS) m->top = m->sel - ROWS + 1;
},
true);
return true;
}
static void scan_enter(void* ctx) {
App* app = ctx;
app->cur_view = ViewScan;
bool ok = bc_scan_start(app->bc);
if(!ok) app_log(app, "SCAN START FAILED 0x%02X", bc_last_error(app->bc));
uint8_t err = ok ? 0 : bc_last_error(app->bc);
with_view_model(app->scan_view, ScanModel * m, { m->scanning = ok; m->scan_err = err; }, true);
}
// ---- page view (scrollable text with optional buttons) -----------------------
static void page_draw(Canvas* c, void* m_) {
PageModel* m = m_;
canvas_clear(c);
canvas_set_font(c, FontSecondary);
bool buttons = m->left || m->center || m->right;
size_t visible = buttons ? 5 : 6;
const char* p = furi_string_get_cstr(m->text);
char line[64];
size_t idx = 0, drawn = 0;
while(*p) {
size_t n = 0, last_space = 0;
int w = 0;
while(p[n] && p[n] != '\n' && n < sizeof(line) - 1) {
int cw = canvas_glyph_width(c, p[n]);
if(w + cw > 123 && n > 0) break;
if(p[n] == ' ') last_space = n;
w += cw;
n++;
}
bool mid = p[n] && p[n] != '\n';
if(mid && last_space) n = last_space;
if(idx >= m->scroll && drawn < visible) {
memcpy(line, p, n);
line[n] = 0;
canvas_draw_str(c, 1, 9 + drawn * 10, line);
drawn++;
}
idx++;
p += n;
if(*p == '\n' || (*p == ' ' && mid)) p++;
}
m->max_scroll = idx > visible ? idx - visible : 0;
if(idx > visible) elements_scrollbar_pos(c, 126, 0, buttons ? 50 : 63, m->scroll, idx);
if(m->left) elements_button_left(c, m->left);
if(m->center) elements_button_center(c, m->center);
if(m->right) elements_button_right(c, m->right);
}
static bool page_input(InputEvent* e, void* ctx) {
App* app = ctx;
if(e->type != InputTypeShort && e->type != InputTypeRepeat) return false;
bool consumed = false, send = false;
AppEvent ev = EvtPageCenter;
with_view_model(
app->page_view,
PageModel * m,
{
switch(e->key) {
case InputKeyUp:
if(m->scroll) m->scroll--;
consumed = true;
break;
case InputKeyDown:
if(m->scroll < m->max_scroll) m->scroll++;
consumed = true;
break;
case InputKeyLeft:
send = m->left != NULL;
ev = EvtPageLeft;
break;
case InputKeyOk:
send = m->center != NULL;
break;
case InputKeyRight:
send = m->right != NULL;
ev = EvtPageRight;
break;
default: break;
}
},
consumed);
if(send && e->type == InputTypeShort) {
view_dispatcher_send_custom_event(app->vd, ev);
return true;
}
return consumed;
}
static void page_set(
App* app,
PageKind kind,
FuriString* text,
const char* left,
const char* center,
const char* right,
bool reset_scroll) {
app->page_kind = kind;
with_view_model(
app->page_view,
PageModel * m,
{
furi_string_set(m->text, text);
if(reset_scroll) m->scroll = 0;
m->left = left;
m->center = center;
m->right = right;
},
true);
}
static void show_view(App* app, ViewId id) {
app->cur_view = id;
view_dispatcher_switch_to_view(app->vd, id);
}
static void show_details(App* app, bool reset_scroll) {
FuriString* s = furi_string_alloc();
build_details(s, &app->dev);
page_set(app, PageDetails, s, NULL, app->dev.evt_type <= 1 ? "Connect" : NULL, NULL, reset_scroll);
furi_string_free(s);
}
static void props_str(FuriString* out, uint8_t p) {
if(p & CHAR_PROP_READ) furi_string_cat_str(out, "R");
if(p & CHAR_PROP_WRITE) furi_string_cat_str(out, "W");
if(p & CHAR_PROP_WRITE_WITHOUT_RESP) furi_string_cat_str(out, "w");
if(p & CHAR_PROP_NOTIFY) furi_string_cat_str(out, "N");
if(p & CHAR_PROP_INDICATE) furi_string_cat_str(out, "I");
if(p & CHAR_PROP_BROADCAST) furi_string_cat_str(out, "B");
}
static void show_char(App* app, bool reset_scroll) {
BcChar* c = &app->chars[app->chr_sel];
FuriString* s = furi_string_alloc();
FuriString* t = furi_string_alloc();
char_label(t, c);
furi_string_printf(s, "%s\nUUID ", furi_string_get_cstr(t));
ble_uuid_str(s, c->uuid, c->uuid_len);
furi_string_cat_printf(s, "\nHandle 0x%04X Props ", c->value_handle);
props_str(s, c->props);
furi_string_cat_printf(s, "\n%s\n", furi_string_get_cstr(app->status));
if(app->value_len >= 0 && (app->value_len > 0 || app->hist_count)) {
furi_string_cat_printf(s, "Value (%d B): ", app->value_len);
ble_hex(s, app->value, app->value_len);
furi_string_cat_str(s, "\n");
ble_decode_value(s, c->uuid, c->uuid_len, app->value, app->value_len);
}
if(app->hist_count) furi_string_cat_str(s, "History:\n");
for(int i = app->hist_count - 1; i >= 0; i--) {
furi_string_cat_printf(s, "%s ", app->hist[i].time);
ble_hex(s, app->hist[i].data, app->hist[i].len);
furi_string_cat_str(s, "\n");
}
bool can_sub = c->props & (CHAR_PROP_NOTIFY | CHAR_PROP_INDICATE);
bool can_write = c->props & (CHAR_PROP_WRITE | CHAR_PROP_WRITE_WITHOUT_RESP);
page_set(
app, PageChar, s, can_sub ? (app->notify_on ? "Unsub" : "Notify") : NULL,
(c->props & CHAR_PROP_READ) ? "Read" : NULL, can_write ? "Write" : NULL, reset_scroll);
furi_string_free(t);
furi_string_free(s);
}
static void hist_push(App* app, const uint8_t* d, uint8_t len) {
if(app->hist_count == HIST_MAX) {
memmove(app->hist, app->hist + 1, sizeof(HistEntry) * (HIST_MAX - 1));
app->hist_count--;
}
HistEntry* h = &app->hist[app->hist_count++];
now_str(h->time, sizeof(h->time));
h->len = len > HIST_DATA ? HIST_DATA : len;
memcpy(h->data, d, h->len);
}
static void popup_cb(void* ctx);
static void number_input_cb(void* ctx, int32_t number);
static void show_popup(App* app, const char* header, const char* text, bool timeout) {
popup_reset(app->popup); // clears callback/context too
popup_set_callback(app->popup, popup_cb);
popup_set_context(app->popup, app);
popup_set_header(app->popup, header, 64, 18, AlignCenter, AlignCenter);
popup_set_text(app->popup, text, 64, 40, AlignCenter, AlignCenter);
if(timeout) {
popup_set_timeout(app->popup, 1800);
popup_enable_timeout(app->popup);
} else {
popup_disable_timeout(app->popup);
}
show_view(app, ViewPopup);
}
static void post_job(App* app, JobType job) {
furi_message_queue_put(app->jobs, &job, 0);
}
static const char* err_name(App* app) {
const char* n = ble_att_error_name(bc_last_error(app->bc));
return n ? n : "error";
}
// ---- worker thread -----------------------------------------------------------
static uint16_t char_end_handle(App* app) {
if(app->chr_sel + 1 < app->chr_count) return app->chars[app->chr_sel + 1].decl_handle - 1;
return app->services[app->svc_sel].end;
}
// If the device advertised no name, read the GAP Device Name (0x2A00) over the
// connection so the device page and service list show its real name, not just
// the vendor. Runs on the worker thread right after service discovery.
static void resolve_name(App* app) {
if(app->dev.name[0]) return; // already have an advertised name
for(int i = 0; i < app->svc_count; i++) {
BcService* s = &app->services[i];
if(!(s->uuid_len == 2 && (s->uuid[0] | (s->uuid[1] << 8)) == 0x1800)) continue; // GAP
BcChar tmp[8];
int n = bc_discover_chars(app->bc, s, tmp, COUNT_OF(tmp));
for(int k = 0; k < n; k++) {
uint16_t u = tmp[k].uuid_len == 2 ? (tmp[k].uuid[0] | (tmp[k].uuid[1] << 8)) : 0;
if(u != 0x2A00 || !(tmp[k].props & CHAR_PROP_READ)) continue;
uint8_t val[BC_NAME_MAX + 1];
int len = bc_read(app->bc, tmp[k].value_handle, val, BC_NAME_MAX);
if(len > 0) {
memcpy(app->dev.name, val, len);
app->dev.name[len] = 0;
app_log(app, "NAME resolved: %s", app->dev.name);
}
return;
}
return; // found GAP but no readable name
}
}
static void do_info(App* app) {
furi_string_printf(app->info_text, "%s\nMTU %u\n", dev_label(&app->dev), bc_mtu(app->bc));
FuriString* label = furi_string_alloc();
FuriString* dec = furi_string_alloc();
bool any = false;
for(int i = 0; i < app->svc_count; i++) {
BcService* s = &app->services[i];
if(s->uuid_len != 2) continue;
uint16_t u = s->uuid[0] | (s->uuid[1] << 8);
if(u != 0x1800 && u != 0x180A && u != 0x180F) continue;
BcChar tmp[12];
int n = bc_discover_chars(app->bc, s, tmp, COUNT_OF(tmp));
for(int k = 0; k < n; k++) {
if(!(tmp[k].props & CHAR_PROP_READ)) continue;
char_label(label, &tmp[k]);
int len = bc_read(app->bc, tmp[k].value_handle, app->value, BC_VALUE_MAX);
furi_string_reset(dec);
if(len < 0) {
furi_string_set_str(dec, err_name(app));
} else {
ble_decode_value(dec, tmp[k].uuid, tmp[k].uuid_len, app->value, len);
size_t nl = furi_string_search_char(dec, '\n', 0);
if(nl != FURI_STRING_FAILURE) furi_string_left(dec, nl);
if(furi_string_empty(dec)) ble_hex(dec, app->value, len);
}
furi_string_cat_printf(
app->info_text, "%s: %s\n", furi_string_get_cstr(label), furi_string_get_cstr(dec));
app_log(app, "INFO %s = %s", furi_string_get_cstr(label), furi_string_get_cstr(dec));
any = true;
}
}
if(!any) furi_string_cat_str(app->info_text, "No GAP / Device Info / Battery service");
furi_string_free(dec);
furi_string_free(label);
}
static int32_t worker_thread(void* ctx) {
App* app = ctx;
JobType job;
while(furi_message_queue_get(app->jobs, &job, FuriWaitForever) == FuriStatusOk) {
if(job == JobQuit) break;
bool ok = false;
switch(job) {
case JobConnect: ok = bc_connect(app->bc, &app->dev, 8000); break;
case JobServices: {
int n = bc_discover_services(app->bc, app->services, BC_MAX_SERVICES);
app->svc_count = n < 0 ? 0 : n;
ok = n >= 0;
if(ok) resolve_name(app);
} break;
case JobChars: {
int n = bc_discover_chars(app->bc, &app->services[app->svc_sel], app->chars, BC_MAX_CHARS);
app->chr_count = n < 0 ? 0 : n;
ok = n >= 0;
} break;
case JobRead: {
int n = bc_read(app->bc, app->chars[app->chr_sel].value_handle, app->value, BC_VALUE_MAX);
app->value_len = n < 0 ? 0 : n;
ok = n >= 0;
} break;
case JobWrite: {
BcChar* c = &app->chars[app->chr_sel];
ok = bc_write(
app->bc, c->value_handle, app->write_buf, app->write_len, c->props & CHAR_PROP_WRITE);
} break;
case JobNotify:
ok = bc_set_notify(app->bc, &app->chars[app->chr_sel], char_end_handle(app), !app->notify_on);
if(ok) app->notify_on = !app->notify_on;
break;
case JobInfo:
do_info(app);
ok = true;
break;
case JobDisconnect:
bc_disconnect(app->bc);
ok = true;
break;
default: break;
}
app->job = job;
app->job_ok = ok;
view_dispatcher_send_custom_event(app->vd, EvtJobDone);
}
return 0;
}
static void notify_cb(uint16_t handle, const uint8_t* data, uint8_t len, void* ctx) {
App* app = ctx;
NotifyMsg m = {.handle = handle, .len = len > HIST_DATA ? HIST_DATA : len};
memcpy(m.data, data, m.len);
if(furi_message_queue_put(app->notify_q, &m, 0) == FuriStatusOk)
view_dispatcher_send_custom_event(app->vd, EvtNotify);
}
// ---- menus & inputs ----------------------------------------------------------
static void services_cb(void* ctx, uint32_t index);
static void chars_cb(void* ctx, uint32_t index);
static void fill_services_menu(App* app) {
submenu_reset(app->services_menu);
submenu_set_header(app->services_menu, dev_label(&app->dev));
submenu_add_item(app->services_menu, "* Read device info", INFO_ITEM, services_cb, app);
FuriString* s = furi_string_alloc();
for(int i = 0; i < app->svc_count; i++) {
BcService* sv = &app->services[i];
const char* n = ble_uuid_name(sv->uuid, sv->uuid_len);
furi_string_reset(s);
if(n) {
furi_string_set_str(s, n);
} else {
ble_uuid_str(s, sv->uuid, sv->uuid_len);
}
submenu_add_item(app->services_menu, furi_string_get_cstr(s), i, services_cb, app);
FuriString* u = furi_string_alloc();
ble_uuid_str(u, sv->uuid, sv->uuid_len);
app_log(
app, "SERVICE %s %s handles 0x%04X-0x%04X", furi_string_get_cstr(u), n ? n : "",
sv->start, sv->end);
furi_string_free(u);
}
furi_string_free(s);
}
static void fill_chars_menu(App* app) {
submenu_reset(app->chars_menu);
BcService* sv = &app->services[app->svc_sel];
const char* sn = ble_uuid_name(sv->uuid, sv->uuid_len);
submenu_set_header(app->chars_menu, sn ? sn : "Characteristics");
FuriString* s = furi_string_alloc();
FuriString* u = furi_string_alloc();
for(int i = 0; i < app->chr_count; i++) {
BcChar* c = &app->chars[i];
char_label(s, c);
furi_string_cat_str(s, " [");
props_str(s, c->props);
furi_string_cat_str(s, "]");
submenu_add_item(app->chars_menu, furi_string_get_cstr(s), i, chars_cb, app);
furi_string_reset(u);
ble_uuid_str(u, c->uuid, c->uuid_len);
app_log(app, "CHAR %s handle 0x%04X props 0x%02X", furi_string_get_cstr(u), c->value_handle, c->props);
}
furi_string_free(u);
furi_string_free(s);
}
static void services_cb(void* ctx, uint32_t index) {
App* app = ctx;
if(index == INFO_ITEM) {
show_popup(app, "Reading info...", "", false);
post_job(app, JobInfo);
return;
}
app->svc_sel = index;
show_popup(app, "Discovering...", "characteristics", false);
post_job(app, JobChars);
}
static void chars_cb(void* ctx, uint32_t index) {
App* app = ctx;
app->chr_sel = index;
app->hist_count = 0;
app->value_len = 0;
app->notify_on = false;
bool readable = app->chars[index].props & CHAR_PROP_READ;
furi_string_set_str(app->status, readable ? "Reading..." : "");
show_char(app, true);
show_view(app, ViewPage);
if(readable) post_job(app, JobRead);
}
static void write_menu_cb(void* ctx, uint32_t index) {
App* app = ctx;
app->write_mode = index;
if(index == 2) {
number_input_set_header_text(app->number_input, "Value (LE int)");
number_input_set_result_callback(app->number_input, number_input_cb, app, 0, INT32_MIN, INT32_MAX);
show_view(app, ViewNumberInput);
return;
}
app->text_buf[0] = 0;
text_input_set_header_text(app->text_input, index == 0 ? "Text value" : "Hex bytes (e.g. 01FF)");
text_input_set_minimum_length(app->text_input, 1);
show_view(app, ViewTextInput);
}
static int hexval(char c) {
if(c >= '0' && c <= '9') return c - '0';
if(c >= 'a' && c <= 'f') return c - 'a' + 10;
if(c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
}
static void start_write(App* app) {
furi_string_set_str(app->status, "Writing...");
show_char(app, false);
show_view(app, ViewPage);
post_job(app, JobWrite);
}
static void text_input_cb(void* ctx) {
App* app = ctx;
if(app->write_mode == 0) {
app->write_len = strlen(app->text_buf);
memcpy(app->write_buf, app->text_buf, app->write_len);
} else {
int n = 0, hi = -1;
for(char* p = app->text_buf; *p; p++) {
if(*p == ' ' || *p == ':' || *p == ',') continue;
int v = hexval(*p);
if(v < 0) {
n = -1;
break;
}
if(hi < 0) {
hi = v;
} else {
app->write_buf[n++] = (hi << 4) | v;
hi = -1;
}
}
if(n <= 0 || hi >= 0) {
furi_string_set_str(app->status, "Bad hex, nothing written");
show_char(app, false);
show_view(app, ViewPage);
return;
}
app->write_len = n;
}
start_write(app);
}
static void number_input_cb(void* ctx, int32_t number) {
App* app = ctx;
int len = app->value_len == 1 || app->value_len == 2 || app->value_len == 4 ? app->value_len :
(number >= -128 && number <= 255) ? 1 :
(number >= -32768 && number <= 65535) ? 2 :
4;
uint32_t v = (uint32_t)number;
for(int i = 0; i < len; i++) app->write_buf[i] = (v >> (8 * i)) & 0xFF;
app->write_len = len;
start_write(app);
}
static void popup_cb(void* ctx) {
App* app = ctx;
view_dispatcher_send_custom_event(app->vd, EvtPopupDone);
}
// ---- navigation --------------------------------------------------------------
static uint32_t nav_exit(void* ctx) {
UNUSED(ctx);
return VIEW_NONE;
}
static uint32_t nav_stay_popup(void* ctx) {
UNUSED(ctx);
// Back on a connect/discover popup: abort and return to the scan list.
nav_app->cancel = true;
nav_app->cur_view = ViewScan;
return ViewScan;
}
static uint32_t nav_page_back(void* ctx) {
UNUSED(ctx);
App* app = nav_app;
switch(app->page_kind) {
case PageDetails: app->cur_view = ViewScan; return ViewScan;
case PageChar: app->cur_view = ViewChars; return ViewChars;
case PageInfo: app->cur_view = ViewServices; return ViewServices;
default: return VIEW_NONE;
}
}
static uint32_t nav_services_back(void* ctx) {
UNUSED(ctx);
App* app = nav_app;
app_log(app, "DISCONNECT");
post_job(app, JobDisconnect);
show_details(app, true);
app->cur_view = ViewPage;
return ViewPage;
}
static uint32_t nav_to_services(void* ctx) {
UNUSED(ctx);
nav_app->cur_view = ViewServices;
return ViewServices;
}
static uint32_t nav_to_page(void* ctx) {
UNUSED(ctx);
nav_app->cur_view = ViewPage;
return ViewPage;
}
// ---- event handling ----------------------------------------------------------
static void on_tick(App* app) {
with_view_model(
app->scan_view,
ScanModel * m,
{
uint8_t sel_addr[6];
bool had = m->count > 0;
if(had) memcpy(sel_addr, m->devs[m->sel].addr, 6);
m->count = bc_snapshot(app->bc, m->devs, BC_MAX_DEVICES);
for(size_t i = 0; i < m->count; i++)
if(!m->devs[i].logged) log_device(app, &m->devs[i]);
if(had)
for(size_t i = 0; i < m->count; i++)
if(memcmp(m->devs[i].addr, sel_addr, 6) == 0) m->sel = i;
if(m->sel >= m->count) m->sel = m->count ? m->count - 1 : 0;
if(m->sel < m->top) m->top = m->sel;
if(m->sel >= m->top + ROWS) m->top = m->sel - ROWS + 1;
},
app->cur_view == ViewScan);
if(app->cur_view == ViewPage && app->page_kind == PageDetails && !bc_is_connected(app->bc)) {
if(bc_get_device(app->bc, app->dev.addr, &app->dev)) show_details(app, false);
}
}
static void on_job_done(App* app) {
if(app->cancel) { // user backed out of the connect/discover popup
app->cancel = false;
if(app->job != JobDisconnect) {
if(bc_is_connected(app->bc))
post_job(app, JobDisconnect);
else
bc_scan_start(app->bc);
}
return;
}
switch(app->job) {
case JobConnect:
if(app->job_ok) {
app_log(app, "CONNECTED %s mtu=%u", dev_label(&app->dev), bc_mtu(app->bc));
popup_set_header(app->popup, "Discovering...", 64, 18, AlignCenter, AlignCenter);
popup_set_text(app->popup, "services", 64, 40, AlignCenter, AlignCenter);
post_job(app, JobServices);
} else {
app_log(app, "CONNECT FAILED %s: %s", dev_label(&app->dev), err_name(app));
app->after_popup = AfterPopupDetails;
show_popup(app, "Connect failed", err_name(app), true);
}
break;
case JobServices:
if(app->job_ok) {
fill_services_menu(app);
show_view(app, ViewServices);
} else {
app->after_popup = AfterPopupDisconnect;
show_popup(app, "Discovery failed", err_name(app), true);
}
break;
case JobChars:
if(app->job_ok) {
fill_chars_menu(app);
show_view(app, ViewChars);
} else {
app->after_popup = AfterPopupServices;
show_popup(app, "Discovery failed", err_name(app), true);
}
break;
case JobRead: {
FuriString* h = furi_string_alloc();
ble_hex(h, app->value, app->value_len);
if(app->job_ok) {
furi_string_printf(app->status, "Read OK (%d B)", app->value_len);
hist_push(app, app->value, app->value_len);
app_log(app, "READ 0x%04X = %s", app->chars[app->chr_sel].value_handle, furi_string_get_cstr(h));
} else {
furi_string_printf(app->status, "Read failed: %s", err_name(app));
app_log(app, "READ 0x%04X failed: %s", app->chars[app->chr_sel].value_handle, err_name(app));
}
furi_string_free(h);
if(app->cur_view == ViewPage && app->page_kind == PageChar) show_char(app, false);
} break;
case JobWrite: {
FuriString* h = furi_string_alloc();
ble_hex(h, app->write_buf, app->write_len);
if(app->job_ok) {
furi_string_printf(app->status, "Write OK (%u B)", app->write_len);
app_log(app, "WRITE 0x%04X = %s", app->chars[app->chr_sel].value_handle, furi_string_get_cstr(h));
if(app->chars[app->chr_sel].props & CHAR_PROP_READ) post_job(app, JobRead);
} else {
furi_string_printf(app->status, "Write failed: %s", err_name(app));
app_log(app, "WRITE 0x%04X failed: %s", app->chars[app->chr_sel].value_handle, err_name(app));
}
furi_string_free(h);
if(app->cur_view == ViewPage && app->page_kind == PageChar) show_char(app, false);
} break;
case JobNotify:
if(app->job_ok) {
furi_string_set_str(app->status, app->notify_on ? "Notifications on" : "Notifications off");
} else {
furi_string_printf(app->status, "Subscribe failed: %s", err_name(app));
}
app_log(app, "NOTIFY 0x%04X %s", app->chars[app->chr_sel].value_handle, furi_string_get_cstr(app->status));
if(app->cur_view == ViewPage && app->page_kind == PageChar) show_char(app, false);
break;
case JobInfo:
page_set(app, PageInfo, app->info_text, NULL, NULL, NULL, true);
show_view(app, ViewPage);
break;
case JobDisconnect:
app_log(app, "DISCONNECTED");
if(app->cur_view == ViewPage || app->cur_view == ViewScan) bc_scan_start(app->bc);
break;
default: break;
}
}
static void on_notify(App* app) {
NotifyMsg m;
while(furi_message_queue_get(app->notify_q, &m, 0) == FuriStatusOk) {
FuriString* h = furi_string_alloc();
ble_hex(h, m.data, m.len);
app_log(app, "NOTIFY 0x%04X = %s", m.handle, furi_string_get_cstr(h));
furi_string_free(h);
if(app->chr_sel >= 0 && app->chr_sel < app->chr_count &&
app->chars[app->chr_sel].value_handle == m.handle) {
memcpy(app->value, m.data, m.len);
app->value_len = m.len;
hist_push(app, m.data, m.len);
furi_string_set_str(app->status, "Notification");
if(app->cur_view == ViewPage && app->page_kind == PageChar) show_char(app, false);
}
}
}
static bool custom_event_cb(void* ctx, uint32_t event) {
App* app = ctx;
switch(event) {
case EvtTick: on_tick(app); break;
case EvtScanSelect: {
bool have = false;
with_view_model(
app->scan_view,
ScanModel * m,
{
if(m->count) {
app->dev = m->devs[m->sel];
have = true;
}
},
false);
if(have) {
show_details(app, true);
show_view(app, ViewPage);
}
} break;
case EvtPageCenter:
if(app->page_kind == PageDetails) {
app_log(app, "CONNECTING %s", dev_label(&app->dev));
app->cancel = false;
show_popup(app, "Connecting...", dev_label(&app->dev), false);
post_job(app, JobConnect);
} else if(app->page_kind == PageChar) {
furi_string_set_str(app->status, "Reading...");
show_char(app, false);
post_job(app, JobRead);
}
break;
case EvtPageLeft:
if(app->page_kind == PageChar) {
furi_string_set_str(app->status, app->notify_on ? "Unsubscribing..." : "Subscribing...");
show_char(app, false);
post_job(app, JobNotify);
}
break;
case EvtPageRight:
if(app->page_kind == PageChar) show_view(app, ViewWriteMenu);
break;
case EvtJobDone: on_job_done(app); break;
case EvtNotify: on_notify(app); break;
case EvtPopupDone:
if(app->cancel) {
app->cancel = false;
bc_scan_start(app->bc);
show_view(app, ViewScan);
break;
}
if(app->after_popup == AfterPopupServices) {
show_view(app, ViewServices);
} else {
if(app->after_popup == AfterPopupDisconnect) post_job(app, JobDisconnect);
bc_scan_start(app->bc);
show_details(app, true);
show_view(app, ViewPage);
}
break;
default: return false;
}
return true;
}
static void timer_cb(void* ctx) {
App* app = ctx;
view_dispatcher_send_custom_event(app->vd, EvtTick);
}
// ---- setup / teardown --------------------------------------------------------
static App* app_alloc(void) {
App* app = malloc(sizeof(App));
memset(app, 0, sizeof(App));
nav_app = app;
app->gui = furi_record_open(RECORD_GUI);
app->storage = furi_record_open(RECORD_STORAGE);
app->status = furi_string_alloc();
app->info_text = furi_string_alloc();
app->chr_sel = -1;
app->jobs = furi_message_queue_alloc(8, sizeof(JobType));
app->notify_q = furi_message_queue_alloc(8, sizeof(NotifyMsg));
app->vd = view_dispatcher_alloc();
view_dispatcher_set_event_callback_context(app->vd, app);
view_dispatcher_set_custom_event_callback(app->vd, custom_event_cb);
view_dispatcher_attach_to_gui(app->vd, app->gui, ViewDispatcherTypeFullscreen);
app->scan_view = view_alloc();
view_allocate_model(app->scan_view, ViewModelTypeLocking, sizeof(ScanModel));
view_set_context(app->scan_view, app);
view_set_draw_callback(app->scan_view, scan_draw);
view_set_input_callback(app->scan_view, scan_input);
view_set_enter_callback(app->scan_view, scan_enter);
view_set_previous_callback(app->scan_view, nav_exit);
view_dispatcher_add_view(app->vd, ViewScan, app->scan_view);
app->page_view = view_alloc();
view_allocate_model(app->page_view, ViewModelTypeLocking, sizeof(PageModel));
with_view_model(app->page_view, PageModel * m, { m->text = furi_string_alloc(); }, false);
view_set_context(app->page_view, app);
view_set_draw_callback(app->page_view, page_draw);
view_set_input_callback(app->page_view, page_input);
view_set_previous_callback(app->page_view, nav_page_back);
view_dispatcher_add_view(app->vd, ViewPage, app->page_view);
app->popup = popup_alloc();
popup_set_callback(app->popup, popup_cb);
popup_set_context(app->popup, app);
view_set_previous_callback(popup_get_view(app->popup), nav_stay_popup);
view_dispatcher_add_view(app->vd, ViewPopup, popup_get_view(app->popup));
app->services_menu = submenu_alloc();
view_set_previous_callback(submenu_get_view(app->services_menu), nav_services_back);
view_dispatcher_add_view(app->vd, ViewServices, submenu_get_view(app->services_menu));
app->chars_menu = submenu_alloc();
view_set_previous_callback(submenu_get_view(app->chars_menu), nav_to_services);
view_dispatcher_add_view(app->vd, ViewChars, submenu_get_view(app->chars_menu));
app->write_menu = submenu_alloc();
submenu_set_header(app->write_menu, "Write value as");
submenu_add_item(app->write_menu, "Text", 0, write_menu_cb, app);
submenu_add_item(app->write_menu, "Hex bytes", 1, write_menu_cb, app);
submenu_add_item(app->write_menu, "Number", 2, write_menu_cb, app);
view_set_previous_callback(submenu_get_view(app->write_menu), nav_to_page);
view_dispatcher_add_view(app->vd, ViewWriteMenu, submenu_get_view(app->write_menu));
app->text_input = text_input_alloc();
text_input_set_result_callback(
app->text_input, text_input_cb, app, app->text_buf, sizeof(app->text_buf), true);
view_set_previous_callback(text_input_get_view(app->text_input), nav_to_page);
view_dispatcher_add_view(app->vd, ViewTextInput, text_input_get_view(app->text_input));
app->number_input = number_input_alloc();
number_input_set_result_callback(app->number_input, number_input_cb, app, 0, INT32_MIN, INT32_MAX);
view_set_previous_callback(number_input_get_view(app->number_input), nav_to_page);
view_dispatcher_add_view(app->vd, ViewNumberInput, number_input_get_view(app->number_input));
app->timer = furi_timer_alloc(timer_cb, FuriTimerTypePeriodic, app);
return app;
}
static void app_free(App* app) {
furi_timer_free(app->timer);
view_dispatcher_remove_view(app->vd, ViewScan);
view_dispatcher_remove_view(app->vd, ViewPage);
view_dispatcher_remove_view(app->vd, ViewPopup);
view_dispatcher_remove_view(app->vd, ViewServices);
view_dispatcher_remove_view(app->vd, ViewChars);
view_dispatcher_remove_view(app->vd, ViewWriteMenu);
view_dispatcher_remove_view(app->vd, ViewTextInput);
view_dispatcher_remove_view(app->vd, ViewNumberInput);
with_view_model(app->page_view, PageModel * m, { furi_string_free(m->text); }, false);
view_free(app->scan_view);
view_free(app->page_view);
popup_free(app->popup);
submenu_free(app->services_menu);
submenu_free(app->chars_menu);
submenu_free(app->write_menu);
text_input_free(app->text_input);
number_input_free(app->number_input);
view_dispatcher_free(app->vd);
furi_message_queue_free(app->jobs);
furi_message_queue_free(app->notify_q);
furi_string_free(app->status);
furi_string_free(app->info_text);
furi_record_close(RECORD_STORAGE);
furi_record_close(RECORD_GUI);
free(app);
}
int32_t bt_inspector_app(void* p) {
UNUSED(p);
App* app = app_alloc();
if(!bc_supported()) {
FuriString* s = furi_string_alloc_set_str(
"Radio stack has no scanning\n"
"This Flipper runs the ST \"BLE Light\" stack (peripheral only). "
"BT Inspector needs firmware built with COPRO_STACK_TYPE=ble_full. "
"See build.sh in the app source.");
page_set(app, PageError, s, NULL, NULL, NULL, true);
furi_string_free(s);
show_view(app, ViewPage);
view_dispatcher_run(app->vd);
app_free(app);
return 0;
}
log_open(app);
app->was_advertising = furi_hal_bt_is_active();
furi_hal_bt_stop_advertising(); // also drops any phone connection; restored on exit
furi_delay_ms(100); // let the resulting disconnect event drain before hooking the dispatcher
app->bc = bc_alloc(notify_cb, app);
app_log(app, "START stack=full advertising_was=%d", app->was_advertising);
app->worker = furi_thread_alloc_ex("BtInspWorker", 3072, worker_thread, app);
furi_thread_start(app->worker);
furi_timer_start(app->timer, furi_ms_to_ticks(300));
show_view(app, ViewScan); // enter callback starts the scan
view_dispatcher_run(app->vd);
furi_timer_stop(app->timer);
post_job(app, JobQuit);
furi_thread_join(app->worker);
furi_thread_free(app->worker);
bc_free(app->bc); // stops scan, disconnects, unregisters the event handler
if(app->was_advertising) furi_hal_bt_start_advertising();
app_log(app, "STOP");
if(app->log) {
storage_file_close(app->log);
storage_file_free(app->log);
}
app_free(app);
return 0;
}