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:
@@ -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`.
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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+");
|
||||
}
|
||||
@@ -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 |
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user