From e863616f825570f5441fe00159421ba6535e7e2e Mon Sep 17 00:00:00 2001 From: andy Date: Fri, 11 Sep 2026 08:39:51 +0100 Subject: [PATCH] Add BT Inspector: BLE scanner and GATT explorer for Flipper Zero Built-in firmware app (needs the full ST BLE stack) with live RSSI-sorted scan list, advertisement decoding (Apple Continuity, Microsoft CDP, Fast Pair, Eddystone), connect/discover/read/write/notify, device-info summary, value history and a timestamped session log. build.sh produces a firmware update package with COPRO_STACK_TYPE=ble_full and the app compiled in. --- .gitignore | 6 + README.md | 197 ++++++ bt_inspector/application.fam | 12 + bt_inspector/ble_central.c | 514 ++++++++++++++ bt_inspector/ble_central.h | 73 ++ bt_inspector/ble_names.c | 418 ++++++++++++ bt_inspector/ble_names.h | 21 + bt_inspector/bt_inspector.c | 1222 ++++++++++++++++++++++++++++++++++ build.sh | 68 ++ 9 files changed, 2531 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 bt_inspector/application.fam create mode 100644 bt_inspector/ble_central.c create mode 100644 bt_inspector/ble_central.h create mode 100644 bt_inspector/ble_names.c create mode 100644 bt_inspector/ble_names.h create mode 100644 bt_inspector/bt_inspector.c create mode 100755 build.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4242647 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +flipperzero-firmware/ +.venv/ +build.log +*.log +__pycache__/ +dist/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..99384b5 --- /dev/null +++ b/README.md @@ -0,0 +1,197 @@ +# BT Inspector for Flipper Zero + +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. + +> **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 +> advertise (peripheral role). It cannot scan for or connect to other devices. +> BT Inspector is therefore delivered as a **custom firmware build** that swaps +> in ST's full BLE stack and compiles the app in. `build.sh` does all of this. +> See [Why a custom firmware build](#why-a-custom-firmware-build). + +## Features + +* **Live scan list.** Every BLE advertiser in range, sorted by RSSI, refreshed + every 300 ms as you move around. Shows name (or "(no name)"), RSSI in dBm + with signal bars, address and vendor. Devices unseen for 60 s drop off. +* **Device page.** Address and address type, current/best RSSI and packet + count, advertising type (connectable or not), flags, advertised service UUIDs + (16/32/128-bit, named when known), service data, Tx power, appearance, + manufacturer data and raw advertisement / scan-response bytes. Updates live. +* **Identification.** Manufacturer IDs map to vendor names. Decoders for: + * Apple Continuity: iBeacon (UUID/major/minor/Tx), Proximity Pairing with + AirPods/Beats **model name** and **left / right / case battery** and + charging state, Nearby Info (device activity), Nearby Action, Find My, + AirDrop, Handoff, AirPlay, tethering. + * Microsoft Connected Devices beacon: device type (Windows desktop/laptop, + Xbox, Surface Hub, Android, iPhone, ...). + * Google Fast Pair model ID, Eddystone UID/URL/TLM (URL decoded), COVID + Exposure Notification. +* **Connect and interrogate.** OK on a connectable device connects (MTU is + negotiated), discovers all primary services, then characteristics with their + properties (`R`ead, `W`rite, `w`rite-without-response, `N`otify, `I`ndicate, + `B`roadcast). +* **Read device info** (first item in the service list): reads every readable + characteristic of the Generic Access, Device Information and Battery services + in one go: device name, appearance, model number, serial number, firmware / + hardware / software revision, manufacturer name, PnP ID, battery level. +* **Characteristic page.** Read any characteristic, including custom 128-bit + ones. Values are shown as hex plus decoded forms: printable string, u8/i8, + u16, u32, and SIG-specific formats (battery %, appearance, preferred + connection parameters, PnP ID, heart rate, temperature, humidity, pressure, + Tx power, date/time). Long values are fetched with read-long. +* **Write** a value back as text, hex bytes or a number (little-endian, sized to + the current value or the magnitude). Uses write-with-response when the + characteristic supports it, otherwise write-without-response. A readable + characteristic is re-read after a write. +* **Subscribe** to notifications / indications (the CCCD is located + automatically). Incoming values update the page and the history. +* **Value history.** Each value seen for the open characteristic (reads and + notifications) is kept with a timestamp, newest first. +* **Session log.** Every event is written with a date/time stamp to + `SD:/apps_data/bt_inspector/bti_YYYYMMDD_HHMMSS.log`: devices found (and + renamed), connects, services, characteristics, reads, writes, notifications, + errors. Copy it off the SD card (or via qFlipper) for analysis. + +## Building and installing + +Requirements: Linux or macOS with `git`, `python3` and a few GB of disk. The +firmware build system downloads its own ARM toolchain. + +```sh +./build.sh # clone firmware 1.4.3, add the app, build with the full BLE stack +./build.sh flash # same, then flash over USB (Flipper connected, qFlipper closed) +``` + +Options via environment variables: `FW_TAG` (firmware tag, default `1.4.3`), +`FW_DIR` (where to clone), `JOBS` (parallel compile jobs, default 4). + +The result is a standard update package in +`flipperzero-firmware/dist/f7-C/f7-update-local/`. To install without USB +flashing, copy that folder to the SD card (for example `SD:/update/`) and open +it in the Flipper's file browser: the updater installs the firmware **and the +full radio stack**. Installing an official firmware later restores the light +stack the same way. + +The first build takes 5-15 minutes; later builds are incremental. + +## Using the app + +BT Inspector appears in the Flipper's main menu. While it runs, the Flipper's +own BLE advertising is stopped (and any phone connection dropped); it is +restored on exit. + +| Screen | Up / Down | OK | Left | Right | Back | +|---|---|---|---|---|---| +| Scan list | select device | device page | | | exit app | +| Device page | scroll | Connect (if connectable) | | | scan list | +| Services | select | open service / read device info | | | disconnect, device page | +| Characteristics | select | open characteristic (auto-read) | | | services | +| Characteristic | scroll | Read | Notify / Unsub | Write | characteristics | +| Write menu | select | Text / Hex bytes / Number | | | characteristic | + +Status messages ("Read OK (5 B)", "Write failed: Write not permitted", +"Notifications on", ...) appear on the characteristic page. + +On stock firmware the app opens with an explanation instead of scanning. + +## Log format + +One event per line: + +``` +[2026-09-11 09:12:03] START stack=full advertising_was=1 +[2026-09-11 09:12:04] DEVICE 5C:F3:70:9A:11:20 ADV_IND rssi=-58 name="Living Room" vendor=Apple adv=02 01 1A 0B FF 4C 00 ... +[2026-09-11 09:12:20] CONNECTING Living Room +[2026-09-11 09:12:21] CONNECTED Living Room mtu=247 +[2026-09-11 09:12:21] SERVICE 180A Device Information handles 0x0010-0x001C +[2026-09-11 09:12:25] CHAR 2A26 handle 0x0016 props 0x02 +[2026-09-11 09:12:26] READ 0x0016 = 31 2E 32 2E 30 +[2026-09-11 09:12:40] WRITE 0x0021 = 01 +[2026-09-11 09:12:41] NOTIFY 0x0024 = 64 +[2026-09-11 09:13:00] DISCONNECT +``` + +A `DEVICE` line is written when a device is first seen and again when its name +changes. `INFO` lines come from "Read device info". + +## Why a custom firmware build + +Three facts about the Flipper Zero drive the design: + +1. The STM32WB55's second core runs a prebuilt ST BLE stack. Flipper ships + `stm32wb5x_BLE_Stack_light_fw.bin` (peripheral + broadcaster only). ST's + `stm32wb5x_BLE_Stack_full_fw.bin` adds observer and central roles. The + firmware build system already supports it (`COPRO_STACK_TYPE=ble_full`) and + the firmware recognises it at runtime (`FuriHalBtStackFull`). +2. External `.fap` apps can only call functions listed in the firmware's API + table. The ST `aci_*` / `hci_*` command functions are not in it, so a `.fap` + cannot start a scan or a connection. Compiling the app into the firmware + (`FlipperAppType.APP`, added with `--extra-int-apps`) gives it access. +3. The full stack is 34 KB larger and loads at 0x080CE000 instead of + 0x080D7000. The built image still leaves 12 flash pages (52 KB) for the + Flipper's internal storage (stock leaves 27). + +`build.sh` also widens the GAP roles the firmware initialises +(`GAP_PERIPHERAL_ROLE | GAP_CENTRAL_ROLE | GAP_OBSERVER_ROLE`) in +`targets/f7/ble_glue/gap.c`. The app itself uses HCI-level scan and connect +commands, so this is belt-and-braces. + +## How it works + +``` +bt_inspector.c UI (ViewDispatcher: scan list, text page, menus, inputs), + worker thread for blocking BLE operations, session log +ble_central.c scan / connect / GATT client on top of the ST stack. + Registers a handler with the firmware's BLE event dispatcher, + acknowledges its own events (advertising reports, our + connection's events) so the firmware's peripheral GAP never + sees them, and turns async ATT events into synchronous calls +ble_names.c vendor / UUID / appearance tables and value decoders +``` + +The app registers for BLE events, stops the Flipper's own advertising, then +issues `hci_le_set_scan_*`, `hci_le_create_connection` and `aci_gatt_*` +commands directly. Connection-complete events are distinguished from the +firmware's peripheral connections by the role field. Advertising reports are +parsed from the raw HCI payload (the ST header's `Advertising_Report_t` is not a +wire overlay). + +## Limitations + +* **BLE only.** The STM32WB55 has no Bluetooth Classic (BR/EDR), so classic + devices cannot be seen from any Flipper. +* Apple battery levels come from what AirPods/Beats broadcast. Battery of + iPhones/Macs is only exposed to paired Apple devices. +* Vendor, UUID and Apple model tables are curated subsets; unknown IDs are + shown as hex. Apple Continuity layouts are community-documented and may drift. +* Value history is kept for the open characteristic; the log file has all of it. +* iOS-only BTInspector features (Shortcuts, Live Activities, background scanning + mapped by location) have no Flipper equivalent. +* Pairing/bonding with the target is not implemented; characteristics that + require encryption report "Insufficient auth" / "Insufficient encryption". +* Tested by compiling into firmware 1.4.3; on-device behaviour needs a Flipper + with the full stack installed. + +## Troubleshooting + +* **"Radio stack has no scanning"** on launch: the light stack is installed. + Install the package produced by `build.sh`, which includes `radio.bin` with + the full stack. +* **Build killed / out of memory**: `JOBS=2 ./build.sh`. +* **Updater link errors about `bt`/`rpc`**: the app manifest must not `require` + the `bt` service (the same app list is linked into the tiny updater image). +* **Connect failed: Timeout**: the device may not be connectable (see the + 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. + +## Layout + +``` +bt_inspector/ app sources + application.fam +build.sh firmware build / flash script +flipperzero-firmware/ created by build.sh (git-ignored) +``` diff --git a/bt_inspector/application.fam b/bt_inspector/application.fam new file mode 100644 index 0000000..ef86730 --- /dev/null +++ b/bt_inspector/application.fam @@ -0,0 +1,12 @@ +# Built-in (firmware-linked) app: it needs the ST GAP/GATT/HCI command API, +# which the firmware does not export to .fap apps. See ../build.sh. +App( + appid="bt_inspector", + name="BT Inspector", + apptype=FlipperAppType.APP, + entry_point="bt_inspector_app", + requires=["gui", "storage"], + stack_size=4 * 1024, + icon="A_Plugins_14", + order=25, +) diff --git a/bt_inspector/ble_central.c b/bt_inspector/ble_central.c new file mode 100644 index 0000000..63e2a74 --- /dev/null +++ b/bt_inspector/ble_central.c @@ -0,0 +1,514 @@ +#include "ble_central.h" +#include +#include +#include +#include +#include +#include + +#define TAG "BtInspector" + +#define OWN_ADDR_TYPE GAP_PUBLIC_ADDR // firmware programs a public identity address +#define PROC_TIMEOUT_MS 5000 + +typedef enum { + FlagConnected = 1 << 0, + FlagConnFailed = 1 << 1, + FlagDisconnected = 1 << 2, + FlagDone = 1 << 3, +} BcFlag; + +struct BleCentral { + FuriMutex* mutex; // guards devices[] + FuriEventFlag* flags; + GapSvcEventHandler* handler; + BcNotifyCb notify_cb; + void* notify_ctx; + + BcDevice devices[BC_MAX_DEVICES]; + bool scanning; + + volatile bool connecting; + volatile bool connected; + volatile uint16_t conn_handle; + uint16_t mtu; + uint8_t proc_error; // from ACI_GATT_PROC_COMPLETE + uint8_t att_error; // from ACI_GATT_ERROR_RESP + uint8_t last_error; + + // procedure output buffers, owned by the caller of the running procedure + BcService* svc_out; + size_t svc_max, svc_count; + BcChar* chr_out; + size_t chr_max, chr_count; + uint8_t* val_out; + size_t val_max, val_len; + uint16_t found_cccd; +}; + +// ---- advertising ----------------------------------------------------------- + +const uint8_t* bc_adv_find(const uint8_t* data, uint8_t len, uint8_t type, uint8_t* out_len) { + uint8_t i = 0; + while(i < len) { + uint8_t l = data[i]; + if(l == 0 || i + 1 + l > len) break; + if(data[i + 1] == type) { + *out_len = l - 1; + return data + i + 2; + } + i += 1 + l; + } + return NULL; +} + +static void update_name(BcDevice* d) { + uint8_t l = 0; + const uint8_t* n = bc_adv_find(d->adv, d->adv_len, 0x09, &l); + if(!n) n = bc_adv_find(d->rsp, d->rsp_len, 0x09, &l); + if(!n) n = bc_adv_find(d->adv, d->adv_len, 0x08, &l); + if(!n) n = bc_adv_find(d->rsp, d->rsp_len, 0x08, &l); + if(!n) return; + if(l > BC_NAME_MAX) l = BC_NAME_MAX; + if(strlen(d->name) == l && memcmp(d->name, n, l) == 0) return; + memcpy(d->name, n, l); + d->name[l] = 0; + d->logged = false; // re-log on name change +} + +static void handle_adv_report(BleCentral* bc, const uint8_t* p, size_t len) { + if(len < 1) return; + uint8_t n = p[0]; + size_t i = 1; + uint32_t now = furi_get_tick(); + furi_mutex_acquire(bc->mutex, FuriWaitForever); + while(n-- && i + 9 <= len) { + uint8_t evt = p[i], at = p[i + 1]; + const uint8_t* addr = p + i + 2; + uint8_t dl = p[i + 8]; + const uint8_t* data = p + i + 9; + if(i + 9 + dl + 1 > len) break; + int8_t rssi = (int8_t)p[i + 9 + dl]; + i += 10 + dl; + if(at > 1) at -= 2; // resolved private -> underlying type + + BcDevice* d = NULL; + BcDevice* victim = NULL; + for(size_t k = 0; k < BC_MAX_DEVICES; k++) { + BcDevice* c = &bc->devices[k]; + if(c->used && c->addr_type == at && memcmp(c->addr, addr, 6) == 0) { + d = c; + break; + } + if(!victim || !c->used || (victim->used && c->last_seen < victim->last_seen)) victim = c; + } + if(!d) { + if(evt == SCAN_RSP) continue; // never seen its advertisement; wait for it + d = victim; + memset(d, 0, sizeof(*d)); + d->used = true; + memcpy(d->addr, addr, 6); + d->addr_type = at; + d->first_seen = now; + d->rssi_max = rssi; + } + d->last_seen = now; + d->packets++; + d->rssi = rssi; + if(rssi > d->rssi_max) d->rssi_max = rssi; + if(dl > BC_ADV_MAX) dl = BC_ADV_MAX; + if(evt == SCAN_RSP) { + memcpy(d->rsp, data, dl); + d->rsp_len = dl; + } else { + d->evt_type = evt; + memcpy(d->adv, data, dl); + d->adv_len = dl; + } + update_name(d); + } + furi_mutex_release(bc->mutex); +} + +// ---- event handler (runs on the BLE glue thread) --------------------------- + +static BleEventAckStatus bc_event_handler(void* event, void* context) { + BleCentral* bc = context; + hci_event_pckt* pkt = (hci_event_pckt*)((hci_uart_pckt*)event)->data; + + switch(pkt->evt) { + case HCI_LE_META_EVT_CODE: { + evt_le_meta_event* meta = (evt_le_meta_event*)pkt->data; + if(meta->subevent == HCI_LE_ADVERTISING_REPORT_SUBEVT_CODE) { + handle_adv_report(bc, meta->data, pkt->plen - 1); + return BleEventAckFlowEnable; + } + if(meta->subevent == HCI_LE_CONNECTION_COMPLETE_SUBEVT_CODE || + meta->subevent == HCI_LE_ENHANCED_CONNECTION_COMPLETE_SUBEVT_CODE) { + // both layouts start with Status, Connection_Handle, Role + hci_le_connection_complete_event_rp0* e = (void*)meta->data; + if(!bc->connecting) return BleEventNotAck; + if(e->Status == 0 && e->Role != 0) return BleEventNotAck; // we are slave: not ours + bc->connecting = false; + if(e->Status == 0) { + bc->conn_handle = e->Connection_Handle; + bc->connected = true; + furi_event_flag_set(bc->flags, FlagConnected); + } else { + bc->last_error = e->Status; + furi_event_flag_set(bc->flags, FlagConnFailed); + } + return BleEventAckFlowEnable; + } + return BleEventNotAck; + } + case HCI_DISCONNECTION_COMPLETE_EVT_CODE: { + hci_disconnection_complete_event_rp0* e = (void*)pkt->data; + if(bc->connected && e->Connection_Handle == bc->conn_handle) { + bc->connected = false; + bc->conn_handle = 0; + furi_event_flag_set(bc->flags, FlagDisconnected); + return BleEventAckFlowEnable; + } + return BleEventNotAck; + } + case HCI_VENDOR_SPECIFIC_DEBUG_EVT_CODE: { + evt_blecore_aci* aci = (evt_blecore_aci*)pkt->data; + uint16_t ch = aci->data[0] | (aci->data[1] << 8); // every event below starts with it + if(!bc->connected || ch != bc->conn_handle) return BleEventNotAck; + + switch(aci->ecode) { + case ACI_ATT_READ_BY_GROUP_TYPE_RESP_VSEVT_CODE: { + aci_att_read_by_group_type_resp_event_rp0* e = (void*)aci->data; + uint8_t rl = e->Attribute_Data_Length; + if(!bc->svc_out || (rl != 6 && rl != 20)) break; + for(uint8_t i = 0; i + rl <= e->Data_Length && bc->svc_count < bc->svc_max; i += rl) { + const uint8_t* r = e->Attribute_Data_List + i; + BcService* s = &bc->svc_out[bc->svc_count++]; + s->start = r[0] | (r[1] << 8); + s->end = r[2] | (r[3] << 8); + s->uuid_len = rl - 4; + memcpy(s->uuid, r + 4, s->uuid_len); + } + } break; + case ACI_ATT_READ_BY_TYPE_RESP_VSEVT_CODE: { + aci_att_read_by_type_resp_event_rp0* e = (void*)aci->data; + uint8_t rl = e->Handle_Value_Pair_Length; + if(!bc->chr_out || (rl != 7 && rl != 21)) break; + for(uint8_t i = 0; i + rl <= e->Data_Length && bc->chr_count < bc->chr_max; i += rl) { + const uint8_t* r = e->Handle_Value_Pair_Data + i; + BcChar* c = &bc->chr_out[bc->chr_count++]; + memset(c, 0, sizeof(*c)); + c->decl_handle = r[0] | (r[1] << 8); + c->props = r[2]; + c->value_handle = r[3] | (r[4] << 8); + c->uuid_len = rl - 5; + memcpy(c->uuid, r + 5, c->uuid_len); + } + } break; + case ACI_ATT_FIND_INFO_RESP_VSEVT_CODE: { + aci_att_find_info_resp_event_rp0* e = (void*)aci->data; + uint8_t rl = e->Format == 1 ? 4 : 18; + for(uint8_t i = 0; i + rl <= e->Event_Data_Length; i += rl) { + const uint8_t* r = e->Handle_UUID_Pair + i; + if(e->Format == 1 && r[2] == 0x02 && r[3] == 0x29 && !bc->found_cccd) + bc->found_cccd = r[0] | (r[1] << 8); + } + } break; + case ACI_ATT_READ_RESP_VSEVT_CODE: + case ACI_ATT_READ_BLOB_RESP_VSEVT_CODE: { + aci_att_read_resp_event_rp0* e = (void*)aci->data; + if(!bc->val_out) break; + size_t n = e->Event_Data_Length; + if(bc->val_len + n > bc->val_max) n = bc->val_max - bc->val_len; + memcpy(bc->val_out + bc->val_len, e->Attribute_Value, n); + bc->val_len += n; + } break; + case ACI_ATT_EXCHANGE_MTU_RESP_VSEVT_CODE: { + aci_att_exchange_mtu_resp_event_rp0* e = (void*)aci->data; + bc->mtu = e->Server_RX_MTU; + } break; + case ACI_GATT_ERROR_RESP_VSEVT_CODE: { + aci_gatt_error_resp_event_rp0* e = (void*)aci->data; + bc->att_error = e->Error_Code; + } break; + case ACI_GATT_PROC_COMPLETE_VSEVT_CODE: { + aci_gatt_proc_complete_event_rp0* e = (void*)aci->data; + bc->proc_error = e->Error_Code; + furi_event_flag_set(bc->flags, FlagDone); + } break; + case ACI_GATT_PROC_TIMEOUT_VSEVT_CODE: + bc->proc_error = 0xFE; + furi_event_flag_set(bc->flags, FlagDone); + break; + case ACI_GATT_INDICATION_VSEVT_CODE: + case ACI_GATT_NOTIFICATION_VSEVT_CODE: { // same layout + if(aci->ecode == ACI_GATT_INDICATION_VSEVT_CODE) aci_gatt_confirm_indication(bc->conn_handle); + aci_gatt_notification_event_rp0* e = (void*)aci->data; + if(bc->notify_cb) + bc->notify_cb( + e->Attribute_Handle, e->Attribute_Value, e->Attribute_Value_Length, + bc->notify_ctx); + } break; + default: break; + } + return BleEventAckFlowEnable; + } + default: return BleEventNotAck; + } +} + +// ---- lifecycle --------------------------------------------------------------- + +bool bc_supported(void) { + return furi_hal_bt_get_radio_stack() == FuriHalBtStackFull; +} + +BleCentral* bc_alloc(BcNotifyCb cb, void* ctx) { + BleCentral* bc = malloc(sizeof(BleCentral)); + bc->mutex = furi_mutex_alloc(FuriMutexTypeNormal); + bc->flags = furi_event_flag_alloc(); + bc->notify_cb = cb; + bc->notify_ctx = ctx; + bc->mtu = 23; + bc->handler = ble_event_dispatcher_register_svc_handler(bc_event_handler, bc); + return bc; +} + +void bc_free(BleCentral* bc) { + bc_scan_stop(bc); + if(bc->connected) bc_disconnect(bc); + ble_event_dispatcher_unregister_svc_handler(bc->handler); + furi_event_flag_free(bc->flags); + furi_mutex_free(bc->mutex); + free(bc); +} + +// ---- scanning ---------------------------------------------------------------- + +bool bc_scan_start(BleCentral* bc) { + if(bc->scanning) return true; + // active scan, 50 ms interval / 30 ms window; no duplicate filtering so RSSI stays live + tBleStatus st = hci_le_set_scan_parameters(0x01, 0x50, 0x30, OWN_ADDR_TYPE, 0x00); + if(st == BLE_STATUS_SUCCESS) st = hci_le_set_scan_enable(0x01, 0x00); + if(st != BLE_STATUS_SUCCESS) { + FURI_LOG_E(TAG, "scan start failed: 0x%02X", st); + bc->last_error = st; + return false; + } + bc->scanning = true; + return true; +} + +void bc_scan_stop(BleCentral* bc) { + if(!bc->scanning) return; + hci_le_set_scan_enable(0x00, 0x00); + bc->scanning = false; +} + +static int cmp_rssi(const void* a, const void* b) { + return ((const BcDevice*)b)->rssi - ((const BcDevice*)a)->rssi; +} + +size_t bc_snapshot(BleCentral* bc, BcDevice* out, size_t max) { + size_t n = 0; + uint32_t now = furi_get_tick(); + furi_mutex_acquire(bc->mutex, FuriWaitForever); + for(size_t k = 0; k < BC_MAX_DEVICES && n < max; k++) { + BcDevice* d = &bc->devices[k]; + if(!d->used) continue; + if(now - d->last_seen > BC_STALE_MS) { + d->used = false; + continue; + } + out[n++] = *d; + d->logged = true; + } + furi_mutex_release(bc->mutex); + qsort(out, n, sizeof(BcDevice), cmp_rssi); + return n; +} + +bool bc_get_device(BleCentral* bc, const uint8_t* addr, BcDevice* out) { + bool found = false; + furi_mutex_acquire(bc->mutex, FuriWaitForever); + for(size_t k = 0; k < BC_MAX_DEVICES; k++) { + BcDevice* d = &bc->devices[k]; + if(d->used && memcmp(d->addr, addr, 6) == 0) { + *out = *d; + found = true; + break; + } + } + furi_mutex_release(bc->mutex); + return found; +} + +// ---- connection -------------------------------------------------------------- + +static bool wait_done(BleCentral* bc, uint32_t timeout) { + uint32_t f = furi_event_flag_wait(bc->flags, FlagDone | FlagDisconnected, FuriFlagWaitAny, timeout); + if(f & FuriFlagError) { + bc->last_error = 0xFE; + return false; + } + if(f & FlagDisconnected) { + bc->last_error = 0xFD; + return false; + } + if(bc->proc_error) { + bc->last_error = bc->att_error ? bc->att_error : bc->proc_error; + return false; + } + return true; +} + +static void proc_begin(BleCentral* bc) { + furi_event_flag_clear(bc->flags, FlagDone); + bc->proc_error = 0; + bc->att_error = 0; + bc->last_error = 0; +} + +bool bc_connect(BleCentral* bc, const BcDevice* dev, uint32_t timeout_ms) { + if(bc->connected) return true; + bc_scan_stop(bc); + furi_event_flag_clear(bc->flags, FlagConnected | FlagConnFailed | FlagDisconnected | FlagDone); + bc->connecting = true; + bc->mtu = 23; + // scan 60/30 ms, conn interval 30-50 ms, latency 0, supervision 5 s + tBleStatus st = hci_le_create_connection( + 0x60, 0x30, 0x00, dev->addr_type, dev->addr, OWN_ADDR_TYPE, 0x18, 0x28, 0, 0x01F4, 0, 0); + if(st != BLE_STATUS_SUCCESS) { + FURI_LOG_E(TAG, "create_connection failed: 0x%02X", st); + bc->connecting = false; + bc->last_error = st; + return false; + } + uint32_t f = furi_event_flag_wait(bc->flags, FlagConnected | FlagConnFailed, FuriFlagWaitAny, timeout_ms); + if(f & FuriFlagError) { + hci_le_create_connection_cancel(); + f = furi_event_flag_wait(bc->flags, FlagConnected | FlagConnFailed, FuriFlagWaitAny, 2000); + if(!(f & FlagConnected)) { + bc->connecting = false; + bc->last_error = 0xFE; + return false; + } + } + if(!(f & FlagConnected)) return false; + + proc_begin(bc); + if(aci_gatt_exchange_config(bc->conn_handle) == BLE_STATUS_SUCCESS) wait_done(bc, 2000); + return true; +} + +void bc_disconnect(BleCentral* bc) { + if(!bc->connected) return; + furi_event_flag_clear(bc->flags, FlagDisconnected); + if(hci_disconnect(bc->conn_handle, 0x13) == BLE_STATUS_SUCCESS) + furi_event_flag_wait(bc->flags, FlagDisconnected, FuriFlagWaitAny, 3000); + bc->connected = false; + bc->conn_handle = 0; +} + +bool bc_is_connected(BleCentral* bc) { + return bc->connected; +} +uint16_t bc_mtu(BleCentral* bc) { + return bc->mtu; +} +uint8_t bc_last_error(BleCentral* bc) { + return bc->last_error; +} + +// ---- GATT client ------------------------------------------------------------- + +int bc_discover_services(BleCentral* bc, BcService* out, size_t max) { + if(!bc->connected) return -1; + proc_begin(bc); + bc->svc_out = out; + bc->svc_max = max; + bc->svc_count = 0; + bool ok = aci_gatt_disc_all_primary_services(bc->conn_handle) == BLE_STATUS_SUCCESS && + wait_done(bc, PROC_TIMEOUT_MS); + bc->svc_out = NULL; + return (ok || bc->svc_count) ? (int)bc->svc_count : -1; +} + +int bc_discover_chars(BleCentral* bc, const BcService* svc, BcChar* out, size_t max) { + if(!bc->connected) return -1; + proc_begin(bc); + bc->chr_out = out; + bc->chr_max = max; + bc->chr_count = 0; + bool ok = aci_gatt_disc_all_char_of_service(bc->conn_handle, svc->start, svc->end) == + BLE_STATUS_SUCCESS && + wait_done(bc, PROC_TIMEOUT_MS); + bc->chr_out = NULL; + return (ok || bc->chr_count) ? (int)bc->chr_count : -1; +} + +int bc_read(BleCentral* bc, uint16_t value_handle, uint8_t* out, size_t max) { + if(!bc->connected) return -1; + bc->val_out = out; + bc->val_max = max; + bc->val_len = 0; + proc_begin(bc); + bool ok = aci_gatt_read_char_value(bc->conn_handle, value_handle) == BLE_STATUS_SUCCESS && + wait_done(bc, PROC_TIMEOUT_MS); + // A full-size response means there may be more: fetch the rest with read-long. + size_t chunk = bc->mtu - 1; + while(ok && bc->val_len % chunk == 0 && bc->val_len > 0 && bc->val_len < max) { + size_t before = bc->val_len; + proc_begin(bc); + bool more = aci_gatt_read_long_char_value(bc->conn_handle, value_handle, before) == + BLE_STATUS_SUCCESS && + wait_done(bc, PROC_TIMEOUT_MS); + if(!more || bc->val_len == before) break; // short/failed continuation: keep what we have + } + bc->val_out = NULL; + return ok ? (int)bc->val_len : -1; +} + +bool bc_write(BleCentral* bc, uint16_t value_handle, const uint8_t* data, uint8_t len, bool with_resp) { + if(!bc->connected) return false; + proc_begin(bc); + tBleStatus st; + if(with_resp) { + st = aci_gatt_write_char_value(bc->conn_handle, value_handle, len, data); + if(st != BLE_STATUS_SUCCESS) { + bc->last_error = st; + return false; + } + return wait_done(bc, PROC_TIMEOUT_MS); + } + st = aci_gatt_write_without_resp(bc->conn_handle, value_handle, len, data); + bc->last_error = st; + return st == BLE_STATUS_SUCCESS; +} + +bool bc_set_notify(BleCentral* bc, BcChar* c, uint16_t end_handle, bool enable) { + if(!bc->connected) return false; + if(!c->cccd_handle) { + if(end_handle <= c->value_handle) return false; + proc_begin(bc); + bc->found_cccd = 0; + bool ok = aci_gatt_disc_all_char_desc(bc->conn_handle, c->value_handle + 1, end_handle) == + BLE_STATUS_SUCCESS && + wait_done(bc, PROC_TIMEOUT_MS); + if(!bc->found_cccd) { + if(ok) bc->last_error = 0x0A; + return false; + } + c->cccd_handle = bc->found_cccd; + } + uint8_t v[2] = {0, 0}; + if(enable) v[0] = (c->props & CHAR_PROP_NOTIFY) ? 0x01 : 0x02; + proc_begin(bc); + tBleStatus st = aci_gatt_write_char_desc(bc->conn_handle, c->cccd_handle, 2, v); + if(st != BLE_STATUS_SUCCESS) { + bc->last_error = st; + return false; + } + return wait_done(bc, PROC_TIMEOUT_MS); +} diff --git a/bt_inspector/ble_central.h b/bt_inspector/ble_central.h new file mode 100644 index 0000000..5f9b270 --- /dev/null +++ b/bt_inspector/ble_central.h @@ -0,0 +1,73 @@ +#pragma once +#include +#include +#include + +#define BC_MAX_DEVICES 32 +#define BC_ADV_MAX 31 +#define BC_NAME_MAX 29 +#define BC_MAX_SERVICES 16 +#define BC_MAX_CHARS 48 +#define BC_VALUE_MAX 256 +#define BC_STALE_MS 60000 + +typedef struct { + uint8_t addr[6]; + uint8_t addr_type; // 0 public, 1 random (resolved 2/3 mapped down) + uint8_t evt_type; // last non-SCAN_RSP advertising type + int8_t rssi; + int8_t rssi_max; + uint32_t first_seen; // furi ticks + uint32_t last_seen; + uint32_t packets; + uint8_t adv[BC_ADV_MAX]; + uint8_t adv_len; + uint8_t rsp[BC_ADV_MAX]; + uint8_t rsp_len; + char name[BC_NAME_MAX + 1]; + bool used; + bool logged; // false until the UI has written it to the log +} BcDevice; + +typedef struct { + uint16_t start, end; + uint8_t uuid_len; + uint8_t uuid[16]; +} BcService; + +typedef struct { + uint16_t decl_handle, value_handle, cccd_handle; + uint8_t props; // CHAR_PROP_* bits + uint8_t uuid_len; + uint8_t uuid[16]; +} BcChar; + +typedef void (*BcNotifyCb)(uint16_t value_handle, const uint8_t* data, uint8_t len, void* ctx); + +typedef struct BleCentral BleCentral; + +bool bc_supported(void); // true when the copro runs the full BLE stack +BleCentral* bc_alloc(BcNotifyCb cb, void* ctx); +void bc_free(BleCentral* bc); + +bool bc_scan_start(BleCentral* bc); +void bc_scan_stop(BleCentral* bc); +// Copies live devices sorted by RSSI, evicts stale ones, marks returned ones as logged. +size_t bc_snapshot(BleCentral* bc, BcDevice* out, size_t max); +bool bc_get_device(BleCentral* bc, const uint8_t* addr, BcDevice* out); + +bool bc_connect(BleCentral* bc, const BcDevice* dev, uint32_t timeout_ms); +void bc_disconnect(BleCentral* bc); +bool bc_is_connected(BleCentral* bc); +uint16_t bc_mtu(BleCentral* bc); +uint8_t bc_last_error(BleCentral* bc); // ATT/BLE error, 0xFE timeout, 0xFD disconnected + +int bc_discover_services(BleCentral* bc, BcService* out, size_t max); +int bc_discover_chars(BleCentral* bc, const BcService* svc, BcChar* out, size_t max); +int bc_read(BleCentral* bc, uint16_t value_handle, uint8_t* out, size_t max); +bool bc_write(BleCentral* bc, uint16_t value_handle, const uint8_t* data, uint8_t len, bool with_resp); +// end_handle: last handle that can belong to this characteristic (next decl - 1, or service end) +bool bc_set_notify(BleCentral* bc, BcChar* c, uint16_t end_handle, bool enable); + +// First AD structure of `type` in adv data; returns payload pointer or NULL. +const uint8_t* bc_adv_find(const uint8_t* data, uint8_t len, uint8_t type, uint8_t* out_len); diff --git a/bt_inspector/ble_names.c b/bt_inspector/ble_names.c new file mode 100644 index 0000000..8344033 --- /dev/null +++ b/bt_inspector/ble_names.c @@ -0,0 +1,418 @@ +#include "ble_names.h" +#include +#include +#include + +typedef struct { + uint16_t id; + const char* name; +} IdName; + +static const char* lookup(const IdName* t, size_t n, uint16_t id) { + for(size_t i = 0; i < n; i++) + if(t[i].id == id) return t[i].name; + return NULL; +} +#define LOOKUP(t, id) lookup(t, COUNT_OF(t), id) + +// ponytail: curated subset of the SIG company table; unknown IDs print as hex. +static const IdName companies[] = { + {0x0000, "Ericsson"}, {0x0001, "Nokia"}, {0x0002, "Intel"}, + {0x0003, "IBM"}, {0x0004, "Toshiba"}, {0x0006, "Microsoft"}, + {0x0008, "Motorola"}, {0x000A, "CSR"}, {0x000D, "Texas Instr."}, + {0x000F, "Broadcom"}, {0x001D, "Qualcomm"}, {0x0025, "NXP"}, + {0x0030, "ST Micro"}, {0x0046, "MediaTek"}, {0x0047, "Bluegiga"}, + {0x004C, "Apple"}, {0x004F, "Nike"}, {0x0059, "Nordic Semi"}, + {0x005D, "Realtek"}, {0x0065, "HP"}, {0x006B, "Polar"}, + {0x0075, "Samsung"}, {0x0087, "Garmin"}, {0x009E, "Bose"}, + {0x00C4, "LG"}, {0x00D2, "Dialog Semi"}, {0x00E0, "Google"}, + {0x0110, "Nikon"}, {0x012D, "Sony"}, {0x0131, "Cypress"}, + {0x0157, "Huami/Amazfit"}, {0x0171, "Amazon"}, {0x01DA, "Logitech"}, + {0x02E5, "Espressif"}, {0x038F, "Xiaomi"}, {0x0499, "Ruuvi"}, + {0x05A7, "Sonos"}, {0x0822, "Adafruit"}, +}; + +static const IdName uuids[] = { + // Services + {0x1800, "Generic Access"}, {0x1801, "Generic Attribute"}, + {0x1802, "Immediate Alert"}, {0x1803, "Link Loss"}, + {0x1804, "Tx Power"}, {0x1805, "Current Time"}, + {0x1808, "Glucose"}, {0x1809, "Health Thermometer"}, + {0x180A, "Device Information"}, {0x180D, "Heart Rate"}, + {0x180F, "Battery"}, {0x1810, "Blood Pressure"}, + {0x1811, "Alert Notification"}, {0x1812, "HID"}, + {0x1813, "Scan Parameters"}, {0x1814, "Running Speed/Cadence"}, + {0x1815, "Automation IO"}, {0x1816, "Cycling Speed/Cadence"}, + {0x1818, "Cycling Power"}, {0x1819, "Location/Navigation"}, + {0x181A, "Environmental Sensing"}, {0x181B, "Body Composition"}, + {0x181C, "User Data"}, {0x181D, "Weight Scale"}, + {0x181E, "Bond Management"}, {0x181F, "Cont. Glucose Monitor"}, + {0x1822, "Pulse Oximeter"}, {0x1824, "Transport Discovery"}, + {0x1826, "Fitness Machine"}, {0x1827, "Mesh Provisioning"}, + {0x1828, "Mesh Proxy"}, {0x1843, "Audio Input Control"}, + {0x1844, "Volume Control"}, {0x1846, "Coordinated Set Id"}, + {0x1848, "Media Control"}, {0x184E, "Audio Stream Control"}, + {0x1850, "Published Audio Cap."}, {0x1854, "Hearing Access"}, + {0x1855, "Telephony/Media Audio"}, + {0xFE2C, "Google Fast Pair"}, {0xFEAA, "Eddystone"}, + {0xFD6F, "Exposure Notification"}, {0xFE95, "Xiaomi Mi"}, + {0xFDAB, "Xiaomi"}, {0xFEE7, "Tencent"}, + {0xFE59, "Nordic DFU"}, {0xFEF5, "Dialog"}, + {0xFE07, "Sonos"}, {0xFEBE, "Bose"}, + {0xFEED, "Tile"}, {0xFEEC, "Tile"}, + {0xFE9F, "Google"}, {0xFEF3, "Google Nearby"}, + // Characteristics + {0x2A00, "Device Name"}, {0x2A01, "Appearance"}, + {0x2A02, "Peripheral Privacy Flag"},{0x2A03, "Reconnection Address"}, + {0x2A04, "Pref. Conn. Parameters"}, {0x2A05, "Service Changed"}, + {0x2A06, "Alert Level"}, {0x2A07, "Tx Power Level"}, + {0x2A08, "Date Time"}, {0x2A19, "Battery Level"}, + {0x2A1C, "Temperature Measurement"},{0x2A23, "System ID"}, + {0x2A24, "Model Number"}, {0x2A25, "Serial Number"}, + {0x2A26, "Firmware Revision"}, {0x2A27, "Hardware Revision"}, + {0x2A28, "Software Revision"}, {0x2A29, "Manufacturer Name"}, + {0x2A2A, "IEEE Regulatory Cert"}, {0x2A2B, "Current Time"}, + {0x2A37, "Heart Rate Measurement"}, {0x2A38, "Body Sensor Location"}, + {0x2A39, "Heart Rate Control Pt"}, {0x2A4A, "HID Information"}, + {0x2A4B, "Report Map"}, {0x2A4C, "HID Control Point"}, + {0x2A4D, "Report"}, {0x2A4E, "Protocol Mode"}, + {0x2A50, "PnP ID"}, {0x2A53, "RSC Measurement"}, + {0x2A5B, "CSC Measurement"}, {0x2A63, "Cycling Power Meas."}, + {0x2A6D, "Pressure"}, {0x2A6E, "Temperature"}, + {0x2A6F, "Humidity"}, {0x2A9D, "Weight Measurement"}, + {0x2AA6, "Central Addr Resolution"},{0x2AC9, "Resolvable Priv Addr Only"}, + {0x2AD2, "Indoor Bike Data"}, {0x2B29, "Client Supported Features"}, + {0x2B2A, "Database Hash"}, {0x2B3A, "Server Supported Features"}, + // Descriptors + {0x2900, "Extended Properties"}, {0x2901, "User Description"}, + {0x2902, "Client Char Config"}, {0x2903, "Server Char Config"}, + {0x2904, "Presentation Format"}, {0x2906, "Valid Range"}, + {0x2908, "Report Reference"}, +}; + +static const char* appearances[] = { + "Unknown", "Phone", "Computer", "Watch", + "Clock", "Display", "Remote Control", "Eye-glasses", + "Tag", "Keyring", "Media Player", "Barcode Scanner", + "Thermometer", "Heart Rate Sensor", "Blood Pressure", "HID", + "Glucose Meter", "Running Sensor", "Cycling", "Control Device", + "Network Device", "Sensor", "Light Fixture", "Fan", + "HVAC", "Air Conditioning", "Humidifier", "Heating", + "Access Control", "Motorized Device", "Power Device", "Light Source", + "Window Covering", "Audio Sink", "Audio Source", "Motorized Vehicle", + "Domestic Appliance", "Wearable Audio", "Aircraft", "AV Equipment", + "Display Equipment", "Hearing Aid", "Gaming", "Signage", +}; + +static const char* hid_sub[] = { + "HID", "Keyboard", "Mouse", "Joystick", "Gamepad", "Digitizer", + "Card Reader", "Digital Pen", "Barcode Scanner", "Touchpad", "Presentation Remote"}; + +const char* ble_company_name(uint16_t id) { + return LOOKUP(companies, id); +} + +static const uint8_t sig_base[16] = { + 0xFB, 0x34, 0x9B, 0x5F, 0x80, 0x00, 0x00, 0x80, 0x00, 0x10, 0x00, 0x00, 0, 0, 0x00, 0x00}; + +typedef struct { + const char* uuid; // canonical text, upper case + const char* name; +} UuidName; +static const UuidName uuids128[] = { + {"6E400001-B5A3-F393-E0A9-E50E24DCCA9E", "Nordic UART"}, + {"6E400002-B5A3-F393-E0A9-E50E24DCCA9E", "Nordic UART RX"}, + {"6E400003-B5A3-F393-E0A9-E50E24DCCA9E", "Nordic UART TX"}, + {"7905F431-B5CE-4E99-A40F-4B1E122D00D0", "Apple Notification Center"}, + {"89D3502B-0F36-433A-8EF4-C502AD55F8DC", "Apple Media"}, + {"D0611E78-BBB4-4591-A5F8-487910AE4366", "Apple Continuity"}, + {"9FA480E0-4967-4542-9390-D343DC5D04AE", "Apple Nearby"}, + {"8667556C-9A37-4C91-84ED-54EE27D90049", "Apple Continuity Char"}, + {"AF0BADB1-5B99-43CD-917A-A77BC549E3CC", "Apple Nearby Char"}, +}; + +const char* ble_uuid_name(const uint8_t* uuid, uint8_t len) { + if(len == 2) return LOOKUP(uuids, uuid[0] | (uuid[1] << 8)); + if(len != 16) return NULL; + if(memcmp(uuid, sig_base, 12) == 0 && uuid[14] == 0 && uuid[15] == 0) + return LOOKUP(uuids, uuid[12] | (uuid[13] << 8)); + FuriString* s = furi_string_alloc(); + ble_uuid_str(s, uuid, 16); + const char* r = NULL; + for(size_t i = 0; i < COUNT_OF(uuids128); i++) + if(furi_string_cmp_str(s, uuids128[i].uuid) == 0) { + r = uuids128[i].name; + break; + } + furi_string_free(s); + return r; +} + +const char* ble_appearance_name(uint16_t a) { + uint16_t cat = a >> 6, sub = a & 0x3F; + if(cat == 15 && sub < COUNT_OF(hid_sub)) return hid_sub[sub]; + if(cat < COUNT_OF(appearances)) return appearances[cat]; + if(cat == 49) return "Pulse Oximeter"; + if(cat == 50) return "Weight Scale"; + if(cat == 52) return "Glucose Monitor"; + return NULL; +} + +const char* ble_adv_type_name(uint8_t t) { + static const char* n[] = {"ADV_IND", "ADV_DIRECT_IND", "ADV_SCAN_IND", "ADV_NONCONN_IND", "SCAN_RSP"}; + return t < COUNT_OF(n) ? n[t] : "?"; +} + +const char* ble_att_error_name(uint8_t c) { + static const IdName e[] = { + {0x01, "Invalid handle"}, {0x02, "Read not permitted"}, + {0x03, "Write not permitted"},{0x04, "Invalid PDU"}, + {0x05, "Insufficient auth"}, {0x06, "Not supported"}, + {0x07, "Invalid offset"}, {0x08, "Insufficient authz"}, + {0x0A, "Attribute not found"},{0x0D, "Invalid length"}, + {0x0E, "Unlikely error"}, {0x0F, "Insufficient encryption"}, + {0x11, "Insufficient resources"}, + {0xFD, "Disconnected"}, {0xFE, "Timeout"}, + }; + return LOOKUP(e, c); +} + +void ble_hex(FuriString* out, const uint8_t* data, size_t len) { + for(size_t i = 0; i < len; i++) furi_string_cat_printf(out, "%s%02X", i ? " " : "", data[i]); +} + +void ble_uuid_str(FuriString* out, const uint8_t* u, uint8_t len) { + if(len == 2) { + furi_string_cat_printf(out, "%04X", u[0] | (u[1] << 8)); + return; + } + // 128-bit UUIDs are little-endian on the wire + for(int i = 15; i >= 0; i--) { + furi_string_cat_printf(out, "%02X", u[i]); + if(i == 12 || i == 10 || i == 8 || i == 6) furi_string_cat_str(out, "-"); + } +} + +void ble_addr_str(FuriString* out, const uint8_t* a) { + furi_string_cat_printf( + out, "%02X:%02X:%02X:%02X:%02X:%02X", a[5], a[4], a[3], a[2], a[1], a[0]); +} + +// ---- Apple Continuity ------------------------------------------------------- +// ponytail: community-documented, unofficial; model/battery layouts may drift. +static const IdName apple_models[] = { + {0x0220, "AirPods"}, {0x0F20, "AirPods 2"}, {0x1320, "AirPods 3"}, + {0x1920, "AirPods 4"}, {0x1B20, "AirPods 4 ANC"}, {0x0E20, "AirPods Pro"}, + {0x1420, "AirPods Pro 2"}, {0x2420, "AirPods Pro 2 USB-C"}, + {0x0A20, "AirPods Max"}, {0x1F20, "AirPods Max USB-C"}, + {0x0320, "Powerbeats3"}, {0x0B20, "Powerbeats Pro"}, {0x1220, "Powerbeats 4"}, + {0x0520, "BeatsX"}, {0x0620, "Beats Solo3"}, {0x0920, "Beats Studio3"}, + {0x1020, "Beats Flex"}, {0x1120, "Beats Studio Buds"},{0x1620, "Beats Fit Pro"}, + {0x1720, "Beats Studio Buds+"},{0x1820, "Beats Studio Pro"}, {0x1C20, "Beats Solo Buds"}, +}; + +static const IdName apple_nearby_actions[] = { + {0x01, "Apple TV Setup"}, {0x04, "Mobile Backup"}, {0x05, "Watch Setup"}, + {0x06, "Apple TV Pair"}, {0x07, "Internet Relay"}, {0x08, "WiFi Password"}, + {0x09, "iOS Setup"}, {0x0A, "Repair"}, {0x0B, "Speaker Setup"}, + {0x0C, "Apple Pay"}, {0x0D, "Whole Home Audio"}, {0x0E, "Dev Tools Pairing"}, + {0x0F, "Answered Call"}, {0x10, "Ended Call"}, {0x11, "DD Ping"}, + {0x12, "DD Pong"}, {0x13, "Remote AutoFill"},{0x14, "Companion Link"}, + {0x15, "Remote Mgmt"}, {0x17, "Remote Display"}, +}; + +static const IdName apple_nearby_info[] = { + {0x01, "Activity reporting off"}, {0x03, "Idle"}, + {0x05, "Audio, screen off"}, {0x07, "Screen on"}, + {0x09, "Video playing"}, {0x0A, "Watch on wrist"}, + {0x0B, "Recent interaction"}, {0x0D, "Driving"}, + {0x0E, "Phone call"}, +}; + +static void pct(FuriString* out, const char* label, uint8_t nib) { + if(nib == 0xF) + furi_string_cat_printf(out, " %s:?", label); + else + furi_string_cat_printf(out, " %s:%u%%", label, nib * 10); +} + +static void decode_apple(FuriString* out, const uint8_t* d, uint8_t len) { + uint8_t i = 0; + while(i + 2 <= len) { + uint8_t type = d[i], l = d[i + 1]; + const uint8_t* p = d + i + 2; + if(i + 2 + l > len) l = len - i - 2; + switch(type) { + case 0x02: + furi_string_cat_str(out, " iBeacon"); + if(l >= 20) { + furi_string_cat_str(out, " "); + for(int k = 0; k < 16; k++) furi_string_cat_printf(out, "%02X", p[k]); + furi_string_cat_printf( + out, "\n major %u minor %u tx %d", (p[16] << 8) | p[17], (p[18] << 8) | p[19], + (int8_t)p[20]); + } + furi_string_cat_str(out, "\n"); + break; + case 0x05: furi_string_cat_str(out, " AirDrop\n"); break; + case 0x07: { + furi_string_cat_str(out, " Proximity Pairing"); + if(l >= 3) { + uint16_t model = (p[1] << 8) | p[2]; + const char* m = LOOKUP(apple_models, model); + if(m) + furi_string_cat_printf(out, ": %s", m); + else + furi_string_cat_printf(out, ": model %04X", model); + } + if(l >= 6) { + furi_string_cat_str(out, "\n Battery"); + pct(out, "L", p[4] >> 4); + pct(out, "R", p[4] & 0xF); + pct(out, "Case", p[5] & 0xF); + if(p[5] & 0x70) + furi_string_cat_printf( + out, " chg:%s%s%s", (p[5] & 0x20) ? "L" : "", (p[5] & 0x10) ? "R" : "", + (p[5] & 0x40) ? "C" : ""); + } + furi_string_cat_str(out, "\n"); + } break; + case 0x09: furi_string_cat_str(out, " AirPlay Target\n"); break; + case 0x0A: furi_string_cat_str(out, " AirPlay Source\n"); break; + case 0x0B: furi_string_cat_str(out, " Magic Switch (Watch)\n"); break; + case 0x0C: furi_string_cat_str(out, " Handoff\n"); break; + case 0x0D: furi_string_cat_str(out, " Tethering Target\n"); break; + case 0x0E: furi_string_cat_str(out, " Tethering Source\n"); break; + case 0x0F: { + const char* a = l >= 2 ? LOOKUP(apple_nearby_actions, p[1]) : NULL; + furi_string_cat_printf(out, " Nearby Action: %s\n", a ? a : "?"); + } break; + case 0x10: { + const char* s = l >= 1 ? LOOKUP(apple_nearby_info, p[0] & 0x0F) : NULL; + furi_string_cat_printf(out, " Nearby Info: %s\n", s ? s : "iOS device"); + } break; + case 0x12: furi_string_cat_str(out, " Find My\n"); break; + default: furi_string_cat_printf(out, " Type %02X (%u B)\n", type, l); break; + } + i += 2 + l; + } +} + +// ---- Microsoft Connected Devices Platform beacon --------------------------- +static const IdName ms_types[] = { + {1, "Xbox One"}, {6, "iPhone"}, {7, "iPad"}, + {8, "Android"}, {9, "Windows Desktop"}, {11, "Windows Phone"}, + {12, "Linux"}, {13, "Windows IoT"}, {14, "Surface Hub"}, + {15, "Windows Laptop"}, {16, "Windows Tablet"}, +}; + +void ble_decode_mfg(FuriString* out, uint16_t company, const uint8_t* d, uint8_t len) { + if(company == 0x004C) { + decode_apple(out, d, len); + } else if(company == 0x0006 && len >= 2 && d[0] == 0x01) { + const char* t = LOOKUP(ms_types, d[1] & 0x1F); + furi_string_cat_printf(out, " Windows CDP: %s\n", t ? t : "?"); + } +} + +void ble_decode_svc_data(FuriString* out, uint16_t uuid, const uint8_t* d, uint8_t len) { + if(uuid == 0xFE2C && len >= 3) { + furi_string_cat_printf(out, " Fast Pair model %02X%02X%02X\n", d[0], d[1], d[2]); + } else if(uuid == 0xFEAA && len >= 1) { + const char* f = d[0] == 0x00 ? "UID" : d[0] == 0x10 ? "URL" : d[0] == 0x20 ? "TLM" : "?"; + furi_string_cat_printf(out, " Eddystone %s\n", f); + if(d[0] == 0x10 && len > 2) { + static const char* pre[] = {"http://www.", "https://www.", "http://", "https://"}; + furi_string_cat_printf(out, " %s", d[2] < 4 ? pre[d[2]] : ""); + for(uint8_t i = 3; i < len; i++) + if(isprint(d[i])) furi_string_push_back(out, d[i]); + furi_string_cat_str(out, "\n"); + } + } else if(uuid == 0xFD6F) { + furi_string_cat_str(out, " COVID Exposure Notification\n"); + } +} + +static bool all_printable(const uint8_t* d, size_t len) { + if(!len) return false; + for(size_t i = 0; i < len; i++) + if(!isprint(d[i]) && d[i] != '\n' && d[i] != '\r' && d[i] != '\t') return false; + return true; +} + +void ble_decode_value(FuriString* out, const uint8_t* uuid, uint8_t uuid_len, const uint8_t* d, size_t len) { + uint16_t u = 0; + if(uuid_len == 2) u = uuid[0] | (uuid[1] << 8); + else if(uuid_len == 16 && memcmp(uuid, sig_base, 12) == 0) u = uuid[12] | (uuid[13] << 8); + + switch(u) { + case 0x2A19: + if(len >= 1) furi_string_cat_printf(out, "%u%%\n", d[0]); + return; + case 0x2A01: + if(len >= 2) { + uint16_t a = d[0] | (d[1] << 8); + const char* n = ble_appearance_name(a); + furi_string_cat_printf(out, "%s (0x%04X)\n", n ? n : "?", a); + } + return; + case 0x2A04: + if(len >= 8) + furi_string_cat_printf( + out, "int %u-%u ms, lat %u, to %u ms\n", (d[0] | (d[1] << 8)) * 5 / 4, + (d[2] | (d[3] << 8)) * 5 / 4, d[4] | (d[5] << 8), (d[6] | (d[7] << 8)) * 10); + return; + case 0x2A50: + if(len >= 7) + furi_string_cat_printf( + out, "%s VID %04X PID %04X ver %04X\n", d[0] == 1 ? "SIG" : d[0] == 2 ? "USB" : "?", + d[1] | (d[2] << 8), d[3] | (d[4] << 8), d[5] | (d[6] << 8)); + return; + case 0x2A37: + if(len >= 2) { + uint16_t bpm = (d[0] & 1) ? (d[1] | (d[2] << 8)) : d[1]; + furi_string_cat_printf(out, "%u bpm\n", bpm); + } + return; + case 0x2A6E: + if(len >= 2) { + int16_t t = d[0] | (d[1] << 8); + furi_string_cat_printf(out, "%d.%02d C\n", t / 100, abs(t % 100)); + } + return; + case 0x2A6F: + if(len >= 2) { + uint16_t h = d[0] | (d[1] << 8); + furi_string_cat_printf(out, "%u.%02u %%RH\n", h / 100, h % 100); + } + return; + case 0x2A6D: + if(len >= 4) { + uint32_t p = d[0] | (d[1] << 8) | (d[2] << 16) | ((uint32_t)d[3] << 24); + furi_string_cat_printf(out, "%lu.%lu hPa\n", p / 1000, (p / 100) % 10); + } + return; + case 0x2A07: + if(len >= 1) furi_string_cat_printf(out, "%d dBm\n", (int8_t)d[0]); + return; + case 0x2A2B: + case 0x2A08: + if(len >= 7) + furi_string_cat_printf( + out, "%04u-%02u-%02u %02u:%02u:%02u\n", d[0] | (d[1] << 8), d[2], d[3], d[4], d[5], + d[6]); + return; + default: break; + } + // Generic: string if printable, plus small integer interpretations. + if(all_printable(d, len)) { + furi_string_cat_str(out, "\""); + for(size_t i = 0; i < len; i++) furi_string_push_back(out, d[i]); + furi_string_cat_str(out, "\"\n"); + } + if(len == 1) furi_string_cat_printf(out, "u8 %u / i8 %d\n", d[0], (int8_t)d[0]); + if(len == 2) furi_string_cat_printf(out, "u16 %u\n", d[0] | (d[1] << 8)); + if(len == 4) + furi_string_cat_printf( + out, "u32 %lu\n", (uint32_t)(d[0] | (d[1] << 8) | (d[2] << 16) | ((uint32_t)d[3] << 24))); +} diff --git a/bt_inspector/ble_names.h b/bt_inspector/ble_names.h new file mode 100644 index 0000000..1f3df2e --- /dev/null +++ b/bt_inspector/ble_names.h @@ -0,0 +1,21 @@ +#pragma once +#include +#include + +// Lookup tables and decoders. All return static strings or NULL when unknown. +const char* ble_company_name(uint16_t id); +const char* ble_uuid_name(const uint8_t* uuid, uint8_t len); // len 2 or 16, little-endian +const char* ble_appearance_name(uint16_t appearance); +const char* ble_adv_type_name(uint8_t evt_type); +const char* ble_att_error_name(uint8_t code); + +void ble_hex(FuriString* out, const uint8_t* data, size_t len); +void ble_uuid_str(FuriString* out, const uint8_t* uuid, uint8_t len); +void ble_addr_str(FuriString* out, const uint8_t* addr); + +// Manufacturer-specific data decoders (Apple Continuity, Microsoft CDP, ...) +void ble_decode_mfg(FuriString* out, uint16_t company, const uint8_t* d, uint8_t len); +// Service data decoders (Fast Pair, Eddystone, Exposure Notification, ...) +void ble_decode_svc_data(FuriString* out, uint16_t uuid, const uint8_t* d, uint8_t len); +// Characteristic value decoder: appends "decoded" representation(s) of a value. +void ble_decode_value(FuriString* out, const uint8_t* uuid, uint8_t uuid_len, const uint8_t* d, size_t len); diff --git a/bt_inspector/bt_inspector.c b/bt_inspector/bt_inspector.c new file mode 100644 index 0000000..740bde1 --- /dev/null +++ b/bt_inspector/bt_inspector.c @@ -0,0 +1,1222 @@ +// BT Inspector: BLE scanner + GATT explorer for Flipper Zero (full-stack firmware build). +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "ble_central.h" +#include "ble_names.h" + +#define TAG "BtInspector" +#define LOG_DIR EXT_PATH("apps_data/bt_inspector") +#define HIST_MAX 16 // ponytail: history kept for the open characteristic only; the .log has everything +#define HIST_DATA 24 +#define ROWS 3u +#define INFO_ITEM 0xFFFF + +typedef enum { + ViewScan, + ViewPage, + ViewPopup, + ViewServices, + ViewChars, + ViewWriteMenu, + ViewTextInput, + ViewNumberInput, +} ViewId; + +typedef enum { + EvtTick, + EvtScanSelect, + EvtPageLeft, + EvtPageCenter, + EvtPageRight, + EvtJobDone, + EvtNotify, + EvtPopupDone, +} AppEvent; + +typedef enum { + JobConnect, + JobServices, + JobChars, + JobRead, + JobWrite, + JobNotify, + JobInfo, + JobDisconnect, + JobQuit, +} JobType; + +typedef enum { PageError, PageDetails, PageChar, PageInfo } PageKind; +typedef enum { AfterPopupDetails, AfterPopupDisconnect, AfterPopupServices } AfterPopup; + +typedef struct { + BcDevice devs[BC_MAX_DEVICES]; + size_t count, sel, top; + bool scanning; +} ScanModel; + +typedef struct { + FuriString* text; + size_t scroll, max_scroll; + const char *left, *center, *right; +} PageModel; + +typedef struct { + char time[12]; + uint8_t len; + uint8_t data[HIST_DATA]; +} HistEntry; + +typedef struct { + uint16_t handle; + uint8_t len; + uint8_t data[HIST_DATA]; +} NotifyMsg; + +typedef struct { + Gui* gui; + ViewDispatcher* vd; + View* scan_view; + View* page_view; + Popup* popup; + Submenu* services_menu; + Submenu* chars_menu; + Submenu* write_menu; + TextInput* text_input; + NumberInput* number_input; + FuriTimer* timer; + Storage* storage; + File* log; + + BleCentral* bc; + FuriThread* worker; + FuriMessageQueue* jobs; + FuriMessageQueue* notify_q; + + ViewId cur_view; + PageKind page_kind; + AfterPopup after_popup; + bool was_advertising; + + BcDevice dev; + BcService services[BC_MAX_SERVICES]; + int svc_count, svc_sel; + BcChar chars[BC_MAX_CHARS]; + int chr_count, chr_sel; + uint8_t value[BC_VALUE_MAX]; + int value_len; + bool notify_on; + HistEntry hist[HIST_MAX]; + int hist_count; + uint8_t write_buf[BC_VALUE_MAX]; + uint8_t write_len; + int write_mode; // 0 text, 1 hex, 2 number + char text_buf[128]; + + JobType job; + bool job_ok; + FuriString* status; + FuriString* info_text; +} App; + +static App* nav_app; // navigation callbacks only receive the view's own context + +// ---- logging ----------------------------------------------------------------- + +static void now_str(char* out, size_t n) { + DateTime dt; + furi_hal_rtc_get_datetime(&dt); + snprintf(out, n, "%02u:%02u:%02u", dt.hour, dt.minute, dt.second); +} + +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 "/bti_%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)) { + FURI_LOG_W(TAG, "log open failed: %s", furi_string_get_cstr(p)); + storage_file_free(app->log); + app->log = NULL; + } + furi_string_free(p); +} + +// ---- text helpers ------------------------------------------------------------ + +static const char* vendor_of(const BcDevice* d, uint16_t* id) { + uint8_t l = 0; + const uint8_t* m = bc_adv_find(d->adv, d->adv_len, 0xFF, &l); + if(!m) m = bc_adv_find(d->rsp, d->rsp_len, 0xFF, &l); + if(!m || l < 2) return NULL; + *id = m[0] | (m[1] << 8); + return ble_company_name(*id); +} + +static const char* dev_label(const BcDevice* d) { + return d->name[0] ? d->name : "(no name)"; +} + +static void char_label(FuriString* out, const BcChar* c) { + const char* n = ble_uuid_name(c->uuid, c->uuid_len); + if(n) { + furi_string_set_str(out, n); + } else { + furi_string_reset(out); + ble_uuid_str(out, c->uuid, c->uuid_len); + } +} + +static void cat_uuid_list(FuriString* out, const uint8_t* p, uint8_t len, uint8_t ulen) { + for(uint8_t i = 0; i + ulen <= len; i += ulen) { + const char* n = ulen == 4 ? NULL : ble_uuid_name(p + i, ulen); + furi_string_cat_str(out, " "); + if(ulen == 4) + furi_string_cat_printf(out, "%02X%02X%02X%02X", p[i + 3], p[i + 2], p[i + 1], p[i]); + else + ble_uuid_str(out, p + i, ulen); + if(n) furi_string_cat_printf(out, " %s", n); + furi_string_cat_str(out, "\n"); + } +} + +static void walk_ad(FuriString* out, const uint8_t* data, uint8_t len) { + uint8_t i = 0; + while(i < len) { + uint8_t l = data[i]; + if(!l || i + 1 + l > len) break; + uint8_t t = data[i + 1], pl = l - 1; + const uint8_t* p = data + i + 2; + switch(t) { + case 0x01: + if(pl) { + furi_string_cat_printf(out, "Flags: %02X", p[0]); + if(p[0] & 0x01) furi_string_cat_str(out, " LtdDisc"); + if(p[0] & 0x02) furi_string_cat_str(out, " GenDisc"); + if(p[0] & 0x04) furi_string_cat_str(out, " NoBR/EDR"); + furi_string_cat_str(out, "\n"); + } + break; + case 0x02: + case 0x03: + furi_string_cat_str(out, "Services (16-bit):\n"); + cat_uuid_list(out, p, pl, 2); + break; + case 0x04: + case 0x05: + furi_string_cat_str(out, "Services (32-bit):\n"); + cat_uuid_list(out, p, pl, 4); + break; + case 0x06: + case 0x07: + furi_string_cat_str(out, "Services (128-bit):\n"); + cat_uuid_list(out, p, pl, 16); + break; + case 0x08: + case 0x09: break; // name shown in the title + case 0x0A: + if(pl) furi_string_cat_printf(out, "Tx power: %d dBm\n", (int8_t)p[0]); + break; + case 0x16: + if(pl >= 2) { + uint16_t u = p[0] | (p[1] << 8); + const char* n = ble_uuid_name(p, 2); + furi_string_cat_printf(out, "Svc data %04X%s%s: ", u, n ? " " : "", n ? n : ""); + ble_hex(out, p + 2, pl - 2); + furi_string_cat_str(out, "\n"); + ble_decode_svc_data(out, u, p + 2, pl - 2); + } + break; + case 0x19: + if(pl >= 2) { + uint16_t a = p[0] | (p[1] << 8); + const char* n = ble_appearance_name(a); + furi_string_cat_printf(out, "Appearance: %s (%04X)\n", n ? n : "?", a); + } + break; + case 0xFF: + if(pl >= 2) { + uint16_t c = p[0] | (p[1] << 8); + const char* n = ble_company_name(c); + furi_string_cat_printf(out, "Mfg %04X %s: ", c, n ? n : "?"); + ble_hex(out, p + 2, pl - 2); + furi_string_cat_str(out, "\n"); + ble_decode_mfg(out, c, p + 2, pl - 2); + } + break; + default: + furi_string_cat_printf(out, "AD %02X: ", t); + ble_hex(out, p, pl); + furi_string_cat_str(out, "\n"); + break; + } + i += 1 + l; + } +} + +static void build_details(FuriString* out, const BcDevice* d) { + furi_string_printf(out, "%s\n", dev_label(d)); + ble_addr_str(out, d->addr); + furi_string_cat_printf(out, " %s\n", d->addr_type ? "random" : "public"); + furi_string_cat_printf( + out, "RSSI %d dBm (best %d) %lu pkts\n", d->rssi, d->rssi_max, (unsigned long)d->packets); + furi_string_cat_printf( + out, "%s%s, %lus ago\n", ble_adv_type_name(d->evt_type), d->evt_type <= 1 ? " connectable" : "", + (unsigned long)((furi_get_tick() - d->last_seen) / 1000)); + walk_ad(out, d->adv, d->adv_len); + walk_ad(out, d->rsp, d->rsp_len); + furi_string_cat_str(out, "Raw adv: "); + ble_hex(out, d->adv, d->adv_len); + if(d->rsp_len) { + furi_string_cat_str(out, "\nRaw rsp: "); + ble_hex(out, d->rsp, d->rsp_len); + } +} + +static void log_device(App* app, const BcDevice* d) { + FuriString* s = furi_string_alloc(); + ble_addr_str(s, d->addr); + uint16_t cid = 0; + const char* v = vendor_of(d, &cid); + FuriString* adv = furi_string_alloc(); + ble_hex(adv, d->adv, d->adv_len); + app_log( + app, "DEVICE %s %s rssi=%d name=\"%s\" vendor=%s adv=%s", furi_string_get_cstr(s), + ble_adv_type_name(d->evt_type), d->rssi, d->name, v ? v : "?", furi_string_get_cstr(adv)); + furi_string_free(adv); + furi_string_free(s); +} + +// ---- scan view --------------------------------------------------------------- + +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, "BT Inspector"); + canvas_set_font(c, FontSecondary); + furi_string_printf(s, m->scanning ? "%u found" : "%u (stopped)", (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)); + int bars = d->rssi > -55 ? 4 : d->rssi > -65 ? 3 : d->rssi > -75 ? 2 : d->rssi > -85 ? 1 : 0; + for(int b = 0; b < bars; b++) canvas_draw_box(c, 96 + b * 3, y + 8 - (b + 1) * 2, 2, (b + 1) * 2); + furi_string_reset(s); + ble_addr_str(s, d->addr); + uint16_t cid = 0; + const char* v = vendor_of(d, &cid); + 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) { + if(e->type == InputTypeShort) view_dispatcher_send_custom_event(app->vd, EvtScanSelect); + 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); + if(!ok) app_log(app, "SCAN START FAILED 0x%02X", bc_last_error(app->bc)); + with_view_model(app->scan_view, ScanModel * m, { m->scanning = ok; }, true); +} + +// ---- page view (scrollable text with optional buttons) ----------------------- + +static void page_draw(Canvas* c, void* m_) { + PageModel* m = m_; + canvas_clear(c); + canvas_set_font(c, FontSecondary); + bool buttons = m->left || m->center || m->right; + size_t visible = buttons ? 5 : 6; + const char* p = furi_string_get_cstr(m->text); + char line[64]; + size_t idx = 0, drawn = 0; + while(*p) { + size_t n = 0, last_space = 0; + int w = 0; + while(p[n] && p[n] != '\n' && n < sizeof(line) - 1) { + int cw = canvas_glyph_width(c, p[n]); + if(w + cw > 123 && n > 0) break; + if(p[n] == ' ') last_space = n; + w += cw; + n++; + } + bool mid = p[n] && p[n] != '\n'; + if(mid && last_space) n = last_space; + if(idx >= m->scroll && drawn < visible) { + memcpy(line, p, n); + line[n] = 0; + canvas_draw_str(c, 1, 9 + drawn * 10, line); + drawn++; + } + idx++; + p += n; + if(*p == '\n' || (*p == ' ' && mid)) p++; + } + m->max_scroll = idx > visible ? idx - visible : 0; + if(idx > visible) elements_scrollbar_pos(c, 126, 0, buttons ? 50 : 63, m->scroll, idx); + if(m->left) elements_button_left(c, m->left); + if(m->center) elements_button_center(c, m->center); + if(m->right) elements_button_right(c, m->right); +} + +static bool page_input(InputEvent* e, void* ctx) { + App* app = ctx; + if(e->type != InputTypeShort && e->type != InputTypeRepeat) return false; + bool consumed = false, send = false; + AppEvent ev = EvtPageCenter; + with_view_model( + app->page_view, + PageModel * m, + { + switch(e->key) { + case InputKeyUp: + if(m->scroll) m->scroll--; + consumed = true; + break; + case InputKeyDown: + if(m->scroll < m->max_scroll) m->scroll++; + consumed = true; + break; + case InputKeyLeft: + send = m->left != NULL; + ev = EvtPageLeft; + break; + case InputKeyOk: + send = m->center != NULL; + break; + case InputKeyRight: + send = m->right != NULL; + ev = EvtPageRight; + break; + default: break; + } + }, + consumed); + if(send && e->type == InputTypeShort) { + view_dispatcher_send_custom_event(app->vd, ev); + return true; + } + return consumed; +} + +static void page_set( + App* app, + PageKind kind, + FuriString* text, + const char* left, + const char* center, + const char* right, + bool reset_scroll) { + app->page_kind = kind; + with_view_model( + app->page_view, + PageModel * m, + { + furi_string_set(m->text, text); + if(reset_scroll) m->scroll = 0; + m->left = left; + m->center = center; + m->right = right; + }, + true); +} + +static void show_view(App* app, ViewId id) { + app->cur_view = id; + view_dispatcher_switch_to_view(app->vd, id); +} + +static void show_details(App* app, bool reset_scroll) { + FuriString* s = furi_string_alloc(); + build_details(s, &app->dev); + page_set(app, PageDetails, s, NULL, app->dev.evt_type <= 1 ? "Connect" : NULL, NULL, reset_scroll); + furi_string_free(s); +} + +static void props_str(FuriString* out, uint8_t p) { + if(p & CHAR_PROP_READ) furi_string_cat_str(out, "R"); + if(p & CHAR_PROP_WRITE) furi_string_cat_str(out, "W"); + if(p & CHAR_PROP_WRITE_WITHOUT_RESP) furi_string_cat_str(out, "w"); + if(p & CHAR_PROP_NOTIFY) furi_string_cat_str(out, "N"); + if(p & CHAR_PROP_INDICATE) furi_string_cat_str(out, "I"); + if(p & CHAR_PROP_BROADCAST) furi_string_cat_str(out, "B"); +} + +static void show_char(App* app, bool reset_scroll) { + BcChar* c = &app->chars[app->chr_sel]; + FuriString* s = furi_string_alloc(); + FuriString* t = furi_string_alloc(); + char_label(t, c); + furi_string_printf(s, "%s\nUUID ", furi_string_get_cstr(t)); + ble_uuid_str(s, c->uuid, c->uuid_len); + furi_string_cat_printf(s, "\nHandle 0x%04X Props ", c->value_handle); + props_str(s, c->props); + furi_string_cat_printf(s, "\n%s\n", furi_string_get_cstr(app->status)); + if(app->value_len >= 0 && (app->value_len > 0 || app->hist_count)) { + furi_string_cat_printf(s, "Value (%d B): ", app->value_len); + ble_hex(s, app->value, app->value_len); + furi_string_cat_str(s, "\n"); + ble_decode_value(s, c->uuid, c->uuid_len, app->value, app->value_len); + } + if(app->hist_count) furi_string_cat_str(s, "History:\n"); + for(int i = app->hist_count - 1; i >= 0; i--) { + furi_string_cat_printf(s, "%s ", app->hist[i].time); + ble_hex(s, app->hist[i].data, app->hist[i].len); + furi_string_cat_str(s, "\n"); + } + bool can_sub = c->props & (CHAR_PROP_NOTIFY | CHAR_PROP_INDICATE); + bool can_write = c->props & (CHAR_PROP_WRITE | CHAR_PROP_WRITE_WITHOUT_RESP); + page_set( + app, PageChar, s, can_sub ? (app->notify_on ? "Unsub" : "Notify") : NULL, + (c->props & CHAR_PROP_READ) ? "Read" : NULL, can_write ? "Write" : NULL, reset_scroll); + furi_string_free(t); + furi_string_free(s); +} + +static void hist_push(App* app, const uint8_t* d, uint8_t len) { + if(app->hist_count == HIST_MAX) { + memmove(app->hist, app->hist + 1, sizeof(HistEntry) * (HIST_MAX - 1)); + app->hist_count--; + } + HistEntry* h = &app->hist[app->hist_count++]; + now_str(h->time, sizeof(h->time)); + h->len = len > HIST_DATA ? HIST_DATA : len; + memcpy(h->data, d, h->len); +} + +static void popup_cb(void* ctx); +static void number_input_cb(void* ctx, int32_t number); + +static void show_popup(App* app, const char* header, const char* text, bool timeout) { + popup_reset(app->popup); // clears callback/context too + 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); + } + show_view(app, ViewPopup); +} + +static void post_job(App* app, JobType job) { + furi_message_queue_put(app->jobs, &job, 0); +} + +static const char* err_name(App* app) { + const char* n = ble_att_error_name(bc_last_error(app->bc)); + return n ? n : "error"; +} + +// ---- worker thread ----------------------------------------------------------- + +static uint16_t char_end_handle(App* app) { + if(app->chr_sel + 1 < app->chr_count) return app->chars[app->chr_sel + 1].decl_handle - 1; + return app->services[app->svc_sel].end; +} + +static void do_info(App* app) { + furi_string_printf(app->info_text, "%s\nMTU %u\n", dev_label(&app->dev), bc_mtu(app->bc)); + FuriString* label = furi_string_alloc(); + FuriString* dec = furi_string_alloc(); + bool any = false; + for(int i = 0; i < app->svc_count; i++) { + BcService* s = &app->services[i]; + if(s->uuid_len != 2) continue; + uint16_t u = s->uuid[0] | (s->uuid[1] << 8); + if(u != 0x1800 && u != 0x180A && u != 0x180F) continue; + BcChar tmp[12]; + int n = bc_discover_chars(app->bc, s, tmp, COUNT_OF(tmp)); + for(int k = 0; k < n; k++) { + if(!(tmp[k].props & CHAR_PROP_READ)) continue; + char_label(label, &tmp[k]); + int len = bc_read(app->bc, tmp[k].value_handle, app->value, BC_VALUE_MAX); + furi_string_reset(dec); + if(len < 0) { + furi_string_set_str(dec, err_name(app)); + } else { + ble_decode_value(dec, tmp[k].uuid, tmp[k].uuid_len, app->value, len); + size_t nl = furi_string_search_char(dec, '\n', 0); + if(nl != FURI_STRING_FAILURE) furi_string_left(dec, nl); + if(furi_string_empty(dec)) ble_hex(dec, app->value, len); + } + furi_string_cat_printf( + app->info_text, "%s: %s\n", furi_string_get_cstr(label), furi_string_get_cstr(dec)); + app_log(app, "INFO %s = %s", furi_string_get_cstr(label), furi_string_get_cstr(dec)); + any = true; + } + } + if(!any) furi_string_cat_str(app->info_text, "No GAP / Device Info / Battery service"); + furi_string_free(dec); + furi_string_free(label); +} + +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; + bool ok = false; + switch(job) { + case JobConnect: ok = bc_connect(app->bc, &app->dev, 8000); break; + case JobServices: { + int n = bc_discover_services(app->bc, app->services, BC_MAX_SERVICES); + app->svc_count = n < 0 ? 0 : n; + ok = n >= 0; + } break; + case JobChars: { + int n = bc_discover_chars(app->bc, &app->services[app->svc_sel], app->chars, BC_MAX_CHARS); + app->chr_count = n < 0 ? 0 : n; + ok = n >= 0; + } break; + case JobRead: { + int n = bc_read(app->bc, app->chars[app->chr_sel].value_handle, app->value, BC_VALUE_MAX); + app->value_len = n < 0 ? 0 : n; + ok = n >= 0; + } break; + case JobWrite: { + BcChar* c = &app->chars[app->chr_sel]; + ok = bc_write( + app->bc, c->value_handle, app->write_buf, app->write_len, c->props & CHAR_PROP_WRITE); + } break; + case JobNotify: + ok = bc_set_notify(app->bc, &app->chars[app->chr_sel], char_end_handle(app), !app->notify_on); + if(ok) app->notify_on = !app->notify_on; + break; + case JobInfo: + do_info(app); + ok = true; + break; + case JobDisconnect: + bc_disconnect(app->bc); + ok = true; + break; + default: break; + } + app->job = job; + app->job_ok = ok; + 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 > HIST_DATA ? HIST_DATA : 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); +} + +// ---- menus & inputs ---------------------------------------------------------- + +static void services_cb(void* ctx, uint32_t index); +static void chars_cb(void* ctx, uint32_t index); + +static void fill_services_menu(App* app) { + submenu_reset(app->services_menu); + submenu_set_header(app->services_menu, dev_label(&app->dev)); + submenu_add_item(app->services_menu, "* Read device info", INFO_ITEM, services_cb, app); + FuriString* s = furi_string_alloc(); + for(int i = 0; i < app->svc_count; i++) { + BcService* sv = &app->services[i]; + const char* n = ble_uuid_name(sv->uuid, sv->uuid_len); + furi_string_reset(s); + if(n) { + furi_string_set_str(s, n); + } else { + ble_uuid_str(s, sv->uuid, sv->uuid_len); + } + submenu_add_item(app->services_menu, furi_string_get_cstr(s), i, services_cb, app); + FuriString* u = furi_string_alloc(); + ble_uuid_str(u, sv->uuid, sv->uuid_len); + app_log( + app, "SERVICE %s %s handles 0x%04X-0x%04X", furi_string_get_cstr(u), n ? n : "", + sv->start, sv->end); + furi_string_free(u); + } + furi_string_free(s); +} + +static void fill_chars_menu(App* app) { + submenu_reset(app->chars_menu); + BcService* sv = &app->services[app->svc_sel]; + const char* sn = ble_uuid_name(sv->uuid, sv->uuid_len); + submenu_set_header(app->chars_menu, sn ? sn : "Characteristics"); + FuriString* s = furi_string_alloc(); + FuriString* u = furi_string_alloc(); + for(int i = 0; i < app->chr_count; i++) { + BcChar* c = &app->chars[i]; + char_label(s, c); + furi_string_cat_str(s, " ["); + props_str(s, c->props); + furi_string_cat_str(s, "]"); + submenu_add_item(app->chars_menu, furi_string_get_cstr(s), i, chars_cb, app); + furi_string_reset(u); + ble_uuid_str(u, c->uuid, c->uuid_len); + app_log(app, "CHAR %s handle 0x%04X props 0x%02X", furi_string_get_cstr(u), c->value_handle, c->props); + } + furi_string_free(u); + furi_string_free(s); +} + +static void services_cb(void* ctx, uint32_t index) { + App* app = ctx; + if(index == INFO_ITEM) { + show_popup(app, "Reading info...", "", false); + post_job(app, JobInfo); + return; + } + app->svc_sel = index; + show_popup(app, "Discovering...", "characteristics", false); + post_job(app, JobChars); +} + +static void chars_cb(void* ctx, uint32_t index) { + App* app = ctx; + app->chr_sel = index; + app->hist_count = 0; + app->value_len = 0; + app->notify_on = false; + bool readable = app->chars[index].props & CHAR_PROP_READ; + furi_string_set_str(app->status, readable ? "Reading..." : ""); + show_char(app, true); + show_view(app, ViewPage); + if(readable) post_job(app, JobRead); +} + +static void write_menu_cb(void* ctx, uint32_t index) { + App* app = ctx; + app->write_mode = index; + if(index == 2) { + number_input_set_header_text(app->number_input, "Value (LE int)"); + number_input_set_result_callback(app->number_input, number_input_cb, app, 0, INT32_MIN, INT32_MAX); + show_view(app, ViewNumberInput); + return; + } + app->text_buf[0] = 0; + text_input_set_header_text(app->text_input, index == 0 ? "Text value" : "Hex bytes (e.g. 01FF)"); + text_input_set_minimum_length(app->text_input, 1); + show_view(app, ViewTextInput); +} + +static int hexval(char c) { + if(c >= '0' && c <= '9') return c - '0'; + if(c >= 'a' && c <= 'f') return c - 'a' + 10; + if(c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} + +static void start_write(App* app) { + furi_string_set_str(app->status, "Writing..."); + show_char(app, false); + show_view(app, ViewPage); + post_job(app, JobWrite); +} + +static void text_input_cb(void* ctx) { + App* app = ctx; + if(app->write_mode == 0) { + app->write_len = strlen(app->text_buf); + memcpy(app->write_buf, app->text_buf, app->write_len); + } else { + int n = 0, hi = -1; + for(char* p = app->text_buf; *p; p++) { + if(*p == ' ' || *p == ':' || *p == ',') continue; + int v = hexval(*p); + if(v < 0) { + n = -1; + break; + } + if(hi < 0) { + hi = v; + } else { + app->write_buf[n++] = (hi << 4) | v; + hi = -1; + } + } + if(n <= 0 || hi >= 0) { + furi_string_set_str(app->status, "Bad hex, nothing written"); + show_char(app, false); + show_view(app, ViewPage); + return; + } + app->write_len = n; + } + start_write(app); +} + +static void number_input_cb(void* ctx, int32_t number) { + App* app = ctx; + int len = app->value_len == 1 || app->value_len == 2 || app->value_len == 4 ? app->value_len : + (number >= -128 && number <= 255) ? 1 : + (number >= -32768 && number <= 65535) ? 2 : + 4; + uint32_t v = (uint32_t)number; + for(int i = 0; i < len; i++) app->write_buf[i] = (v >> (8 * i)) & 0xFF; + app->write_len = len; + start_write(app); +} + +static void popup_cb(void* ctx) { + App* app = ctx; + view_dispatcher_send_custom_event(app->vd, EvtPopupDone); +} + +// ---- navigation -------------------------------------------------------------- + +static uint32_t nav_exit(void* ctx) { + UNUSED(ctx); + return VIEW_NONE; +} + +static uint32_t nav_stay_popup(void* ctx) { + UNUSED(ctx); + return ViewPopup; +} + +static uint32_t nav_page_back(void* ctx) { + UNUSED(ctx); + App* app = nav_app; + switch(app->page_kind) { + case PageDetails: app->cur_view = ViewScan; return ViewScan; + case PageChar: app->cur_view = ViewChars; return ViewChars; + case PageInfo: app->cur_view = ViewServices; return ViewServices; + default: return VIEW_NONE; + } +} + +static uint32_t nav_services_back(void* ctx) { + UNUSED(ctx); + App* app = nav_app; + app_log(app, "DISCONNECT"); + post_job(app, JobDisconnect); + show_details(app, true); + app->cur_view = ViewPage; + return ViewPage; +} + +static uint32_t nav_to_services(void* ctx) { + UNUSED(ctx); + nav_app->cur_view = ViewServices; + return ViewServices; +} + +static uint32_t nav_to_page(void* ctx) { + UNUSED(ctx); + nav_app->cur_view = ViewPage; + return ViewPage; +} + +// ---- event handling ---------------------------------------------------------- + +static void on_tick(App* app) { + 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); + for(size_t i = 0; i < m->count; i++) + if(!m->devs[i].logged) log_device(app, &m->devs[i]); + 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; + }, + app->cur_view == ViewScan); + if(app->cur_view == ViewPage && app->page_kind == PageDetails && !bc_is_connected(app->bc)) { + if(bc_get_device(app->bc, app->dev.addr, &app->dev)) show_details(app, false); + } +} + +static void on_job_done(App* app) { + switch(app->job) { + case JobConnect: + if(app->job_ok) { + app_log(app, "CONNECTED %s mtu=%u", dev_label(&app->dev), bc_mtu(app->bc)); + popup_set_header(app->popup, "Discovering...", 64, 18, AlignCenter, AlignCenter); + popup_set_text(app->popup, "services", 64, 40, AlignCenter, AlignCenter); + post_job(app, JobServices); + } else { + app_log(app, "CONNECT FAILED %s: %s", dev_label(&app->dev), err_name(app)); + app->after_popup = AfterPopupDetails; + show_popup(app, "Connect failed", err_name(app), true); + } + break; + case JobServices: + if(app->job_ok) { + fill_services_menu(app); + show_view(app, ViewServices); + } else { + app->after_popup = AfterPopupDisconnect; + show_popup(app, "Discovery failed", err_name(app), true); + } + break; + case JobChars: + if(app->job_ok) { + fill_chars_menu(app); + show_view(app, ViewChars); + } else { + app->after_popup = AfterPopupServices; + show_popup(app, "Discovery failed", err_name(app), true); + } + break; + case JobRead: { + FuriString* h = furi_string_alloc(); + ble_hex(h, app->value, app->value_len); + if(app->job_ok) { + furi_string_printf(app->status, "Read OK (%d B)", app->value_len); + hist_push(app, app->value, app->value_len); + app_log(app, "READ 0x%04X = %s", app->chars[app->chr_sel].value_handle, furi_string_get_cstr(h)); + } else { + furi_string_printf(app->status, "Read failed: %s", err_name(app)); + app_log(app, "READ 0x%04X failed: %s", app->chars[app->chr_sel].value_handle, err_name(app)); + } + furi_string_free(h); + if(app->cur_view == ViewPage && app->page_kind == PageChar) show_char(app, false); + } break; + case JobWrite: { + FuriString* h = furi_string_alloc(); + ble_hex(h, app->write_buf, app->write_len); + if(app->job_ok) { + furi_string_printf(app->status, "Write OK (%u B)", app->write_len); + app_log(app, "WRITE 0x%04X = %s", app->chars[app->chr_sel].value_handle, furi_string_get_cstr(h)); + if(app->chars[app->chr_sel].props & CHAR_PROP_READ) post_job(app, JobRead); + } else { + furi_string_printf(app->status, "Write failed: %s", err_name(app)); + app_log(app, "WRITE 0x%04X failed: %s", app->chars[app->chr_sel].value_handle, err_name(app)); + } + furi_string_free(h); + if(app->cur_view == ViewPage && app->page_kind == PageChar) show_char(app, false); + } break; + case JobNotify: + if(app->job_ok) { + furi_string_set_str(app->status, app->notify_on ? "Notifications on" : "Notifications off"); + } else { + furi_string_printf(app->status, "Subscribe failed: %s", err_name(app)); + } + app_log(app, "NOTIFY 0x%04X %s", app->chars[app->chr_sel].value_handle, furi_string_get_cstr(app->status)); + if(app->cur_view == ViewPage && app->page_kind == PageChar) show_char(app, false); + break; + case JobInfo: + page_set(app, PageInfo, app->info_text, NULL, NULL, NULL, true); + show_view(app, ViewPage); + break; + case JobDisconnect: + app_log(app, "DISCONNECTED"); + if(app->cur_view == ViewPage || app->cur_view == ViewScan) bc_scan_start(app->bc); + break; + default: break; + } +} + +static void on_notify(App* app) { + NotifyMsg m; + while(furi_message_queue_get(app->notify_q, &m, 0) == FuriStatusOk) { + FuriString* h = furi_string_alloc(); + ble_hex(h, m.data, m.len); + app_log(app, "NOTIFY 0x%04X = %s", m.handle, furi_string_get_cstr(h)); + furi_string_free(h); + if(app->chr_sel >= 0 && app->chr_sel < app->chr_count && + app->chars[app->chr_sel].value_handle == m.handle) { + memcpy(app->value, m.data, m.len); + app->value_len = m.len; + hist_push(app, m.data, m.len); + furi_string_set_str(app->status, "Notification"); + if(app->cur_view == ViewPage && app->page_kind == PageChar) show_char(app, false); + } + } +} + +static bool custom_event_cb(void* ctx, uint32_t event) { + App* app = ctx; + switch(event) { + case EvtTick: on_tick(app); break; + case EvtScanSelect: { + 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) { + show_details(app, true); + show_view(app, ViewPage); + } + } break; + case EvtPageCenter: + if(app->page_kind == PageDetails) { + app_log(app, "CONNECTING %s", dev_label(&app->dev)); + show_popup(app, "Connecting...", dev_label(&app->dev), false); + post_job(app, JobConnect); + } else if(app->page_kind == PageChar) { + furi_string_set_str(app->status, "Reading..."); + show_char(app, false); + post_job(app, JobRead); + } + break; + case EvtPageLeft: + if(app->page_kind == PageChar) { + furi_string_set_str(app->status, app->notify_on ? "Unsubscribing..." : "Subscribing..."); + show_char(app, false); + post_job(app, JobNotify); + } + break; + case EvtPageRight: + if(app->page_kind == PageChar) show_view(app, ViewWriteMenu); + break; + case EvtJobDone: on_job_done(app); break; + case EvtNotify: on_notify(app); break; + case EvtPopupDone: + if(app->after_popup == AfterPopupServices) { + show_view(app, ViewServices); + } else { + if(app->after_popup == AfterPopupDisconnect) post_job(app, JobDisconnect); + bc_scan_start(app->bc); + show_details(app, true); + show_view(app, ViewPage); + } + 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 -------------------------------------------------------- + +static App* app_alloc(void) { + App* app = malloc(sizeof(App)); + memset(app, 0, sizeof(App)); + nav_app = app; + app->gui = furi_record_open(RECORD_GUI); + app->storage = furi_record_open(RECORD_STORAGE); + app->status = furi_string_alloc(); + app->info_text = furi_string_alloc(); + app->chr_sel = -1; + app->jobs = furi_message_queue_alloc(8, sizeof(JobType)); + app->notify_q = furi_message_queue_alloc(8, 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->page_view = view_alloc(); + view_allocate_model(app->page_view, ViewModelTypeLocking, sizeof(PageModel)); + with_view_model(app->page_view, PageModel * m, { m->text = furi_string_alloc(); }, false); + view_set_context(app->page_view, app); + view_set_draw_callback(app->page_view, page_draw); + view_set_input_callback(app->page_view, page_input); + view_set_previous_callback(app->page_view, nav_page_back); + view_dispatcher_add_view(app->vd, ViewPage, app->page_view); + + app->popup = popup_alloc(); + popup_set_callback(app->popup, popup_cb); + popup_set_context(app->popup, app); + 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->services_menu = submenu_alloc(); + view_set_previous_callback(submenu_get_view(app->services_menu), nav_services_back); + view_dispatcher_add_view(app->vd, ViewServices, submenu_get_view(app->services_menu)); + + app->chars_menu = submenu_alloc(); + view_set_previous_callback(submenu_get_view(app->chars_menu), nav_to_services); + view_dispatcher_add_view(app->vd, ViewChars, submenu_get_view(app->chars_menu)); + + app->write_menu = submenu_alloc(); + submenu_set_header(app->write_menu, "Write value as"); + submenu_add_item(app->write_menu, "Text", 0, write_menu_cb, app); + submenu_add_item(app->write_menu, "Hex bytes", 1, write_menu_cb, app); + submenu_add_item(app->write_menu, "Number", 2, write_menu_cb, app); + view_set_previous_callback(submenu_get_view(app->write_menu), nav_to_page); + view_dispatcher_add_view(app->vd, ViewWriteMenu, submenu_get_view(app->write_menu)); + + app->text_input = text_input_alloc(); + text_input_set_result_callback( + app->text_input, text_input_cb, app, app->text_buf, sizeof(app->text_buf), true); + view_set_previous_callback(text_input_get_view(app->text_input), nav_to_page); + view_dispatcher_add_view(app->vd, ViewTextInput, text_input_get_view(app->text_input)); + + app->number_input = number_input_alloc(); + number_input_set_result_callback(app->number_input, number_input_cb, app, 0, INT32_MIN, INT32_MAX); + view_set_previous_callback(number_input_get_view(app->number_input), nav_to_page); + view_dispatcher_add_view(app->vd, ViewNumberInput, number_input_get_view(app->number_input)); + + app->timer = furi_timer_alloc(timer_cb, FuriTimerTypePeriodic, app); + return app; +} + +static void app_free(App* app) { + furi_timer_free(app->timer); + view_dispatcher_remove_view(app->vd, ViewScan); + view_dispatcher_remove_view(app->vd, ViewPage); + view_dispatcher_remove_view(app->vd, ViewPopup); + view_dispatcher_remove_view(app->vd, ViewServices); + view_dispatcher_remove_view(app->vd, ViewChars); + view_dispatcher_remove_view(app->vd, ViewWriteMenu); + view_dispatcher_remove_view(app->vd, ViewTextInput); + view_dispatcher_remove_view(app->vd, ViewNumberInput); + with_view_model(app->page_view, PageModel * m, { furi_string_free(m->text); }, false); + view_free(app->scan_view); + view_free(app->page_view); + popup_free(app->popup); + submenu_free(app->services_menu); + submenu_free(app->chars_menu); + submenu_free(app->write_menu); + text_input_free(app->text_input); + number_input_free(app->number_input); + view_dispatcher_free(app->vd); + furi_message_queue_free(app->jobs); + furi_message_queue_free(app->notify_q); + furi_string_free(app->status); + furi_string_free(app->info_text); + furi_record_close(RECORD_STORAGE); + furi_record_close(RECORD_GUI); + free(app); +} + +int32_t bt_inspector_app(void* p) { + UNUSED(p); + App* app = app_alloc(); + + if(!bc_supported()) { + FuriString* s = furi_string_alloc_set_str( + "Radio stack has no scanning\n" + "This Flipper runs the ST \"BLE Light\" stack (peripheral only). " + "BT Inspector needs firmware built with COPRO_STACK_TYPE=ble_full. " + "See build.sh in the app source."); + page_set(app, PageError, s, NULL, NULL, NULL, true); + furi_string_free(s); + show_view(app, ViewPage); + view_dispatcher_run(app->vd); + app_free(app); + return 0; + } + + log_open(app); + app->was_advertising = furi_hal_bt_is_active(); + furi_hal_bt_stop_advertising(); // also drops any phone connection; restored on exit + furi_delay_ms(100); // let the resulting disconnect event drain before hooking the dispatcher + app->bc = bc_alloc(notify_cb, app); + app_log(app, "START stack=full advertising_was=%d", app->was_advertising); + + app->worker = furi_thread_alloc_ex("BtInspWorker", 3072, worker_thread, app); + furi_thread_start(app->worker); + furi_timer_start(app->timer, furi_ms_to_ticks(300)); + + show_view(app, ViewScan); // enter callback starts the scan + view_dispatcher_run(app->vd); + + furi_timer_stop(app->timer); + post_job(app, JobQuit); + furi_thread_join(app->worker); + furi_thread_free(app->worker); + bc_free(app->bc); // stops scan, disconnects, unregisters the event handler + if(app->was_advertising) furi_hal_bt_start_advertising(); + app_log(app, "STOP"); + if(app->log) { + storage_file_close(app->log); + storage_file_free(app->log); + } + app_free(app); + return 0; +} diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..5fe76e5 --- /dev/null +++ b/build.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Build a Flipper Zero firmware update package that contains BT Inspector. +# +# Why a firmware build and not a plain .fap: +# * The stock radio coprocessor image is ST's "BLE Light" stack: peripheral/ +# broadcaster only. Scanning and connecting as a central need the "full" +# stack (COPRO_STACK_TYPE=ble_full, already supported by fbt). +# * The firmware does not export the ST GAP/GATT/HCI command API to external +# apps, so the app is compiled in as a built-in ("--extra-int-apps"). +# +# Usage: +# ./build.sh build -> flipperzero-firmware/dist/f7-C/f7-update-*/ (update package) +# ./build.sh flash build and flash over USB (Flipper connected, qFlipper closed) +# FW_TAG=1.4.3 ./build.sh pin a different firmware tag (default 1.4.3) +# JOBS=2 ./build.sh limit parallel compile jobs (default 4) +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APP_SRC="$HERE/bt_inspector" +FW_DIR="${FW_DIR:-$HERE/flipperzero-firmware}" +FW_TAG="${FW_TAG:-1.4.3}" +FW_REPO="https://github.com/flipperdevices/flipperzero-firmware.git" + +if [ ! -d "$FW_DIR/.git" ]; then + echo ">> cloning firmware $FW_TAG into $FW_DIR" + git clone --depth 1 --branch "$FW_TAG" --recurse-submodules --shallow-submodules "$FW_REPO" "$FW_DIR" +fi + +cd "$FW_DIR" + +# Let GAP take the central/observer roles as well as peripheral (harmless on the +# full stack; the app itself uses HCI-level scan/connect commands). +GAP_C=targets/f7/ble_glue/gap.c +if grep -q "^ GAP_PERIPHERAL_ROLE,$" "$GAP_C"; then + sed -i 's/^ GAP_PERIPHERAL_ROLE,$/ GAP_PERIPHERAL_ROLE | GAP_CENTRAL_ROLE | GAP_OBSERVER_ROLE,/' "$GAP_C" + echo ">> patched $GAP_C: GAP roles = peripheral|central|observer" +fi + +# Link (or refresh) the app into the user apps directory. +mkdir -p applications_user +rm -rf applications_user/bt_inspector +cp -r "$APP_SRC" applications_user/bt_inspector + +FBT_ARGS=( + COMPACT=1 DEBUG=0 # release build, same as official images + COPRO_STACK_TYPE=ble_full + COPRO_STACK_BIN=stm32wb5x_BLE_Stack_full_fw.bin + --extra-int-apps=bt_inspector +) + +echo ">> building update package (full BLE stack + BT Inspector)" +./fbt -j"${JOBS:-4}" "${FBT_ARGS[@]}" updater_package + +PKG=$(ls -d dist/f7-C/f7-update-* 2>/dev/null | head -1) +echo +echo ">> update package: $PKG" +echo ">> radio stack in package:" +grep -E "^Radio" "$PKG/update.fuf" || true +echo ">> firmware size:" +ls -l "$PKG/firmware.dfu" +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" + +if [ "${1:-}" = "flash" ]; then + echo ">> flashing over USB" + ./fbt -j"${JOBS:-4}" "${FBT_ARGS[@]}" flash_usb_full +fi