BT Inspector, BLE HID Host and Sensor Dashboard showed a "Connecting..." / "Discovering..." popup whose Back handler ignored the key, so if a connect or service discovery ran long the user was stuck until it timed out. Back on that popup now sets a cancel flag and returns to the scan list immediately; the worker thread notices the flag when its current operation finishes, drops the half-made connection and resumes scanning, and the completion handler ignores the cancelled result instead of jumping into the device/readings view. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LEaD4ZeAfvWLBUxXp8G11r
1251 lines
44 KiB
C
1251 lines
44 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;
|
|
}
|
|
|
|
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;
|
|
} 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;
|
|
}
|