diff --git a/README.md b/README.md index 2e7ceb3..67da50a 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/ble_hid_host/application.fam b/ble_hid_host/application.fam new file mode 100644 index 0000000..97f2573 --- /dev/null +++ b/ble_hid_host/application.fam @@ -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", +) diff --git a/ble_hid_host/ble_hid_host.c b/ble_hid_host/ble_hid_host.c new file mode 100644 index 0000000..254e9fc --- /dev/null +++ b/ble_hid_host/ble_hid_host.c @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#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; +} diff --git a/ble_hid_host/hid_decode.c b/ble_hid_host/hid_decode.c new file mode 100644 index 0000000..aeaf00b --- /dev/null +++ b/ble_hid_host/hid_decode.c @@ -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+"); +} diff --git a/ble_hid_host/hid_decode.h b/ble_hid_host/hid_decode.h new file mode 100644 index 0000000..be28602 --- /dev/null +++ b/ble_hid_host/hid_decode.h @@ -0,0 +1,8 @@ +#pragma once +#include +#include + +// 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); diff --git a/ble_hid_host/icon.png b/ble_hid_host/icon.png new file mode 100644 index 0000000..2d6f2fb Binary files /dev/null and b/ble_hid_host/icon.png differ diff --git a/build.sh b/build.sh index 5a12e9c..0fb090c 100755 --- a/build.sh +++ b/build.sh @@ -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" diff --git a/bt_inspector/ble_central.c b/libble/ble_central.c similarity index 100% rename from bt_inspector/ble_central.c rename to libble/ble_central.c diff --git a/bt_inspector/ble_central.h b/libble/ble_central.h similarity index 100% rename from bt_inspector/ble_central.h rename to libble/ble_central.h diff --git a/bt_inspector/ble_names.c b/libble/ble_names.c similarity index 100% rename from bt_inspector/ble_names.c rename to libble/ble_names.c diff --git a/bt_inspector/ble_names.h b/libble/ble_names.h similarity index 100% rename from bt_inspector/ble_names.h rename to libble/ble_names.h