feat: v1.9 — multi-channel scanning, BLE fingerprinting

Multi-channel scanning (CHANSCAN command):
- Periodic channel hopping (1-13) with 100ms dwell for broader probe capture
- CHANSCAN ON/OFF/NOW/INTERVAL subcommands
- New NVS keys: chanscan (i8), chanscan_int (i32)
- Emits EVENT,hostname,chanscan=done channels=13 on completion
- PROBE_DATA now includes channel number

BLE fingerprinting:
- Extended BLE_DATA format with company_id, tx_power, flags
- Extracts manufacturer data from BLE advertisements
- Common IDs: 0x004C (Apple), 0x00E0 (Google), 0x0075 (Samsung)

STATUS output now includes chanscan=on/off field.
This commit is contained in:
user
2026-02-05 17:38:08 +01:00
parent 9234ff00de
commit d58b6dd814

View File

@@ -193,6 +193,14 @@ static int s_calib_nsub = 0;
static float s_pr_scores[PRESENCE_WINDOW];
static uint32_t s_pr_score_idx = 0;
/* Multi-channel scanning */
#define CHANSCAN_CHANNELS 13
#define CHANSCAN_DWELL_MS 100
static bool s_chanscan_enabled = false;
static int s_chanscan_interval_s = 300; /* 5 min default */
static volatile bool s_chanscan_active = false;
static int64_t s_chanscan_last = 0;
/* Probe dedup rate (moved before config_load_nvs for NVS access) */
#define PROBE_DEDUP_DEFAULT_US 10000000LL
static int64_t s_probe_dedup_us = PROBE_DEDUP_DEFAULT_US;
@@ -283,6 +291,14 @@ static void config_load_nvs(void)
s_baseline_nsub = (int)bl_nsub;
}
}
int8_t chanscan;
if (nvs_get_i8(h, "chanscan", &chanscan) == ESP_OK) {
s_chanscan_enabled = (chanscan != 0);
}
int32_t chanscan_int;
if (nvs_get_i32(h, "chanscan_int", &chanscan_int) == ESP_OK && chanscan_int >= 60 && chanscan_int <= 3600) {
s_chanscan_interval_s = (int)chanscan_int;
}
nvs_close(h);
ESP_LOGI(TAG, "NVS loaded: hostname=%s rate=%d tx_power=%d adaptive=%d threshold=%.6f ble=%d target=%s:%d csi_mode=%d hybrid_n=%d powersave=%d presence=%d pr_thresh=%.4f baseline_nsub=%d",
s_hostname, s_send_frequency, s_tx_power_dbm, s_adaptive, s_motion_threshold, s_ble_enabled,
@@ -769,27 +785,50 @@ static int ble_gap_event_cb(struct ble_gap_event *event, void *arg)
disc->rssi, disc->addr.val[5], disc->addr.val[4], disc->addr.val[3],
disc->addr.val[2], disc->addr.val[1], disc->addr.val[0]);
/* Parse advertisement for device name */
/* Parse advertisement for device name, manufacturer data, TX power, flags */
struct ble_hs_adv_fields fields;
int rc = ble_hs_adv_parse_fields(&fields, disc->data, disc->length_data);
char name[32] = "";
if (rc == 0 && fields.name != NULL && fields.name_len > 0) {
int nlen = fields.name_len < (int)sizeof(name) - 1 ? fields.name_len : (int)sizeof(name) - 1;
memcpy(name, fields.name, nlen);
name[nlen] = '\0';
uint16_t company_id = 0;
int8_t tx_power = 127; /* 127 = not present */
uint8_t adv_flags = 0;
if (rc == 0) {
/* Device name */
if (fields.name != NULL && fields.name_len > 0) {
int nlen = fields.name_len < (int)sizeof(name) - 1 ? fields.name_len : (int)sizeof(name) - 1;
memcpy(name, fields.name, nlen);
name[nlen] = '\0';
}
/* Manufacturer-specific data: first 2 bytes = company ID (little-endian) */
if (fields.mfg_data != NULL && fields.mfg_data_len >= 2) {
company_id = fields.mfg_data[0] | (fields.mfg_data[1] << 8);
}
/* TX power level */
if (fields.tx_pwr_lvl_is_present) {
tx_power = fields.tx_pwr_lvl;
}
/* Advertisement flags (always present in struct, 0 if not in advert) */
adv_flags = fields.flags;
}
/* Send BLE_DATA via UDP */
char buf[160];
/* Send BLE_DATA via UDP with extended format */
char buf[192];
int len = snprintf(buf, sizeof(buf),
"BLE_DATA,%s,%02x:%02x:%02x:%02x:%02x:%02x,%d,%s,%s\n",
"BLE_DATA,%s,%02x:%02x:%02x:%02x:%02x:%02x,%d,%s,%s,0x%04X,%d,%u\n",
s_hostname,
disc->addr.val[5], disc->addr.val[4], disc->addr.val[3],
disc->addr.val[2], disc->addr.val[1], disc->addr.val[0],
disc->rssi,
disc->addr.type == BLE_ADDR_PUBLIC ? "pub" : "rnd",
name);
name,
company_id,
(int)tx_power,
(unsigned)adv_flags);
if (s_udp_socket >= 0) {
sendto(s_udp_socket, buf, len, 0,
@@ -847,6 +886,9 @@ static void ble_host_task(void *param)
nimble_port_freertos_deinit();
}
/* --- Forward declarations --- */
static void channel_scan_run(void);
/* --- Adaptive sampling --- */
static void adaptive_task(void *arg)
@@ -908,6 +950,15 @@ static void adaptive_task(void *arg)
}
}
/* Periodic channel scanning */
if (s_chanscan_enabled && !s_chanscan_active) {
int64_t now = esp_timer_get_time();
int64_t interval_us = (int64_t)s_chanscan_interval_s * 1000000LL;
if (s_chanscan_last == 0 || (now - s_chanscan_last) >= interval_us) {
channel_scan_run();
}
}
if (!s_adaptive || s_energy_idx < WANDER_WINDOW) continue;
/* Compute mean */
@@ -966,6 +1017,56 @@ static void adaptive_task(void *arg)
}
}
/* --- Channel scanning --- */
static void channel_scan_run(void)
{
if (s_chanscan_active) return;
s_chanscan_active = true;
/* Get current AP info for return */
wifi_ap_record_t ap;
uint8_t home_channel = 1;
if (esp_wifi_sta_get_ap_info(&ap) == ESP_OK) {
home_channel = ap.primary;
}
ESP_LOGI(TAG, "CHANSCAN: starting scan, home channel=%d", home_channel);
/* Stop ping to pause CSI during scan */
if (s_ping_handle) {
esp_ping_stop(s_ping_handle);
esp_ping_delete_session(s_ping_handle);
s_ping_handle = NULL;
}
/* Hop through channels 1-13 */
for (int ch = 1; ch <= CHANSCAN_CHANNELS; ch++) {
esp_wifi_set_channel(ch, WIFI_SECOND_CHAN_NONE);
vTaskDelay(pdMS_TO_TICKS(CHANSCAN_DWELL_MS));
}
/* Return to AP channel */
esp_wifi_set_channel(home_channel, WIFI_SECOND_CHAN_NONE);
/* Restart ping */
wifi_ping_router_start();
s_chanscan_last = esp_timer_get_time();
s_chanscan_active = false;
/* Emit completion event */
char event[128];
int len = snprintf(event, sizeof(event),
"EVENT,%s,chanscan=done channels=%d",
s_hostname, CHANSCAN_CHANNELS);
if (s_udp_socket >= 0) {
sendto(s_udp_socket, event, len, 0,
(struct sockaddr *)&s_dest_addr, sizeof(s_dest_addr));
}
ESP_LOGI(TAG, "CHANSCAN: complete, returned to channel %d", home_channel);
}
/* --- OTA --- */
static void ota_task(void *arg)
@@ -1155,12 +1256,13 @@ static void wifi_promiscuous_cb(void *buf, wifi_promiscuous_pkt_type_t type)
char probe[192];
int len = snprintf(probe, sizeof(probe),
"PROBE_DATA,%s,%02x:%02x:%02x:%02x:%02x:%02x,%d,%s\n",
"PROBE_DATA,%s,%02x:%02x:%02x:%02x:%02x:%02x,%d,%s,%d\n",
s_hostname,
hdr->addr2[0], hdr->addr2[1], hdr->addr2[2],
hdr->addr2[3], hdr->addr2[4], hdr->addr2[5],
pkt->rx_ctrl.rssi,
ssid);
ssid,
pkt->rx_ctrl.channel);
if (s_udp_socket >= 0) {
sendto(s_udp_socket, probe, len, 0,
@@ -1427,7 +1529,7 @@ static int cmd_handle(const char *cmd, char *reply, size_t reply_size)
" hostname=%s version=%s adaptive=%s motion=%d ble=%s target=%s:%d"
" temp=%.1f csi_count=%lu boots=%lu rssi_min=%d rssi_max=%d"
" csi_mode=%s hybrid_n=%d auth=%s flood_thresh=%d/%d powersave=%s"
" presence=%s pr_score=%.4f",
" presence=%s pr_score=%.4f chanscan=%s",
uptime_str, (long long)up, (unsigned long)heap, rssi, channel, (int)s_tx_power_dbm,
s_send_frequency, actual_rate,
s_hostname, app_desc->version,
@@ -1439,7 +1541,8 @@ static int cmd_handle(const char *cmd, char *reply, size_t reply_size)
s_auth_secret[0] ? "on" : "off",
s_flood_thresh, s_flood_window_s,
s_powersave ? "on" : "off",
s_presence_enabled ? "on" : "off", s_pr_last_score);
s_presence_enabled ? "on" : "off", s_pr_last_score,
s_chanscan_enabled ? "on" : "off");
return strlen(reply);
}
@@ -1912,6 +2015,46 @@ static int cmd_handle(const char *cmd, char *reply, size_t reply_size)
return strlen(reply);
}
/* CHANSCAN [ON|OFF|NOW|INTERVAL <60-3600>] */
if (strcmp(cmd, "CHANSCAN") == 0) {
snprintf(reply, reply_size, "OK CHANSCAN %s interval=%ds active=%s",
s_chanscan_enabled ? "on" : "off",
s_chanscan_interval_s,
s_chanscan_active ? "yes" : "no");
return strlen(reply);
}
if (strncmp(cmd, "CHANSCAN ", 9) == 0) {
const char *arg = cmd + 9;
if (strncmp(arg, "ON", 2) == 0) {
s_chanscan_enabled = true;
config_save_i8("chanscan", 1);
snprintf(reply, reply_size, "OK CHANSCAN on interval=%ds", s_chanscan_interval_s);
} else if (strncmp(arg, "OFF", 3) == 0) {
s_chanscan_enabled = false;
config_save_i8("chanscan", 0);
snprintf(reply, reply_size, "OK CHANSCAN off");
} else if (strncmp(arg, "NOW", 3) == 0) {
if (s_chanscan_active) {
snprintf(reply, reply_size, "ERR CHANSCAN already in progress");
} else {
channel_scan_run();
snprintf(reply, reply_size, "OK CHANSCAN triggered");
}
} else if (strncmp(arg, "INTERVAL ", 9) == 0) {
int val = atoi(arg + 9);
if (val < 60 || val > 3600) {
snprintf(reply, reply_size, "ERR CHANSCAN INTERVAL range 60-3600 seconds");
return strlen(reply);
}
s_chanscan_interval_s = val;
config_save_i32("chanscan_int", (int32_t)val);
snprintf(reply, reply_size, "OK CHANSCAN INTERVAL %ds (saved)", val);
} else {
snprintf(reply, reply_size, "ERR CHANSCAN ON|OFF|NOW|INTERVAL <60-3600>");
}
return strlen(reply);
}
snprintf(reply, reply_size, "ERR UNKNOWN");
return strlen(reply);
}