Add BLE Sensor Dashboard app

Connects to a selected peripheral, finds readable/notifying characteristics with known SIG meanings, and shows them decoded and updating live. Reuses libble (central + value decoders).
This commit is contained in:
2026-08-11 01:31:47 +01:00
parent 7c0e3894e1
commit e333307a96
4 changed files with 474 additions and 1 deletions
+15
View File
@@ -0,0 +1,15 @@
# Needs firmware from ../build.sh (full BLE stack). Staged with libble/.
App(
appid="ble_sensor",
name="BLE Sensor Dashboard",
apptype=FlipperAppType.EXTERNAL,
entry_point="ble_sensor_app",
requires=["gui", "storage"],
stack_size=4 * 1024,
fap_category="Bluetooth",
fap_icon="icon.png",
fap_version="1.0",
fap_description="Connect a BLE sensor and watch its readings decoded live",
fap_author="andydixon",
fap_weburl="https://github.com/andydixon/flipper-bt-inspector",
)
+458
View File
@@ -0,0 +1,458 @@
// BLE Sensor Dashboard: connect to a selected BLE peripheral, find its readable
// and notifying characteristics with known SIG meanings (battery, heart rate,
// temperature, humidity, pressure, Tx power, ...), and show them decoded and
// updating live. Reuses the libble central/GATT layer and value decoders.
#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/popup.h>
#include <ble/core/ble_defs.h>
#include <string.h>
#include "ble_central.h"
#include "ble_names.h"
#define TAG "BleSensor"
#define ROWS 3
#define MONITOR_MAX 12
#define VAL_MAX 32
typedef enum { ViewScan, ViewReadings, ViewPopup } ViewId;
typedef enum { EvtTick, EvtSelect, EvtNotify, EvtJobDone, EvtPopupDone } AppEvent;
typedef enum { JobConnect, JobDisconnect, JobQuit } JobType;
typedef struct {
BcDevice devs[BC_MAX_DEVICES];
size_t count, sel, top;
} ScanModel;
typedef struct {
uint16_t handle;
uint8_t uuid[16];
uint8_t uuid_len;
char name[24];
uint8_t val[VAL_MAX];
uint8_t val_len;
bool have;
} Reading;
typedef struct {
char header[40];
Reading r[MONITOR_MAX];
int count;
size_t scroll;
} ReadingsModel;
typedef struct {
uint16_t handle;
uint8_t len;
uint8_t data[VAL_MAX];
} NotifyMsg;
typedef struct {
Gui* gui;
ViewDispatcher* vd;
View* scan_view;
View* readings_view;
Popup* popup;
FuriTimer* timer;
BleCentral* bc;
FuriThread* worker;
FuriMessageQueue* jobs;
FuriMessageQueue* notify_q;
ViewId cur;
BcDevice dev;
BcService services[BC_MAX_SERVICES];
BcChar chars[BC_MAX_CHARS];
bool connected_ok;
char setup_msg[40];
} App;
static const char* dev_label(const BcDevice* d) {
return d->name[0] ? d->name : "(no name)";
}
// ---- worker: connect + collect readable/notify known sensor chars ----------
static uint16_t char_end(App* app, int svc, int idx, int nch) {
if(idx + 1 < nch) return app->chars[idx + 1].decl_handle - 1;
return app->services[svc].end;
}
static bool sensor_setup(App* app) {
int nsvc = bc_discover_services(app->bc, app->services, BC_MAX_SERVICES);
if(nsvc < 0) {
strncpy(app->setup_msg, "discovery failed", sizeof(app->setup_msg) - 1);
return false;
}
with_view_model(
app->readings_view, ReadingsModel * m, { m->count = 0; m->scroll = 0; }, false);
int found = 0;
for(int s = 0; s < nsvc; s++) {
int nch = bc_discover_chars(app->bc, &app->services[s], app->chars, BC_MAX_CHARS);
for(int i = 0; i < nch && found < MONITOR_MAX; i++) {
BcChar* c = &app->chars[i];
const char* name = ble_uuid_name(c->uuid, c->uuid_len);
if(!name) continue; // only known SIG characteristics
bool readable = c->props & CHAR_PROP_READ;
bool notif = c->props & (CHAR_PROP_NOTIFY | CHAR_PROP_INDICATE);
if(!readable && !notif) continue;
Reading rd;
memset(&rd, 0, sizeof(rd));
rd.handle = c->value_handle;
rd.uuid_len = c->uuid_len;
memcpy(rd.uuid, c->uuid, c->uuid_len);
strncpy(rd.name, name, sizeof(rd.name) - 1);
if(readable) {
int n = bc_read(app->bc, c->value_handle, rd.val, VAL_MAX);
if(n >= 0) {
rd.val_len = n;
rd.have = true;
}
}
if(notif) bc_set_notify(app->bc, c, char_end(app, s, i, nch), true);
with_view_model(
app->readings_view,
ReadingsModel * m,
{
if(m->count < MONITOR_MAX) m->r[m->count++] = rd;
},
false);
found++;
}
}
if(!found) {
strncpy(app->setup_msg, "no known sensor chars", sizeof(app->setup_msg) - 1);
return false;
}
snprintf(app->setup_msg, sizeof(app->setup_msg), "%d reading(s)", found);
return true;
}
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;
if(job == JobConnect)
app->connected_ok = bc_connect(app->bc, &app->dev, 8000) && sensor_setup(app);
else if(job == JobDisconnect) {
bc_disconnect(app->bc);
bc_scan_start(app->bc);
}
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 > VAL_MAX ? VAL_MAX : 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);
}
// ---- scan view --------------------------------------------------------------
static const char* vendor_of(const BcDevice* d) {
uint8_t l = 0;
const uint8_t* mm = bc_adv_find(d->adv, d->adv_len, 0xFF, &l);
if(!mm) mm = bc_adv_find(d->rsp, d->rsp_len, 0xFF, &l);
if(!mm || l < 2) return NULL;
return ble_company_name(mm[0] | (mm[1] << 8));
}
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, "Sensor Dashboard");
canvas_set_font(c, FontSecondary);
furi_string_printf(s, "%u", (unsigned)m->count);
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));
furi_string_reset(s);
ble_addr_str(s, d->addr);
const char* v = vendor_of(d);
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 && e->type == InputTypeShort) {
view_dispatcher_send_custom_event(app->vd, EvtSelect);
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 = ViewScan;
bc_scan_start(app->bc);
}
// ---- readings view ----------------------------------------------------------
static void readings_draw(Canvas* c, void* m_) {
ReadingsModel* m = m_;
FuriString* s = furi_string_alloc();
canvas_clear(c);
canvas_set_font(c, FontPrimary);
canvas_draw_str(c, 2, 9, m->header[0] ? m->header : "Readings");
canvas_set_font(c, FontSecondary);
canvas_draw_line(c, 0, 11, 127, 11);
int visible = 5;
for(int r = 0; r < visible; r++) {
int idx = (int)m->scroll + r;
if(idx >= m->count) break;
Reading* rd = &m->r[idx];
furi_string_printf(s, "%s: ", rd->name);
if(rd->have)
ble_decode_value(s, rd->uuid, rd->uuid_len, rd->val, rd->val_len);
else
furi_string_cat_str(s, "...\n");
size_t nl = furi_string_search_char(s, '\n', 0);
if(nl != FURI_STRING_FAILURE) furi_string_left(s, nl);
elements_string_fit_width(c, s, 124);
canvas_draw_str(c, 2, 22 + r * 10, furi_string_get_cstr(s));
}
if(m->count > visible) elements_scrollbar_pos(c, 126, 12, 52, m->scroll, m->count);
furi_string_free(s);
}
static bool readings_input(InputEvent* e, void* ctx) {
App* app = ctx;
if(e->type != InputTypeShort && e->type != InputTypeRepeat) return false;
if(e->key != InputKeyUp && e->key != InputKeyDown) return false;
with_view_model(
app->readings_view,
ReadingsModel * m,
{
int maxs = m->count > 5 ? m->count - 5 : 0;
if(e->key == InputKeyDown && (int)m->scroll < maxs) m->scroll++;
if(e->key == InputKeyUp && m->scroll > 0) m->scroll--;
},
true);
return true;
}
static uint32_t readings_back(void* ctx) {
App* app = ctx;
furi_message_queue_put(app->jobs, &(JobType){JobDisconnect}, 0);
app->cur = ViewScan;
return ViewScan;
}
static uint32_t scan_exit(void* ctx) {
UNUSED(ctx);
return VIEW_NONE;
}
static uint32_t stay_popup(void* ctx) {
UNUSED(ctx);
return ViewPopup;
}
static void popup_cb(void* ctx) {
App* app = ctx;
view_dispatcher_send_custom_event(app->vd, EvtPopupDone);
}
static void on_notify(App* app) {
NotifyMsg msg;
while(furi_message_queue_get(app->notify_q, &msg, 0) == FuriStatusOk) {
with_view_model(
app->readings_view,
ReadingsModel * m,
{
for(int i = 0; i < m->count; i++)
if(m->r[i].handle == msg.handle) {
m->r[i].val_len = msg.len;
memcpy(m->r[i].val, msg.data, msg.len);
m->r[i].have = true;
}
},
app->cur == ViewReadings);
}
}
static bool custom_cb(void* ctx, uint32_t event) {
App* app = ctx;
switch(event) {
case EvtTick:
if(app->cur == ViewScan)
with_view_model(
app->scan_view,
ScanModel * m,
{
m->count = bc_snapshot(app->bc, m->devs, BC_MAX_DEVICES);
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;
},
true);
break;
case EvtSelect: {
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) {
popup_reset(app->popup);
popup_set_callback(app->popup, popup_cb);
popup_set_context(app->popup, app);
popup_set_header(app->popup, "Connecting...", 64, 26, AlignCenter, AlignCenter);
popup_set_text(app->popup, dev_label(&app->dev), 64, 40, AlignCenter, AlignCenter);
app->cur = ViewPopup;
view_dispatcher_switch_to_view(app->vd, ViewPopup);
furi_message_queue_put(app->jobs, &(JobType){JobConnect}, 0);
}
} break;
case EvtNotify: on_notify(app); break;
case EvtJobDone:
if(app->connected_ok) {
with_view_model(
app->readings_view, ReadingsModel * m,
{ snprintf(m->header, sizeof(m->header), "%s", dev_label(&app->dev)); }, false);
app->cur = ViewReadings;
view_dispatcher_switch_to_view(app->vd, ViewReadings);
} else {
if(bc_is_connected(app->bc)) bc_disconnect(app->bc);
popup_set_header(app->popup, "No sensor data", 64, 26, AlignCenter, AlignCenter);
popup_set_text(app->popup, app->setup_msg, 64, 40, AlignCenter, AlignCenter);
popup_set_timeout(app->popup, 1800);
popup_enable_timeout(app->popup);
}
break;
case EvtPopupDone:
bc_scan_start(app->bc);
app->cur = ViewScan;
view_dispatcher_switch_to_view(app->vd, ViewScan);
break;
default: return false;
}
return true;
}
static void timer_cb(void* ctx) {
App* app = ctx;
view_dispatcher_send_custom_event(app->vd, EvtTick);
}
int32_t ble_sensor_app(void* p) {
UNUSED(p);
App* app = malloc(sizeof(App));
memset(app, 0, sizeof(App));
app->gui = furi_record_open(RECORD_GUI);
app->jobs = furi_message_queue_alloc(8, sizeof(JobType));
app->notify_q = furi_message_queue_alloc(16, 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_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, scan_exit);
view_dispatcher_add_view(app->vd, ViewScan, app->scan_view);
app->readings_view = view_alloc();
view_allocate_model(app->readings_view, ViewModelTypeLocking, sizeof(ReadingsModel));
view_set_context(app->readings_view, app);
view_set_draw_callback(app->readings_view, readings_draw);
view_set_input_callback(app->readings_view, readings_input);
view_set_previous_callback(app->readings_view, readings_back);
view_dispatcher_add_view(app->vd, ViewReadings, app->readings_view);
app->popup = popup_alloc();
view_set_previous_callback(popup_get_view(app->popup), stay_popup);
view_dispatcher_add_view(app->vd, ViewPopup, popup_get_view(app->popup));
app->timer = furi_timer_alloc(timer_cb, FuriTimerTypePeriodic, app);
if(!bc_supported()) {
popup_reset(app->popup);
popup_set_header(app->popup, "Full BLE stack needed", 64, 26, AlignCenter, AlignCenter);
popup_set_text(app->popup, "Build with ble_full", 64, 40, AlignCenter, AlignCenter);
view_set_previous_callback(popup_get_view(app->popup), scan_exit);
view_dispatcher_switch_to_view(app->vd, ViewPopup);
view_dispatcher_run(app->vd);
goto teardown;
}
bool was_adv = furi_hal_bt_is_active();
furi_hal_bt_stop_advertising();
furi_delay_ms(100);
app->bc = bc_alloc(notify_cb, app);
app->worker = furi_thread_alloc_ex("BleSensorW", 3072, worker_thread, app);
furi_thread_start(app->worker);
furi_timer_start(app->timer, furi_ms_to_ticks(300));
view_dispatcher_switch_to_view(app->vd, ViewScan);
view_dispatcher_run(app->vd);
furi_timer_stop(app->timer);
furi_message_queue_put(app->jobs, &(JobType){JobQuit}, 0);
furi_thread_join(app->worker);
furi_thread_free(app->worker);
bc_free(app->bc);
if(was_adv) furi_hal_bt_start_advertising();
teardown:
furi_timer_free(app->timer);
view_dispatcher_remove_view(app->vd, ViewScan);
view_dispatcher_remove_view(app->vd, ViewReadings);
view_dispatcher_remove_view(app->vd, ViewPopup);
view_free(app->scan_view);
view_free(app->readings_view);
popup_free(app->popup);
view_dispatcher_free(app->vd);
furi_message_queue_free(app->jobs);
furi_message_queue_free(app->notify_q);
furi_record_close(RECORD_GUI);
free(app);
return 0;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 95 B

+1 -1
View File
@@ -21,7 +21,7 @@
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APPS=(bt_inspector ble_hid_host) # app dirs under $HERE; BLE apps staged with libble/
APPS=(bt_inspector ble_hid_host ble_sensor) # app dirs under $HERE; BLE apps staged with libble/
APP_SRC="$HERE/bt_inspector" # used only for the BLE_API=min symbol scan
FW_DIR="${FW_DIR:-$HERE/flipperzero-firmware}"
FW_TAG="${FW_TAG:-1.4.3}"