diff --git a/ble_tracker/application.fam b/ble_tracker/application.fam new file mode 100644 index 0000000..3b4d7f3 --- /dev/null +++ b/ble_tracker/application.fam @@ -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", +) diff --git a/ble_tracker/ble_tracker.c b/ble_tracker/ble_tracker.c new file mode 100644 index 0000000..337b098 --- /dev/null +++ b/ble_tracker/ble_tracker.c @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include +#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; +} diff --git a/ble_tracker/icon.png b/ble_tracker/icon.png new file mode 100644 index 0000000..2d6f2fb Binary files /dev/null and b/ble_tracker/icon.png differ diff --git a/build.sh b/build.sh index cefbed6..76073a8 100755 --- a/build.sh +++ b/build.sh @@ -21,7 +21,7 @@ set -euo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -APPS=(bt_inspector ble_hid_host ble_sensor) # app dirs under $HERE; BLE apps staged with libble/ +APPS=(bt_inspector ble_hid_host ble_sensor ble_tracker) # app dirs under $HERE; BLE apps staged with libble/ APP_SRC="$HERE/bt_inspector" # used only for the BLE_API=min symbol scan FW_DIR="${FW_DIR:-$HERE/flipperzero-firmware}" FW_TAG="${FW_TAG:-1.4.3}"