Ryan Malloy 5a853c15fc Fix event system init and add SSP auto-accept for E2E testing
Two fixes for the E2E test failures:

1. event_reporter_init() was never called in app_main(), so the
   FreeRTOS queue and reporter task were never created. Every BT
   event (pair_request, gatt_read, gatt_write, gatt_subscribe)
   was silently dropped at the NULL-queue guard.

2. SSP Numeric Comparison requires both sides to confirm, but
   bt_pair blocks until completion — creating a deadlock since
   the LLM can't send classic_pair_respond while waiting. Added
   auto_accept flag to set_ssp_mode that auto-confirms numeric
   comparison requests in the GAP callback.
2026-02-02 21:05:28 -07:00

658 lines
22 KiB
C

/*
* bt_classic.c -- Classic Bluetooth GAP/SSP + minimal SPP peripheral.
*
* Provides pairing support across all four SSP association models
* (Just Works, Numeric Comparison, Passkey Display, Passkey Entry)
* plus legacy PIN for pre-2.1 devices. SPP is registered so remote
* devices have a service to connect to.
*/
#include "bt_classic.h"
#include "protocol.h"
#include "uart_handler.h"
#include "event_reporter.h"
#include "esp_bt.h"
#include "esp_bt_main.h"
#include "esp_gap_bt_api.h"
#include "esp_bt_device.h"
#include "esp_spp_api.h"
#include "esp_log.h"
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
static const char *TAG = "classic";
#define SPP_SERVER_NAME "mcbt_spp"
#define SPP_TAG "spp"
/* ------------------------------------------------------------------ */
/* Module state */
/* ------------------------------------------------------------------ */
typedef enum {
PAIR_TYPE_NONE = 0,
PAIR_TYPE_NUMERIC_COMPARISON,
PAIR_TYPE_PASSKEY_DISPLAY,
PAIR_TYPE_PASSKEY_ENTRY,
PAIR_TYPE_PIN_REQUEST,
} pair_type_t;
static struct {
bool enabled;
bool discoverable;
esp_bt_io_cap_t io_cap;
char pin_code[17];
bool ssp_enabled;
bool auto_accept; /* Auto-confirm SSP pairing (for testing) */
/* Pending pairing state */
char pending_pair_address[18];
char pending_pair_cmd_id[32];
bool pair_pending;
pair_type_t pair_type;
/* SPP handle for the listening server */
uint32_t spp_handle;
} classic_state = {
.io_cap = ESP_BT_IO_CAP_IO, /* display+yesno (numeric comparison) */
.ssp_enabled = true,
};
/* ------------------------------------------------------------------ */
/* Address helpers */
/* ------------------------------------------------------------------ */
static bool parse_bd_addr(const char *str, esp_bd_addr_t addr)
{
if (!str || strlen(str) < 17) {
return false;
}
unsigned int b[6];
int rc = sscanf(str, "%02x:%02x:%02x:%02x:%02x:%02x",
&b[0], &b[1], &b[2], &b[3], &b[4], &b[5]);
if (rc != 6) {
/* try uppercase */
rc = sscanf(str, "%02X:%02X:%02X:%02X:%02X:%02X",
&b[0], &b[1], &b[2], &b[3], &b[4], &b[5]);
}
if (rc != 6) {
return false;
}
for (int i = 0; i < 6; i++) {
addr[i] = (uint8_t)b[i];
}
return true;
}
static void bd_addr_to_str(const esp_bd_addr_t addr, char *str)
{
sprintf(str, "%02X:%02X:%02X:%02X:%02X:%02X",
addr[0], addr[1], addr[2], addr[3], addr[4], addr[5]);
}
/* ------------------------------------------------------------------ */
/* GAP callback */
/* ------------------------------------------------------------------ */
static void gap_cb(esp_bt_gap_cb_event_t event, esp_bt_gap_cb_param_t *param)
{
char addr_str[18];
switch (event) {
/* --- Pairing complete --- */
case ESP_BT_GAP_AUTH_CMPL_EVT: {
bd_addr_to_str(param->auth_cmpl.bda, addr_str);
bool ok = (param->auth_cmpl.stat == ESP_BT_STATUS_SUCCESS);
ESP_LOGI(TAG, "auth_cmpl: %s %s (lk_type=%d)",
addr_str, ok ? "success" : "FAIL",
param->auth_cmpl.lk_type);
event_report_pair_complete(addr_str, ok);
/* If a pair_respond command is waiting, resolve it now. */
if (classic_state.pair_pending &&
strcmp(classic_state.pending_pair_address, addr_str) == 0) {
cJSON *d = cJSON_CreateObject();
cJSON_AddStringToObject(d, "address", addr_str);
cJSON_AddBoolToObject(d, "success", ok);
uart_send_response(classic_state.pending_pair_cmd_id, STATUS_OK, d);
classic_state.pair_pending = false;
}
break;
}
/* --- Numeric Comparison (SSP model 2) --- */
case ESP_BT_GAP_CFM_REQ_EVT: {
bd_addr_to_str(param->cfm_req.bda, addr_str);
uint32_t passkey = param->cfm_req.num_val;
ESP_LOGI(TAG, "cfm_req: %s passkey=%06" PRIu32, addr_str, passkey);
event_report_pair_request(addr_str, "numeric_comparison", (int)passkey);
if (classic_state.auto_accept) {
/* Auto-confirm for automated E2E testing */
esp_bt_gap_ssp_confirm_reply(param->cfm_req.bda, true);
ESP_LOGI(TAG, "cfm_req: auto-accepted (auto_accept=true)");
} else {
/* Stash address so pair_respond can reply */
strncpy(classic_state.pending_pair_address, addr_str,
sizeof(classic_state.pending_pair_address) - 1);
classic_state.pair_type = PAIR_TYPE_NUMERIC_COMPARISON;
}
break;
}
/* --- Passkey Display (SSP model 3 -- we show, remote enters) --- */
case ESP_BT_GAP_KEY_NOTIF_EVT: {
bd_addr_to_str(param->key_notif.bda, addr_str);
uint32_t passkey = param->key_notif.passkey;
ESP_LOGI(TAG, "key_notif: %s passkey=%06" PRIu32, addr_str, passkey);
event_report_pair_request(addr_str, "passkey_display", (int)passkey);
/* Nothing to wait for -- the remote side enters the key. */
break;
}
/* --- Passkey Entry (SSP model 4 -- we must enter) --- */
case ESP_BT_GAP_KEY_REQ_EVT: {
bd_addr_to_str(param->key_req.bda, addr_str);
ESP_LOGI(TAG, "key_req: %s (waiting for passkey)", addr_str);
event_report_pair_request(addr_str, "passkey_entry", 0);
strncpy(classic_state.pending_pair_address, addr_str,
sizeof(classic_state.pending_pair_address) - 1);
classic_state.pair_type = PAIR_TYPE_PASSKEY_ENTRY;
break;
}
/* --- Legacy PIN request (pre-2.1 devices) --- */
case ESP_BT_GAP_PIN_REQ_EVT: {
bd_addr_to_str(param->pin_req.bda, addr_str);
bool min_16 = param->pin_req.min_16_digit;
ESP_LOGI(TAG, "pin_req: %s min_16=%d", addr_str, min_16);
/* Auto-reply if a PIN is pre-configured */
if (classic_state.pin_code[0] != '\0') {
uint8_t pin_len = (uint8_t)strlen(classic_state.pin_code);
esp_bt_pin_code_t pin;
memset(pin, 0, sizeof(pin));
memcpy(pin, classic_state.pin_code, pin_len);
esp_bt_gap_pin_reply(param->pin_req.bda, true, pin_len, pin);
ESP_LOGI(TAG, "pin_req: auto-replied with stored PIN");
/* Still report the event so the host sees what happened */
event_report_pair_request(addr_str, "pin_request", 0);
break;
}
/* No stored PIN -- ask the host */
event_report_pair_request(addr_str, "pin_request", 0);
strncpy(classic_state.pending_pair_address, addr_str,
sizeof(classic_state.pending_pair_address) - 1);
classic_state.pair_type = PAIR_TYPE_PIN_REQUEST;
break;
}
/* --- Discovery (we're a peripheral, not scanning -- just log) --- */
case ESP_BT_GAP_DISC_STATE_CHANGED_EVT:
ESP_LOGI(TAG, "discovery state changed: %d",
param->disc_st_chg.state);
break;
case ESP_BT_GAP_DISC_RES_EVT:
/* We don't initiate discovery, ignore. */
break;
default:
ESP_LOGD(TAG, "gap event %d (unhandled)", event);
break;
}
}
/* ------------------------------------------------------------------ */
/* SPP callback (minimal -- just report connects/disconnects) */
/* ------------------------------------------------------------------ */
static void spp_cb(esp_spp_cb_event_t event, esp_spp_cb_param_t *param)
{
char addr_str[18];
switch (event) {
case ESP_SPP_INIT_EVT:
if (param->init.status == ESP_SPP_SUCCESS) {
esp_spp_start_srv(ESP_SPP_SEC_AUTHENTICATE, ESP_SPP_ROLE_SLAVE,
0, SPP_SERVER_NAME);
ESP_LOGI(SPP_TAG, "SPP initialised, starting server");
} else {
ESP_LOGE(SPP_TAG, "SPP init failed: %d", param->init.status);
}
break;
case ESP_SPP_SRV_OPEN_EVT:
bd_addr_to_str(param->srv_open.rem_bda, addr_str);
classic_state.spp_handle = param->srv_open.handle;
ESP_LOGI(SPP_TAG, "SPP client connected: %s (handle=%" PRIu32 ")",
addr_str, param->srv_open.handle);
event_report_connect(addr_str, "classic");
break;
case ESP_SPP_CLOSE_EVT:
ESP_LOGI(SPP_TAG, "SPP disconnected (handle=%" PRIu32 ")",
param->close.handle);
/* We don't have the remote address in CLOSE_EVT on all IDF versions,
* so report with handle info. */
{
cJSON *d = cJSON_CreateObject();
cJSON_AddNumberToObject(d, "handle", (double)param->close.handle);
cJSON_AddStringToObject(d, "transport", "classic");
event_report(EVT_DISCONNECT, d);
}
if (classic_state.spp_handle == param->close.handle) {
classic_state.spp_handle = 0;
}
break;
case ESP_SPP_START_EVT:
if (param->start.status == ESP_SPP_SUCCESS) {
ESP_LOGI(SPP_TAG, "SPP server started (scn=%d)", param->start.scn);
} else {
ESP_LOGE(SPP_TAG, "SPP server start failed: %d",
param->start.status);
}
break;
case ESP_SPP_DATA_IND_EVT:
/* Data received over SPP -- log but don't process for now. */
ESP_LOGI(SPP_TAG, "SPP data: %d bytes on handle %" PRIu32,
param->data_ind.len, param->data_ind.handle);
break;
default:
ESP_LOGD(SPP_TAG, "spp event %d", event);
break;
}
}
/* ------------------------------------------------------------------ */
/* IO capability string mapping */
/* ------------------------------------------------------------------ */
static bool str_to_io_cap(const char *s, esp_bt_io_cap_t *out)
{
if (!s || !out) return false;
if (strcmp(s, IO_CAP_DISPLAY_ONLY) == 0) {
*out = ESP_BT_IO_CAP_OUT;
} else if (strcmp(s, IO_CAP_DISPLAY_YESNO) == 0) {
*out = ESP_BT_IO_CAP_IO;
} else if (strcmp(s, IO_CAP_KEYBOARD_ONLY) == 0) {
*out = ESP_BT_IO_CAP_IN;
} else if (strcmp(s, IO_CAP_NO_IO) == 0) {
*out = ESP_BT_IO_CAP_NONE;
} else if (strcmp(s, IO_CAP_KEYBOARD_DISPLAY) == 0) {
*out = ESP_BT_IO_CAP_IO; /* closest match in Bluedroid */
} else {
return false;
}
return true;
}
/* ------------------------------------------------------------------ */
/* Command handlers */
/* ------------------------------------------------------------------ */
void cmd_classic_enable(const char *id, cJSON *params)
{
(void)params;
if (classic_state.enabled) {
uart_send_response(id, STATUS_OK, cJSON_CreateString("already enabled"));
return;
}
/* Release BLE-only memory when running dual-mode.
* esp_bt_controller_mem_release(ESP_BT_MODE_BLE) would be called
* if we only wanted Classic; here we keep both available.
* If the controller is already initialised (e.g. BLE brought it up),
* skip controller init. */
esp_bt_controller_status_t ctrl_status = esp_bt_controller_get_status();
if (ctrl_status == ESP_BT_CONTROLLER_STATUS_IDLE) {
esp_bt_controller_config_t bt_cfg = BT_CONTROLLER_INIT_CONFIG_DEFAULT();
esp_err_t err = esp_bt_controller_init(&bt_cfg);
if (err != ESP_OK) {
ESP_LOGE(TAG, "controller init failed: %s", esp_err_to_name(err));
uart_send_response(id, STATUS_ERROR,
cJSON_CreateString(esp_err_to_name(err)));
return;
}
}
if (esp_bt_controller_get_status() == ESP_BT_CONTROLLER_STATUS_INITED) {
esp_err_t err = esp_bt_controller_enable(ESP_BT_MODE_BTDM);
if (err != ESP_OK) {
ESP_LOGE(TAG, "controller enable failed: %s", esp_err_to_name(err));
uart_send_response(id, STATUS_ERROR,
cJSON_CreateString(esp_err_to_name(err)));
return;
}
}
/* Bluedroid init + enable */
esp_bluedroid_status_t bd_status = esp_bluedroid_get_status();
if (bd_status == ESP_BLUEDROID_STATUS_UNINITIALIZED) {
esp_err_t err = esp_bluedroid_init();
if (err != ESP_OK) {
ESP_LOGE(TAG, "bluedroid init failed: %s", esp_err_to_name(err));
uart_send_response(id, STATUS_ERROR,
cJSON_CreateString(esp_err_to_name(err)));
return;
}
}
if (esp_bluedroid_get_status() == ESP_BLUEDROID_STATUS_INITIALIZED) {
esp_err_t err = esp_bluedroid_enable();
if (err != ESP_OK) {
ESP_LOGE(TAG, "bluedroid enable failed: %s", esp_err_to_name(err));
uart_send_response(id, STATUS_ERROR,
cJSON_CreateString(esp_err_to_name(err)));
return;
}
}
/* Register GAP callback */
esp_bt_gap_register_callback(gap_cb);
/* Configure SSP IO capability */
esp_bt_sp_param_t iocap = classic_state.io_cap;
esp_bt_gap_set_security_param(ESP_BT_SP_IOCAP_MODE, &iocap,
sizeof(iocap));
ESP_LOGI(TAG, "SSP io_cap set to %d", classic_state.io_cap);
/* Initialise and register SPP */
esp_spp_register_callback(spp_cb);
esp_spp_cfg_t spp_cfg = {
.mode = ESP_SPP_MODE_CB,
.enable_l2cap_ertm = false,
};
esp_err_t err = esp_spp_enhanced_init(&spp_cfg);
if (err != ESP_OK) {
ESP_LOGW(TAG, "spp_enhanced_init: %s (trying legacy init)",
esp_err_to_name(err));
/* Fall back for older IDF builds that lack enhanced init */
esp_spp_init(ESP_SPP_MODE_CB);
}
classic_state.enabled = true;
ESP_LOGI(TAG, "Classic BT enabled");
uart_send_response(id, STATUS_OK, NULL);
}
void cmd_classic_disable(const char *id, cJSON *params)
{
(void)params;
if (!classic_state.enabled) {
uart_send_response(id, STATUS_OK, cJSON_CreateString("already disabled"));
return;
}
esp_spp_deinit();
esp_bluedroid_disable();
classic_state.enabled = false;
classic_state.discoverable = false;
classic_state.pair_pending = false;
classic_state.spp_handle = 0;
ESP_LOGI(TAG, "Classic BT disabled");
uart_send_response(id, STATUS_OK, NULL);
}
void cmd_classic_set_discoverable(const char *id, cJSON *params)
{
if (!classic_state.enabled) {
uart_send_response(id, STATUS_ERROR,
cJSON_CreateString("classic BT not enabled"));
return;
}
bool discoverable = true;
const cJSON *j = cJSON_GetObjectItem(params, "discoverable");
if (cJSON_IsBool(j)) {
discoverable = cJSON_IsTrue(j);
}
if (discoverable) {
esp_bt_gap_set_scan_mode(ESP_BT_CONNECTABLE,
ESP_BT_GENERAL_DISCOVERABLE);
ESP_LOGI(TAG, "now discoverable + connectable");
} else {
esp_bt_gap_set_scan_mode(ESP_BT_NON_CONNECTABLE,
ESP_BT_NON_DISCOVERABLE);
ESP_LOGI(TAG, "now non-discoverable + non-connectable");
}
classic_state.discoverable = discoverable;
cJSON *data = cJSON_CreateObject();
cJSON_AddBoolToObject(data, "discoverable", discoverable);
uart_send_response(id, STATUS_OK, data);
}
void cmd_classic_pair_respond(const char *id, cJSON *params)
{
if (!classic_state.enabled) {
uart_send_response(id, STATUS_ERROR,
cJSON_CreateString("classic BT not enabled"));
return;
}
const cJSON *j_addr = cJSON_GetObjectItem(params, "address");
const cJSON *j_accept = cJSON_GetObjectItem(params, "accept");
const cJSON *j_pass = cJSON_GetObjectItem(params, "passkey");
const cJSON *j_pin = cJSON_GetObjectItem(params, "pin");
if (!cJSON_IsString(j_addr)) {
uart_send_response(id, STATUS_ERROR,
cJSON_CreateString("missing 'address'"));
return;
}
esp_bd_addr_t addr;
if (!parse_bd_addr(j_addr->valuestring, addr)) {
uart_send_response(id, STATUS_ERROR,
cJSON_CreateString("invalid address format"));
return;
}
bool accept = cJSON_IsBool(j_accept) ? cJSON_IsTrue(j_accept) : true;
/* Determine which pairing reply to issue based on pending type */
switch (classic_state.pair_type) {
case PAIR_TYPE_NUMERIC_COMPARISON:
esp_bt_gap_ssp_confirm_reply(addr, accept);
ESP_LOGI(TAG, "ssp_confirm_reply: %s accept=%d",
j_addr->valuestring, accept);
break;
case PAIR_TYPE_PASSKEY_ENTRY:
if (!cJSON_IsNumber(j_pass)) {
uart_send_response(id, STATUS_ERROR,
cJSON_CreateString("passkey_entry requires 'passkey'"));
return;
}
esp_bt_gap_ssp_passkey_reply(addr, accept,
(uint32_t)j_pass->valueint);
ESP_LOGI(TAG, "ssp_passkey_reply: %s accept=%d passkey=%d",
j_addr->valuestring, accept, j_pass->valueint);
break;
case PAIR_TYPE_PIN_REQUEST: {
if (!cJSON_IsString(j_pin)) {
uart_send_response(id, STATUS_ERROR,
cJSON_CreateString("pin_request requires 'pin'"));
return;
}
const char *pin_str = j_pin->valuestring;
uint8_t pin_len = (uint8_t)strlen(pin_str);
if (pin_len == 0 || pin_len > 16) {
uart_send_response(id, STATUS_ERROR,
cJSON_CreateString("pin must be 1-16 characters"));
return;
}
esp_bt_pin_code_t pin;
memset(pin, 0, sizeof(pin));
memcpy(pin, pin_str, pin_len);
esp_bt_gap_pin_reply(addr, accept, pin_len, pin);
ESP_LOGI(TAG, "pin_reply: %s accept=%d len=%d",
j_addr->valuestring, accept, pin_len);
break;
}
default:
uart_send_response(id, STATUS_ERROR,
cJSON_CreateString("no pending pair request"));
return;
}
/* Stash the command id so AUTH_CMPL can send the final response */
strncpy(classic_state.pending_pair_cmd_id, id,
sizeof(classic_state.pending_pair_cmd_id) - 1);
classic_state.pending_pair_cmd_id[sizeof(classic_state.pending_pair_cmd_id) - 1] = '\0';
classic_state.pair_pending = true;
/* Don't send the response yet -- AUTH_CMPL will do it. */
}
void cmd_classic_set_ssp_mode(const char *id, cJSON *params)
{
if (!classic_state.enabled) {
uart_send_response(id, STATUS_ERROR,
cJSON_CreateString("classic BT not enabled"));
return;
}
const cJSON *j_mode = cJSON_GetObjectItem(params, "mode");
if (!cJSON_IsString(j_mode)) {
uart_send_response(id, STATUS_ERROR,
cJSON_CreateString("missing 'mode'"));
return;
}
const char *mode = j_mode->valuestring;
esp_bt_io_cap_t new_cap;
if (strcmp(mode, "just_works") == 0) {
new_cap = ESP_BT_IO_CAP_NONE;
} else if (strcmp(mode, "numeric_comparison") == 0) {
new_cap = ESP_BT_IO_CAP_IO;
} else if (strcmp(mode, "passkey_entry") == 0) {
new_cap = ESP_BT_IO_CAP_IN;
} else if (strcmp(mode, "passkey_display") == 0) {
new_cap = ESP_BT_IO_CAP_OUT;
} else {
cJSON *err = cJSON_CreateObject();
cJSON_AddStringToObject(err, "error", "unknown mode");
cJSON_AddStringToObject(err, "valid",
"just_works, numeric_comparison, passkey_entry, passkey_display");
uart_send_response(id, STATUS_ERROR, err);
return;
}
classic_state.io_cap = new_cap;
esp_bt_sp_param_t iocap = new_cap;
esp_bt_gap_set_security_param(ESP_BT_SP_IOCAP_MODE, &iocap,
sizeof(iocap));
/* Optional auto_accept flag for automated testing */
const cJSON *j_auto = cJSON_GetObjectItem(params, "auto_accept");
if (cJSON_IsBool(j_auto)) {
classic_state.auto_accept = cJSON_IsTrue(j_auto);
}
ESP_LOGI(TAG, "SSP mode set to '%s' (io_cap=%d, auto_accept=%d)",
mode, new_cap, classic_state.auto_accept);
cJSON *data = cJSON_CreateObject();
cJSON_AddStringToObject(data, "mode", mode);
cJSON_AddNumberToObject(data, "io_cap", new_cap);
cJSON_AddBoolToObject(data, "auto_accept", classic_state.auto_accept);
uart_send_response(id, STATUS_OK, data);
}
/* ------------------------------------------------------------------ */
/* State query */
/* ------------------------------------------------------------------ */
bool bt_classic_is_enabled(void)
{
return classic_state.enabled;
}
/* ------------------------------------------------------------------ */
/* Configure helpers (called from the configure command handler) */
/* ------------------------------------------------------------------ */
void bt_classic_set_io_cap(const char *io_cap_str)
{
esp_bt_io_cap_t cap;
if (str_to_io_cap(io_cap_str, &cap)) {
classic_state.io_cap = cap;
ESP_LOGI(TAG, "io_cap configured: %s -> %d", io_cap_str, cap);
/* Apply immediately if already enabled */
if (classic_state.enabled) {
esp_bt_sp_param_t iocap = cap;
esp_bt_gap_set_security_param(ESP_BT_SP_IOCAP_MODE, &iocap,
sizeof(iocap));
}
} else {
ESP_LOGW(TAG, "unknown io_cap string: '%s'", io_cap_str);
}
}
void bt_classic_set_device_class(uint32_t cod_raw)
{
esp_bt_cod_t cod;
memset(&cod, 0, sizeof(cod));
cod.minor = (cod_raw >> 2) & 0x3F;
cod.major = (cod_raw >> 8) & 0x1F;
cod.service = (cod_raw >> 13) & 0x7FF;
esp_err_t err = esp_bt_gap_set_cod(cod, ESP_BT_SET_COD_MAJOR_MINOR | ESP_BT_SET_COD_SERVICE_CLASS);
if (err == ESP_OK) {
ESP_LOGI(TAG, "CoD set to 0x%06" PRIx32, cod_raw);
} else {
ESP_LOGE(TAG, "set_cod failed: %s", esp_err_to_name(err));
}
}
/* ------------------------------------------------------------------ */
/* Init (called early, before any commands) */
/* ------------------------------------------------------------------ */
void bt_classic_init(void)
{
memset(&classic_state, 0, sizeof(classic_state));
classic_state.io_cap = ESP_BT_IO_CAP_IO;
classic_state.ssp_enabled = true;
ESP_LOGI(TAG, "Classic BT module ready");
}