Add BLE HID Host app; share the central layer via libble/

Extract the reusable BLE central/GATT client and name/value decoders into
libble/ (moved out of bt_inspector). build.sh now stages libble/*.{c,h}
into each app dir at build time and builds every app in APPS=(...).

New app ble_hid_host: connects to a BLE keyboard or mouse, discovers the
HID service (0x1812), sets protocol mode (boot when offered, else report),
subscribes to the input-report characteristics, and decodes keyboard
(modifiers + key names) and mouse (buttons + dx/dy/wheel) reports live.
Events scroll on screen and are logged with raw bytes to
apps_data/ble_hid_host/. No pairing, so encryption-gated keyboards stay
silent; open devices and most mice work. Both faps build and pass APPCHK.
This commit is contained in:
2026-09-11 13:14:57 +01:00
parent 4b887b1ffb
commit b1efa9cd4e
11 changed files with 781 additions and 17 deletions
+30 -7
View File
@@ -1,9 +1,13 @@
# BT Inspector for Flipper Zero
# Flipper Zero BLE tools
A BLE scanner and GATT explorer for the Flipper Zero, in the spirit of
BTInspector for iOS: discover nearby Bluetooth Low Energy devices, see their
signal strength and everything they broadcast, then connect and inspect their
services and characteristics: read, write, subscribe, and log it all.
Two BLE apps for the Flipper Zero, sharing one central/GATT layer (`libble/`):
* **BT Inspector** — a BLE scanner and GATT explorer, in the spirit of
BTInspector for iOS: discover nearby Bluetooth Low Energy devices, see their
signal strength and everything they broadcast, then connect and inspect their
services and characteristics: read, write, subscribe, and log it all.
* **BLE HID Host** — connect to a Bluetooth keyboard or mouse and watch its
input reports decoded live (keys, modifiers, mouse buttons and movement).
> **Read this first.** This app cannot run on stock Flipper firmware. The
> Flipper's radio coprocessor ships with ST's "BLE Light" stack, which can only
@@ -254,10 +258,29 @@ wire overlay).
advertising type on the device page), or it is out of range. Random-address
devices that rotate their address may need re-selecting from the list.
## The BLE HID Host app
Select a device from the scan list; the app connects, finds the HID service
(0x1812), sets the protocol mode (boot when the device offers boot reports,
otherwise report mode), and subscribes to its input-report characteristics.
Keyboard reports are decoded to modifier + key names, mouse reports to button
state and dx/dy/wheel. Events scroll on screen (Up/Down to review history) and
are logged to `SD:/apps_data/ble_hid_host/hid_*.log` with the raw bytes. Back
disconnects and returns to scanning.
It cannot pair, so keyboards that require an encrypted link before sending
reports will connect but stay silent; open ones and most mice work.
## Layout
```
bt_inspector/ app sources, application.fam, icon.png
build.sh firmware build / flash script
libble/ shared BLE central / GATT client + name & value decoders
bt_inspector/ BT Inspector app (application.fam, icon.png, bt_inspector.c)
ble_hid_host/ BLE HID Host app (HID-over-GATT setup + report decoding)
build.sh firmware build / flash script (stages libble into each app)
flipperzero-firmware/ created by build.sh (git-ignored)
```
`build.sh` copies `libble/*.{c,h}` into each app directory at build time, so
both apps compile against one source of truth. Add an app by dropping its
directory next to these and adding its name to `APPS=(...)` in `build.sh`.
+16
View File
@@ -0,0 +1,16 @@
# External app. Needs firmware built by ../build.sh: full BLE stack plus the
# ST aci_*/hci_* command API exported to apps.
App(
appid="ble_hid_host",
name="BLE HID Host",
apptype=FlipperAppType.EXTERNAL,
entry_point="ble_hid_host_app",
requires=["gui", "storage"],
stack_size=4 * 1024,
fap_category="Bluetooth",
fap_icon="icon.png",
fap_version="1.0",
fap_description="Connect a BLE keyboard or mouse and watch its input reports",
fap_author="andydixon",
fap_weburl="https://github.com/andydixon/flipper-bt-inspector",
)
+638
View File
@@ -0,0 +1,638 @@
// BLE HID Host: connect to a Bluetooth Low Energy keyboard or mouse and show
// its input reports live. Reuses the libble central/GATT layer. Needs the
// full BLE stack + exported ST command API (see ../build.sh).
#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 <storage/storage.h>
#include <ble/core/ble_defs.h>
#include <string.h>
#include <stdarg.h>
#include "ble_central.h"
#include "ble_names.h"
#include "hid_decode.h"
#define TAG "BleHidHost"
#define LOG_DIR EXT_PATH("apps_data/ble_hid_host")
#define ROWS 3
#define EVENTS_MAX 32
#define SUBS_MAX 8
#define HID_NOTIFY_MAX 32
#define UUID_HID 0x1812
#define UUID_PROTO_MODE 0x2A4E
#define UUID_REPORT 0x2A4D
#define UUID_BOOT_KBD 0x2A22
#define UUID_BOOT_MOUSE 0x2A33
#define UUID_CTRL_POINT 0x2A4C
typedef enum { ViewScan, ViewMonitor, ViewPopup } ViewId;
typedef enum {
EvtTick,
EvtSelect,
EvtNotify,
EvtJobDone,
EvtPopupDone,
} AppEvent;
typedef enum { JobConnect, JobDisconnect, JobQuit } JobType;
typedef enum { KindAuto, KindKbd, KindMouse } ReportKind;
typedef struct {
BcDevice devs[BC_MAX_DEVICES];
size_t count, sel, top;
bool scanning;
} ScanModel;
typedef struct {
char lines[EVENTS_MAX][40];
int count; // total ever, index with % EVENTS_MAX
char header[40];
char status[40];
size_t scroll;
} MonModel;
typedef struct {
uint16_t handle;
ReportKind kind;
} Sub;
typedef struct {
Gui* gui;
ViewDispatcher* vd;
View* scan_view;
View* mon_view;
Popup* popup;
FuriTimer* timer;
Storage* storage;
File* log;
BleCentral* bc;
FuriThread* worker;
FuriMessageQueue* jobs;
FuriMessageQueue* notify_q;
ViewId cur_view;
BcDevice dev;
BcService services[BC_MAX_SERVICES];
BcChar chars[BC_MAX_CHARS];
Sub subs[SUBS_MAX];
int sub_count;
bool connected_ok;
char setup_msg[48];
} App;
typedef struct {
uint16_t handle;
uint8_t len;
uint8_t data[HID_NOTIFY_MAX];
} NotifyMsg;
// ---- logging ----------------------------------------------------------------
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 "/hid_%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)) {
storage_file_free(app->log);
app->log = NULL;
}
furi_string_free(p);
}
// ---- monitor event feed -----------------------------------------------------
static void mon_add(App* app, const char* text) {
with_view_model(
app->mon_view,
MonModel * m,
{
strncpy(m->lines[m->count % EVENTS_MAX], text, sizeof(m->lines[0]) - 1);
m->lines[m->count % EVENTS_MAX][sizeof(m->lines[0]) - 1] = 0;
m->count++;
m->scroll = 0; // jump to newest
},
app->cur_view == ViewMonitor);
}
static void mon_set_status(App* app, const char* header, const char* status) {
with_view_model(
app->mon_view,
MonModel * m,
{
if(header) strncpy(m->header, header, sizeof(m->header) - 1);
if(status) strncpy(m->status, status, sizeof(m->status) - 1);
},
app->cur_view == ViewMonitor);
}
static const char* dev_label(const BcDevice* d) {
return d->name[0] ? d->name : "(no name)";
}
// ---- HID setup (worker thread) ----------------------------------------------
static uint16_t char_end_handle(App* app, int svc, int idx, int nchars) {
if(idx + 1 < nchars) return app->chars[idx + 1].decl_handle - 1;
return app->services[svc].end;
}
static uint16_t uuid16(const BcChar* c) {
return c->uuid_len == 2 ? (c->uuid[0] | (c->uuid[1] << 8)) : 0;
}
static bool hid_setup(App* app) {
app->sub_count = 0;
int nsvc = bc_discover_services(app->bc, app->services, BC_MAX_SERVICES);
if(nsvc < 0) {
snprintf(app->setup_msg, sizeof(app->setup_msg), "Service discovery failed");
return false;
}
int hid = -1;
for(int i = 0; i < nsvc; i++)
if(app->services[i].uuid_len == 2 &&
(app->services[i].uuid[0] | (app->services[i].uuid[1] << 8)) == UUID_HID)
hid = i;
if(hid < 0) {
snprintf(app->setup_msg, sizeof(app->setup_msg), "No HID service (0x1812)");
return false;
}
int nch = bc_discover_chars(app->bc, &app->services[hid], app->chars, BC_MAX_CHARS);
if(nch < 0) {
snprintf(app->setup_msg, sizeof(app->setup_msg), "Char discovery failed");
return false;
}
bool boot_available = false;
int proto_idx = -1, ctrl_idx = -1;
for(int i = 0; i < nch; i++) {
uint16_t u = uuid16(&app->chars[i]);
if(u == UUID_BOOT_KBD || u == UUID_BOOT_MOUSE) boot_available = true;
if(u == UUID_PROTO_MODE) proto_idx = i;
if(u == UUID_CTRL_POINT) ctrl_idx = i;
}
// Protocol mode: prefer boot (fixed report layout) when the device offers it.
if(proto_idx >= 0) {
uint8_t mode = boot_available ? 0 : 1;
bc_write(app->bc, app->chars[proto_idx].value_handle, &mode, 1, false);
app_log(app, "SET protocol mode = %s", mode ? "report" : "boot");
}
// Keep the device out of suspend.
if(ctrl_idx >= 0) {
uint8_t exit_suspend = 1;
bc_write(app->bc, app->chars[ctrl_idx].value_handle, &exit_suspend, 1, false);
}
for(int i = 0; i < nch && app->sub_count < SUBS_MAX; i++) {
BcChar* c = &app->chars[i];
uint16_t u = uuid16(c);
bool is_input = (u == UUID_BOOT_KBD || u == UUID_BOOT_MOUSE || u == UUID_REPORT);
bool boot = (u == UUID_BOOT_KBD || u == UUID_BOOT_MOUSE);
if(!is_input || !(c->props & (CHAR_PROP_NOTIFY | CHAR_PROP_INDICATE))) continue;
if(boot_available && u == UUID_REPORT) continue; // using boot chars instead
if(!boot_available && boot) continue;
uint16_t end = char_end_handle(app, hid, i, nch);
if(bc_set_notify(app->bc, c, end, true)) {
ReportKind k = u == UUID_BOOT_KBD ? KindKbd : u == UUID_BOOT_MOUSE ? KindMouse : KindAuto;
app->subs[app->sub_count++] = (Sub){.handle = c->value_handle, .kind = k};
app_log(app, "SUBSCRIBE handle 0x%04X uuid 0x%04X", c->value_handle, u);
}
}
if(!app->sub_count) {
snprintf(app->setup_msg, sizeof(app->setup_msg), "HID service has no input reports");
return false;
}
snprintf(
app->setup_msg, sizeof(app->setup_msg), "%s mode, %d report(s)",
boot_available ? "boot" : "report", app->sub_count);
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) && hid_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 > HID_NOTIFY_MAX ? HID_NOTIFY_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);
}
// ---- report decoding --------------------------------------------------------
static ReportKind kind_for(App* app, uint16_t handle, uint8_t len) {
for(int i = 0; i < app->sub_count; i++)
if(app->subs[i].handle == handle) {
if(app->subs[i].kind != KindAuto) return app->subs[i].kind;
break;
}
return len >= 8 ? KindKbd : KindMouse; // boot-report heuristic for report-mode inputs
}
static void decode_kbd(FuriString* out, const uint8_t* d, uint8_t len) {
if(len < 3) {
furi_string_cat_str(out, "(empty)");
return;
}
hid_mods_str(out, d[0]);
bool any = false;
for(uint8_t i = 2; i < len && i < 8; i++) {
if(!d[i]) continue;
const char* n = hid_key_name(d[i]);
if(n)
furi_string_cat_printf(out, "%s ", n);
else
furi_string_cat_printf(out, "[%02X] ", d[i]);
any = true;
}
if(!any && d[0] == 0) furi_string_cat_str(out, "(release)");
}
static void decode_mouse(FuriString* out, const uint8_t* d, uint8_t len) {
if(len < 1) return;
uint8_t b = d[0];
furi_string_cat_printf(
out, "%s%s%s", (b & 1) ? "L" : "-", (b & 2) ? "R" : "-", (b & 4) ? "M" : "-");
if(len >= 3) furi_string_cat_printf(out, " dx%+d dy%+d", (int8_t)d[1], (int8_t)d[2]);
if(len >= 4 && d[3]) furi_string_cat_printf(out, " whl%+d", (int8_t)d[3]);
}
static void on_notify(App* app) {
NotifyMsg m;
while(furi_message_queue_get(app->notify_q, &m, 0) == FuriStatusOk) {
FuriString* line = furi_string_alloc();
FuriString* raw = furi_string_alloc();
ble_hex(raw, m.data, m.len);
if(kind_for(app, m.handle, m.len) == KindKbd)
decode_kbd(line, m.data, m.len);
else
decode_mouse(line, m.data, m.len);
app_log(app, "REPORT 0x%04X %s | %s", m.handle, furi_string_get_cstr(line), furi_string_get_cstr(raw));
mon_add(app, furi_string_get_cstr(line));
furi_string_free(raw);
furi_string_free(line);
}
}
// ---- 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, "BLE HID Host");
canvas_set_font(c, FontSecondary);
furi_string_printf(s, "%u dev", (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_view = ViewScan;
bool ok = bc_scan_start(app->bc);
with_view_model(app->scan_view, ScanModel * m, { m->scanning = ok; }, true);
}
// ---- monitor view -----------------------------------------------------------
static void mon_draw(Canvas* c, void* m_) {
MonModel* m = m_;
canvas_clear(c);
canvas_set_font(c, FontPrimary);
canvas_draw_str(c, 2, 9, m->header[0] ? m->header : "HID");
canvas_set_font(c, FontSecondary);
canvas_draw_line(c, 0, 11, 127, 11);
canvas_draw_str(c, 2, 21, m->status);
int total = m->count;
int visible = 4; // event lines
int shown = total < visible ? total : visible;
for(int r = 0; r < shown; r++) {
// newest at bottom; scroll shifts the window back into history
int idx = total - 1 - (int)m->scroll - (shown - 1 - r);
if(idx < 0 || idx < total - EVENTS_MAX) continue;
canvas_draw_str(c, 2, 33 + r * 10, m->lines[idx % EVENTS_MAX]);
}
if(total > visible) elements_scrollbar_pos(c, 126, 23, 41, m->scroll, total);
}
static bool mon_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->mon_view,
MonModel * m,
{
int maxs = m->count > 4 ? m->count - 4 : 0;
if(e->key == InputKeyDown && (int)m->scroll < maxs) m->scroll++; // into history
if(e->key == InputKeyUp && m->scroll > 0) m->scroll--;
},
true);
return true;
}
static uint32_t nav_exit(void* ctx) {
UNUSED(ctx);
return VIEW_NONE;
}
// Back from the monitor: disconnect and return to scanning.
static uint32_t nav_mon_back(void* ctx) {
App* app = ctx;
furi_message_queue_put(app->jobs, &(JobType){JobDisconnect}, 0);
app->cur_view = ViewScan;
return ViewScan;
}
static uint32_t nav_stay_popup(void* ctx) {
UNUSED(ctx);
return ViewPopup;
}
// ---- events -----------------------------------------------------------------
static void popup_cb(void* ctx) {
App* app = ctx;
view_dispatcher_send_custom_event(app->vd, EvtPopupDone);
}
static void show_popup(App* app, const char* header, const char* text, bool timeout) {
popup_reset(app->popup);
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);
}
app->cur_view = ViewPopup;
view_dispatcher_switch_to_view(app->vd, ViewPopup);
}
static void on_tick(App* app) {
if(app->cur_view != ViewScan) return;
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);
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;
},
true);
}
static bool custom_event_cb(void* ctx, uint32_t event) {
App* app = ctx;
switch(event) {
case EvtTick: on_tick(app); 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) {
app_log(app, "CONNECTING %s", dev_label(&app->dev));
show_popup(app, "Connecting...", dev_label(&app->dev), false);
furi_message_queue_put(app->jobs, &(JobType){JobConnect}, 0);
}
} break;
case EvtNotify: on_notify(app); break;
case EvtJobDone:
if(app->connected_ok) {
char hdr[40];
snprintf(hdr, sizeof(hdr), "%s", dev_label(&app->dev));
with_view_model(app->mon_view, MonModel * m, { m->count = 0; m->scroll = 0; }, false);
mon_set_status(app, hdr, app->setup_msg);
app_log(app, "CONNECTED %s: %s", dev_label(&app->dev), app->setup_msg);
app->cur_view = ViewMonitor;
view_dispatcher_switch_to_view(app->vd, ViewMonitor);
} else if(bc_is_connected(app->bc)) {
// connected but HID setup failed: report and drop
app_log(app, "SETUP FAILED %s: %s", dev_label(&app->dev), app->setup_msg);
bc_disconnect(app->bc);
show_popup(app, "Not a HID device", app->setup_msg, true);
} else {
const char* n = ble_att_error_name(bc_last_error(app->bc));
app_log(app, "CONNECT FAILED %s", dev_label(&app->dev));
show_popup(app, "Connect failed", n ? n : "timeout", true);
}
break;
case EvtPopupDone:
bc_scan_start(app->bc);
app->cur_view = 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);
}
// ---- setup / teardown -------------------------------------------------------
int32_t ble_hid_host_app(void* p) {
UNUSED(p);
App* app = malloc(sizeof(App));
memset(app, 0, sizeof(App));
app->gui = furi_record_open(RECORD_GUI);
app->storage = furi_record_open(RECORD_STORAGE);
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_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->mon_view = view_alloc();
view_allocate_model(app->mon_view, ViewModelTypeLocking, sizeof(MonModel));
view_set_context(app->mon_view, app);
view_set_draw_callback(app->mon_view, mon_draw);
view_set_input_callback(app->mon_view, mon_input);
view_set_previous_callback(app->mon_view, nav_mon_back);
view_dispatcher_add_view(app->vd, ViewMonitor, app->mon_view);
app->popup = popup_alloc();
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->timer = furi_timer_alloc(timer_cb, FuriTimerTypePeriodic, app);
if(!bc_supported()) {
show_popup(
app, "Full BLE stack needed",
"This firmware runs BLE Light. Build with ble_full (see build.sh).", false);
view_set_previous_callback(popup_get_view(app->popup), nav_exit);
view_dispatcher_run(app->vd);
goto cleanup;
}
log_open(app);
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_log(app, "START");
app->worker = furi_thread_alloc_ex("HidHostWorker", 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();
app_log(app, "STOP");
if(app->log) {
storage_file_close(app->log);
storage_file_free(app->log);
}
cleanup:
furi_timer_free(app->timer);
view_dispatcher_remove_view(app->vd, ViewScan);
view_dispatcher_remove_view(app->vd, ViewMonitor);
view_dispatcher_remove_view(app->vd, ViewPopup);
view_free(app->scan_view);
view_free(app->mon_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_STORAGE);
furi_record_close(RECORD_GUI);
free(app);
return 0;
}
+74
View File
@@ -0,0 +1,74 @@
#include "hid_decode.h"
// USB HID Usage Table (keyboard/keypad page 0x07), common subset.
const char* hid_key_name(uint8_t u) {
static char one[2];
if(u >= 0x04 && u <= 0x1D) { // a-z
one[0] = 'a' + (u - 0x04);
one[1] = 0;
return one;
}
if(u >= 0x1E && u <= 0x26) { // 1-9
one[0] = '1' + (u - 0x1E);
one[1] = 0;
return one;
}
switch(u) {
case 0x27: return "0";
case 0x28: return "Enter";
case 0x29: return "Esc";
case 0x2A: return "Bksp";
case 0x2B: return "Tab";
case 0x2C: return "Space";
case 0x2D: return "-";
case 0x2E: return "=";
case 0x2F: return "[";
case 0x30: return "]";
case 0x31: return "\\";
case 0x33: return ";";
case 0x34: return "'";
case 0x35: return "`";
case 0x36: return ",";
case 0x37: return ".";
case 0x38: return "/";
case 0x39: return "Caps";
case 0x3A: return "F1";
case 0x3B: return "F2";
case 0x3C: return "F3";
case 0x3D: return "F4";
case 0x3E: return "F5";
case 0x3F: return "F6";
case 0x40: return "F7";
case 0x41: return "F8";
case 0x42: return "F9";
case 0x43: return "F10";
case 0x44: return "F11";
case 0x45: return "F12";
case 0x46: return "PrtSc";
case 0x47: return "ScrLk";
case 0x48: return "Pause";
case 0x49: return "Ins";
case 0x4A: return "Home";
case 0x4B: return "PgUp";
case 0x4C: return "Del";
case 0x4D: return "End";
case 0x4E: return "PgDn";
case 0x4F: return "Right";
case 0x50: return "Left";
case 0x51: return "Down";
case 0x52: return "Up";
case 0x53: return "NumLk";
default: return NULL;
}
}
void hid_mods_str(FuriString* out, uint8_t m) {
if(m & 0x01) furi_string_cat_str(out, "LCtrl+");
if(m & 0x02) furi_string_cat_str(out, "LShift+");
if(m & 0x04) furi_string_cat_str(out, "LAlt+");
if(m & 0x08) furi_string_cat_str(out, "LGui+");
if(m & 0x10) furi_string_cat_str(out, "RCtrl+");
if(m & 0x20) furi_string_cat_str(out, "RShift+");
if(m & 0x40) furi_string_cat_str(out, "RAlt+");
if(m & 0x80) furi_string_cat_str(out, "RGui+");
}
+8
View File
@@ -0,0 +1,8 @@
#pragma once
#include <furi.h>
#include <stdint.h>
// Human-readable name for a USB HID keyboard usage id (0 => NULL).
const char* hid_key_name(uint8_t usage);
// Append active modifier names ("Ctrl+Shift+") for a boot-keyboard modifier byte.
void hid_mods_str(FuriString* out, uint8_t mods);
Binary file not shown.

After

Width:  |  Height:  |  Size: 95 B

+15 -10
View File
@@ -21,7 +21,8 @@
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APP_SRC="$HERE/bt_inspector"
APPS=(bt_inspector ble_hid_host) # app dirs under $HERE, each 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}"
FW_REPO="https://github.com/flipperdevices/flipperzero-firmware.git"
@@ -83,10 +84,14 @@ for name in ["ble_hci_le", "ble_gap_aci", "ble_gatt_aci", "ble_hal_aci", "ble_l2
print(">> patched " + p + ': extern "C" guards')
PY
# Link (or refresh) the app into the user apps directory.
# Stage each app into applications_user, with the shared libble sources copied
# alongside so the fap glob (*.c) picks them up.
mkdir -p applications_user
rm -rf applications_user/bt_inspector
cp -r "$APP_SRC" applications_user/bt_inspector
for app in "${APPS[@]}"; do
rm -rf "applications_user/$app"
cp -r "$HERE/$app" "applications_user/$app"
cp "$HERE"/libble/*.c "$HERE"/libble/*.h "applications_user/$app/"
done
FBT_ARGS=(
COMPACT=1 DEBUG=0 # release build, same as official images
@@ -203,12 +208,12 @@ if enable_res:
PY
if [ "${1:-}" = "fap" ]; then
./fbt -j"${JOBS:-4}" "${FBT_ARGS[@]}" fap_bt_inspector
echo ">> $(ls build/f7-firmware-C/.extapps/bt_inspector.fap)"
./fbt -j"${JOBS:-4}" "${FBT_ARGS[@]}" "${APPS[@]/#/fap_}"
ls build/f7-firmware-C/.extapps/*.fap
exit 0
fi
echo ">> building update package (full BLE stack + BT Inspector .fap)"
echo ">> building update package (full BLE stack + apps)"
./fbt -j"${JOBS:-4}" "${FBT_ARGS[@]}" updater_package
PKG=$(ls -d dist/f7-C/f7-update-* 2>/dev/null | head -1)
@@ -218,9 +223,9 @@ echo ">> radio stack in package:"
grep -E "^Radio" "$PKG/update.fuf" || true
echo ">> firmware size:"
ls -l "$PKG/firmware.dfu"
echo ">> app (also inside the package's resources, installed to apps/Bluetooth/):"
ls -l build/f7-firmware-C/.extapps/bt_inspector.fap
echo ">> SDK for building the app with ufbt: $(ls dist/f7-C/flipper-z-f7-sdk-*.zip)"
echo ">> apps (also inside the package's resources, installed to apps/Bluetooth/):"
ls -l build/f7-firmware-C/.extapps/*.fap
echo ">> SDK for building the apps with ufbt: $(ls dist/f7-C/flipper-z-f7-sdk-*.zip)"
echo
echo "Install: copy '$PKG' to the SD card (e.g. /ext/update/) and run it from"
echo "the file browser, or use: ./build.sh flash"