Phone:

Hidden from the page source until you click: friction against scrapers, not a guarantee.

Email:

[email protected]

Category:

Embedded

Published:

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

Flipper Blue++: Giving the Flipper Zero the Bluetooth Stack It Was Built For

Open the Bluetooth menu on a stock Flipper Zero and you get one thing: the Flipper advertising itself so the mobile app can find it. It cannot see the BLE devices around it, it cannot connect to one, 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 is about what the stock firmware actually lacks, what changes to unlock it, and how to drive the new capability from your own code.

What the stock firmware is missing

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. There is no observer role, so no scanning; no central role, so no outbound connections; and no GATT client, so even if you could connect you could not walk the remote device's services.

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.

Even with the full stack loaded, though, a stock app still could not use it, and 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

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

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.

The second change is the one that actually unlocks things for app authors. The script copies ST's BLE command headers into the SDK, wraps them in extern "C" guards (the API table is compiled as C++ and ST's generated headers have none), lets fbt regenerate api_symbols.csv, and 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.

The third change removes the sub-GHz region gate, which is unrelated to Bluetooth but 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, and the usual warning applies: transmitting outside your allocation may be illegal where you are.

Driving it from an app

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.

The central problem is that the firmware's own BLE code is still running. It 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.

The second problem is that 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, so 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);

bc_supported is worth a word. It checks furi_hal_bt_get_radio_stack() for FuriHalBtStackFull, and every bundled app calls it first, because 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 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, and 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.

Declaring an app for this firmware

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, so 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.

What you get out of the box

The apps are where the unlock becomes tangible. BT Inspector is the flagship: 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), then connect, service discovery, characteristic reads and writes, notifications, and a timestamped log of all of it on the SD card. Around it sit 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 and a battery health monitor. The full write-up is on the project page.

Installing it

The releases page has two downloads: the full update package, which contains the firmware, the full radio stack and all ten apps, and 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.

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, which means anything a device gates behind an encrypted link (a keyboard that only sends reports after bonding, a characteristic that returns "insufficient authentication") stays out of reach for now. 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.