// SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2026 Andy Dixon // 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; volatile bool cancel; char setup_msg[48]; } App; typedef struct { uint16_t handle; uint8_t len; uint8_t data[HID_NOTIFY_MAX]; } NotifyMsg; // ---- logging ---------------------------------------------------------------- static App* s_app; // popup Back callback (popup view ctx isn't the App) 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) { bool ok = bc_connect(app->bc, &app->dev, 8000) && hid_setup(app); if(app->cancel) { if(bc_is_connected(app->bc)) bc_disconnect(app->bc); bc_scan_start(app->bc); ok = false; } app->connected_ok = ok; } 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_connecting_back(void* ctx) { UNUSED(ctx); s_app->cancel = true; s_app->cur_view = ViewScan; return ViewScan; } // ---- 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)); app->cancel = false; 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->cancel) { app->cancel = false; break; } 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)); s_app = 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_connecting_back); 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; }