Articles / Embedded

  • flipper-zero
  • bluetooth
  • ble
  • firmware
  • stm32wb55
  • embedded

Flipper Blue++: The Bluetooth Stack the Flipper Zero Was Built For

A stock Flipper Zero cannot see the BLE devices around it. Open the Bluetooth menu and you get one thing: the Flipper advertising itself so the mobile app can find it. It cannot connect to another device, and it cannot read a single GATT characteristic.

For a device sold as a wireless multi-tool that is a strange gap, and it turns out the gap is a configuration choice rather than a hardware one. Flipper Blue++ is my firmware build that closes it, plus ten apps that use the result; the code is on GitHub.

This post covers what the stock firmware actually lacks, what changes to unlock it, and how to drive the new capability from your own code.

The radio can do far more than the firmware lets it

The Flipper's MCU is an STM32WB55, which has two cores. The application core runs the Flipper firmware. The second core runs a prebuilt Bluetooth stack from ST that the application core talks to over a mailbox.

ST publish several variants of that stack, and Flipper ship the one called BLE Light: stm32wb5x_BLE_Stack_light_fw.bin. Light means peripheral and broadcaster roles only. The radio can advertise and accept a connection from a phone, and that is the whole feature list. What's missing:

  • no observer role, so no scanning
  • no central role, so no outbound connections
  • no GATT client, so even if you could connect you could not walk the remote device's services

Quick detour: the build system already knew

ST also ship stm32wb5x_BLE_Stack_full_fw.bin, which adds all three. The really annoying part, once you notice it, is that the Flipper build system already knows about it.

There is a COPRO_STACK_TYPE=ble_full option in fbt, and the firmware checks at runtime which stack is loaded and reports FuriHalBtStackFull when it finds it. The plumbing is there. Nobody turned it on.

The second lock: apps can't reach the commands

Even with the full stack loaded, a stock app still could not use it. This is the second, less obvious limitation.

External apps, the .fap files you drop on the SD card, can only call functions that appear in the firmware's exported API table. The ST command functions (aci_gap_*, aci_gatt_*, hci_le_*) are not in that table. So the radio might be capable of scanning, but no app has a way to ask it to.

Three patches, no fork

Blue++ does not fork the firmware. A single build.sh clones official firmware 1.4.3, applies three changes and builds the lot. That means tracking a new upstream release is a tag bump rather than a rebase.

1. Full stack, wider GAP roles

The first change selects the full stack and widens the GAP roles the firmware initialises. Stock firmware calls aci_gap_init with GAP_PERIPHERAL_ROLE alone; the patch makes that GAP_PERIPHERAL_ROLE | GAP_CENTRAL_ROLE | GAP_OBSERVER_ROLE. Without this the stack would still refuse central-side procedures even though it is capable of them.

2. Exporting the BLE commands to apps

