Convert to external .fap; export ST BLE command API from the firmware

build.sh now adds ST's aci_*/hci_* headers to the SDK (with extern "C"
guards, the API table is C++), lets fbt regenerate api_symbols.csv and
enables the selected functions (BLE_API=hci,gatt default; min; all).
The app builds as a normal .fap shipped inside the update package and
loadable from SD, saving ~12 KB of firmware flash versus the built-in
(min mode: ~22 KB). qsort replaced with an insertion sort since libc's
qsort is not exported to apps. README documents modes and flash budget.
This commit is contained in:
2026-09-11 11:56:17 +01:00
parent e863616f82
commit 999b20f378
6 changed files with 201 additions and 36 deletions
+51 -17
View File
@@ -9,7 +9,8 @@ services and characteristics: read, write, subscribe, and log it all.
> 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.
> in ST's full BLE stack and exports the BLE command API to apps. The app itself
> is a normal `.fap`. `build.sh` does all of this.
> See [Why a custom firmware build](#why-a-custom-firmware-build).
## Features
@@ -62,19 +63,34 @@ 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 # clone firmware 1.4.3, export the BLE API, build firmware + .fap
./build.sh flash # same, then flash over USB (Flipper connected, qFlipper closed)
./build.sh fap # rebuild only the .fap after editing the app
```
Options via environment variables: `FW_TAG` (firmware tag, default `1.4.3`),
`FW_DIR` (where to clone), `JOBS` (parallel compile jobs, default 4).
`FW_DIR` (where to clone), `JOBS` (parallel compile jobs, default 4),
`BLE_API` (which ST API to export, see below).
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.
`flipperzero-firmware/dist/f7-C/f7-update-local/`. It contains the firmware,
the **full radio stack**, and the app as `apps/Bluetooth/bt_inspector.fap` in
its resources. 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.
Installing an official firmware later restores the light stack the same way.
The `.fap` alone is at `flipperzero-firmware/build/f7-firmware-C/.extapps/`;
after the firmware is installed once, app updates are just a file copy to
`SD:/apps/Bluetooth/`. It only loads on this firmware (API version 87.2 with
the exported symbols); the stock loader rejects it with an API mismatch.
To build the app with `ufbt` instead of the firmware tree, point `ufbt` at the
SDK the build produces:
```sh
ufbt update --local=flipperzero-firmware/dist/f7-C/flipper-z-f7-sdk-local.zip
cd bt_inspector && ufbt # -> dist/bt_inspector.fap ; `ufbt launch` installs it
```
The first build takes 5-15 minutes; later builds are incremental.
@@ -128,12 +144,26 @@ Three facts about the Flipper Zero drive the design:
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).
table. The ST `aci_*` / `hci_*` command functions are not in it, so a stock
`.fap` cannot start a scan or a connection. `build.sh` adds ST's command
headers to the SDK (`lib/stm32wb.scons`), adds `extern "C"` guards to them
(the API table is C++), lets `fbt` regenerate `api_symbols.csv`, and enables
the selected functions. The API minor version becomes 87.2.
3. Every exported function is kept in the firmware image, so `BLE_API` trades
generality for flash. The full stack loads at 0x080CE000 (light: 0x080D7000);
the space between the firmware and the stack is the Flipper's internal
storage:
| `BLE_API` | exported | firmware | pages left, full stack | pages left, extended stack |
|---|---|---|---|---|
| `min` (app's own 15 imports) | 15 | 770 KB | 18 | 9 |
| `hci,gatt` (default) | 127 | 779 KB | 15 | 6 |
| `all` | 211 | ~788 KB | 13 | 4 |
| stock light-stack firmware | 0 | 768 KB | 27 | n/a |
Any comma list of `hci,gap,gatt,hal,l2cap` works. `extended` refers to
`stm32wb5x_BLE_Stack_full_extended_fw.bin` (BLE 5 extended advertising,
Coded PHY), which loads at 0x080C5000; it is not selected by this script.
`build.sh` also widens the GAP roles the firmware initialises
(`GAP_PERIPHERAL_ROLE | GAP_CENTRAL_ROLE | GAP_OBSERVER_ROLE`) in
@@ -182,8 +212,12 @@ wire overlay).
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).
* **App refuses to load ("API mismatch" / unresolved symbol)**: the firmware on
the device is not the one from `build.sh`, or it was built with a smaller
`BLE_API` than the app needs. Reinstall the update package.
* **"API version is still WIP"** during a build: `fbt` found symbols the script
did not classify. Open `targets/f7/api_symbols.csv`, replace the remaining
`?` marks with `+` or `-`, rerun.
* **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.
@@ -191,7 +225,7 @@ wire overlay).
## Layout
```
bt_inspector/ app sources + application.fam
bt_inspector/ app sources, application.fam, icon.png
build.sh firmware build / flash script
flipperzero-firmware/ created by build.sh (git-ignored)
```
+9 -5
View File
@@ -1,12 +1,16 @@
# 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.
# External app. Needs firmware built by ../build.sh: full BLE stack plus the
# ST aci_*/hci_* command API exported to apps.
App(
appid="bt_inspector",
name="BT Inspector",
apptype=FlipperAppType.APP,
apptype=FlipperAppType.EXTERNAL,
entry_point="bt_inspector_app",
requires=["gui", "storage"],
stack_size=4 * 1024,
icon="A_Plugins_14",
order=25,
fap_category="Bluetooth",
fap_icon="icon.png",
fap_version="1.0",
fap_description="BLE scanner and GATT explorer (full-stack firmware)",
fap_author="andydixon",
fap_weburl="https://github.com/andydixon/flipper-bt-inspector",
)
+38 -9
View File
@@ -1,13 +1,37 @@
#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 <ble/core/ble_defs.h>
#include <ble/core/ble_std.h>
#include <ble/core/auto/ble_types.h>
#include <ble/core/auto/ble_vs_codes.h>
#include <ble/core/auto/ble_hci_le.h>
#include <ble/core/auto/ble_gatt_aci.h>
#include <string.h>
#include <stdlib.h>
#define TAG "BtInspector"
// Wire layout of the packets handed to dispatcher handlers (from ST's tl.h,
// redeclared here so the app only needs the exported SDK headers).
typedef struct __attribute__((packed)) {
uint8_t type;
uint8_t data[1];
} BcHciPkt;
typedef struct __attribute__((packed)) {
uint8_t evt;
uint8_t plen;
uint8_t data[1];
} BcHciEvent;
typedef struct __attribute__((packed)) {
uint8_t subevent;
uint8_t data[1];
} BcLeMetaEvent;
typedef struct __attribute__((packed)) {
uint16_t ecode;
uint8_t data[1];
} BcAciEvent;
#define OWN_ADDR_TYPE GAP_PUBLIC_ADDR // firmware programs a public identity address
#define PROC_TIMEOUT_MS 5000
@@ -134,11 +158,11 @@ static void handle_adv_report(BleCentral* bc, const uint8_t* p, size_t len) {
static BleEventAckStatus bc_event_handler(void* event, void* context) {
BleCentral* bc = context;
hci_event_pckt* pkt = (hci_event_pckt*)((hci_uart_pckt*)event)->data;
BcHciEvent* pkt = (BcHciEvent*)((BcHciPkt*)event)->data;
switch(pkt->evt) {
case HCI_LE_META_EVT_CODE: {
evt_le_meta_event* meta = (evt_le_meta_event*)pkt->data;
BcLeMetaEvent* meta = (BcLeMetaEvent*)pkt->data;
if(meta->subevent == HCI_LE_ADVERTISING_REPORT_SUBEVT_CODE) {
handle_adv_report(bc, meta->data, pkt->plen - 1);
return BleEventAckFlowEnable;
@@ -173,7 +197,7 @@ static BleEventAckStatus bc_event_handler(void* event, void* context) {
return BleEventNotAck;
}
case HCI_VENDOR_SPECIFIC_DEBUG_EVT_CODE: {
evt_blecore_aci* aci = (evt_blecore_aci*)pkt->data;
BcAciEvent* aci = (BcAciEvent*)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;
@@ -306,9 +330,6 @@ void bc_scan_stop(BleCentral* bc) {
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;
@@ -325,7 +346,15 @@ size_t bc_snapshot(BleCentral* bc, BcDevice* out, size_t max) {
d->logged = true;
}
furi_mutex_release(bc->mutex);
qsort(out, n, sizeof(BcDevice), cmp_rssi);
for(size_t i = 1; i < n; i++) { // insertion sort by RSSI, strongest first (n <= 32)
BcDevice d = out[i];
size_t j = i;
while(j > 0 && out[j - 1].rssi < d.rssi) {
out[j] = out[j - 1];
j--;
}
out[j] = d;
}
return n;
}
+1 -1
View File
@@ -10,7 +10,7 @@
#include <gui/modules/text_input.h>
#include <gui/modules/number_input.h>
#include <storage/storage.h>
#include <ble/ble.h>
#include <ble/core/ble_defs.h>
#include <string.h>
#include <stdarg.h>
#include "ble_central.h"
Binary file not shown.

After

Width:  |  Height:  |  Size: 95 B

+102 -4
View File
@@ -6,11 +6,16 @@
# 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").
# apps. This script adds those headers to the SDK and accepts the new
# aci_*/hci_* symbols, so the app builds as a normal .fap (bumps the API
# minor version: the .fap only loads on this firmware).
#
# Usage:
# ./build.sh build -> flipperzero-firmware/dist/f7-C/f7-update-*/ (update package)
# ./build.sh build -> flipperzero-firmware/dist/f7-C/f7-update-*/ (update package,
# includes the .fap under apps/Bluetooth) + SDK zip for ufbt
# ./build.sh flash build and flash over USB (Flipper connected, qFlipper closed)
# ./build.sh fap rebuild only the .fap (firmware already built once)
# BLE_API=all|min|hci,gap,gatt,hal,l2cap which ST API to export (default hci,gatt)
# 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
@@ -36,6 +41,48 @@ if grep -q "^ GAP_PERIPHERAL_ROLE,$" "$GAP_C"; then
echo ">> patched $GAP_C: GAP roles = peripheral|central|observer"
fi
# Export the ST BLE command API (aci_*/hci_*) to external apps.
WB_SCONS=lib/stm32wb.scons
python3 - "$WB_SCONS" <<'PY'
import re, sys
p = sys.argv[1]
s = open(p).read()
block = """# BT Inspector: expose the ST BLE command API to .fap apps
env.Append(
SDK_HEADERS=[
File("stm32wb_copro/wpan/ble/core/auto/ble_hci_le.h"),
File("stm32wb_copro/wpan/ble/core/auto/ble_gap_aci.h"),
File("stm32wb_copro/wpan/ble/core/auto/ble_gatt_aci.h"),
File("stm32wb_copro/wpan/ble/core/auto/ble_hal_aci.h"),
File("stm32wb_copro/wpan/ble/core/auto/ble_l2cap_aci.h"),
File("stm32wb_copro/wpan/ble/core/auto/ble_vs_codes.h"),
],
)
"""
s = re.sub(r"\n*# BT Inspector: expose.*?\n\)\n", "\n", s, flags=re.S) # drop any earlier copy
if block not in s:
s = s.replace('Return("lib")', block + 'Return("lib")', 1) # must run before Return
open(p, "w").write(s)
print(">> patched " + p + ": ST BLE headers added to SDK")
PY
# ST's generated headers have no extern "C" guards; the API table is C++.
python3 - <<'PY'
import re
for name in ["ble_hci_le", "ble_gap_aci", "ble_gatt_aci", "ble_hal_aci", "ble_l2cap_aci"]:
p = f"lib/stm32wb_copro/wpan/ble/core/auto/{name}.h"
s = open(p).read()
if "__cplusplus" in s:
continue
guard = re.search(r"#define \w+_H__\n", s).group(0)
s = s.replace(guard, guard + '#ifdef __cplusplus\nextern "C" {\n#endif\n', 1)
i = s.rstrip().rfind("#endif")
s = s[:i] + "#ifdef __cplusplus\n}\n#endif\n" + s[i:]
open(p, "w").write(s)
print(">> patched " + p + ': extern "C" guards')
PY
# Link (or refresh) the app into the user apps directory.
mkdir -p applications_user
rm -rf applications_user/bt_inspector
@@ -45,10 +92,58 @@ 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)"
API_CSV=targets/f7/api_symbols.csv
# BLE_API selects which ST command headers are exported to apps (each exported
# function is kept in the firmware image):
# hci,gatt scanning, connections, GATT client/server (~12 KB, default)
# all also gap, hal, l2cap (~20 KB)
# min only the functions BT Inspector imports (~1.5 KB)
BLE_API="${BLE_API:-hci,gatt}"
if grep -q "^Function,?," "$API_CSV" || ! grep -qE "^Header,\+,.*ble_hci_le\.h" "$API_CSV"; then
# fbt notices the new headers, rewrites the csv with '?' entries and stops.
echo ">> syncing API symbol table (expected to stop once)"
./fbt -j"${JOBS:-4}" "${FBT_ARGS[@]}" api_check || true
fi
python3 - "$API_CSV" "$BLE_API" "$APP_SRC" <<'PY'
import re, sys, glob
csv_path, mode, app_src = sys.argv[1:4]
hdrs = {"hci": "ble_hci_le", "gap": "ble_gap_aci", "gatt": "ble_gatt_aci", "hal": "ble_hal_aci", "l2cap": "ble_l2cap_aci"}
owner = {}
for key, name in hdrs.items():
src = open(f"lib/stm32wb_copro/wpan/ble/core/auto/{name}.h").read()
for fn in re.findall(r"^tBleStatus (\w+)\(", src, re.M):
owner[fn] = key
if mode == "all":
selected = set(owner)
elif mode == "min":
selected = set()
for f in glob.glob(app_src + "/*.c"):
selected |= set(re.findall(r"\b((?:aci_|hci_)\w+)\(", open(f).read()))
else:
want = set(mode.split(","))
selected = {fn for fn, key in owner.items() if key in want}
out, n_on = [], 0
for line in open(csv_path).read().splitlines():
parts = line.split(",")
if len(parts) >= 3 and parts[0] == "Function" and parts[2] in owner:
parts[1] = "+" if parts[2] in selected else "-"
n_on += parts[1] == "+"
elif len(parts) >= 2 and parts[1] == "?":
parts[1] = "-" if parts[0] == "Variable" else "+"
out.append(",".join(parts))
open(csv_path, "w").write("\n".join(out) + "\n")
print(f">> BLE API export ({mode}): {n_on} of {len(owner)} aci_*/hci_* functions enabled")
PY
if [ "${1:-}" = "fap" ]; then
./fbt -j"${JOBS:-4}" "${FBT_ARGS[@]}" fap_bt_inspector
echo ">> $(ls build/f7-firmware-C/.extapps/bt_inspector.fap)"
exit 0
fi
echo ">> building update package (full BLE stack + BT Inspector .fap)"
./fbt -j"${JOBS:-4}" "${FBT_ARGS[@]}" updater_package
PKG=$(ls -d dist/f7-C/f7-update-* 2>/dev/null | head -1)
@@ -58,6 +153,9 @@ echo ">> radio stack in package:"
grep -E "^Radio" "$PKG/update.fuf" || true
echo ">> firmware size:"
ls -l "$PKG/firmware.dfu"
echo ">> app (also inside the package's resources, installed to apps/Bluetooth/):"
ls -l build/f7-firmware-C/.extapps/bt_inspector.fap
echo ">> SDK for building the app with ufbt: $(ls dist/f7-C/flipper-z-f7-sdk-*.zip)"
echo
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"