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.
This commit is contained in:
2026-04-04 03:39:51 +01:00
commit b1c879d305
9 changed files with 2531 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
flipperzero-firmware/
.venv/
build.log
*.log
__pycache__/
dist/
+197
View File
@@ -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)
```
+12
View File
@@ -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,
)
+514
View File
@@ -0,0 +1,514 @@
#include "ble_central.h"
#include <furi_hal.h>
#include <furi_ble/event_dispatcher.h>
#include <ble/ble.h>
#include <interface/patterns/ble_thread/tl/tl.h>
#include <string.h>
#include <stdlib.h>
#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);
}
+73
View File
@@ -0,0 +1,73 @@
#pragma once
#include <furi.h>
#include <stdint.h>
#include <stdbool.h>
#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);
+418
View File
@@ -0,0 +1,418 @@
#include "ble_names.h"
#include <string.h>
#include <ctype.h>
#include <stdlib.h>
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)));
}
+21
View File
@@ -0,0 +1,21 @@
#pragma once
#include <furi.h>
#include <stdint.h>
// 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);
File diff suppressed because it is too large Load Diff
Executable
+68
View File
@@ -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