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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user