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