Add BLE Tracker Detector app

Passively scans and flags item trackers by advertising signature (Apple Find My/AirTag, Tile, Samsung SmartTag), with a following-you warning for a tracker seen persistently. Read-only; reuses libble.
This commit is contained in:
2026-08-11 19:48:55 +01:00
parent e333307a96
commit 03c28c347c
4 changed files with 312 additions and 1 deletions
+15
View File
@@ -0,0 +1,15 @@
# Needs firmware from ../build.sh (full BLE stack). Staged with libble/.
App(
appid="ble_tracker",
name="BLE Tracker Detector",
apptype=FlipperAppType.EXTERNAL,
entry_point="ble_tracker_app",
requires=["gui", "storage"],
stack_size=4 * 1024,
fap_category="Bluetooth",
fap_icon="icon.png",
fap_version="1.0",
fap_description="Detect nearby item trackers (AirTag, Tile, SmartTag) and following alerts",
fap_author="andydixon",
fap_weburl="https://github.com/andydixon/flipper-bt-inspector",
)
+296
View File
@@ -0,0 +1,296 @@
// BLE Tracker Detector: passively scan and flag nearby item trackers by their
// advertising signatures (Apple Find My / AirTag, Tile, Samsung SmartTag), plus
// a "following" warning for a tracker seen persistently over time. Read-only.
#include <furi.h>
#include <furi_hal.h>
#include <gui/gui.h>
#include <gui/view.h>
#include <gui/view_dispatcher.h>
#include <gui/elements.h>
#include <storage/storage.h>
#include <string.h>
#include <stdarg.h>
#include "ble_central.h"
#include "ble_names.h"
#define TAG "BleTracker"
#define LOG_DIR EXT_PATH("apps_data/ble_tracker")
#define ROWS 4
#define FOLLOW_SECS 120 // seen this long => "following" warning
#define FOLLOW_HITS 8
typedef enum { TrackNone, TrackAirTag, TrackFindMy, TrackTile, TrackSmartTag } TrackerType;
static const char* type_name(TrackerType t) {
switch(t) {
case TrackAirTag: return "AirTag";
case TrackFindMy: return "Find My";
case TrackTile: return "Tile";
case TrackSmartTag: return "SmartTag";
default: return "?";
}
}
// Classify a device from its advertisement. Returns TrackNone if not a tracker.
static TrackerType classify(const BcDevice* d) {
uint8_t l = 0;
const uint8_t* mfg = bc_adv_find(d->adv, d->adv_len, 0xFF, &l);
if(!mfg) mfg = bc_adv_find(d->rsp, d->rsp_len, 0xFF, &l);
if(mfg && l >= 3 && mfg[0] == 0x4C && mfg[1] == 0x00) {
if(mfg[2] == 0x12) return TrackAirTag; // Find My "offline finding"
if(mfg[2] == 0x07) return TrackFindMy; // proximity pairing / nearby
}
// Service data / 16-bit service UUID lists.
for(int which = 0; which < 2; which++) {
const uint8_t* adv = which ? d->rsp : d->adv;
uint8_t alen = which ? d->rsp_len : d->adv_len;
uint8_t sl = 0;
const uint8_t* sd = bc_adv_find(adv, alen, 0x16, &sl); // service data 16-bit
if(sd && sl >= 2) {
uint16_t u = sd[0] | (sd[1] << 8);
if(u == 0xFEED || u == 0xFEEC) return TrackTile;
if(u == 0xFD5A) return TrackSmartTag;
}
for(uint8_t t = 0x02; t <= 0x03; t++) {
uint8_t ul = 0;
const uint8_t* us = bc_adv_find(adv, alen, t, &ul);
for(uint8_t i = 0; us && i + 2 <= ul; i += 2) {
uint16_t u = us[i] | (us[i + 1] << 8);
if(u == 0xFEED || u == 0xFEEC) return TrackTile;
if(u == 0xFD5A) return TrackSmartTag;
}
}
}
return TrackNone;
}
typedef struct {
uint8_t addr[6];
TrackerType type;
int8_t rssi;
uint32_t first_seen, last_seen, hits;
bool warned;
} Tracker;
typedef struct {
Tracker items[BC_MAX_DEVICES];
size_t count, sel, top;
} ListModel;
typedef struct {
Gui* gui;
ViewDispatcher* vd;
View* view;
FuriTimer* timer;
Storage* storage;
File* log;
BleCentral* bc;
Tracker db[BC_MAX_DEVICES];
size_t db_count;
} App;
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("[%02u:%02u:%02u] ", 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 tick(App* app) {
BcDevice snap[BC_MAX_DEVICES];
size_t n = bc_snapshot(app->bc, snap, BC_MAX_DEVICES);
uint32_t now = furi_get_tick();
for(size_t i = 0; i < n; i++) {
TrackerType t = classify(&snap[i]);
if(t == TrackNone) continue;
Tracker* rec = NULL;
for(size_t k = 0; k < app->db_count; k++)
if(memcmp(app->db[k].addr, snap[i].addr, 6) == 0) rec = &app->db[k];
if(!rec && app->db_count < BC_MAX_DEVICES) {
rec = &app->db[app->db_count++];
memset(rec, 0, sizeof(*rec));
memcpy(rec->addr, snap[i].addr, 6);
rec->first_seen = now;
FuriString* a = furi_string_alloc();
ble_addr_str(a, snap[i].addr);
app_log(app, "TRACKER %s %s rssi=%d", type_name(t), furi_string_get_cstr(a), snap[i].rssi);
furi_string_free(a);
}
if(!rec) continue;
rec->type = t;
rec->rssi = snap[i].rssi;
rec->last_seen = now;
rec->hits++;
if(!rec->warned && rec->hits >= FOLLOW_HITS &&
(now - rec->first_seen) >= furi_ms_to_ticks(FOLLOW_SECS * 1000)) {
rec->warned = true;
FuriString* a = furi_string_alloc();
ble_addr_str(a, rec->addr);
app_log(app, "FOLLOWING? %s %s for %lus", type_name(t), furi_string_get_cstr(a),
(unsigned long)((now - rec->first_seen) / 1000));
furi_string_free(a);
}
}
// Publish sorted-by-rssi copy to the view model.
with_view_model(
app->view,
ListModel * m,
{
m->count = app->db_count;
for(size_t i = 0; i < app->db_count; i++) {
m->items[i].type = app->db[i].type;
memcpy(m->items[i].addr, app->db[i].addr, 6);
m->items[i].rssi = app->db[i].rssi;
m->items[i].first_seen = app->db[i].first_seen;
m->items[i].last_seen = app->db[i].last_seen;
m->items[i].hits = app->db[i].hits;
m->items[i].warned = app->db[i].warned;
}
for(size_t i = 1; i < m->count; i++) { // insertion sort by rssi
Tracker t = m->items[i];
size_t j = i;
while(j > 0 && m->items[j - 1].rssi < t.rssi) {
m->items[j] = m->items[j - 1];
j--;
}
m->items[j] = t;
}
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;
},
true);
}
static void draw_cb(Canvas* c, void* m_) {
ListModel* m = m_;
FuriString* s = furi_string_alloc();
canvas_clear(c);
canvas_set_font(c, FontPrimary);
canvas_draw_str(c, 2, 9, "Tracker Detector");
canvas_set_font(c, FontSecondary);
furi_string_printf(s, "%u", (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...");
uint32_t now = furi_get_tick();
for(size_t r = 0; r < ROWS && m->top + r < m->count; r++) {
Tracker* t = &m->items[m->top + r];
int y = 13 + r * 13;
bool sel = (m->top + r == m->sel);
if(sel) {
canvas_draw_rbox(c, 0, y, 122, 13, 2);
canvas_set_color(c, ColorWhite);
}
furi_string_printf(s, "%s%s ", t->warned ? "! " : "", type_name(t->type));
ble_addr_str(s, t->addr);
elements_string_fit_width(c, s, 100);
canvas_draw_str(c, 3, y + 9, furi_string_get_cstr(s));
furi_string_printf(s, "%d", t->rssi);
canvas_draw_str_aligned(c, 119, y + 9, AlignRight, AlignBottom, furi_string_get_cstr(s));
if(sel) canvas_set_color(c, ColorBlack);
UNUSED(now);
}
if(m->count > ROWS) elements_scrollbar_pos(c, 125, 13, 51, m->sel, m->count);
furi_string_free(s);
}
static bool input_cb(InputEvent* e, void* ctx) {
App* app = ctx;
if(e->type != InputTypeShort && e->type != InputTypeRepeat) return false;
if(e->key != InputKeyUp && e->key != InputKeyDown) return false;
with_view_model(
app->view,
ListModel * 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 uint32_t exit_cb(void* ctx) {
UNUSED(ctx);
return VIEW_NONE;
}
static void timer_cb(void* ctx) {
tick((App*)ctx);
}
int32_t ble_tracker_app(void* p) {
UNUSED(p);
App* app = malloc(sizeof(App));
memset(app, 0, sizeof(App));
app->gui = furi_record_open(RECORD_GUI);
app->storage = furi_record_open(RECORD_STORAGE);
app->vd = view_dispatcher_alloc();
view_dispatcher_attach_to_gui(app->vd, app->gui, ViewDispatcherTypeFullscreen);
app->view = view_alloc();
view_allocate_model(app->view, ViewModelTypeLocking, sizeof(ListModel));
view_set_context(app->view, app);
view_set_draw_callback(app->view, draw_cb);
view_set_input_callback(app->view, input_cb);
view_set_previous_callback(app->view, exit_cb);
view_dispatcher_add_view(app->vd, 0, app->view);
if(!bc_supported()) {
// No full stack: show nothing meaningful, just run empty (Back exits).
view_dispatcher_switch_to_view(app->vd, 0);
view_dispatcher_run(app->vd);
goto teardown;
}
storage_simply_mkdir(app->storage, LOG_DIR);
DateTime dt;
furi_hal_rtc_get_datetime(&dt);
FuriString* path = furi_string_alloc_printf(
LOG_DIR "/track_%02u%02u%02u.log", dt.hour, dt.minute, dt.second);
app->log = storage_file_alloc(app->storage);
if(!storage_file_open(app->log, furi_string_get_cstr(path), FSAM_WRITE, FSOM_CREATE_ALWAYS)) {
storage_file_free(app->log);
app->log = NULL;
}
furi_string_free(path);
bool was_adv = furi_hal_bt_is_active();
furi_hal_bt_stop_advertising();
furi_delay_ms(100);
app->bc = bc_alloc(NULL, NULL);
bc_scan_start(app->bc);
app->timer = furi_timer_alloc(timer_cb, FuriTimerTypePeriodic, app);
furi_timer_start(app->timer, furi_ms_to_ticks(500));
view_dispatcher_switch_to_view(app->vd, 0);
view_dispatcher_run(app->vd);
furi_timer_stop(app->timer);
furi_timer_free(app->timer);
bc_free(app->bc);
if(was_adv) furi_hal_bt_start_advertising();
if(app->log) {
storage_file_close(app->log);
storage_file_free(app->log);
}
teardown:
view_dispatcher_remove_view(app->vd, 0);
view_free(app->view);
view_dispatcher_free(app->vd);
furi_record_close(RECORD_STORAGE);
furi_record_close(RECORD_GUI);
free(app);
return 0;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 95 B