The second change is the one that actually unlocks things for app authors. The script does four things:

  1. copies ST's BLE command headers into the SDK
  2. wraps them in extern "C" guards (the API table is compiled as C++ and ST's generated headers have none)
  3. lets fbt regenerate api_symbols.csv
  4. marks the selected functions as exported

With the default BLE_API=all that is 211 functions. The cost is flash: every exported symbol has to stay in the image, and the space between the firmware and the radio stack is the Flipper's internal storage. The full stack also loads at a lower address than the light one.

The net effect is that the default build leaves 13 internal storage pages free against 27 on stock. If that matters to you, BLE_API=min exports only what the bundled apps import and gets you back to 18.

3. The sub-GHz region gate

The third change removes the sub-GHz region gate. It's unrelated to Bluetooth, but it is what every "unlocked" firmware does and costs nothing extra once you are patching anyway.

It is a build-time switch: SUBGHZ_UNLOCK=0 keeps the limits in. The usual warning applies: transmitting outside your allocation may be illegal where you are.

How the app library tames the stack's events

You could call the ST commands directly, and I will show that shape in a moment. But the ten bundled apps share a small library, libble, that turns the stack's asynchronous event soup into ordinary blocking calls. Understanding what it does is the quickest way to understand the stack.

Problem one: the firmware's own BLE code is still running

The firmware has a GAP event handler, it expects to be the peripheral, and it gets confused by advertising reports and connection events it did not ask for.

So the first thing the library does is register its own handler with the firmware's event dispatcher and consume the events it owns before the peripheral code ever sees them:

  • Advertising reports are parsed out of the raw HCI payload.
  • Connection-complete events are attributed by the role field, so the library knows which link is its own outbound connection and which is a phone connecting inbound.

Problem two: raw scan commands are disallowed

Scanning cannot use the raw hci_le_set_scan_* commands while the firmware's GAP layer is initialised; the stack returns "command disallowed". The GAP observation procedure works instead, which is what the library uses:

// Active scan, 50 ms interval, 30 ms window, public own address,
// no duplicate filtering so RSSI stays live.
tBleStatus st = aci_gap_start_observation_proc(
    0x0050, 0x0030, 0x01, GAP_PUBLIC_ADDR, 0x00, 0x00);

Connecting is the same story: aci_gap_create_connection rather than the raw HCI equivalent, followed by an MTU exchange once the link is up.

Every GATT operation after that is a command that returns immediately and an event that arrives later. The library pairs each one with an event flag and waits with a timeout. From the app's point of view that collapses to a handful of calls:

if(!bc_supported()) {
    // Light stack loaded: tell the user, do not pretend to scan.
    return 1;
}
BleCentral* bc = bc_alloc(on_notify, app);
bc_scan_start(bc);

// ... later, from the UI thread, refresh the list:
BcDevice devs[BC_MAX_DEVICES];
size_t n = bc_snapshot(bc, devs, BC_MAX_DEVICES); // sorted by RSSI

// The user picked one.
if(bc_connect(bc, &devs[sel], 8000)) {
    BcService svcs[BC_MAX_SERVICES];
    int ns = bc_discover_services(bc, svcs, BC_MAX_SERVICES);
    BcChar chars[BC_MAX_CHARS];
    int nc = bc_discover_chars(bc, &svcs[0], chars, BC_MAX_CHARS);

    uint8_t val[BC_VALUE_MAX];
    int len = bc_read(bc, chars[0].value_handle, val, sizeof(val));
    bc_set_notify(bc, &chars[1], svcs[0].end, true);
}
bc_free(bc);

Why every app calls bc_supported first

bc_supported checks furi_hal_bt_get_radio_stack() for FuriHalBtStackFull, and every bundled app calls it first. An app that assumes the full stack and gets the light one will fail in a confusing way several calls later rather than immediately.

The notify callback fires on the stack's event thread, so it should copy the bytes and signal the UI, not draw.

The rest of the header

The rest is small:

  • bc_read handles long reads transparently by following up with aci_gatt_read_long_char_value until the value stops growing.
  • bc_write picks write-with-response or write-without-response from a flag.
  • bc_set_notify discovers the client characteristic configuration descriptor itself and writes the two-byte enable value to it.

If you would rather skip the library and talk to the stack directly, everything it calls is exported. Include ble/ble.h from the patched SDK and the aci_gatt_disc_all_primary_services, aci_gatt_read_char_value and friends are yours.

Apps need nothing special, except the API version

An app that uses the exported commands needs nothing special in its application.fam; it is an ordinary external app. What changes is the API version.

Exporting new symbols bumps the firmware's API minor version. So a Blue++ app refuses to load on stock firmware with an API mismatch error rather than crashing, and a stock app keeps loading on Blue++ as before.

The build script copies libble into any app directory that includes ble_central.h. Adding an app to the suite is a matter of dropping a directory next to the others and adding its name to the APPS array.

./build.sh          # clone firmware 1.4.3, patch, build firmware and apps
./build.sh flash    # the same, then flash over USB
./build.sh fap      # rebuild only the apps once the firmware exists

To iterate on one app without the whole firmware tree, point ufbt at the SDK the build emits and it works like any other Flipper app project.

Ten apps that show off the unlock

The apps are where the unlock becomes tangible. BT Inspector is the flagship, and it does a lot:

  • a live scan list sorted by signal strength
  • a device page that decodes every advertising structure it knows about: Apple Continuity down to AirPods battery levels, Microsoft CDP, Google Fast Pair, Eddystone, Exposure Notification
  • connect, service discovery, characteristic reads and writes, notifications
  • a timestamped log of all of it on the SD card

Around it sit the rest of the suite:

  • a HID host that shows keyboard and mouse input decoded live
  • a sensor dashboard that reads and subscribes to every SIG-standard characteristic it finds
  • a passive tracker detector for AirTags, Tiles and SmartTags
  • a GATT fuzzer for devices you own
  • a beacon broadcaster
  • a GATT echo server
  • two sub-GHz spectrum tools
  • a battery health monitor

The full write-up is on the project page.

Installing it (back up first)

The releases page has two downloads:

  • the full update package, which contains the firmware, the full radio stack and all ten apps
  • a zip of just the .fap files, for anyone already on Blue++ who only wants fresh apps

Unpack the update package and use qFlipper's "Install from file" on the update file inside. Or copy the folder to SD:/update/ and run it from the Flipper's file browser.

Installing replaces the radio stack as well as the firmware, so take a backup first. Any official release installed the same way puts the stock stack back.

What it still can't do

Two honest caveats. The STM32WB55 has no Bluetooth Classic radio, so headphones and other BR/EDR devices are invisible to any Flipper, patched or not.

And none of the central apps pair or bond yet. Anything a device gates behind an encrypted link stays out of reach for now: a keyboard that only sends reports after bonding, say, or a characteristic that returns "insufficient authentication".

Scanning and connecting are proven on hardware. Several of the apps beyond BT Inspector are built and reviewed but not yet exercised end to end, and the release notes say which. That is the next thing on the list.